@pellux/goodvibes-tui 1.25.0 → 1.27.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (78) hide show
  1. package/CHANGELOG.md +15 -0
  2. package/README.md +1 -1
  3. package/docs/foundation-artifacts/operator-contract.json +9605 -3119
  4. package/package.json +9 -7
  5. package/scripts/postinstall.js +57 -1
  6. package/src/audio/activation-sound.ts +117 -0
  7. package/src/audio/capture.ts +112 -0
  8. package/src/audio/onnxruntime-assets.d.ts +24 -0
  9. package/src/audio/voice-capture-wiring.ts +119 -0
  10. package/src/audio/voice-input-session.ts +181 -0
  11. package/src/audio/wake-inference.ts +182 -0
  12. package/src/audio/wake-runtime.ts +341 -0
  13. package/src/cli/bundle-command.ts +4 -1
  14. package/src/cli/help.ts +10 -0
  15. package/src/cli/management-utils.ts +4 -1
  16. package/src/cli/redaction.ts +68 -3
  17. package/src/config/goodvibes-home.ts +10 -0
  18. package/src/config/secret-config.ts +64 -8
  19. package/src/core/turn-event-wiring.ts +7 -16
  20. package/src/core/voice-capture-status.ts +53 -0
  21. package/src/core/voice-stt-gateway.ts +75 -0
  22. package/src/core/wake-provision-runner.ts +86 -0
  23. package/src/core/wake-provision-status.ts +170 -0
  24. package/src/core/workstream-notification.ts +69 -0
  25. package/src/daemon/cli.ts +86 -5
  26. package/src/daemon/handlers/credentials.ts +15 -5
  27. package/src/daemon/provision-wake-model.ts +81 -0
  28. package/src/daemon/send/channels.ts +200 -0
  29. package/src/daemon/send/command.ts +332 -0
  30. package/src/daemon/send/composition.ts +99 -0
  31. package/src/daemon/send/failure-text.ts +93 -0
  32. package/src/daemon/send/inert-text.ts +225 -0
  33. package/src/daemon/send/stdin.ts +24 -0
  34. package/src/input/command-registry.ts +6 -0
  35. package/src/input/commands/config.ts +68 -2
  36. package/src/input/commands/experience-runtime.ts +37 -3
  37. package/src/input/commands/local-provider-runtime.ts +9 -1
  38. package/src/input/commands/operator-runtime.ts +6 -1
  39. package/src/input/commands/payment-card-intake.ts +200 -0
  40. package/src/input/commands/profile-render.ts +250 -0
  41. package/src/input/commands/profile-runtime.ts +362 -0
  42. package/src/input/commands/profile-types.ts +381 -0
  43. package/src/input/commands.ts +7 -0
  44. package/src/input/composer-editor.ts +4 -0
  45. package/src/input/handler-shortcuts.ts +8 -0
  46. package/src/input/keybindings.ts +10 -1
  47. package/src/input/onboarding/onboarding-feature-units.ts +5 -5
  48. package/src/input/onboarding/onboarding-wizard-apply.ts +13 -1
  49. package/src/input/payments-config.ts +167 -0
  50. package/src/input/provider-key-intake.ts +10 -2
  51. package/src/input/settings-modal-commit.ts +146 -0
  52. package/src/input/settings-modal-data.ts +30 -0
  53. package/src/input/settings-modal-reset.ts +6 -2
  54. package/src/input/settings-modal-secrets.ts +9 -2
  55. package/src/input/settings-modal-types.ts +30 -2
  56. package/src/input/settings-modal.ts +27 -107
  57. package/src/main.ts +12 -14
  58. package/src/panels/modals/marketplace-modal.ts +5 -0
  59. package/src/panels/modals/skills-modal.ts +5 -0
  60. package/src/renderer/process-indicator.ts +66 -0
  61. package/src/renderer/settings-modal-helpers.ts +8 -2
  62. package/src/renderer/settings-modal.ts +16 -1
  63. package/src/renderer/shell-surface.ts +22 -3
  64. package/src/runtime/bootstrap-core.ts +7 -7
  65. package/src/runtime/bootstrap.ts +3 -3
  66. package/src/runtime/device-posture-composition.ts +110 -0
  67. package/src/runtime/disposal-wiring.ts +16 -0
  68. package/src/runtime/install-self-check-startup.ts +23 -0
  69. package/src/runtime/onboarding/types.ts +6 -1
  70. package/src/runtime/path-shadow-startup.ts +243 -0
  71. package/src/runtime/path-shadow.ts +418 -0
  72. package/src/runtime/reachability-notice.ts +100 -0
  73. package/src/runtime/runtime-services-types.ts +260 -0
  74. package/src/runtime/services.ts +95 -209
  75. package/src/runtime/voice-setup-services.ts +50 -4
  76. package/src/shell/voice-capture-shell.ts +69 -0
  77. package/src/verification/verification-ledger.ts +163 -24
  78. package/src/version.ts +1 -1
