opera-browser-cli 0.1.45 → 0.1.47

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.
@@ -1,18 +1,46 @@
1
1
  /**
2
2
  * HTTP client for the opera-browser-cli bridge + bridge lifecycle management.
3
+ *
4
+ * The lifecycle rules, in one place:
5
+ *
6
+ * - A process is only ever signalled once it has been positively identified
7
+ * as our bridge — by answering /health, or by matching a PID file entry
8
+ * recorded on this same boot. A recycled PID after a reboot must never be
9
+ * mistaken for ours.
10
+ * - A bridge running a different package version is unusable, however
11
+ * healthy it looks: it is serving pre-upgrade code from memory.
12
+ * - Exactly one process starts a bridge at a time (an exclusive lock), and
13
+ * if the port it wants is taken it moves to the next one.
14
+ * - A connection lost mid-command is recovered transparently, except for the
15
+ * expensive Opera AI tools, which are never silently replayed.
3
16
  */
4
17
  import { spawn } from "node:child_process";
5
- import { mkdirSync, openSync, readFileSync, existsSync } from "node:fs";
18
+ import { closeSync, existsSync, mkdirSync, openSync, readFileSync, renameSync, statSync, unlinkSync, writeSync, } from "node:fs";
6
19
  import { join } from "node:path";
7
20
  import { homedir } from "node:os";
8
21
  import { request } from "node:http";
9
22
  import { AxiError } from "axi-sdk-js";
10
- import { resolveBridgeScript } from "./bridge.js";
23
+ import { resolveBridgeLauncher, } from "./bridge.js";
24
+ import { computeBootMinute, isOurBridge, isUsableBridge, parseHealth, sameBoot, } from "./identity.js";
25
+ import { getPackageVersion } from "./version.js";
11
26
  const STATE_DIR = join(homedir(), ".opera-browser-cli");
12
27
  const PID_FILE = join(STATE_DIR, "bridge.pid");
13
28
  const CONFIG_FILE = join(STATE_DIR, "config");
14
29
  const LOG_FILE = join(STATE_DIR, "bridge.log");
30
+ const LOCK_FILE = join(STATE_DIR, "bridge.lock");
15
31
  const DEFAULT_PORT = 9225;
32
+ /** How many consecutive ports to try before giving up. */
33
+ const PORT_SCAN_COUNT = 10;
34
+ /** Budget for a single bridge process to reach READY (Chrome launch is slow). */
35
+ const START_TIMEOUT_MS = 30_000;
36
+ /** A start lock older than this is assumed abandoned. */
37
+ const LOCK_STALE_MS = 60_000;
38
+ /** Grace period for a SIGTERMed bridge before escalating to SIGKILL. */
39
+ const STOP_GRACE_MS = 5_000;
40
+ /** Rotate the bridge log past this size so it cannot grow without bound. */
41
+ const MAX_LOG_BYTES = 5 * 1024 * 1024;
42
+ /** Lines of bridge.log to quote back when a startup fails. */
43
+ const LOG_TAIL_LINES = 20;
16
44
  export function getLogFile() {
17
45
  return LOG_FILE;
18
46
  }
@@ -80,6 +108,14 @@ function readPidFile() {
80
108
  return null;
81
109
  }
82
110
  }
111
+ function removePidFile() {
112
+ try {
113
+ unlinkSync(PID_FILE);
114
+ }
115
+ catch {
116
+ // Already gone — fine
117
+ }
118
+ }
83
119
  /** Read the bridge's per-instance auth token from the PID file, if present. */
84
120
  function readBridgeToken() {
85
121
  return readPidFile()?.token ?? null;
@@ -93,6 +129,37 @@ function isProcessAlive(pid) {
93
129
  return false;
94
130
  }
95
131
  }
