@yolo-labs/yolobridge 0.1.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.
@@ -0,0 +1,136 @@
1
+ /**
2
+ * Thin HTTP client for the daemon-facing YoloBridge surface
3
+ * (`common-api/src/routes/yolobridge.ts`, mounted under
4
+ * `/v1/workspaces/:workspaceId/yolobridge`). Shapes here are read
5
+ * directly off that route file + `yolobridge-service.ts`, not guessed.
6
+ */
7
+ export class YoloBridgeApiError extends Error {
8
+ status;
9
+ code;
10
+ constructor(message, status, code) {
11
+ super(message);
12
+ this.status = status;
13
+ this.code = code;
14
+ this.name = 'YoloBridgeApiError';
15
+ }
16
+ }
17
+ function base(cfg) {
18
+ return cfg.commonApiBaseUrl.replace(/\/+$/, '');
19
+ }
20
+ function authHeaders(cfg) {
21
+ return { Authorization: `Bearer ${cfg.accessToken}` };
22
+ }
23
+ async function parseErrorBody(res) {
24
+ try {
25
+ const body = (await res.json());
26
+ return { message: typeof body?.error === 'string' ? body.error : `HTTP ${res.status}`, code: body?.code };
27
+ }
28
+ catch {
29
+ return { message: `HTTP ${res.status}` };
30
+ }
31
+ }
32
+ export async function attach(cfg, workspaceId, hostLabel) {
33
+ const fetchImpl = cfg.fetchImpl ?? fetch;
34
+ const res = await fetchImpl(`${base(cfg)}/v1/workspaces/${workspaceId}/yolobridge/attach`, {
35
+ method: 'POST',
36
+ headers: { ...authHeaders(cfg), 'Content-Type': 'application/json' },
37
+ body: JSON.stringify(hostLabel ? { hostLabel } : {}),
38
+ });
39
+ if (!res.ok) {
40
+ const { message, code } = await parseErrorBody(res);
41
+ throw new YoloBridgeApiError(`attach failed: ${message}`, res.status, code);
42
+ }
43
+ const body = (await res.json());
44
+ if (typeof body?.tileId !== 'string' || typeof body?.attachmentId !== 'string') {
45
+ throw new YoloBridgeApiError('attach returned an unexpected shape', res.status);
46
+ }
47
+ return { tileId: body.tileId, attachmentId: body.attachmentId };
48
+ }
49
+ /**
50
+ * `GET /v1/workspaces/selectable` — slim `{id,name,status}` list of the
51
+ * caller's own non-terminated, non-ephemeral workspaces (capped at 500
52
+ * server-side), authenticated the same `flexibleAuth` tier as every other
53
+ * call in this file. There's no membership/role model in this codebase —
54
+ * a user's workspaces are strictly `{ userId: <them> }` — so this is a
55
+ * single owner-scoped list, not a "workspaces I can see" query.
56
+ * (`common-api/src/routes/workspaces.ts`, `WorkspaceService.listSelectable`.)
57
+ */
58
+ export async function listSelectableWorkspaces(cfg) {
59
+ const fetchImpl = cfg.fetchImpl ?? fetch;
60
+ const res = await fetchImpl(`${base(cfg)}/v1/workspaces/selectable`, {
61
+ method: 'GET',
62
+ headers: authHeaders(cfg),
63
+ });
64
+ if (!res.ok) {
65
+ const { message, code } = await parseErrorBody(res);
66
+ throw new YoloBridgeApiError(`list workspaces failed: ${message}`, res.status, code);
67
+ }
68
+ const body = (await res.json());
69
+ if (!Array.isArray(body?.workspaces)) {
70
+ throw new YoloBridgeApiError('list workspaces returned an unexpected shape', res.status);
71
+ }
72
+ const workspaces = [];
73
+ for (const w of body.workspaces) {
74
+ if (typeof w?.id !== 'string' || typeof w?.status !== 'string') {
75
+ throw new YoloBridgeApiError('list workspaces returned an unexpected shape', res.status);
76
+ }
77
+ workspaces.push({ id: w.id, name: typeof w.name === 'string' ? w.name : '', status: w.status });
78
+ }
79
+ return workspaces;
80
+ }
81
+ export async function detach(cfg, workspaceId, attachmentId) {
82
+ const fetchImpl = cfg.fetchImpl ?? fetch;
83
+ const res = await fetchImpl(`${base(cfg)}/v1/workspaces/${workspaceId}/yolobridge/attach/${attachmentId}`, {
84
+ method: 'DELETE',
85
+ headers: authHeaders(cfg),
86
+ });
87
+ // 204 on success; a 404 (already detached) is treated as success too —
88
+ // detach is idempotent from the CLI's point of view.
89
+ if (!res.ok && res.status !== 404) {
90
+ const { message, code } = await parseErrorBody(res);
91
+ throw new YoloBridgeApiError(`detach failed: ${message}`, res.status, code);
92
+ }
93
+ }
94
+ /** Opens the raw `GET /stream` connection. Caller owns reading/parsing `res.body`. */
95
+ export async function openStream(cfg, workspaceId, attachmentId) {
96
+ const fetchImpl = cfg.fetchImpl ?? fetch;
97
+ const url = `${base(cfg)}/v1/workspaces/${workspaceId}/yolobridge/stream?attachmentId=${encodeURIComponent(attachmentId)}`;
98
+ const res = await fetchImpl(url, { method: 'GET', headers: { ...authHeaders(cfg), Accept: 'text/event-stream' } });
99
+ if (!res.ok || !res.body) {
100
+ const { message, code } = await parseErrorBody(res);
101
+ throw new YoloBridgeApiError(`stream open failed: ${message}`, res.status, code);
102
+ }
103
+ return res;
104
+ }
105
+ export async function postHeartbeat(cfg, workspaceId, attachmentId) {
106
+ const body = await postEvent(cfg, workspaceId, { attachmentId, type: 'heartbeat' });
107
+ return Boolean(body?.recorded);
108
+ }
109
+ export async function postReadOutputReply(cfg, workspaceId, attachmentId, requestId, output, busy) {
110
+ const body = await postEvent(cfg, workspaceId, {
111
+ attachmentId,
112
+ type: 'read-output-reply',
113
+ requestId,
114
+ output,
115
+ busy,
116
+ });
117
+ return Boolean(body?.resolved);
118
+ }
119
+ async function postEvent(cfg, workspaceId, payload) {
120
+ const fetchImpl = cfg.fetchImpl ?? fetch;
121
+ const res = await fetchImpl(`${base(cfg)}/v1/workspaces/${workspaceId}/yolobridge/events`, {
122
+ method: 'POST',
123
+ headers: { ...authHeaders(cfg), 'Content-Type': 'application/json' },
124
+ body: JSON.stringify(payload),
125
+ });
126
+ if (!res.ok) {
127
+ const { message, code } = await parseErrorBody(res);
128
+ throw new YoloBridgeApiError(`events (${payload.type}) failed: ${message}`, res.status, code);
129
+ }
130
+ try {
131
+ return await res.json();
132
+ }
133
+ catch {
134
+ return undefined;
135
+ }
136
+ }
@@ -0,0 +1,299 @@
1
+ /**
2
+ * `yolo-bridge attach <workspaceId>` — the daemon loop.
3
+ *
4
+ * Calls `POST .../yolobridge/attach`, then holds `GET .../yolobridge/stream`
5
+ * open: parses SSE frames (sse-frame-parser.ts), dispatches them
6
+ * (frame-actions.ts), reconnects with backoff on disconnect
7
+ * (reconnect.ts), and posts a heartbeat every ~10s while connected
8
+ * (heartbeat.ts). All the pure logic lives in those sibling modules and is
9
+ * unit tested there; this file is the network/process glue that wires
10
+ * them together, plus a light structural test below driving it through a
11
+ * fake in-memory SSE stream.
12
+ *
13
+ * Sleep/wake-aware resilience: see reconnect.ts's header comment — plain
14
+ * bounded exponential backoff is implemented; true OS sleep/wake signal
15
+ * detection is NOT, and is called out there and in the final report as a
16
+ * deliberate scope cut.
17
+ */
18
+ import { Readable } from 'node:stream';
19
+ import { StringDecoder } from 'node:string_decoder';
20
+ import * as readline from 'node:readline';
21
+ import { SseFrameParser } from './sse-frame-parser.js';
22
+ import { actionForFrame } from './frame-actions.js';
23
+ import { startHeartbeat, defaultTimers } from './heartbeat.js';
24
+ import { nextBackoffMs } from './reconnect.js';
25
+ import { deliverPromptToLocalAgent, captureLocalAgentOutput } from './local-agent.js';
26
+ import * as apiClient from './api-client.js';
27
+ import { refreshAccessToken as refreshAccessTokenApi } from './device-auth.js';
28
+ import { loadAuth, saveAuth, saveAttachment, clearAttachment } from './config-store.js';
29
+ const DEFAULT_AUTH_URL = 'https://auth.yololabs.ai';
30
+ /** Refresh once the access token has less than this much validity left.
31
+ * Production access tokens live 24h; 5min gives ample margin against a
32
+ * slow/retried refresh call before the old token actually 401s. */
33
+ const DEFAULT_REFRESH_BUFFER_MS = 5 * 60_000;
34
+ /** How often to re-check `shouldStop()`/a failed-refresh flag while an SSE
35
+ * stream is open and blocked on `for await`. The server holds the stream
36
+ * open indefinitely (keepalive pings only), so without an active poll here
37
+ * a stop signal would never be noticed until the stream happened to end on
38
+ * its own — which, by design, it doesn't. Small enough to be prompt,
39
+ * cheap enough to not matter (a no-op comparison on every tick). */
40
+ const STOP_POLL_INTERVAL_MS = 250;
41
+ const defaultSleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
42
+ export async function runAttachDaemon(deps) {
43
+ const { workspaceId, commonApiBaseUrl, hostLabel, auth, env, io, fetchImpl, } = deps;
44
+ const shouldStop = deps.shouldStop ?? (() => false);
45
+ const sleep = deps.sleep ?? defaultSleep;
46
+ const log = deps.log ?? ((line) => process.stdout.write(`${line}\n`));
47
+ const deliverPrompt = deps.deliverPrompt ?? deliverPromptToLocalAgent;
48
+ const captureOutput = deps.captureOutput ?? captureLocalAgentOutput;
49
+ const timers = deps.timers ?? defaultTimers;
50
+ const now = deps.now ?? Date.now;
51
+ const refreshBufferMs = deps.refreshBufferMs ?? DEFAULT_REFRESH_BUFFER_MS;
52
+ const authBaseUrl = deps.authBaseUrl ?? process.env.YOLOBRIDGE_AUTH_URL ?? DEFAULT_AUTH_URL;
53
+ const doRefresh = deps.refreshAccessToken ?? refreshAccessTokenApi;
54
+ const cfg = { commonApiBaseUrl, accessToken: auth.accessToken, fetchImpl };
55
+ let currentAuth = auth;
56
+ /**
57
+ * Proactive refresh (Bug 2 fix): checked before opening/reopening the
58
+ * stream and on every heartbeat tick while connected, so the daemon
59
+ * rotates its access token well before the 24h production expiry
60
+ * instead of degrading into a silent zombie that just starts 401ing.
61
+ * Updates both the in-memory `cfg`/`currentAuth` used by every
62
+ * subsequent API call in this process AND the on-disk auth.json (via
63
+ * `saveAuth`) so a later `status`/restart also sees the fresh token.
64
+ */
65
+ async function ensureFreshToken() {
66
+ if (now() < currentAuth.expiresAtMs - refreshBufferMs)
67
+ return { ok: true };
68
+ const result = await doRefresh(authBaseUrl, currentAuth.refreshToken, fetchImpl);
69
+ if (result.status !== 'ok') {
70
+ return { ok: false, message: result.message };
71
+ }
72
+ currentAuth = {
73
+ accessToken: result.tokens.accessToken,
74
+ refreshToken: result.tokens.refreshToken,
75
+ tokenType: currentAuth.tokenType,
76
+ expiresAtMs: result.tokens.expiresAtMs,
77
+ };
78
+ cfg.accessToken = currentAuth.accessToken;
79
+ saveAuth(currentAuth, env, io);
80
+ log('Access token refreshed.');
81
+ return { ok: true };
82
+ }
83
+ // Cover the case where the daemon is (re)started against a token that's
84
+ // already within the refresh buffer of expiry (e.g. `attach` run right
85
+ // after a long-down period) — refresh before the very first network
86
+ // call, not just before subsequent reconnects.
87
+ const initialRefresh = await ensureFreshToken();
88
+ if (!initialRefresh.ok) {
89
+ log(`Token refresh failed: ${initialRefresh.message}`);
90
+ log('Run `yolo-bridge login` again.');
91
+ return { ok: false, reason: 'refresh-failed', message: initialRefresh.message };
92
+ }
93
+ let attachmentId;
94
+ let tileId;
95
+ try {
96
+ const result = await apiClient.attach(cfg, workspaceId, hostLabel);
97
+ attachmentId = result.attachmentId;
98
+ tileId = result.tileId;
99
+ }
100
+ catch (err) {
101
+ return { ok: false, reason: 'attach-failed', message: err instanceof Error ? err.message : String(err) };
102
+ }
103
+ saveAttachment({ workspaceId, tileId, attachmentId, attachedAt: new Date().toISOString() }, env, io);
104
+ log(`Attached. tileId=${tileId} attachmentId=${attachmentId}`);
105
+ // Codex-found race: if something already asked us to stop WHILE the
106
+ // initial refresh/attach network round trip above was in flight (e.g. the
107
+ // local agent process this daemon spawns exits almost immediately), the
108
+ // caller's own onExit-triggered best-effort detach ran too early — before
109
+ // this attachment existed anywhere — and found nothing to clean up. The
110
+ // `while (!shouldStop())` loop below would otherwise exit on its very
111
+ // first check having never opened a stream, returning `{ ok: true,
112
+ // reason: 'stopped' }` with the attachment just created above left as a
113
+ // permanent orphan (the CLI's own post-return cleanup skips it too, since
114
+ // it believes the onExit path already handled detaching). Catch it here,
115
+ // right after this call is the one that created it, so there's exactly
116
+ // one place responsible for cleaning up what it made.
117
+ if (shouldStop()) {
118
+ await apiClient.detach(cfg, workspaceId, attachmentId).catch((err) => {
119
+ log(`Cleanup detach failed: ${err instanceof Error ? err.message : String(err)}`);
120
+ });
121
+ clearAttachment(env, io);
122
+ return { ok: true, reason: 'stopped' };
123
+ }
124
+ let heartbeat;
125
+ let attempt = 0;
126
+ /** Set when a proactive refresh (see ensureFreshToken) fails while a
127
+ * stream is open — picked up right after the current for-await unwinds
128
+ * (forced via the stop-poll below) so the daemon stops instead of
129
+ * looping forever reconnecting with a dead token. */
130
+ let refreshFailed;
131
+ try {
132
+ while (!shouldStop()) {
133
+ const preStreamRefresh = await ensureFreshToken();
134
+ if (!preStreamRefresh.ok) {
135
+ refreshFailed = preStreamRefresh;
136
+ break;
137
+ }
138
+ let sawDetached = false;
139
+ try {
140
+ const res = await apiClient.openStream(cfg, workspaceId, attachmentId);
141
+ attempt = 0; // reset backoff on a successful connect
142
+ const parser = new SseFrameParser();
143
+ const nodeStream = Readable.fromWeb(res.body);
144
+ // Codex review (2026-08-23, fourth pass): a per-chunk
145
+ // `chunk.toString('utf-8')` decodes each network chunk in
146
+ // isolation — if a multibyte UTF-8 character (e.g. in a
147
+ // non-ASCII prompt) straddles a chunk boundary, each half decodes
148
+ // independently to a replacement character (U+FFFD), corrupting
149
+ // the prompt before it ever reaches JSON parsing or the PTY.
150
+ // `StringDecoder` carries incomplete trailing bytes over to the
151
+ // next `write()` call, so a split character reassembles correctly
152
+ // regardless of where the network happened to cut the chunk. Scoped
153
+ // per-connection (declared here, not outside the `while` loop) —
154
+ // a new connection can't continue a byte sequence from a previous
155
+ // one, so fresh decoder state per attempt is correct, matching the
156
+ // per-connection `parser` right above.
157
+ const decoder = new StringDecoder('utf-8');
158
+ // Bug 1 fix: the server holds this stream open indefinitely
159
+ // (keepalive pings only), so `for await` below never completes on
160
+ // its own — `shouldStop()` being poll-based (not push-based; see
161
+ // cli.ts's SIGINT/SIGTERM handler) means it must be actively
162
+ // polled independent of whether/when the next chunk arrives, and
163
+ // acted on by tearing the stream down, or a signal during an
164
+ // active stream is never actually noticed. Same mechanism also
165
+ // unblocks a mid-stream proactive-refresh failure (`refreshFailed`
166
+ // above) instead of riding out the connection to its next natural
167
+ // event.
168
+ const stopPollHandle = timers.setInterval(() => {
169
+ if (shouldStop() || refreshFailed) {
170
+ nodeStream.destroy();
171
+ }
172
+ }, STOP_POLL_INTERVAL_MS);
173
+ try {
174
+ for await (const chunk of nodeStream) {
175
+ const text = Buffer.isBuffer(chunk) ? decoder.write(chunk) : String(chunk);
176
+ for (const frame of parser.push(text)) {
177
+ const action = actionForFrame(frame);
178
+ switch (action.kind) {
179
+ case 'connected':
180
+ log('Stream connected.');
181
+ heartbeat?.stop();
182
+ heartbeat = startHeartbeat(async () => {
183
+ const refreshCheck = await ensureFreshToken();
184
+ if (!refreshCheck.ok) {
185
+ refreshFailed = refreshCheck;
186
+ return;
187
+ }
188
+ await apiClient.postHeartbeat(cfg, workspaceId, attachmentId);
189
+ }, (err) => log(`heartbeat error: ${err instanceof Error ? err.message : String(err)}`), undefined, deps.timers);
190
+ // Send one immediately so status isn't stale for the first ~10s.
191
+ apiClient.postHeartbeat(cfg, workspaceId, attachmentId).catch((err) => log(`initial heartbeat error: ${err instanceof Error ? err.message : String(err)}`));
192
+ break;
193
+ case 'ping':
194
+ break;
195
+ case 'prompt':
196
+ await deliverPrompt(action.prompt);
197
+ break;
198
+ case 'read-output': {
199
+ const captured = await captureOutput();
200
+ await apiClient
201
+ .postReadOutputReply(cfg, workspaceId, attachmentId, action.requestId, captured.output, captured.busy)
202
+ .catch((err) => log(`read-output reply failed: ${err instanceof Error ? err.message : String(err)}`));
203
+ break;
204
+ }
205
+ case 'detached':
206
+ log('Detached by server.');
207
+ sawDetached = true;
208
+ break;
209
+ case 'unknown':
210
+ log(`Unrecognized frame type: ${action.event}`);
211
+ break;
212
+ }
213
+ if (sawDetached)
214
+ break;
215
+ }
216
+ if (sawDetached)
217
+ break;
218
+ }
219
+ }
220
+ finally {
221
+ timers.clearInterval(stopPollHandle);
222
+ }
223
+ }
224
+ catch (err) {
225
+ log(`Stream error: ${err instanceof Error ? err.message : String(err)}`);
226
+ }
227
+ heartbeat?.stop();
228
+ heartbeat = undefined;
229
+ if (sawDetached) {
230
+ clearAttachment(env, io);
231
+ return { ok: true, reason: 'detached-by-server' };
232
+ }
233
+ if (refreshFailed)
234
+ break;
235
+ if (shouldStop())
236
+ break;
237
+ attempt += 1;
238
+ const delay = nextBackoffMs(attempt, deps.backoffOpts);
239
+ log(`Reconnecting in ${delay}ms (attempt ${attempt})...`);
240
+ await sleep(delay);
241
+ }
242
+ }
243
+ finally {
244
+ heartbeat?.stop();
245
+ }
246
+ if (refreshFailed) {
247
+ log(`Token refresh failed: ${refreshFailed.message}`);
248
+ log('Run `yolo-bridge login` again.');
249
+ return { ok: false, reason: 'refresh-failed', message: refreshFailed.message };
250
+ }
251
+ return { ok: true, reason: 'stopped' };
252
+ }
253
+ /** Convenience wrapper: loads auth from disk first (used by cli.ts). */
254
+ export async function runAttachFromDisk(opts) {
255
+ const auth = loadAuth(opts.env, opts.io);
256
+ if (!auth)
257
+ return { ok: false, reason: 'not-logged-in' };
258
+ return runAttachDaemon({ ...opts, auth });
259
+ }
260
+ function defaultPrompt(question) {
261
+ return new Promise((resolve) => {
262
+ const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
263
+ rl.question(question, (answer) => {
264
+ rl.close();
265
+ resolve(answer);
266
+ });
267
+ });
268
+ }
269
+ export async function pickWorkspaceFromDisk(deps) {
270
+ const auth = loadAuth(deps.env, deps.io);
271
+ if (!auth)
272
+ return { ok: false, reason: 'not-logged-in' };
273
+ const log = deps.log ?? ((line) => process.stdout.write(`${line}\n`));
274
+ const prompt = deps.prompt ?? defaultPrompt;
275
+ const cfg = {
276
+ commonApiBaseUrl: deps.commonApiBaseUrl,
277
+ accessToken: auth.accessToken,
278
+ fetchImpl: deps.fetchImpl,
279
+ };
280
+ let workspaces;
281
+ try {
282
+ workspaces = await apiClient.listSelectableWorkspaces(cfg);
283
+ }
284
+ catch (err) {
285
+ return { ok: false, reason: 'list-failed', message: err instanceof Error ? err.message : String(err) };
286
+ }
287
+ if (workspaces.length === 0)
288
+ return { ok: false, reason: 'no-workspaces' };
289
+ log('Select a workspace to attach:');
290
+ workspaces.forEach((ws, i) => {
291
+ log(` ${i + 1}. ${ws.name || '(unnamed)'} [${ws.status}] ${ws.id}`);
292
+ });
293
+ const answer = (await prompt('Enter a number: ')).trim();
294
+ const index = Number.parseInt(answer, 10);
295
+ if (!Number.isInteger(index) || index < 1 || index > workspaces.length) {
296
+ return { ok: false, reason: 'no-selection' };
297
+ }
298
+ return { ok: true, workspaceId: workspaces[index - 1].id };
299
+ }
@@ -0,0 +1,46 @@
1
+ /**
2
+ * Best-effort "open the verification URL in a browser" helper.
3
+ *
4
+ * JUDGMENT CALL (plan says either is fine, "your call, note which you
5
+ * picked"): no `open`/`opener` npm dependency added. Shelling out to the
6
+ * platform's own opener (`open` on macOS, `xdg-open` on Linux, `start` via
7
+ * `cmd` on Windows) covers the common case with zero new dependency
8
+ * surface, and login always prints the URL + code clearly regardless of
9
+ * whether the spawn succeeds — headless/SSH sessions (a real case for a
10
+ * CLI daemon meant to run on a dev box) degrade to "copy this URL"
11
+ * automatically, no separate code path needed.
12
+ */
13
+ import { spawn as realSpawn } from 'node:child_process';
14
+ export function openBrowserBestEffort(url, spawnImpl = realSpawn) {
15
+ try {
16
+ const platform = process.platform;
17
+ let child;
18
+ if (platform === 'darwin') {
19
+ child = spawnImpl('open', [url], { stdio: 'ignore', detached: true });
20
+ }
21
+ else if (platform === 'win32') {
22
+ child = spawnImpl('cmd', ['/c', 'start', '""', url], { stdio: 'ignore', detached: true, shell: true });
23
+ }
24
+ else {
25
+ child = spawnImpl('xdg-open', [url], { stdio: 'ignore', detached: true });
26
+ }
27
+ // Codex review (2026-08-23, fourth pass): a missing opener binary (no
28
+ // `xdg-open` on a minimal/headless Linux box, the common case this
29
+ // helper is meant to degrade gracefully on) doesn't throw SYNCHRONOUSLY
30
+ // — spawn() returns a child and emits `error` (e.g. ENOENT) on a later
31
+ // tick, which this surrounding try/catch cannot catch. An EventEmitter
32
+ // `error` event with no listener throws and crashes the process, so
33
+ // login was crashing instead of degrading to "copy this URL" as
34
+ // intended. Register the listener BEFORE unref() so it's in place for
35
+ // that later tick.
36
+ child.on('error', () => {
37
+ // Best-effort only — the caller always prints the URL, so a failed
38
+ // spawn (no DISPLAY, missing xdg-open, headless box) is a non-event.
39
+ });
40
+ child.unref();
41
+ }
42
+ catch {
43
+ // Best-effort only — the caller always prints the URL, so a failed
44
+ // spawn (no DISPLAY, missing xdg-open, headless box) is a non-event.
45
+ }
46
+ }