@@ -0,0 +1,110 @@
1
+ /**
2
+ * device-posture-composition.ts — the paired-phone feature inside THIS daemon.
3
+ *
4
+ * A phone pairs with whichever daemon the person runs, and for this owner that
5
+ * daemon is hosted by the terminal app. Until this module existed the app had no
6
+ * device posture at all: `device.nodes.maxPaired` was enforced at the pairing
7
+ * path (SDK-side) and `device.capabilities.mode` was an onboarding toggle, while
8
+ * the other eleven `device.*` keys were recorded, read back, and governed
9
+ * nothing — the capability service they describe was never built here, so there
10
+ * was nothing for them to govern.
11
+ *
12
+ * The feature itself is platform-owned (`platform/devices`): the settings→policy
13
+ * mapping, the grants ledger, the capture store, the housekeeping sweeps, the
14
+ * confirmation flow, and the `phone` tool. This module supplies the three seams
15
+ * that are actually ours and nothing else:
16
+ *
17
+ * - the peer transport — the same DistributedRuntimeManager the remote surface
18
+ * pairs devices onto, so a phone paired here is a node here,
19
+ * - the approval path — the shared approval broker, so the confirmation
20
+ * appears wherever the person is looking (terminal, web app, companion),
21
+ * - the storage root and the live config manager.
22
+ *
23
+ * Constructing this starts nothing. `startHousekeeping()` is called from the
24
+ * bootstrap tail: grants and captures both outlive a restart, so a grant whose
25
+ * phone is gone, or a capture torn by a crash, is reaped BEFORE the first
26
+ * request of this run is served — and the periodic sweep after it is what keeps
27
+ * a long-running daemon from going days without one. A failed sweep is logged
28
+ * and the app still runs: housekeeping failing is a reason to say so, not a
29
+ * reason to refuse to start.
30
+ */
31
+ import { logger } from '@pellux/goodvibes-sdk/platform/utils';
32
+ import { createDevicePostureRuntime, registerDevicePhoneTool } from '@pellux/goodvibes-sdk/platform/devices';
33
+ import type {
34
+ DeviceApprovalBridge,
35
+ DevicePeerTransport,
36
+ DevicePhoneToolRegistry,
37
+ DevicePostureRuntime,
38
+ } from '@pellux/goodvibes-sdk/platform/devices';
39
+ import { registerDevicesGatewayMethods } from '@pellux/goodvibes-sdk/platform/control-plane';
40
+ import type { GatewayMethodCatalog } from '@pellux/goodvibes-sdk/platform/control-plane';
41
+ import type { ConfigManager } from '@pellux/goodvibes-sdk/platform/config';
42
+
43
+ /** Who this surface records in the device audit trail. */
44
+ export const TUI_DEVICE_ACTOR = 'tui:phone-tool';
45
+
46
+ export interface DevicePostureCompositionOptions {
47
+ readonly configManager: ConfigManager;
48
+ /** The runtime devices pair onto; its listPeers/invokePeer are the transport. */
49
+ readonly distributedRuntime: DevicePeerTransport;
50
+ readonly approvals: DeviceApprovalBridge;
51
+ /** Surface-scoped directory the grants ledger, captures and disclosure live in. */
52
+ readonly stateDirectory: string;
53
+ /**
54
+ * Binding the catalog turns devices.nodes.list / devices.grants.* /
55
+ * devices.housekeeping.run from cataloged-but-unhandled into real handlers,
56
+ * which is what makes the grants surface work in the web app and the companion
57
+ * rather than only through this app's own tool.
58
+ */
59
+ readonly gatewayMethods?: GatewayMethodCatalog | undefined;
60
+ readonly getSessionId?: (() => string | undefined) | undefined;
61
+ }
62
+
63
+ export interface DevicePostureServices {
64
+ readonly devicePosture: DevicePostureRuntime;
65
+ }
66
+
67
+ /** Build the device posture runtime and bind the device verbs to it. */
68
+ export function createDevicePostureServices(options: DevicePostureCompositionOptions): DevicePostureServices {
69
+ const devicePosture = createDevicePostureRuntime({
70
+ transport: options.distributedRuntime,
71
+ approvals: options.approvals,
72
+ config: options.configManager,
73
+ stateDirectory: options.stateDirectory,
74
+ actor: TUI_DEVICE_ACTOR,
75
+ ...(options.getSessionId ? { getSessionId: options.getSessionId } : {}),
76
+ });
77
+ if (options.gatewayMethods) registerDevicesGatewayMethods(options.gatewayMethods, devicePosture);
78
+ return { devicePosture };
79
+ }
80
+
81
+ /**
82
+ * Everything a host with a tool registry has to do once it is up: register the
83
+ * `phone` tool — the only path that reaches the capability service, so without it
84
+ * the posture keys govern nothing a session can observe — and start housekeeping.
85
+ *
86
+ * One call, because these two belong to the same feature and a host that did the
87
+ * first and forgot the second would serve requests while never reaping a grant
88
+ * whose phone is gone.
89
+ */
90
+ export function installDevicePosture(
91
+ toolRegistry: DevicePhoneToolRegistry,
92
+ devicePosture: DevicePostureRuntime,
93
+ ): void {
94
+ registerDevicePhoneTool(toolRegistry, devicePosture);
95
+ startDeviceHousekeeping(devicePosture);
96
+ }
97
+
98
+ /**
99
+ * The recovery sweep plus the periodic timer. Separate from construction so
100
+ * composing a runtime in a test starts no timer and touches no disk, and
101
+ * separate from the call above so the standalone daemon — which registers no
102
+ * tools — still sweeps.
103
+ */
104
+ export function startDeviceHousekeeping(devicePosture: DevicePostureRuntime): void {
105
+ void devicePosture.startHousekeeping().catch((error: unknown) => {
106
+ logger.warn('Device housekeeping sweep failed at startup', {
107
+ error: error instanceof Error ? error.message : String(error),
108
+ });
109
+ });
110
+ }
@@ -38,6 +38,14 @@ export { createDisposalScope };
38
38
  export interface SurfaceRuntimePollerOwners extends Omit<RuntimePollerOwners, 'stopConfigWatch'> {
39
39
  /** Fork-only: the repeating crash-residue sweep (durability-services.ts). */
40
40
  readonly stopDurabilityHousekeeping: () => void;
41
+ /**
42
+ * Fork-only: the wake-word recovery sweep and a pending boot provision
43
+ * (voice-setup-services.ts). Started only when an entrypoint opted into boot
44
+ * provisioning, and a no-op otherwise — but it is on this list unconditionally,
45
+ * because "the graph did not start it this time" is not a reason for the
46
+ * teardown path to have no way to stop it.
47
+ */
48
+ readonly stopWakeHousekeeping: () => void;
41
49
  /**
42
50
  * Fork-only: the daemon handler surfaces (daemon-handler-composition.ts).
43
51
  *
@@ -47,6 +55,12 @@ export interface SurfaceRuntimePollerOwners extends Omit<RuntimePollerOwners, 's
47
55
  * exists, so if this surface does not stop them nothing does.
48
56
  */
49
57
  readonly daemonHandlers: { readonly unregister: () => void };
58
+ /**
59
+ * Fork-only: the paired-phone feature's housekeeping timer
60
+ * (device-posture-composition.ts). Started by whichever entry point boots the
61
+ * host, so the stop belongs on this list rather than in one shutdown path.
62
+ */
63
+ readonly devicePosture: { readonly stopHousekeeping: () => void };
50
64
  }
