opera-devtools-mcp 0.7.0 → 0.8.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 (55) hide show
  1. package/README.md +1 -1
  2. package/build/src/ToolHandler.js +9 -2
  3. package/build/src/bin/chrome-devtools.js +30 -97
  4. package/build/src/bin/opera-browser-cli.js +102 -0
  5. package/build/src/bin/opera-devtools-mcp.js +20 -1
  6. package/build/src/browser.js +18 -9
  7. package/build/src/daemon/client.js +46 -40
  8. package/build/src/daemon/daemon.js +62 -39
  9. package/build/src/opera/branding.js +4 -2
  10. package/build/src/opera/browserActivity.js +62 -0
  11. package/build/src/opera/browserCleanup.js +123 -0
  12. package/build/src/opera/browserErrors.js +66 -0
  13. package/build/src/opera/browserFlags.js +184 -38
  14. package/build/src/opera/browserTarget.js +513 -0
  15. package/build/src/opera/cdpErrors.js +391 -0
  16. package/build/src/opera/cliCommands.js +378 -0
  17. package/build/src/opera/cliOutput.js +284 -0
  18. package/build/src/opera/compactSnapshot.js +525 -0
  19. package/build/src/opera/config.js +166 -0
  20. package/build/src/opera/daemonLifecycle.js +257 -0
  21. package/build/src/opera/daemonLog.js +103 -0
  22. package/build/src/opera/daemonPidFile.js +83 -0
  23. package/build/src/opera/daemonShutdown.js +66 -0
  24. package/build/src/opera/daemonSocket.js +87 -0
  25. package/build/src/opera/daemonStreaming.js +130 -0
  26. package/build/src/opera/daemonToolCall.js +26 -0
  27. package/build/src/opera/detect.js +114 -0
  28. package/build/src/opera/doctor.js +317 -0
  29. package/build/src/opera/envConfig.js +229 -0
  30. package/build/src/opera/launcherNotice.js +116 -0
  31. package/build/src/opera/legacyBridgeCleanup.js +297 -0
  32. package/build/src/opera/logs.js +133 -0
  33. package/build/src/opera/mcpServerSupervisor.js +128 -0
  34. package/build/src/opera/migrationShared.js +164 -0
  35. package/build/src/opera/operaPages.js +56 -0
  36. package/build/src/opera/pageIdRouting.js +35 -0
  37. package/build/src/opera/pageRecovery.js +53 -0
  38. package/build/src/opera/profile.js +270 -0
  39. package/build/src/opera/refArgs.js +36 -0
  40. package/build/src/opera/serviceWorkerRetry.js +46 -4
  41. package/build/src/opera/setup.js +290 -0
  42. package/build/src/opera/skills/SKILL.md +160 -0
  43. package/build/src/opera/streamingTools.js +73 -0
  44. package/build/src/opera/suggestions.js +67 -0
  45. package/build/src/opera/toolHandlerHooks.js +25 -1
  46. package/build/src/opera/tools/opera.js +107 -38
  47. package/build/src/opera/urlResolver.js +69 -0
  48. package/build/src/opera/webStorageWarning.js +92 -0
  49. package/build/src/third_party/devtools-formatter-worker.js +1 -0
  50. package/build/src/third_party/devtools-heap-snapshot-worker.js +1 -0
  51. package/build/src/third_party/index.js +2 -1
  52. package/build/src/utils/url.js +6 -0
  53. package/build/src/version.js +1 -1
  54. package/package.json +12 -10
  55. package/build/src/bin/opera-devtools.js +0 -10
@@ -4,11 +4,17 @@
4
4
  * Copyright 2026 Google LLC
5
5
  * SPDX-License-Identifier: Apache-2.0
6
6
  */
7
- import fs, { constants, openSync, writeSync, closeSync } from 'node:fs';
7
+ import fs, { constants, writeSync, closeSync } from 'node:fs';
8
8
  import { createServer } from 'node:net';
9
9
  import os from 'node:os';
10
10
  import path from 'node:path';
11
11
  import process from 'node:process';