132
+ /**
133
+ * Whether a PID file entry may be signalled.
134
+ *
135
+ * Requires the entry to record the boot it was written on, and that boot to be
136
+ * the current one. Entries without a boot stamp (pre-0.1.46) are only
137
+ * trustworthy when something has *also* identified the port as ours — see
138
+ * `resolveSignalablePid`.
139
+ */
140
+ function pidFileIsFromThisBoot(info) {
141
+ return (typeof info.bootMinute === "number" &&
142
+ sameBoot(info.bootMinute, computeBootMinute()));
143
+ }
144
+ /**
145
+ * Work out which PID, if any, it is safe to signal for the bridge on `port`.
146
+ *
147
+ * `health.pid` is authoritative — that process just told us who it is. Older
148
+ * bridges do not report a PID; for those we fall back to the PID file, but only
149
+ * when it names the same port, which means the file was written by whatever is
150
+ * answering there now.
151
+ */
152
+ function resolveSignalablePid(port, health) {
153
+ if (health.pid > 0)
154
+ return health.pid;
155
+ const info = readPidFile();
156
+ if (info && info.port === port && isProcessAlive(info.pid))
157
+ return info.pid;
158
+ return null;
159
+ }
160
+ // ---------------------------------------------------------------------------
161
+ // HTTP
162
+ // ---------------------------------------------------------------------------
96
163
  function httpGet(port, path, timeoutMs = 2000, token) {
97
164
  return new Promise((resolve, reject) => {
98
165
  const req = request({
@@ -181,138 +248,601 @@ function httpPost(port, path, body, timeoutMs = 120_000, onLog, token) {
181
248
  req.end();
182
249
  });
183
250
  }
184
- async function isBridgeHealthy(port) {
251
+ function sleep(ms) {
252
+ return new Promise((r) => setTimeout(r, ms));
253
+ }
254
+ // ---------------------------------------------------------------------------
255
+ // Discovery
256
+ // ---------------------------------------------------------------------------
257
+ /** The ports a bridge may live on, in preference order. */
258
+ export function candidatePorts() {
259
+ const base = Number.parseInt(process.env.OPERA_CLI_PORT ?? String(DEFAULT_PORT), 10);
260
+ const start = Number.isFinite(base) ? base : DEFAULT_PORT;
261
+ return Array.from({ length: PORT_SCAN_COUNT }, (_, i) => start + i);
262
+ }
263
+ /**
264
+ * Ask what is listening on a port.
265
+ *
266
+ * Returns the identity if it is one of our bridges (of any version), and null
267
+ * for everything else: nothing listening, a foreign server, or a response we
268
+ * cannot parse. A foreign server is deliberately indistinguishable from an
269
+ * empty port here — the caller handles both the same way, by moving on and
270
+ * letting the bridge's own EADDRINUSE handling sort out the collision.
271
+ */
272
+ async function probeHealth(port) {
185
273
  try {
186
- const resp = await httpGet(port, "/health", 2000);
187
- const data = JSON.parse(resp);
188
- return data.status === "ok";
274
+ return parseHealth(await httpGet(port, "/health", 2000));
189
275
  }
190
276
  catch {
191
- return false;
277
+ return null;
192
278
  }
193
279
  }
280
+ async function probeAll(ports) {
281
+ return Promise.all(ports.map(async (port) => ({ port, health: await probeHealth(port) })));
282
+ }
194
283
  /**
195
- * Check what is listening on a port.
196
- * Returns "ok" if it is our bridge, "conflict" if something else responded,
197
- * or "free" if nothing is listening.
284
+ * Find a bridge we can use, cleaning up any of our own that we cannot.
285
+ *
286
+ * Stale-version bridges are shut down rather than left running: they hold a
287
+ * port, they will never become usable, and leaving them behind is how a machine
288
+ * accumulates zombies across upgrades.
198
289
  */
199
- async function checkPortStatus(port) {
290
+ export async function findUsableBridge(ports) {
291
+ const version = getPackageVersion();
292
+ // Fast path: the port in the PID file is nearly always the answer, and
293
+ // checking it alone keeps the common case to a single round trip.
294
+ const preferred = readPidFile()?.port;
295
+ if (preferred !== undefined && ports.includes(preferred)) {
296
+ const health = await probeHealth(preferred);
297
+ if (isUsableBridge(health, version))
298
+ return preferred;
299
+ if (isOurBridge(health))
300
+ await shutdownBridgeOnPort(preferred, health);
301
+ }
302
+ const probes = await probeAll(ports.filter((p) => p !== preferred));
303
+ for (const { port, health } of probes) {
304
+ if (isUsableBridge(health, version))
305
+ return port;
306
+ }
307
+ for (const { port, health } of probes) {
308
+ if (isOurBridge(health))
309
+ await shutdownBridgeOnPort(port, health);
310
+ }
311
+ return null;
312
+ }
313
+ /** Poll for a bridge someone else is starting. */
314
+ async function waitForUsableBridge(ports, timeoutMs) {
315
+ const deadline = Date.now() + timeoutMs;
316
+ while (Date.now() < deadline) {
317
+ const port = await findUsableBridge(ports);
318
+ if (port !== null)
319
+ return port;
320
+ await sleep(250);
321
+ }
322
+ return null;
323
+ }
324
+ // ---------------------------------------------------------------------------
325
+ // Shutdown
326
+ // ---------------------------------------------------------------------------
327
+ /** SIGTERM, wait, SIGKILL. Returns true if the process is gone afterwards. */
328
+ async function terminateProcess(pid) {
200
329
  try {
201
- const resp = await httpGet(port, "/health", 2000);
202
- const data = JSON.parse(resp);
203
- if (data.server === "opera-browser-cli") {
204
- return data.status === "ok" ? "ok" : "free";
205
- }
206
- return "conflict";
330
+ process.kill(pid, "SIGTERM");
331
+ }
332
+ catch {
333
+ return { gone: true, forced: false };
334
+ }
335
+ const deadline = Date.now() + STOP_GRACE_MS;
336
+ while (Date.now() < deadline) {
337
+ if (!isProcessAlive(pid))
338
+ return { gone: true, forced: false };
339
+ await sleep(100);
340
+ }
341
+ try {
342
+ process.kill(pid, "SIGKILL");
207
343
  }
208
344
  catch {
209
- return "free";
345
+ return { gone: true, forced: true };
210
346
  }
347
+ await sleep(200);
348
+ return { gone: !isProcessAlive(pid), forced: true };
211
349
  }
212
- function sleep(ms) {
213
- return new Promise((r) => setTimeout(r, ms));
350
+ /** Shut down a bridge we have positively identified on `port`. */
351
+ async function shutdownBridgeOnPort(port, health) {
352
+ const pid = resolveSignalablePid(port, health);
353
+ if (pid === null)
354
+ return;
355
+ await terminateProcess(pid);
356
+ if (readPidFile()?.pid === pid)
357
+ removePidFile();
214
358
  }
215
- /**
216
- * Ensure the bridge is running, starting it if needed. Returns the port.
217
- */
218
- export async function ensureBridge() {
219
- const port = parseInt(process.env.OPERA_CLI_PORT ?? String(DEFAULT_PORT), 10);
220
- // Check existing bridge via PID file (lenient: we trust our own PID file).
221
- const pidInfo = readPidFile();
222
- if (pidInfo && isProcessAlive(pidInfo.pid)) {
223
- if (await isBridgeHealthy(pidInfo.port)) {
224
- return pidInfo.port;
359
+ /** Shut down every bridge of ours across the candidate ports. */
360
+ async function shutdownOurBridges(ports) {
361
+ for (const { port, health } of await probeAll(ports)) {
362
+ if (isOurBridge(health))
363
+ await shutdownBridgeOnPort(port, health);
364
+ }
365
+ }
366
+ let holdingLock = false;
367
+ function readLock() {
368
+ try {
369
+ const data = JSON.parse(readFileSync(LOCK_FILE, "utf-8"));
370
+ if (typeof data.pid !== "number" || typeof data.startedAt !== "number") {
371
+ return null;
225
372
  }
373
+ return { pid: data.pid, startedAt: data.startedAt };
374
+ }
375
+ catch {
376
+ return null;
377
+ }
378
+ }
379
+ function acquireStartLock() {
380
+ try {
381
+ mkdirSync(STATE_DIR, { recursive: true });
382
+ const fd = openSync(LOCK_FILE, "wx");
226
383
  try {
227
- process.kill(pidInfo.pid, "SIGTERM");
384
+ writeSync(fd, JSON.stringify({ pid: process.pid, startedAt: Date.now() }));
228
385
  }
229
- catch {
230
- // Best effort — if shutdown fails, the startup poll below will time out.
386
+ finally {
387
+ closeSync(fd);
231
388
  }
389
+ holdingLock = true;
390
+ // Held only while we hold the lock, so an interrupted start still releases
391
+ // it and we never accumulate listeners across repeated acquisitions.
392
+ process.on("exit", releaseStartLock);
393
+ return true;
232
394
  }
233
- // Check for a foreign server already occupying the target port before spawning.
234
- const portStatus = await checkPortStatus(port);
235
- if (portStatus === "ok") {
236
- // A healthy bridge is already running (no PID file or stale PID).
237
- return port;
395
+ catch {
396
+ return false;
238
397
  }
239
- if (portStatus === "conflict") {
240
- throw new CdpError(`Port ${port} is in use by a different server (not opera-devtools-mcp). Stop it or choose a different port.`, "BRIDGE_NOT_READY", [
241
- `Stop the process on port ${port} and try again, or set OPERA_CLI_PORT to a different port number`,
242
- ]);
398
+ }
399
+ function releaseStartLock() {
400
+ if (!holdingLock)
401
+ return;
402
+ holdingLock = false;
403
+ process.off("exit", releaseStartLock);
404
+ try {
405
+ unlinkSync(LOCK_FILE);
406
+ }
407
+ catch {
408
+ // Someone else cleaned it up — fine
243
409
  }
244
- // Start a new bridge
245
- const bridgeScript = resolveBridgeScript(import.meta.dirname);
246
- // Try .ts first (dev mode), fall back to .js (built)
247
- const script = existsSync(bridgeScript.replace(/\.js$/, ".ts"))
248
- ? bridgeScript.replace(/\.js$/, ".ts")
249
- : bridgeScript;
250
- const runner = script.endsWith(".ts") ? "tsx" : "node";
251
- // Pipe bridge stdout/stderr to ~/.opera-browser-cli/bridge.log so failures
252
- // are inspectable. Falls back to "ignore" if the file can't be opened.
253
- let stdio = "ignore";
410
+ }
411
+ /** True when the lock is held by a dead process or has simply been there too long. */
412
+ function startLockIsStale() {
413
+ const lock = readLock();
414
+ if (lock === null)
415
+ return true; // unreadable or malformed
416
+ if (!isProcessAlive(lock.pid))
417
+ return true;
418
+ return Date.now() - lock.startedAt > LOCK_STALE_MS;
419
+ }
420
+ /**
421
+ * Remove an abandoned lock and take it.
422
+ *
423
+ * Two processes can both decide a lock is stale and both end up believing they
424
+ * hold it. That is tolerable: the loser's bridge fails with EADDRINUSE and
425
+ * retries the next port, which is exactly the path the port scan already
426
+ * handles. The lock removes the common case; the port scan is the real backstop.
427
+ */
428
+ function stealStartLock() {
429
+ try {
430
+ unlinkSync(LOCK_FILE);
431
+ }
432
+ catch {
433
+ // Already gone
434
+ }
435
+ return acquireStartLock();
436
+ }
437
+ // ---------------------------------------------------------------------------
438
+ // Logging
439
+ // ---------------------------------------------------------------------------
440
+ /** Rotate the bridge log now, whatever its size. Used by `doctor --fix`. */
441
+ export function rotateBridgeLog() {
442
+ try {
443
+ renameSync(LOG_FILE, `${LOG_FILE}.1`);
444
+ return true;
445
+ }
446
+ catch {
447
+ return false;
448
+ }
449
+ }
450
+ function rotateLogIfLarge() {
451
+ try {
452
+ if (statSync(LOG_FILE).size < MAX_LOG_BYTES)
453
+ return;
454
+ renameSync(LOG_FILE, `${LOG_FILE}.1`);
455
+ }
456
+ catch {
457
+ // No log yet, or rotation is not possible — never block a start over it.
458
+ }
459
+ }
460
+ /** The tail of the bridge log, for quoting back when a start fails. */
461
+ function readLogTail(lines = LOG_TAIL_LINES) {
462
+ try {
463
+ const all = readFileSync(LOG_FILE, "utf-8").split("\n").filter(Boolean);
464
+ return all.slice(-lines).join("\n");
465
+ }
466
+ catch {
467
+ return "";
468
+ }
469
+ }
470
+ function openLogFd() {
254
471
  try {
255
472
  mkdirSync(STATE_DIR, { recursive: true });
256
- const logFd = openSync(LOG_FILE, "a");
257
- stdio = ["ignore", logFd, logFd];
473
+ rotateLogIfLarge();
474
+ return openSync(LOG_FILE, "a");
258
475
  }
259
476
  catch {
260
- // Log directory unwritable — bridge still runs, just no logs.
477
+ // Log directory unwritable — the bridge still runs, just without logs.
478
+ return null;
261
479
  }
262
- const child = spawn(runner === "tsx" ? "npx" : "node", runner === "tsx" ? ["tsx", script] : [script], {
263
- stdio,
480
+ }
481
+ /**
482
+ * Start one bridge process on one port and wait for its handshake.
483
+ *
484
+ * The bridge reports READY or FAILED on stdout, so a dead child is detected in
485
+ * milliseconds instead of costing the full startup budget. Its stderr goes to
486
+ * the log file, whose tail is folded into the failure detail.
487
+ */
488
+ async function spawnBridge(port) {
489
+ const launcher = resolveBridgeLauncher(import.meta.dirname);
490
+ if (!launcher.ok)
491
+ return { ok: false, reason: launcher.reason };
492
+ const logFd = openLogFd();
493
+ const child = spawn(launcher.command, launcher.args, {
494
+ stdio: ["ignore", "pipe", logFd ?? "ignore"],
264
495
  env: { ...process.env, OPERA_CLI_PORT: String(port) },
265
496
  detached: true,
266
497
  });
267
- child.unref();
268
- // Poll for health (max 30s — Chrome launch can be slow)
269
- const deadline = Date.now() + 30_000;
270
- while (Date.now() < deadline) {
271
- if (await isBridgeHealthy(port)) {
498
+ return new Promise((resolve) => {
499
+ let settled = false;
500
+ let buffer = "";
501
+ const finish = (outcome) => {
502
+ if (settled)
503
+ return;
504
+ settled = true;
505
+ clearTimeout(timer);
506
+ child.removeAllListeners("exit");
507
+ child.removeAllListeners("error");
508
+ child.stdout?.removeAllListeners("data");
509
+ // Release the pipe so this process can exit; the bridge writes nothing
510
+ // to stdout after the handshake and guards against EPIPE regardless.
511
+ child.stdout?.destroy();
512
+ child.unref();
513
+ if (logFd !== null) {
514
+ try {
515
+ closeSync(logFd);
516
+ }
517
+ catch {
518
+ // Already closed
519
+ }
520
+ }
521
+ resolve(outcome);
522
+ };
523
+ const timer = setTimeout(() => finish({ ok: false, reason: "timeout", detail: readLogTail() }), START_TIMEOUT_MS);
524
+ child.stdout?.setEncoding("utf-8");
525
+ child.stdout?.on("data", (chunk) => {
526
+ buffer += chunk;
527
+ let newline;
528
+ while ((newline = buffer.indexOf("\n")) !== -1) {
529
+ const line = buffer.slice(0, newline).trim();
530
+ buffer = buffer.slice(newline + 1);
531
+ if (line === "READY") {
532
+ finish({ ok: true });
533
+ return;
534
+ }
535
+ if (line.startsWith("FAILED ")) {
536
+ const rest = line.slice("FAILED ".length);
537
+ const spaceAt = rest.indexOf(" ");
538
+ finish({
539
+ ok: false,
540
+ reason: spaceAt === -1 ? rest : rest.slice(0, spaceAt),
541
+ detail: spaceAt === -1 ? undefined : rest.slice(spaceAt + 1),
542
+ });
543
+ return;
544
+ }
545
+ }
546
+ });
547
+ child.on("error", (error) => finish({ ok: false, reason: "spawn-failed", detail: error.message }));
548
+ child.on("exit", (code) => finish({
549
+ ok: false,
550
+ reason: code === 75 ? "port-in-use" : "exited",
551
+ detail: `bridge exited with code ${code}\n${readLogTail()}`,
552
+ }));
553
+ });
554
+ }
555
+ function startFailureError(outcome, ports) {
556
+ const detail = outcome.detail ? `\n${outcome.detail}` : "";
557
+ switch (outcome.reason) {
558
+ case "mcp-connect":
559
+ return new CdpError(`Bridge could not connect to opera-devtools-mcp.${detail}`, "BRIDGE_NOT_READY", [
560
+ "Check that opera-devtools-mcp is installed: `npx opera-devtools-mcp@latest --help`",
561
+ "For local dev: set OPERA_CLI_MCP_BIN to the linked binary",
562
+ "Run `opera-browser-cli logs` for the full bridge output",
563
+ ]);
564
+ case "state-dir-unwritable":
565
+ return new CdpError(`Bridge cannot write to its state directory (${outcome.detail ?? STATE_DIR}).`, "BRIDGE_NOT_READY", [
566
+ `Check ownership: \`ls -ld ${outcome.detail ?? STATE_DIR}\``,
567
+ `If it is root-owned from an earlier sudo run: \`sudo chown -R "$(whoami)" ${outcome.detail ?? STATE_DIR}\``,
568
+ ]);
569
+ case "tsx-not-installed":
570
+ return new CdpError("Bridge cannot run from TypeScript source — tsx is not installed.", "BRIDGE_NOT_READY", [
571
+ "Run `npm install` in the opera-browser-cli checkout",
572
+ "Or build first: `npm run build`",
573
+ ]);
574
+ case "bridge-not-built":
575
+ return new CdpError("Bridge entrypoint not found — the package looks unbuilt.", "BRIDGE_NOT_READY", ["Run `npm run build` in the opera-browser-cli checkout"]);
576
+ case "port-in-use":
577
+ return new CdpError(`Ports ${ports[0]}-${ports[ports.length - 1]} are all in use by other servers.`, "BRIDGE_NOT_READY", [
578
+ "Free one of those ports, or set OPERA_CLI_PORT to a different base port",
579
+ ]);
580
+ case "timeout":
581
+ return new CdpError(`Bridge did not become ready within ${START_TIMEOUT_MS / 1000}s.${detail}`, "BRIDGE_NOT_READY", [
582
+ "Run `opera-browser-cli logs` to see what the bridge was doing",
583
+ "Run `opera-browser-cli doctor` to check the configuration",
584
+ ]);
585
+ default:
586
+ return new CdpError(`Bridge failed to start.${detail}`, "BRIDGE_NOT_READY", [
587
+ "Run `opera-browser-cli logs` for the full bridge output",
588
+ "Run `opera-browser-cli doctor` to check the configuration",
589
+ ]);
590
+ }
591
+ }
592
+ /**
593
+ * Take the start lock and bring a bridge up, walking the port range.
594
+ *
595
+ * If another process holds the lock we wait for its bridge instead of racing
596
+ * it; only an abandoned lock is stolen.
597
+ */
598
+ async function startBridge(ports, attempt = 0) {
599
+ if (!acquireStartLock()) {
600
+ const port = await waitForUsableBridge(ports, START_TIMEOUT_MS);
601
+ if (port !== null)
272
602
  return port;
603
+ if (attempt >= 1 || !startLockIsStale() || !stealStartLock()) {
604
+ throw new CdpError("Timed out waiting for another opera-browser-cli process to start the bridge", "BRIDGE_NOT_READY", [
605
+ "Run `opera-browser-cli logs` to see what the other process was doing",
606
+ "Run `opera-browser-cli restart` to force a clean start",
607
+ ]);
273
608
  }
274
- await sleep(500);
609
+ return startBridge(ports, attempt + 1);
610
+ }
611
+ try {
612
+ let lastOutcome = { ok: false, reason: "port-in-use" };
613
+ for (const port of ports) {
614
+ lastOutcome = await spawnBridge(port);
615
+ if (lastOutcome.ok)
616
+ return port;
617
+ // Only a port collision is worth trying the next port for; anything else
618
+ // will fail the same way everywhere, so surface it immediately.
619
+ if (lastOutcome.reason !== "port-in-use")
620
+ break;
621
+ }
622
+ throw startFailureError(lastOutcome, ports);
623
+ }
624
+ finally {
625
+ releaseStartLock();
275
626
  }
276
- throw new CdpError("Bridge failed to start within 30s", "BRIDGE_NOT_READY", [
277
- "For local dev: set OPERA_CLI_MCP_BIN to the linked binary, e.g. OPERA_CLI_MCP_BIN=opera-devtools-mcp",
278
- "For published version: check that opera-devtools-mcp is installed: npx opera-devtools-mcp@latest --help",
279
- ]);
280
627
  }
628
+ /**
629
+ * Ensure a bridge running our version is up. Returns the port it is on.
630
+ */
631
+ export async function ensureBridge(options = {}) {
632
+ const ports = candidatePorts();
633
+ if (options.forceRestart) {
634
+ await shutdownOurBridges(ports);
635
+ }
636
+ else {
637
+ const existing = await findUsableBridge(ports);
638
+ if (existing !== null)
639
+ return existing;
640
+ }
641
+ return startBridge(ports);
642
+ }
643
+ /**
644
+ * Stop the bridge.
645
+ *
646
+ * Looks past the PID file: if the file is missing or stale but a bridge of ours
647
+ * is answering on one of the candidate ports, that one is stopped too. Escalates
648
+ * to SIGKILL rather than reporting success against a process that ignored the
649
+ * signal, and always leaves the PID file cleaned up.
650
+ */
651
+ export async function stopBridge() {
652
+ const result = {
653
+ stopped: false,
654
+ stale: false,
655
+ forced: false,
656
+ pid: null,
657
+ port: null,
658
+ };
659
+ // Prefer a live, identified bridge — that is the one actually holding a port.
660
+ for (const { port, health } of await probeAll(candidatePorts())) {
661
+ if (!isOurBridge(health))
662
+ continue;
663
+ const pid = resolveSignalablePid(port, health);
664
+ if (pid === null)
665
+ continue;
666
+ const outcome = await terminateProcess(pid);
667
+ result.stopped ||= outcome.gone;
668
+ result.forced ||= outcome.forced;
669
+ result.pid ??= pid;
670
+ result.port ??= port;
671
+ }
672
+ const info = readPidFile();
673
+ if (info) {
674
+ if (!result.stopped) {
675
+ // Nothing answered. Only signal the recorded PID if the file is provably
676
+ // from this boot — otherwise the PID may belong to a stranger.
677
+ if (pidFileIsFromThisBoot(info) && isProcessAlive(info.pid)) {
678
+ const outcome = await terminateProcess(info.pid);
679
+ result.stopped = outcome.gone;
680
+ result.forced = outcome.forced;
681
+ result.pid = info.pid;
682
+ result.port = info.port;
683
+ }
684
+ else {
685
+ result.stale = true;
686
+ result.pid = info.pid;
687
+ result.port = info.port;
688
+ }
689
+ }
690
+ removePidFile();
691
+ }
692
+ return result;
693
+ }
694
+ /** Stop whatever is running and bring a fresh bridge up. Returns the port. */
695
+ export async function restartBridge() {
696
+ return ensureBridge({ forceRestart: true });
697
+ }
698
+ // ---------------------------------------------------------------------------
699
+ // Tool calls
700
+ // ---------------------------------------------------------------------------
281
701
  const OPERA_AI_TIMEOUT = 1_200_000; // 20 minutes
282
702
  const OPERA_AI_TOOLS = new Set([
283
703
  "opera_chat",
284
704
  "opera_do",
285
705
  "opera_research",
286
706
  "opera_make",
707
+ "opera_call_mcp_tool",
287
708
  ]);
288
709
  /**
289
- * Call an MCP tool via the bridge. Returns the text result.
710
+ * Tools that must never be replayed after a dropped connection.
711
+ *
712
+ * All four Opera AI tools are long-running, billable, and may have already
713
+ * acted on the page before the bridge went away. A silent second run could
714
+ * double a booking as easily as it could double a bill.
290
715
  */
291
- export async function callTool(name, args = {}) {
292
- const port = await ensureBridge();
716
+ const NON_REPLAYABLE_TOOLS = OPERA_AI_TOOLS;
717
+ function errorMessageOf(error) {
718
+ return error instanceof Error ? error.message : String(error);
719
+ }
720
+ /** A dropped or rejected bridge connection, as opposed to a tool-level failure. */
721
+ function isTransportFailure(message) {
722
+ return (/ECONNREFUSED|ECONNRESET|EPIPE|socket hang up|MCP transport disconnected/i.test(message) || isAuthFailure(message));
723
+ }
724
+ /**
725
+ * The bridge's own 401. Matched exactly: page content and tool output routinely
726
+ * contain the word "unauthorized" and must not trigger a restart.
727
+ */
728
+ function isAuthFailure(message) {
729
+ return message.trim().toLowerCase() === "unauthorized";
730
+ }
731
+ /**
732
+ * A page-state race rather than a real failure: the DOM moved under us while
733
+ * the call was in flight. Common during navigation, and almost always gone by
734
+ * the time we ask again.
735
+ */
736
+ function isTransientPageFailure(message) {
737
+ return /detached|execution context was destroyed|cannot find context|no node with given id|target closed/i.test(message);
738
+ }
739
+ async function callToolOnce(name, args, options) {
740
+ const port = await ensureBridge(options);
293
741
  const isStreaming = OPERA_AI_TOOLS.has(name);
294
742
  const timeoutMs = isStreaming ? OPERA_AI_TIMEOUT : undefined;
295
743
  const onLog = isStreaming
296
744
  ? (msg) => process.stderr.write(msg + "\n")
297
745
  : undefined;
746
+ const resp = await httpPost(port, "/call", { name, args }, timeoutMs, onLog, readBridgeToken());
747
+ const data = JSON.parse(resp);
748
+ if (data.error)
749
+ throw new Error(data.error);
750
+ return data.result ?? "";
751
+ }
752
+ /**
753
+ * Call an MCP tool via the bridge. Returns the text result.
754
+ *
755
+ * A connection lost mid-call is recovered once: the bridge is restarted and the
756
+ * call replayed. The Opera AI tools are exempt from *that* recovery — they are
757
+ * reported instead, so the user decides whether to pay for a second run.
758
+ *
759
+ * A second, distinct failure is also repaired: the bridge answers, but the
760
+ * browser it was told to drive is unreachable (a dead attach URL, or a managed
761
+ * launch that never produced a browser). devtools-mcp reports that as a tool
762
+ * *result*, not an error, so it would otherwise look like success. We rebuild
763
+ * the bridge against the current target and retry once — safe for every tool,
764
+ * because nothing could have acted on a browser that was never reached.
765
+ */
766
+ export async function callTool(name, args = {}) {
767
+ let result;
298
768
  try {
299
- const token = readBridgeToken();
300
- const resp = await httpPost(port, "/call", { name, args }, timeoutMs, onLog, token);
301
- const data = JSON.parse(resp);
302
- if (data.error) {
303
- throw new Error(data.error);
769
+ result = await callToolOnce(name, args, {});
770
+ }
771
+ catch (error) {
772
+ return recoverFailedCall(name, args, error);
773
+ }
774
+ // The bridge answered, but the browser it was told to drive is unreachable
775
+ // (a dead attach URL, or a managed launch that never produced a browser).
776
+ // Rebuild the bridge against the current target and retry once — safe for
777
+ // every tool, because nothing could have acted on a browser that was never
778
+ // reached. A persistent failure is a real error, not a fake success.
779
+ if (isBrowserUnreachableResult(result)) {
780
+ try {
781
+ const recovered = await callToolOnce(name, args, { forceRestart: true });
782
+ if (!isBrowserUnreachableResult(recovered))
783
+ return recovered;
784
+ }
785
+ catch (retryError) {
786
+ return recoverFailedCall(name, args, retryError);
787
+ }
788
+ throw browserUnreachableError();
789
+ }
790
+ return result;
791
+ }
792
+ /**
793
+ * Handle an exception thrown by the bridge: recover what is worth recovering
794
+ * (transient page races, dropped transport), and map the rest to an error code.
795
+ */
796
+ async function recoverFailedCall(name, args, error) {
797
+ const message = errorMessageOf(error);
798
+ // A page-state race is worth one immediate retry against the same bridge —
799
+ // no restart, no user-visible failure.
800
+ if (isTransientPageFailure(message) && !NON_REPLAYABLE_TOOLS.has(name)) {
801
+ await sleep(250);
802
+ try {
803
+ return await callToolOnce(name, args, {});
804
+ }
805
+ catch (retryError) {
806
+ throw mapErrorMessage(errorMessageOf(retryError));
304
807
  }
305
- return data.result ?? "";
306
808
  }
307
- catch (err) {
308
- const message = err instanceof Error ? err.message : String(err);
809
+ if (!isTransportFailure(message))
309
810
  throw mapErrorMessage(message);
811
+ if (NON_REPLAYABLE_TOOLS.has(name)) {
812
+ throw new CdpError(`The bridge connection dropped while running ${name}, and the command was not retried automatically because it may already have taken effect.`, "BRIDGE_NOT_READY", [
813
+ "Re-run the command — the bridge restarts automatically",
814
+ "Run `opera-browser-cli logs` to see why the bridge dropped",
815
+ ]);
816
+ }
817
+ try {
818
+ return await callToolOnce(name, args, { forceRestart: true });
819
+ }
820
+ catch (retryError) {
821
+ throw mapErrorMessage(errorMessageOf(retryError));
310
822
  }
311
823
  }
824
+ /** devtools-mcp's "I have no browser to talk to" result text. */
825
+ function isBrowserUnreachableResult(result) {
826
+ return /could not connect to chrome|failed to fetch browser websocket url/i.test(result);
827
+ }
828
+ function browserUnreachableError() {
829
+ return new CdpError("The browser is not reachable. It may be running without a debugging port, or the bridge is pointing at a browser that has closed.", "BROWSER_ERROR", [
830
+ "Run `opera-browser-cli doctor` to check the profile and bridge state",
831
+ "Restart the running browser with a debug port: `opera-browser-cli open <url> --takeover`",
832
+ "Or use a separate profile (no flag) if the browser cannot be restarted",
833
+ ]);
834
+ }
312
835
  export function mapErrorMessage(message) {
836
+ if (isAuthFailure(message)) {
837
+ return new CdpError("Bridge rejected the auth token", "BRIDGE_NOT_READY", [
838
+ "Run `opera-browser-cli restart` to issue a fresh token",
839
+ "Run `opera-browser-cli doctor` to inspect the bridge state",
840
+ ]);
841
+ }
313
842
  if (message.includes("ECONNREFUSED") || message.includes("ECONNRESET")) {
314
843
  return new CdpError("Bridge is not running", "BRIDGE_NOT_READY", [
315
844
  "Run `opera-browser-cli open <url>` — the bridge starts automatically",
845
+ "Run `opera-browser-cli restart` if it keeps failing",
316
846
  ]);
317
847
  }
318
848
  if ((message.includes("uid") || message.includes("element")) &&
@@ -335,9 +865,26 @@ export function mapErrorMessage(message) {
335
865
  if (message.includes("User is not signed in") ||
336
866
  (message.includes("Opera.dispatchAction") &&
337
867
  message.includes("not signed in"))) {
338
- return new CdpError("Opera: user is not signed in", "BROWSER_ERROR", [
339
- "Sign in to your Opera account to use this feature",
340
- "Run `opera-browser-cli setup` to configure the executable path",
868
+ return new CdpError("Opera: user is not signed in", "AUTH_REQUIRED", [
869
+ "Run `opera-browser-cli login` to sign in to your Opera account",
870
+ "Run `opera-browser-cli doctor` to inspect the current configuration",
871
+ ]);
872
+ }
873
+ if (message.includes("MCP Hub extension not available")) {
874
+ return new CdpError("MCP Hub extension not loaded. Load it in opera://extensions.", "EXTENSION_NOT_FOUND", ["Visit opera://extensions", "Load the MCP Hub extension (unpacked)"]);
875
+ }
876
+ // MCP-specific errors: guarded by MCP context to avoid false matches on generic server/not found.
877
+ // The extension surfaces these as raw strings from thrown hub errors.
878
+ if ((message.includes("MCP") || message.includes("opera_list_mcp")) &&
879
+ message.includes("not found")) {
880
+ return new CdpError(message, "NOT_FOUND", [
881
+ "Run 'opera-browser-cli mcp-servers' to see available servers.",
882
+ ]);
883
+ }
884
+ if ((message.includes("MCP") || message.includes("opera_list_mcp")) &&
885
+ message.includes("not connected")) {
886
+ return new CdpError(message, "SERVER_DISCONNECTED", [
887
+ "Connect the server in the MCP Hub sidepanel.",
341
888
  ]);
342
889
  }
343
890
  // Try to parse JSON error
@@ -355,55 +902,92 @@ export function mapErrorMessage(message) {
355
902
  return new CdpError(message, "UNKNOWN");
356
903
  }
357
904
  /**
358
- * Inspect the bridge without starting it. Used by `opera-browser-cli doctor`.
905
+ * Inspect the bridge without starting it. Used by `doctor` and `status`.
359
906
  */
360
907
  export async function getBridgeStatus() {
361
- const pidInfo = readPidFile();
362
- if (!pidInfo) {
908
+ const expectedVersion = getPackageVersion();
909
+ const base = {
910
+ pidFileExists: false,
911
+ processAlive: false,
912
+ healthy: false,
913
+ port: null,
914
+ pid: null,
915
+ runningVersion: null,
916
+ expectedVersion,
917
+ versionSkew: false,
918
+ stalePidFile: false,
919
+ };
920
+ // A live bridge is the best source of truth, wherever its port came from.
921
+ for (const { port, health } of await probeAll(candidatePorts())) {
922
+ if (!isOurBridge(health))
923
+ continue;
363
924
  return {
364
- pidFileExists: false,
365
- processAlive: false,
366
- healthy: false,
367
- port: null,
368
- pid: null,
925
+ ...base,
926
+ pidFileExists: existsSync(PID_FILE),
927
+ processAlive: true,
928
+ healthy: isUsableBridge(health, expectedVersion),
929
+ port,
930
+ pid: health.pid > 0 ? health.pid : (readPidFile()?.pid ?? null),
931
+ runningVersion: health.version,
932
+ versionSkew: health.version !== expectedVersion,
369
933
  };
370
934
  }
371
- const alive = isProcessAlive(pidInfo.pid);
372
- const healthy = alive ? await isBridgeHealthy(pidInfo.port) : false;
935
+ const info = readPidFile();
936
+ if (!info)
937
+ return base;
938
+ const fromThisBoot = pidFileIsFromThisBoot(info);
939
+ const alive = fromThisBoot && isProcessAlive(info.pid);
373
940
  return {
941
+ ...base,
374
942
  pidFileExists: true,
375
943
  processAlive: alive,
376
- healthy,
377
- port: pidInfo.port,
378
- pid: pidInfo.pid,
944
+ port: info.port,
945
+ pid: info.pid,
946
+ // Nothing answered on any port, so a PID file that survives is stale
947
+ // whether its process is gone or merely wedged.
948
+ stalePidFile: !alive || !fromThisBoot,
379
949
  };
380
950
  }
951
+ /** The bridge to read from, without starting one. */
952
+ async function activeBridge() {
953
+ const info = readPidFile();
954
+ if (info) {
955
+ const health = await probeHealth(info.port);
956
+ if (isUsableBridge(health, getPackageVersion())) {
957
+ return { port: info.port, token: info.token ?? null };
958
+ }
959
+ }
960
+ const port = await findUsableBridge(candidatePorts());
961
+ if (port === null)
962
+ return null;
963
+ return { port, token: readBridgeToken() };
964
+ }
381
965
  /** Retrieve the most recent snapshot the bridge has cached, without triggering a new one. */
382
966
  export async function getLastSnapshot() {
383
- const pidInfo = readPidFile();
384
- if (!pidInfo || !isProcessAlive(pidInfo.pid))
967
+ const bridge = await activeBridge();
968
+ if (bridge === null)
385
969
  return null;
386
970
  try {
387
- const resp = await httpGet(pidInfo.port, "/last-snapshot", 2000, pidInfo.token);
971
+ const resp = await httpGet(bridge.port, "/last-snapshot", 2000, bridge.token);
388
972
  const data = JSON.parse(resp);
389
973
  if (data.error || !data.raw)
390
974
  return null;
391
- return { raw: data.raw, pageUrl: data.pageUrl ?? null, capturedAt: data.capturedAt ?? 0 };
975
+ return {
976
+ raw: data.raw,
977
+ pageUrl: data.pageUrl ?? null,
978
+ capturedAt: data.capturedAt ?? 0,
979
+ };
392
980
  }
393
981
  catch {
394
982
  return null;
395
983
  }
396
984
  }
397
985
  export async function getSessionSnapshotIfRunning() {
398
- const pidInfo = readPidFile();
399
- if (!pidInfo || !isProcessAlive(pidInfo.pid)) {
400
- return null;
401
- }
402
- if (!(await isBridgeHealthy(pidInfo.port))) {
986
+ const bridge = await activeBridge();
987
+ if (bridge === null)
403
988
  return null;
404
- }
405
989
  try {
406
- const resp = await httpPost(pidInfo.port, "/call", { name: "take_snapshot", args: {} }, 5000, undefined, pidInfo.token);
990
+ const resp = await httpPost(bridge.port, "/call", { name: "take_snapshot", args: {} }, 5000, undefined, bridge.token);
407
991
  const data = JSON.parse(resp);
408
992
  if (data.error)
409
993
  return null;
@@ -413,18 +997,4 @@ export async function getSessionSnapshotIfRunning() {
413
997
  return null;
414
998
  }
415
999
  }
416
- /**
417
- * Stop the bridge process.
418
- */
419
- export function stopBridge() {
420
- const pidInfo = readPidFile();
421
- if (!pidInfo) {
422
- return false;
423
- }
424
- if (isProcessAlive(pidInfo.pid)) {
425
- process.kill(pidInfo.pid, "SIGTERM");
426
- return true;
427
- }
428
- return false;
429
- }
430
1000
  //# sourceMappingURL=client.js.map