51
65
 
52
66
  /**
@@ -72,6 +86,8 @@ export function registerSurfaceRuntimePollers(
72
86
  ): void {
73
87
  registerRuntimePollers(registry, { ...services, stopConfigWatch: extras.stopConfigWatch });
74
88
  registry.add('durability housekeeping', services.stopDurabilityHousekeeping);
89
+ registry.add('device housekeeping', () => services.devicePosture.stopHousekeeping());
90
+ registry.add('wake-word housekeeping', services.stopWakeHousekeeping);
75
91
  // Registered LAST so it tears down FIRST (the scope unwinds in reverse), which
76
92
  // is the order daemon/cli.ts already used by hand: release the handler surfaces
77
93
  // — closing the inbox store and stopping its poll timers — before the pollers
@@ -17,6 +17,7 @@
17
17
  import { existsSync } from 'node:fs';
18
18
  import { getGoodVibesPackageRoot, resolveGoodVibesDaemonExecutable } from '../cli/service-posture.ts';
19
19
  import { runInstallSelfCheck } from './install-self-check.ts';
20
+ import { announceReachability } from './path-shadow-startup.ts';
20
21
  import type { SystemMessageRouter } from '../core/system-message-router.ts';
21
22
 
22
23
  export function announceInstallSelfCheck(router: SystemMessageRouter): void {
@@ -34,3 +35,25 @@ export function announceInstallSelfCheck(router: SystemMessageRouter): void {
34
35
  // Best-effort — an install self-check must never block or crash boot.
35
36
  }
36
37
  }
38
+
39
+ /**
40
+ * The one call boot makes about the state of this install: is it complete
41
+ * (the check above), is it the copy the shell actually reaches, and is it the
42
+ * current release (path-shadow-startup).
43
+ *
44
+ * They are announced together because they are the same question from the
45
+ * user's side — "am I looking at the build I think I am" — and because the
46
+ * three answers are useless apart: a complete install nobody can reach, or a
47
+ * reachable install three versions old, both end with the product appearing
48
+ * to have lost a capability it never had.
49
+ *
50
+ * The reachability half is async (it may look up the latest release) and is
51
+ * deliberately not awaited: boot continues, the notice arrives through the
52
+ * same router a moment later.
53
+ */
54
+ export function announceInstallHealth(router: SystemMessageRouter): void {
55
+ announceInstallSelfCheck(router);
56
+ void announceReachability(router).catch(() => {
57
+ // Best-effort — a reachability check must never block or crash boot.
58
+ });
59
+ }
@@ -281,7 +281,12 @@ export type OnboardingApplyOperation =
281
281
  readonly kind: 'set-secret';