12
+ import { claimPidFile } from '../opera/daemonPidFile.js';
13
+ import { installShutdownHandlers, recordShutdownReason, } from '../opera/daemonShutdown.js';
14
+ import { dispatchSocketMessage, reportStartupFailure, } from '../opera/daemonSocket.js';
15
+ import { attachLogForwarding } from '../opera/daemonStreaming.js';
16
+ import { callDaemonTool } from '../opera/daemonToolCall.js';
17
+ import { superviseMcpServer } from '../opera/mcpServerSupervisor.js';
12
18
  import { Client, PipeTransport, StdioClientTransport, } from '../third_party/index.js';
13
19
  import { logger, puppeteerLogger } from '../utils/logger.js';
14
20
  import { VERSION } from '../version.js';
@@ -54,16 +60,11 @@ catch (err) {
54
60
  }
55
61
  let fd = -1;
56
62
  try {
57
- // Open the file with flags to:
58
- // - O_WRONLY: Write-only
59
- // - O_CREAT: Create if it doesn't exist
60
- // - O_TRUNC: Truncate to zero length if it exists
61
- // - O_NOFOLLOW: DO NOT follow symlinks.
62
- // - 0o600: Permissions: read/write for owner, no permissions for others.
63
- fd = openSync(pidFilePath, constants.O_WRONLY |
64
- constants.O_CREAT |
65
- constants.O_TRUNC |
66
- constants.O_NOFOLLOW, 0o600);
63
+ // The claim is `O_WRONLY | O_CREAT | O_EXCL | O_NOFOLLOW` — write only,
64
+ // create if absent, fail if another daemon owns it, never follow a symlink —
65
+ // at 0o600. See `opera/daemonPidFile.ts` for why it is not upstream's
66
+ // `O_TRUNC`.
67
+ fd = claimPidFile(sessionId);
67
68
  writeSync(fd, process.pid.toString());
68
69
  }