282
282
  readonly key: string;
283
283
  readonly value: string;
284
- readonly scope?: 'project' | 'user';
284
+ /**
285
+ * Which secret tier the value is filed in. 'daemon' is the tier the
286
+ * daemon reads with every surface closed — see
287
+ * config/secret-config.ts#defaultSecretBackedScope.
288
+ */
289
+ readonly scope?: 'project' | 'user' | 'daemon';
285
290
  readonly medium: 'secure' | 'plaintext';
286
291
  }
287
292
  | {
@@ -0,0 +1,243 @@
1
+ /**
2
+ * path-shadow-startup.ts — the boot-time surfacing of "is this the build you
3
+ * are actually reaching, and is it the current one".
4
+ *
5
+ * It wires the real host inputs (this process's executable, the real PATH,
6
+ * existsSync/realpathSync, and a bounded `<path> --version` spawn) into the
7
+ * pure scan in path-shadow.ts and the pure wording in reachability-notice.ts,
8
+ * then prints the result through the same SystemMessageRouter the rest of
9
+ * startup uses, at high priority, before the session gets going.
10
+ *
11
+ * Cost discipline, because this runs on every start:
12
+ * - the first scan is existence-only: no process is spawned while there is
13
+ * nothing to report, which is the overwhelmingly common case;
14
+ * - versions are probed only after a shadow has already been found, and
15
+ * only with `--version`, bounded by a short timeout;
16
+ * - the latest-release lookup only happens when it can actually change what
17
+ * the user should do: a package-managed or source install (which will
18
+ * never swap itself), or an install that has just been found unreachable.
19
+ * A healthy binary install has already been brought to the latest release
20
+ * by the launch auto-updater, so asking again would be a network round
21
+ * trip that can only confirm what just happened.
22
+ *
23
+ * Every failure is swallowed. A reachability check must never block or crash
24
+ * boot.
25
+ */
26
+
27
+ import { spawnSync } from 'node:child_process';
28
+ import { realpathSync, statSync } from 'node:fs';
29
+ import { basename, dirname, join } from 'node:path';
30
+ import { detectInstallKind, fallbackUpdateCommand, type UpdateFetchLike } from './update-check.ts';
31
+ import { checkForUpdate } from '../input/commands/update-runtime.ts';
32
+ import {
33
+ scanCommandShadows,
34
+ splitPathEntries,
35
+ type ShadowScanResult,
36
+ } from './path-shadow.ts';
37
+ import { buildReachabilityNotices, type ReachabilityNotice } from './reachability-notice.ts';
38
+ import { VERSION } from '../version.ts';
39
+ import type { SystemMessageRouter } from '../core/system-message-router.ts';
40
+
41
+ /** The commands a goodvibes install places side by side in one directory. */
42
+ export const INSTALLED_COMMANDS = ['goodvibes', 'goodvibes-daemon', 'goodvibes-agent'] as const;
43
+
44
+ /** How long a `<path> --version` probe may take before it is abandoned. */
45
+ const VERSION_PROBE_TIMEOUT_MS = 3000;
46
+ /** How long the latest-release lookup may take before startup moves on without it. */
47
+ const LATEST_LOOKUP_TIMEOUT_MS = 2500;
48
+
49
+ function safeRealPath(path: string): string {
50
+ try {
51
+ return realpathSync(path);
52
+ } catch {
53
+ return path;
54
+ }
55
+ }
56
+
57
+ function isExecutableFile(path: string): boolean {
58
+ try {
59
+ const stats = statSync(path);
60
+ if (!stats.isFile()) return false;
61
+ // Any execute bit is enough: the shell searches for an executable file,
62
+ // and which bit applies depends on ownership we are not going to compute.
63
+ return (stats.mode & 0o111) !== 0;
64
+ } catch {
65
+ return false;
66
+ }
67
+ }
68
+
69
+ /**
70
+ * Runs `<path> --version` and returns its first line. Never inherits stdio, so
71
+ * a binary that tries to draw a terminal cannot disturb this one.
72
+ */
73
+ export function probeVersionLine(path: string): string | undefined {
74
+ try {
75
+ const result = spawnSync(path, ['--version'], {
76
+ timeout: VERSION_PROBE_TIMEOUT_MS,
77
+ encoding: 'utf8',
78
+ stdio: ['ignore', 'pipe', 'ignore'],
79
+ windowsHide: true,
80
+ });
81
+ if (result.error || typeof result.stdout !== 'string') return undefined;
82
+ return result.stdout.split('\n')[0];
83
+ } catch {
84
+ return undefined;
85
+ }
86
+ }
87
+
88
+ /**
89
+ * The PATH directory that provides THIS running executable, which is the
90
+ * position everything else is measured against.
91
+ *
92
+ * Preferred answer: the first PATH entry whose `<dir>/<command>` resolves,
93
+ * through symlinks, to this same file. That is exactly "where the shell would
94
+ * find me", and it is right for a linked package install as well as a
95
+ * standalone binary.
96
+ *
97
+ * Fallback, for a standalone binary only: the directory the executable sits
98
+ * in. That directory being absent from PATH is itself worth reporting — an
99
+ * installed binary nobody can reach by name. For a package-managed install
100
+ * the executable lives inside node_modules, which is never on PATH and never
101
+ * meant to be, so there is nothing honest to say and the check stays silent.
102
+ */
103
+ export function resolveSelfDirectory(input: {
104
+ readonly execPath: string;
105
+ readonly command: string;
106
+ readonly pathEntries: readonly string[];
107
+ readonly realPath: (path: string) => string;
108
+ readonly isExecutableFile: (path: string) => boolean;
109
+ }): string | undefined {
110
+ const self = input.realPath(input.execPath);
111
+ for (const directory of input.pathEntries) {
112
+ const candidate = join(directory, input.command);
113
+ if (!input.isExecutableFile(candidate)) continue;
114
+ if (input.realPath(candidate) === self) return directory;
115
+ }
116
+ if (detectInstallKind(input.execPath) === 'binary') return dirname(self);
117
+ return undefined;
118
+ }
119
+
120
+ /** Which of the installed commands actually sit in this directory. */
121
+ function commandsPresentIn(directory: string, exists: (path: string) => boolean): string[] {
122
+ return INSTALLED_COMMANDS.filter((command) => exists(join(directory, command)));
123
+ }
124
+
125
+ export interface ReachabilityCheckResult {
126
+ readonly notices: readonly ReachabilityNotice[];
127
+ readonly scan?: ShadowScanResult | undefined;
128
+ }
129
+
130
+ /**
131
+ * The whole check, with the network lookup injected so tests never reach it.
132
+ * Returns the notices to print; an empty list is the healthy case.
133
+ */
134
+ export async function runReachabilityCheck(input: {
135
+ readonly execPath: string;
136
+ readonly pathValue: string | undefined;
137
+ readonly homeDir: string;
138
+ readonly runningVersion: string;
139
+ readonly commandName?: string | undefined;
140
+ /** Resolves the newest released version, or undefined when it cannot be determined. */
141
+ readonly resolveLatest: () => Promise<string | undefined>;
142
+ // The host touches, injectable so a test drives a whole scenario with fake
143
+ // paths and never spawns anything. Production passes none of these.
144
+ readonly isExecutableFile?: ((path: string) => boolean) | undefined;
145
+ readonly realPath?: ((path: string) => string) | undefined;
146
+ readonly probeVersion?: ((path: string) => string | undefined) | undefined;
147
+ }): Promise<ReachabilityCheckResult> {
148
+ const command = input.commandName ?? basename(input.execPath);
149
+ const pathEntries = splitPathEntries(input.pathValue);
150
+ const installKind = detectInstallKind(input.execPath);
151
+ const fileIsExecutable = input.isExecutableFile ?? isExecutableFile;
152
+ const resolvePath = input.realPath ?? safeRealPath;
153
+ const versionProbe = input.probeVersion ?? probeVersionLine;
154
+
155
+ // A source checkout is not an install: there is no maintained copy to be
156
+ // shadowed and no release to be behind.
157
+ if (installKind === 'source') return { notices: [] };
158
+
159
+ const selfDirectory = resolveSelfDirectory({
160
+ execPath: input.execPath,
161
+ command,
162
+ pathEntries,
163
+ realPath: resolvePath,
164
+ isExecutableFile: fileIsExecutable,
165
+ });
166
+ if (!selfDirectory) return { notices: [] };
167
+
168
+ const commands = commandsPresentIn(selfDirectory, fileIsExecutable);
169
+ const base = {
170
+ commands: commands.length > 0 ? commands : [command],
171
+ installDir: selfDirectory,
172
+ pathEntries,
173
+ homeDir: input.homeDir,
174
+ isExecutableFile: fileIsExecutable,
175
+ realPath: resolvePath,
176
+ };
177
+
178
+ // Existence-only first: nothing is spawned while there is nothing to report.
179
+ const cheapScan = scanCommandShadows(base);
180
+ const scan = cheapScan.hasProblem
181
+ ? scanCommandShadows({ ...base, probeVersion: versionProbe })
182
+ : cheapScan;
183
+
184
+ const latestVersion = scan.hasProblem || installKind !== 'binary'
185
+ ? await input.resolveLatest()
186
+ : undefined;
187
+
188
+ return {
189
+ scan,
190
+ notices: buildReachabilityNotices({
191
+ scan,
192
+ runningVersion: input.runningVersion,
193
+ latestVersion,
194
+ updateCommand: installKind === 'binary'
195
+ ? 'curl -fsSL https://goodvibes.sh/install.sh | sh'
196
+ : fallbackUpdateCommand(installKind),
197
+ }),
198
+ };
199
+ }
200
+
201
+ /** The real latest-release lookup, bounded so a slow network cannot hold up boot. */
202
+ async function resolveLatestReleaseBounded(): Promise<string | undefined> {
203
+ try {
204
+ // The same release lookup /update uses — there is deliberately no second
205
+ // source of truth for what the latest version is.
206
+ const lookup = checkForUpdate(fetch as UpdateFetchLike, VERSION).then((result) => result.latestTag);
207
+ const timed = await Promise.race([
208
+ lookup,
209
+ new Promise<undefined>((resolve) => {
210
+ const timer = setTimeout(() => resolve(undefined), LATEST_LOOKUP_TIMEOUT_MS);
211
+ timer.unref?.();
212
+ }),
213
+ ]);
214
+ return timed ?? undefined;
215
+ } catch {
216
+ return undefined;
217
+ }
218
+ }
219
+
220
+ /**
221
+ * Boot-time wiring. Prints one high-priority system message per line so the
222
+ * report reads as prose rather than a single wrapped paragraph, and swallows
223
+ * everything.
224
+ */
225
+ export async function announceReachability(router: SystemMessageRouter): Promise<void> {
226
+ try {
227
+ const result = await runReachabilityCheck({
228
+ execPath: process.execPath,
229
+ pathValue: process.env['PATH'],
230
+ homeDir: process.env['HOME'] ?? '',
231
+ runningVersion: VERSION,
232
+ commandName: 'goodvibes',
233
+ resolveLatest: resolveLatestReleaseBounded,
234
+ });
235
+ for (const notice of result.notices) {
236
+ for (const line of notice.lines) {
237
+ router.high(`[Install] ${line}`);
238
+ }
239
+ }
240
+ } catch {
241
+ // Best-effort — a reachability check must never block or crash boot.
242
+ }
243
+ }