69
70
  catch (err) {
@@ -88,33 +89,54 @@ const mcpServerArgs = process.argv.slice(2);
88
89
  let mcpClient = null;
89
90
  let mcpTransport = null;
90
91
  let server = null;
92
+ /** Set once `listen` succeeds, so a later failure knows it owns the socket. */
93
+ let bound = false;
94
+ /** Brings the MCP server back when its stdio child dies; see the module. */
95
+ const mcpSupervisor = superviseMcpServer({
96
+ sessionId,
97
+ connect: setupMCPClient,
98
+ handles: () => ({ client: mcpClient, transport: mcpTransport }),
99
+ giveUp: () => cleanup(1),
100
+ });
91
101
  async function setupMCPClient() {
92
102
  console.log('Setting up MCP client connection...');
93
103
  // Create stdio transport for chrome-devtools-mcp
94
- mcpTransport = new StdioClientTransport({
104
+ const transport = new StdioClientTransport({
95
105
  command: process.execPath,
96
106
  args: [INDEX_SCRIPT_PATH, ...mcpServerArgs],
97
107
  env: process.env,
98
108
  });
109
+ mcpTransport = transport;
99
110
  mcpClient = new Client({
100
111
  name: DAEMON_CLIENT_NAME,
101
112
  version: VERSION,
102
113
  }, {
103
114
  capabilities: {},
104
115
  });
105
- await mcpClient.connect(mcpTransport);
116
+ // Set onclose BEFORE connect: the SDK's Protocol.connect() captures the
117
+ // transport's existing onclose and wraps it. Setting it after connect
118
+ // overwrites that wrapper, so _onclose() never runs and pending callTool
119
+ // requests hang forever when the MCP server dies. The callback carries the
120
+ // transport it belongs to, so a respawn can tell a server it replaced from the
121
+ // one it is watching.
122
+ transport.onclose = () => mcpSupervisor.onTransportClosed(transport);
123
+ await mcpClient.connect(transport);
124
+ // Opera AI tools stream their output as `notifications/message` chunks; the
125
+ // socket protocol carries them to the CLI. See `opera/daemonStreaming.ts`.
126
+ attachLogForwarding(mcpClient);
106
127
  console.log('MCP client connected');
107
128
  }
108
- async function handleRequest(msg) {
129
+ async function handleRequest(msg, streamToken) {
109
130
  try {
110
131
  if (msg.method === 'invoke_tool') {
111
132
  if (!mcpClient) {
112
133
  throw new Error('MCP client not initialized');
113
134
  }
114
135
  const { tool, args } = msg;
115
- const result = (await mcpClient.callTool({
116
- name: tool,
117
- arguments: args || {},
136
+ const result = (await callDaemonTool(mcpClient, {
137
+ tool,
138
+ args,
139
+ streamToken,
118
140
  }));
119
141
  return {
120
142
  success: true,
@@ -126,7 +148,7 @@ async function handleRequest(msg) {
126
148
  await started;
127
149
  // Trigger cleanup asynchronously.
128
150
  setImmediate(() => {
129
- void cleanup();
151
+ void cleanup(0, 'stopped by request');
130
152
  });
131
153
  return {
132
154
  success: true,
@@ -177,7 +199,7 @@ async function startSocketServer() {
177
199
  const transport = new PipeTransport(socket, socket, puppeteerLogger);
178
200
  transport.onmessage = async (message) => {
179
201
  logger?.('onmessage', message);
180
- const response = await handleRequest(JSON.parse(message));
202
+ const response = await dispatchSocketMessage(message, transport, handleRequest);
181
203
  transport.send(JSON.stringify(response));
182
204
  socket.end();
183
205
  };
@@ -190,6 +212,7 @@ async function startSocketServer() {
190
212
  readableAll: false,
191
213
  writableAll: false,
192
214
  }, async () => {
215
+ bound = true;
193
216
  console.log(`Daemon server listening on ${socketPath}`);
194
217
  try {
195
218
  // Setup MCP client
@@ -206,7 +229,14 @@ async function startSocketServer() {
206
229
  });
207
230
  });
208
231
  }
209
- async function cleanup(exitCode = 0) {
232
+ async function cleanup(exitCode = 0, reason) {
233
+ // The reason is the diagnostic, so it goes first: `recordShutdownReason` is
234
+ // best-effort, and nothing else in this teardown should be able to cost us it.
235
+ recordShutdownReason(sessionId, reason);
236
+ // Then stop supervising. Closing the client below closes the transport, which
237
+ // would otherwise land in the respawn path and spawn a replacement server
238
+ // while this teardown is running.
239
+ mcpSupervisor.stop();
210
240
  console.log('Cleaning up daemon...');
211
241
  try {
212
242
  await mcpClient?.close();
@@ -239,28 +269,21 @@ async function cleanup(exitCode = 0) {
239
269
  }
240
270
  process.exit(exitCode);
241
271
  }
242
- // Handle shutdown signals
243
- process.on('SIGTERM', () => {
244
- void cleanup();
245
- });
246
- process.on('SIGINT', () => {
247
- void cleanup();
248
- });
249
- process.on('SIGHUP', () => {
250
- void cleanup();
251
- });
252
- // Handle uncaught errors
253
- process.on('uncaughtException', error => {
254
- logger?.('Uncaught exception:', error);
255
- void cleanup(1);
256
- });
257
- process.on('unhandledRejection', error => {
258
- logger?.('Unhandled rejection:', error);
259
- void cleanup(1);
272
+ // The shutdown wiring — three signals and the two uncaught-error handlers, each
273
+ // naming itself in the reason the CLI reports — lives in
274
+ // `opera/daemonShutdown.ts`. Every path that reaches it records why it left, so
275
+ // "left no reason behind" means only what it says: a SIGKILL or the OOM killer.
276
+ installShutdownHandlers({
277
+ onSignal: reason => cleanup(0, reason),
278
+ onException: reason => cleanup(1, reason),
260
279
  });
261
280
  // Start the server
262
281
  const started = startSocketServer().catch(error => {
263
282
  logger?.('Failed to start daemon server:', error);
264
- void cleanup(1);
283
+ void reportStartupFailure(error, {
284
+ socketBound: bound,
285
+ sessionId,
286
+ teardown: reason => cleanup(1, reason),
287
+ });
265
288
  });
266
289
  //# sourceMappingURL=daemon.js.map
@@ -15,8 +15,10 @@
15
15
  export const PACKAGE_NAME = 'opera-devtools-mcp';
16
16
  /** MCP server binary / daemon app name, also used as `process.title`. */
17
17
  export const MCP_BIN_NAME = PACKAGE_NAME;
18
- /** CLI binary name, e.g. `opera-devtools start`. */
19
- export const CLI_BIN_NAME = 'opera-devtools';
18
+ /** CLI binary name, e.g. `opera-browser-cli start`. The fork's sole CLI. */
19
+ export const CLI_BIN_NAME = 'opera-browser-cli';
20
+ /** Directory under `$HOME` holding the per-user config file (matches the CLI binary name). */
21
+ export const STATE_DIR_NAME = '.opera-browser-cli';
20
22
  /** Human-readable product name used in log lines. */
21
23
  export const PRODUCT_NAME = 'Opera DevTools MCP Server';
22
24
  /** Public repository, referenced from help text and disclaimers. */
@@ -0,0 +1,62 @@
1
+ /**
2
+ * @license
3
+ * Copyright 2026 Opera Norway AS. All rights reserved.
4
+ *
5
+ * This file is an original work developed by Opera.
6
+ */
7
+ /**
8
+ * Who is using the shared browser at this moment.
9
+ *
10
+ * One daemon serves every terminal through one MCP server and one browser
11
+ * (`../browser.ts` holds a module-level singleton), so invocations that know
12
+ * nothing about each other overlap freely: an Opera AI action streams for
13
+ * minutes while another terminal's `take_snapshot` arrives, and a second
14
+ * `opera_do` joins the first. That is fine for reading the page and fatal for
15
+ * *replacing the browser*, which closes every page in it — including the pages
16
+ * of whatever is still running.
17
+ *
18
+ * `opera/browserFlags.ts` is the one thing that ever replaces the browser, and
19
+ * it asks here first. A count per tool name rather than a boolean because the
20
+ * refusal names the tools holding the browser, and because two invocations of
21
+ * the same tool are two claims rather than one.
22
+ *
23
+ * `ToolHandler` pairs `noteToolStarted` with `noteToolFinished` around every
24
+ * invocation, the second from the same `finally` that releases the tool mutex —
25
+ * so an invocation that fails is not left counted, and neither is one that
26
+ * `beforeInvoke` itself refused (`opera/toolHandlerHooks.ts`).
27
+ */
28
+ const inFlight = new Map();
29
+ /** Claim the browser for the invocation of `toolName` that is starting. */
30
+ export function noteToolStarted(toolName) {
31
+ inFlight.set(toolName, (inFlight.get(toolName) ?? 0) + 1);
32
+ }
33
+ /** Release one claim on the browser; unknown names are ignored. */
34
+ export function noteToolFinished(toolName) {
35
+ const count = inFlight.get(toolName);
36
+ if (count === undefined) {
37
+ return;
38
+ }
39
+ if (count > 1) {
40
+ inFlight.set(toolName, count - 1);
41
+ return;
42
+ }
43
+ inFlight.delete(toolName);
44
+ }
45
+ /**
46
+ * The tools using the browser besides the invocation of `self` asking, split by
47
+ * name so a refusal can name them, sorted so its wording is stable.
48
+ */
49
+ export function otherBrowserUsers(self) {
50
+ const names = new Set();
51
+ for (const [name, count] of inFlight) {
52
+ if (name === self ? count > 1 : count > 0) {
53
+ names.add(name);
54
+ }
55
+ }
56
+ return [...names].sort();
57
+ }
58
+ /** Test seam: forget every in-flight invocation. */
59
+ export function resetBrowserActivity() {
60
+ inFlight.clear();
61
+ }
62
+ //# sourceMappingURL=browserActivity.js.map
@@ -0,0 +1,123 @@
1
+ /**
2
+ * @license
3
+ * Copyright 2026 Opera Norway AS. All rights reserved.
4
+ *
5
+ * This file is an original work developed by Opera.
6
+ */
7
+ /**
8
+ * Browser process-tree teardown, owned by Opera.
9
+ *
10
+ * Chrome calls `setsid()` while it initializes, so the browser is a process
11
+ * group leader and its helpers (GPU, renderer, utility) join *its* group rather
12
+ * than sitting below it in the process tree. That is why a helper survives
13
+ * SIGKILL to the main process: it is a sibling, not a child, so killing the
14
+ * parent orphans it instead of ending it.
15
+ *
16
+ * Killing the group is therefore the only teardown that reaches the helpers. A
17
+ * descendant walk from the browser pid cannot: by the time `disconnected`
18
+ * fires the pid is gone, the helpers are re-parented to init, and the walk
19
+ * returns nothing.
20
+ *
21
+ * On Linux the group id persists as long as any member is alive, even after the
22
+ * leader exits, so `kill(-pgid)` still names the whole group at that point.
23
+ *
24
+ * `watchBrowserForOrphans` is the seam `src/browser.ts` calls: it owns the pid
25
+ * capture and the `disconnected` listener, so the upstream file gets one line
26
+ * after `launch()` rather than a handler body.
27
+ */
28
+ import process from 'node:process';
29
+ /**
30
+ * SIGKILL every process in the browser's process group.
31
+ *
32
+ * Synchronous by design: `process.kill` is a syscall, and this runs from the
33
+ * `disconnected` handler, where the SIGKILL has to reach the group before the
34
+ * event loop yields to whatever teardown is already in flight.
35
+ *
36
+ * Takes the group id rather than the `Browser`: `browser.process()` is not
37
+ * reliable once `disconnected` has fired — the main process is gone, and a pid
38
+ * read at that point could already name an unrelated process that the kernel
39
+ * reused. The caller captures the pid at launch and holds it; the group outlives
40
+ * its leader, so that value still addresses every helper.
41
+ *
42
+ * No-op on Windows, which has no POSIX process groups: there the browser's
43
+ * helpers are its children and Puppeteer's own close path reaps them.
44
+ */
45
+ export function killBrowserProcessGroup(pid) {
46
+ if (process.platform === 'win32' || !pid) {
47
+ return;
48
+ }
49
+ try {
50
+ process.kill(-pid, 'SIGKILL');
51
+ }
52
+ catch {
53
+ // ESRCH — the group is already gone, which is what a graceful `close()`
54
+ // leaves behind, so the signal is a no-op either way. EPERM — the browser
55
+ // is not a group leader, which `setsid()` makes impossible for a browser
56
+ // Puppeteer launched.
57
+ }
58
+ }
59
+ /**
60
+ * Whether the teardown above must stay its hand — set while *we* close the
61
+ * browser.
62
+ *
63
+ * `disconnected` is not an abnormal-exit signal: Puppeteer's own `close()` ends
64
+ * with `disconnect()`, so a deliberate shutdown fires the same event a crash
65
+ * does. Killing the process group at that moment SIGKILLs a browser that is
66
+ * still flushing its profile — IndexedDB and LevelDB writes are done by its
67
+ * child storage utility, not by the browser process — and a store that loses
68
+ * those writes keeps records pointing at files that never landed. Blink reports
69
+ * the read of such a record as `NotReadableError: Data lost due to missing
70
+ * file. Affected record should be considered irrecoverable` (`indexeddb/`
71
+ * `idb_request_queue_item.cc`), and Opera AI's chat path then fails on every
72
+ * later run in that profile.
73
+ *
74
+ * So the group kill is armed for a browser that goes away without us closing
75
+ * it, and disarmed for the close we asked for.
76
+ *
77
+ * The flag lives in the watching handler's closure, not in module state: a
78
+ * handler for a browser we already closed can fire *after* the next launch has
79
+ * installed its own, and module state would then be read through the new cycle
80
+ * — an arm resets it to false, so the late handler for the deliberately closed
81
+ * browser sees "not ours" and SIGKILLs a group whose helpers may still be
82
+ * flushing that profile. One flag per armed handler means each browser's
83
+ * teardown answers only for its own close.
84
+ */
85
+ let disarmActiveWatcher;
86
+ /**
87
+ * Mark the teardown that follows as deliberate, so the `disconnected` it emits
88
+ * is not answered with a group kill. Called by both browser-closing paths in
89
+ * `src/browser.ts`, which are the only places that close a browser we launched.
90
+ */
91
+ export function disarmBrowserOrphanCleanup() {
92
+ disarmActiveWatcher?.();
93
+ }
94
+ /**
95
+ * Arm the browser's teardown: when it disconnects without closing, kill the
96
+ * process group it left behind.
97
+ *
98
+ * The pid is read here, while the browser is alive, because by the time
99
+ * `disconnected` fires the main process is gone and a pid read then could
100
+ * already name a process the kernel reused. `.once` because Puppeteer emits
101
+ * `disconnected` on an abnormal exit and again on `close()`, and one group kill
102
+ * is the whole job.
103
+ *
104
+ * Called from `ensureBrowserLaunched` right after `launch()`, which is the one
105
+ * place in `src/browser.ts` that owns a launched browser. Each launch installs
106
+ * a fresh watcher with its own disarm flag, so a browser launched after a
107
+ * deliberate close is protected again — and the previous launch's watcher,
108
+ * which may fire afterwards, is still answered by its own flag.
109
+ */
110
+ export function watchBrowserForOrphans(browser) {
111
+ const pgid = browser.process()?.pid;
112
+ let disarmed = false;
113
+ disarmActiveWatcher = () => {
114
+ disarmed = true;
115
+ };
116
+ browser.once('disconnected', () => {
117
+ if (disarmed) {
118
+ return;
119
+ }
120
+ killBrowserProcessGroup(pgid);
121
+ });
122
+ }
123
+ //# sourceMappingURL=browserCleanup.js.map
@@ -0,0 +1,66 @@
1
+ /**
2
+ * @license
3
+ * Copyright 2026 Opera Norway AS. All rights reserved.
4
+ *
5
+ * This file is an original work developed by Opera.
6
+ */
7
+ function attachTarget(target) {
8
+ return target.browserURL ?? target.wsEndpoint ?? target.userDataDir;
9
+ }
10
+ /**
11
+ * The opening of {@link profileInUse}, exported because a second caller has to
12
+ * recognise the failure without matching the prose again: the CLI settles a
13
+ * conflict it finds this way (see `cliCommands.ts`), and the two spellings of
14
+ * "the profile is in use" must not drift apart.
15
+ */
16
+ export const PROFILE_IN_USE_PREFIX = 'A browser is already running with the profile';
17
+ /** A launch failed because a browser already holds the profile. */
18
+ export function profileInUse(userDataDir) {
19
+ return (`${PROFILE_IN_USE_PREFIX} ${userDataDir}, so a second one cannot be launched on it. ` +
20
+ `Either drive that browser — start it with --remote-debugging-port=<port> (9222 is the usual one) and run with --browser-url=http://127.0.0.1:<port> ` +
21
+ `(or --autoConnect on Chrome 144+) — or launch on a separate profile with --isolated.`);
22
+ }
23
+ /** Whether a failure message is one {@link profileInUse} produced. */
24
+ export function isProfileInUseMessage(message) {
25
+ return message.includes(PROFILE_IN_USE_PREFIX);
26
+ }
27
+ /**
28
+ * The opening of {@link attachFailed} and {@link noDevToolsEndpoint}, which
29
+ * both say the daemon was told to drive a browser that is not there.
30
+ */
31
+ export const ATTACH_FAILED_PREFIX = 'Could not attach to the browser';
32
+ /**
33
+ * The browser failure a tool reply describes, or undefined when it describes
34
+ * none.
35
+ *
36
+ * Both of these mean the daemon has no browser to drive — one because a launch
37
+ * was refused, the other because the browser it attached to is gone — and both
38
+ * leave it that way until something re-reads the environment, because a daemon
39
+ * fixes its browser at startup. `cliCommands.ts` settles them for that reason.
40
+ */
41
+ export function classifyBrowserFailure(message) {
42
+ if (isProfileInUseMessage(message)) {
43
+ return 'profile-in-use';
44
+ }
45
+ if (message.includes(ATTACH_FAILED_PREFIX)) {
46
+ return 'unreachable';
47
+ }
48
+ return undefined;
49
+ }
50
+ /**
51
+ * An attach through a profile found no DevTools endpoint in it, i.e. the browser
52
+ * is running but was not started with remote debugging.
53
+ */
54
+ export function noDevToolsEndpoint(userDataDir) {
55
+ return (`Could not attach to the browser running with the profile ${userDataDir}: no DevTools endpoint was found there. ` +
56
+ `Check that it is running and that remote debugging is enabled (chrome://inspect/#remote-debugging).`);
57
+ }
58
+ /** An attach failed outright: the browser is gone, or not debuggable. */
59
+ export function attachFailed(target, autoConnect) {
60
+ const at = attachTarget(target);
61
+ const hint = autoConnect ? ' (chrome://inspect/#remote-debugging)' : '';
62
+ return (`Could not attach to the browser${at ? ` at ${at}` : ''}. ` +
63
+ `Check that it is running with remote debugging enabled${hint}. ` +
64
+ `An attached browser is not managed by this daemon, so it is not restarted for you — start it again and re-attach.`);
65
+ }
66
+ //# sourceMappingURL=browserErrors.js.map