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
package/README.md CHANGED
@@ -10,7 +10,7 @@ control and inspect a live browser. It acts as a Model-Context-Protocol
10
10
  DevTools for reliable automation, in-depth debugging, and performance analysis.
11
11
  When connected to Opera Neon, it also exposes Opera's built-in AI capabilities.
12
12
 
13
- ## [Tool reference](./docs/tool-reference.md) | [Changelog](./CHANGELOG.md) | [Contributing](./CONTRIBUTING.md) | [Troubleshooting](./docs/troubleshooting.md) | [Design Principles](./docs/design-principles.md)
13
+ ## [Tool reference](./docs/tool-reference.md) | [Changelog](./CHANGELOG.md) | [Contributing](./CONTRIBUTING.md) | [Troubleshooting](./docs/troubleshooting.md) | [Design Principles](./docs/design-principles.md) | [Stress testing](./docs/stress-testing.md)
14
14
 
15
15
  ## Key features
16
16
 
@@ -5,10 +5,13 @@
5
5
  *
6
6
  * Modified by Opera Software AS: optional `hooks` seam (see
7
7
  * ./opera/toolHandlerHooks.ts) for mutex bypass, browser relaunch and log
8
- * streaming. Keep the diff to the three `this.hooks?.` call sites.
8
+ * streaming, plus one page-resolution call that tolerates a selection the user
9
+ * closed (see ./opera/pageRecovery.ts). Keep the diff to the three
10
+ * `this.hooks?.` call sites and that line.
9
11
  */
10
12
  import { McpResponse } from './McpResponse.js';
11
13
  import { CLI_BIN_NAME } from './opera/branding.js';
14
+ import { resolveSelectedPage } from './opera/pageRecovery.js';
12
15
  import { SlimMcpResponse } from './SlimMcpResponse.js';
13
16
  import { ClearcutLogger } from './telemetry/ClearcutLogger.js';
14
17
  import { zod } from './third_party/index.js';
@@ -204,7 +207,7 @@ export class ToolHandler {
204
207
  pageId !== undefined &&
205
208
  !this.serverArgs.slim
206
209
  ? context.getPageById(pageId)
207
- : context.getSelectedMcpPage();
210
+ : await resolveSelectedPage(context, response);
208
211
  response.setPage(page);
209
212
  if (this.tool.blockedByDialog) {
210
213
  page.throwIfDialogOpen();
@@ -265,6 +268,10 @@ export class ToolHandler {
265
268
  };
266
269
  }
267
270
  finally {
271
+ // Before telemetry and the mutex release: the claim `beforeInvoke` took
272
+ // has to be released even when the invocation failed, or the next Opera
273
+ // AI tool waits on a browser this one only looks like it is using.
274
+ this.hooks?.afterInvoke(this.tool);
268
275
  void ClearcutLogger.get()?.logToolInvocation({
269
276
  toolName: this.tool.name,
270
277
  params,
@@ -7,10 +7,14 @@
7
7
  * Modified by Opera Software AS.
8
8
  */
9
9
  import process from 'node:process';
10
- import { startDaemon, stopDaemon, sendCommand, handleResponse, verifyDaemonVersion, } from '../daemon/client.js';
10
+ import { startDaemon, stopDaemon, sendCommand } from '../daemon/client.js';
11
11
  import { isDaemonRunning, serializeArgs, assertValidSessionId, } from '../daemon/utils.js';
12
12
  import { logDisclaimers } from '../index.js';
13
13
  import { CLI_BIN_NAME, MCP_BIN_NAME, PACKAGE_NAME } from '../opera/branding.js';
14
+ import { describeBrowserMode } from '../opera/browserFlags.js';
15
+ import { EXIT_CODES } from '../opera/cdpErrors.js';
16
+ import { registerOperaCommands, registerToolCommand, } from '../opera/cliCommands.js';
17
+ import { launcherMigrationNotice } from '../opera/launcherNotice.js';
14
18
  import { hideBin, yargs } from '../third_party/index.js';
15
19
  import { checkForUpdates } from '../utils/check-for-updates.js';
16
20
  import { VERSION } from '../version.js';
@@ -19,6 +23,13 @@ import { mcpOptions, parseArguments, getMcpOptionsForViaCli, } from '../config/m
19
23
  process.title = CLI_BIN_NAME;
20
24
  await checkForUpdates(`Run \`npm install -g ${PACKAGE_NAME}@latest\` and \`${CLI_BIN_NAME} start\` to update and restart the daemon.`);
21
25
  const DEFAULT_CLI_ARGS = ['--viaCli'];
26
+ // `--version` is also what a user runs when something looks wrong, so it has to
27
+ // carry the phase-2 recipe when the compatibility launcher is what ran this CLI
28
+ // (`src/opera/launcherNotice.ts`). Without the marker the banner is the plain
29
+ // version, exactly as before.
30
+ const versionBanner = [VERSION, launcherMigrationNotice()]
31
+ .filter(Boolean)
32
+ .join('\n\n');
22
33
  async function start(args, sessionId) {
23
34
  const combinedArgs = [...DEFAULT_CLI_ARGS, ...args];
24
35
  await startDaemon(combinedArgs, sessionId);
@@ -52,7 +63,7 @@ const y = yargs(hideBin(process.argv))
52
63
  },
53
64
  })
54
65
  .demandCommand()
55
- .version(VERSION)
66
+ .version(versionBanner)
56
67
  .strict()
57
68
  .help(true)
58
69
  .wrap(120)
@@ -64,20 +75,22 @@ const y = yargs(hideBin(process.argv))
64
75
  msg.includes('Unknown arguments')) {
65
76
  console.error('\n=========================================');
66
77
  console.error('💡 TIP FOR AI AGENT / DEVELOPER:');
67
- console.error('In the `chrome-devtools` CLI:');
78
+ console.error(`In the \`${CLI_BIN_NAME}\` CLI:`);
68
79
  console.error('1. Required parameters MUST be passed as positional arguments (without flags).');
69
- console.error(' - INCORRECT: chrome-devtools click --pageId 1 --uid "1_2"');
70
- console.error(' - CORRECT: chrome-devtools click 1 "1_2"');
80
+ console.error(` - INCORRECT: ${CLI_BIN_NAME} click --pageId 1 --uid "1_2"`);
81
+ console.error(` - CORRECT: ${CLI_BIN_NAME} click 1 "1_2"`);
71
82
  console.error('2. Optional parameters are passed as double-dash options/flags (e.g. --dblClick true).');
72
83
  console.error('3. Make sure to escape quotes properly for your shell environment.');
73
- console.error('Run `chrome-devtools <command> --help` to see exact positional and optional parameters.');
84
+ console.error(`Run \`${CLI_BIN_NAME} <command> --help\` to see exact positional and optional parameters.`);
74
85
  console.error('=========================================');
75
86
  }
76
87
  }
77
88
  else if (err) {
78
89
  console.error(err);
79
90
  }
80
- process.exit(1);
91
+ // A parse failure is exactly what exit code 2 means — "fix the command" — so
92
+ // it is reported as one rather than as an unknown failure.
93
+ process.exit(msg ? EXIT_CODES.VALIDATION_ERROR : EXIT_CODES.UNKNOWN);
81
94
  });
82
95
  y.command('start', `Start or restart ${MCP_BIN_NAME}`, y => y
83
96
  .options(getCliOptions())
@@ -113,9 +126,12 @@ y.command('status', `Checks if ${MCP_BIN_NAME} is running`, y => y, async (argv)
113
126
  if (response.success) {
114
127
  const data = JSON.parse(response.result);
115
128
  console.log(`pid=${data.pid} socket=${data.socketPath} start-date=${data.startDate} version=${data.version}`);
129
+ // Who owns the browser decides what recovery to expect from it: we
130
+ // relaunch what we launched, and never touch a browser we attached to.
131
+ console.log(`browser=${describeBrowserMode(data.args)}`);
116
132
  console.log(`args=${JSON.stringify(data.args)}`);
117
133
  if (data.version !== VERSION) {
118
- console.warn(`Warning: Daemon server version (${data.version}) does not match CLI version (${VERSION}). Run 'chrome-devtools start' to update and restart the daemon.`);
134
+ console.warn(`Warning: Daemon server version (${data.version}) does not match CLI version (${VERSION}). Run '${CLI_BIN_NAME} start' to update and restart the daemon.`);
119
135
  }
120
136
  }
121
137
  else {
@@ -136,96 +152,13 @@ y.command('stop', `Stop ${MCP_BIN_NAME} if any`, y => y, async (argv) => {
136
152
  await stopDaemon(sessionId);
137
153
  process.exit(0);
138
154
  });
155
+ // The fork's own commands (`setup`, `doctor`, `logs`, `url`) and the wrapper
156
+ // that turns a generated tool definition into a runnable command live in
157
+ // `src/opera/cliCommands.ts`, along with the exit-code and streaming plumbing
158
+ // of a tool call. `start` stays here: it is upstream's, and prepends `--viaCli`.
159
+ registerOperaCommands(y, { start });
139
160
  for (const [commandName, commandDef] of Object.entries(commands)) {
140
- const args = commandDef.args;
141
- const requiredArgNames = Object.keys(args).filter(name => args[name].required);
142
- const optionalArgNames = Object.keys(args).filter(name => !args[name].required);
143
- let commandStr = commandName;
144
- for (const arg of requiredArgNames) {
145
- commandStr += ` <${arg}>`;
146
- }
147
- for (const arg of optionalArgNames) {
148
- commandStr += ` [--${arg}]`;
149
- }
150
- y.command(commandStr, commandDef.description, y => {
151
- y.option('output-format', {
152
- choices: ['md', 'json'],
153
- default: 'md',
154
- });
155
- for (const [argName, opt] of Object.entries(args)) {
156
- const type = opt.type === 'integer' || opt.type === 'number'
157
- ? 'number'
158
- : opt.type === 'boolean'
159
- ? 'boolean'
160
- : opt.type === 'array'
161
- ? 'array'
162
- : 'string';
163
- if (opt.required) {
164
- const options = {
165
- describe: opt.description,
166
- type: type,
167
- };
168
- if (opt.default !== undefined) {
169
- options.default = opt.default;
170
- }
171
- if (opt.enum) {
172
- options.choices = opt.enum;
173
- }
174
- y.positional(argName, options);
175
- }
176
- else {
177
- const options = {
178
- describe: opt.description,
179
- type: type,
180
- };
181
- if (opt.default !== undefined) {
182
- options.default = opt.default;
183
- }
184
- if (opt.enum) {
185
- options.choices = opt.enum;
186
- }
187
- y.option(argName, options);
188
- }
189
- }
190
- }, async (argv) => {
191
- const sessionId = argv.sessionId;
192
- try {
193
- const versionWarningPromise = isDaemonRunning(sessionId)
194
- ? verifyDaemonVersion(sessionId, VERSION)
195
- : Promise.resolve(undefined);
196
- if (!isDaemonRunning(sessionId)) {
197
- await start(serializeArgs(mcpOptions, argv), sessionId);
198
- }
199
- const commandArgs = {};
200
- for (const argName of Object.keys(args)) {
201
- if (argName in argv) {
202
- commandArgs[argName] = argv[argName];
203
- }
204
- }
205
- const response = await sendCommand({
206
- method: 'invoke_tool',
207
- tool: commandName,
208
- args: commandArgs,
209
- }, sessionId);
210
- if (response.success) {
211
- console.log(await handleResponse(JSON.parse(response.result), argv['output-format']));
212
- }
213
- else {
214
- console.error('Error:', response.error);
215
- }
216
- const versionWarning = await versionWarningPromise;
217
- if (versionWarning) {
218
- console.warn(versionWarning);
219
- }
220
- if (!response.success) {
221
- process.exit(1);
222
- }
223
- }
224
- catch (error) {
225
- console.error('Failed to execute command:', error);
226
- process.exit(1);
227
- }
228
- });
161
+ registerToolCommand(y, commandName, commandDef, { start });
229
162
  }
230
163
  await y.parse();
231
164
  //# sourceMappingURL=chrome-devtools.js.map
@@ -0,0 +1,102 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * @license
4
+ * Copyright 2026 Opera Software AS.
5
+ * SPDX-License-Identifier: Apache-2.0
6
+ */
7
+ // Opera-named bin entry (see `package.json` `bin`). The implementation lives in
8
+ // the upstream-owned file and is branded via `src/opera/branding.ts`.
9
+ //
10
+ // The guard comes FIRST, and has to stay first: it installs on import, and the
11
+ // imports below it reach `third_party`, whose bundled `debug` reads the
12
+ // `localStorage` global and prints Node's Web Storage warning. An import listed
13
+ // before this one that reaches `third_party` puts the warning back in front of
14
+ // every command's output.
15
+ import { preloadWebStorageWarningGuardInChildren } from '../opera/webStorageWarning.js';
16
+ import { CLI_BIN_NAME } from '../opera/branding.js';
17
+ import { extractTakeoverFlag, preflightBrowser, sessionIdFromArgv, } from '../opera/browserTarget.js';
18
+ import { formatError } from '../opera/cliOutput.js';
19
+ import { autoConfigure, shouldAutoConfigure } from '../opera/config.js';
20
+ import { applyEnvToArgv, loadOperaCliConfig } from '../opera/envConfig.js';
21
+ import { runLegacyMigrationGuard } from '../opera/legacyBridgeCleanup.js';
22
+ /**
23
+ * Configure a fresh machine in place, without asking. Skips pure queries and
24
+ * the (future) `setup`/`logs` commands; reports what it did on stderr so a
25
+ * scripted user sees the same first-run note as opera-browser-cli.
26
+ */
27
+ function ensureConfigured(argv) {
28
+ if (!shouldAutoConfigure(argv)) {
29
+ return;
30
+ }
31
+ const command = argv[2];
32
+ const result = autoConfigure();
33
+ if (result.status === 'configured') {
34
+ const ai = result.browser.isNeon
35
+ ? 'Opera AI available'
36
+ : 'chat only — install Opera Neon for invoke-do/make/research';
37
+ process.stderr.write(`configured: ${result.browser.name} (${ai}) — run \`${CLI_BIN_NAME} setup\` to change\n`);
38
+ return;
39
+ }
40
+ if (result.status === 'no-browser' && command !== 'doctor') {
41
+ process.stderr.write(`hint: no Opera installation found — run \`${CLI_BIN_NAME} setup\`, or set OPERA_CLI_EXECUTABLE_PATH\n`);
42
+ }
43
+ }
44
+ // Promote ~/.opera-browser-cli/config into process.env (and warn on unknown
45
+ // keys) before the CLI spawns the daemon, which inherits this environment.
46
+ loadOperaCliConfig();
47
+ // A machine that upgraded from the two-package era may still have the old HTTP
48
+ // bridge running, holding port 9225 and a browser of its own. Nothing runs at
49
+ // install time any more — npm ≥12 blocks install scripts unless the user opts in
50
+ // — so this is the path that always runs: the launcher spawns this CLI for every
51
+ // command, `--version` and `--help` included. One file read when there is no
52
+ // bridge; the port probe behind it runs once per boot. Never throws.
53
+ //
54
+ // The home is the *invoking* user's, not `os.homedir()`: under `sudo`, the
55
+ // bridge (and its PID file) belongs to the user who started it.
56
+ await runLegacyMigrationGuard();
57
+ // The daemon cannot import this module before its own static imports reach
58
+ // `third_party`, so the guard travels to it (and to the MCP server it spawns)
59
+ // through NODE_OPTIONS instead.
60
+ preloadWebStorageWarningGuardInChildren();
61
+ // On a machine that has never been configured, detect the installed Opera
62
+ // build and write a config + set OPERA_CLI_* so this first command works.
63
+ ensureConfigured(process.argv);
64
+ // `--takeover` decides how a profile conflict is settled, and belongs to the
65
+ // preflight rather than to any command — every command parser is strict, so it
66
+ // is read and removed before one of them sees it. It is left in the environment
67
+ // because the preflight is not the only place a conflict is settled: a tool
68
+ // call that fails on one settles it too, one process deeper than any argument
69
+ // the command parser kept (`cliCommands.ts`).
70
+ const takeover = extractTakeoverFlag(process.argv);
71
+ if (takeover) {
72
+ process.env.OPERA_CLI_TAKEOVER = '1';
73
+ }
74
+ // Which browser to drive is decided here, before the command starts a daemon:
75
+ // settling a conflict may need to ask the user, and the daemon is detached with
76
+ // no terminal. The decision travels as OPERA_CLI_* environment, which the
77
+ // daemon and the MCP server it spawns both inherit — and, for `start`, as the
78
+ // flags translated from it immediately below.
79
+ try {
80
+ await preflightBrowser(process.argv.slice(2), sessionIdFromArgv(process.argv), takeover);
81
+ }
82
+ catch (error) {
83
+ const { output, exitCode } = await formatError(error);
84
+ console.error(output);
85
+ process.exit(exitCode);
86
+ }
87
+ // `start` is the command that decides the daemon's browser options, and its own
88
+ // defaults (`--headless` from the viaCli options, `--isolated`) are serialized
89
+ // as if the user had passed them — so the MCP server bin's `applyEnvToArgv`,
90
+ // which skips a flag already on argv, can no longer see OPERA_CLI_HEADED or
91
+ // OPERA_CLI_USER_DATA_DIR. Translating the environment into explicit flags
92
+ // here, before yargs parses the command, lets the config beat those defaults.
93
+ // Only `start` takes the browser flags; `status`, `stop` and the tool commands
94
+ // have strict parsers that would reject them, and a directly-run MCP server
95
+ // still does the translation in its own bin.
96
+ if (process.argv[2] === 'start') {
97
+ applyEnvToArgv(process.argv);
98
+ }
99
+ // Dynamic import: static imports are hoisted above the module body, so the
100
+ // config load above would otherwise run after the CLI already parsed argv.
101
+ await import('./chrome-devtools.js');
102
+ //# sourceMappingURL=opera-browser-cli.js.map
@@ -6,5 +6,24 @@
6
6
  */
7
7
  // Opera-named bin entry (see `package.json` `bin`). The implementation lives in
8
8
  // the upstream-owned file and is branded via `src/opera/branding.ts`.
9
- import './chrome-devtools-mcp.js';
9
+ import { applyEnvToArgv, loadOperaCliConfig } from '../opera/envConfig.js';
10
+ import { runLegacyMigrationGuard } from '../opera/legacyBridgeCleanup.js';
11
+ // Import for its side effect: silences Node's Web Storage warning before
12
+ // `third_party` reads `localStorage`. See `opera/webStorageWarning.ts`.
13
+ import '../opera/webStorageWarning.js';
14
+ // Apply ~/.opera-browser-cli/config and OPERA_CLI_* env vars before the
15
+ // upstream implementation reads `process.argv`.
16
+ loadOperaCliConfig();
17
+ applyEnvToArgv(process.argv);
18
+ // An MCP client starts this bin directly, with no CLI invocation anywhere, so
19
+ // the migration guard has to run here too: a machine that upgraded from the
20
+ // two-package era may still have the old HTTP bridge (port 9225) holding a
21
+ // browser. Nothing runs at install time — npm ≥12 blocks install scripts unless
22
+ // the user opts in — and this file is also the server the daemon spawns, so both
23
+ // shapes of MCP start are covered. One file read when there is no bridge; the
24
+ // port probe behind it runs once per boot. Never throws, never writes to stdout.
25
+ await runLegacyMigrationGuard();
26
+ // Dynamic import: static imports are hoisted above the module body, so the
27
+ // env/config setup above would otherwise run after the server parsed argv.
28
+ await import('./chrome-devtools-mcp.js');
10
29
  //# sourceMappingURL=opera-devtools-mcp.js.map
@@ -9,6 +9,8 @@ import { execSync } from 'node:child_process';
9
9
  import fs from 'node:fs';
10
10
  import os from 'node:os';
11
11
  import path from 'node:path';
12
+ import { disarmBrowserOrphanCleanup, watchBrowserForOrphans, } from './opera/browserCleanup.js';
13
+ import { attachFailed, noDevToolsEndpoint, profileInUse, } from './opera/browserErrors.js';
12
14
  import { puppeteer } from './third_party/index.js';
13
15
  import { logger, puppeteerLogger } from './utils/logger.js';
14
16
  import { isAllowedUrl } from './utils/url.js';
@@ -73,9 +75,7 @@ export async function ensureBrowserConnected(options) {
73
75
  connectOptions.browserWSEndpoint = browserWSEndpoint;
74
76
  }
75
77
  catch (error) {
76
- throw new Error(`Could not connect to Chrome in ${userDataDir}. Check if Chrome is running and remote debugging is enabled by going to chrome://inspect/#remote-debugging.`, {
77
- cause: error,
78
- });
78
+ throw new Error(noDevToolsEndpoint(userDataDir), { cause: error });
79
79
  }
80
80
  }
81
81
  else {
@@ -98,9 +98,7 @@ export async function ensureBrowserConnected(options) {
98
98
  browser = connected;
99
99
  }
100
100
  catch (err) {
101
- throw new Error(`Could not connect to Chrome. ${autoConnect ? `Check if Chrome is running and remote debugging is enabled by going to chrome://inspect/#remote-debugging.` : `Check if Chrome is running.`}`, {
102
- cause: err,
103
- });
101
+ throw new Error(attachFailed(options, autoConnect), { cause: err });
104
102
  }
105
103
  logger?.('Connected Puppeteer');
106
104
  return browser;
@@ -190,9 +188,10 @@ export async function launch(options) {
190
188
  catch (error) {
191
189
  if (userDataDir &&
192
190
  error.message.includes('The browser is already running')) {
193
- throw new Error(`The browser is already running for ${userDataDir}. Use --isolated to run multiple browser instances.`, {
194
- cause: error,
195
- });
191
+ // The wording lives in `opera/browserErrors.ts`: upstream's answer to a
192
+ // profile in use ("use --isolated") is the wrong one for the case that
193
+ // matters — driving the browser you already have.
194
+ throw new Error(profileInUse(userDataDir), { cause: error });
196
195
  }
197
196
  throw error;
198
197
  }
@@ -203,6 +202,12 @@ export async function ensureBrowserLaunched(options) {
203
202
  }
204
203
  // Assign mode before browser; see the connect path above for rationale.
205
204
  const launched = await launch(options);
205
+ // Chrome's helpers sit in the browser's process group, not under it in the
206
+ // process tree, so a browser that dies without closing — SIGKILL, a crash,
207
+ // an OOM kill — leaves them running as orphans. `disconnected` is the only
208
+ // signal that the browser is gone, and the group kill it arms is the only
209
+ // teardown that reaches the helpers; see `opera/browserCleanup.ts`.
210
+ watchBrowserForOrphans(launched);
206
211
  browserMode = 'launched';
207
212
  browser = launched;
208
213
  return browser;
@@ -226,6 +231,9 @@ export async function closeBrowser() {
226
231
  return;
227
232
  }
228
233
  if (mode === 'launched') {
234
+ // The close below emits `disconnected` too; without this the orphan-cleanup
235
+ // handler would SIGKILL the group mid-shutdown. See `opera/browserCleanup.ts`.
236
+ disarmBrowserOrphanCleanup();
229
237
  await b.close().catch(err => {
230
238
  logger?.('Failed to close browser', err);
231
239
  });
@@ -237,6 +245,7 @@ export async function closeBrowser() {
237
245
  }
238
246
  export async function closeBrowserIfOpen() {
239
247
  if (browser?.connected) {
248
+ disarmBrowserOrphanCleanup();
240
249
  try {
241
250
  await browser.close();
242
251
  }
@@ -6,9 +6,11 @@
6
6
  import { spawn } from 'node:child_process';
7
7
  import fs from 'node:fs';
8
8
  import net from 'node:net';
9
- import { PipeTransport } from '../third_party/index.js';
9
+ import { ensureCleanStart, readExitReason } from '../opera/daemonLifecycle.js';
10
+ import { nameSpawnFailure, openDaemonLog } from '../opera/daemonLog.js';
11
+ import { requestOverSocket } from '../opera/daemonStreaming.js';
10
12
  import { getTempFilePath } from '../utils/files.js';
11
- import { logger, puppeteerLogger } from '../utils/logger.js';
13
+ import { logger } from '../utils/logger.js';
12
14
  import { DAEMON_SCRIPT_PATH, getSocketPath, getPidFilePath, isDaemonRunning, } from './utils.js';
13
15
  const FILE_TIMEOUT = 10_000;
14
16
  const READY_CHECK_INTERVAL = 100;
@@ -77,64 +79,68 @@ async function waitForDaemonReady(sessionId) {
77
79
  throw new Error(`Timeout: daemon not ready within ${FILE_TIMEOUT}ms`, lastError === undefined ? undefined : { cause: lastError });
78
80
  }
79
81
  export async function startDaemon(mcpArgs = [], sessionId) {
80
- if (isDaemonRunning(sessionId)) {
82
+ // Not `isDaemonRunning`: that reads the pid file, and a daemon whose pid file
83
+ // is gone (or whose socket a racing starter unlinked) is invisible to it.
84
+ // `ensureCleanStart` probes the socket and reaps whatever the last daemon left
85
+ // behind, so the daemon we are about to fork is the only one in the session.
86
+ if (await ensureCleanStart(sessionId)) {
81
87
  logger?.('Daemon is already running');
82
88
  await waitForDaemonReady(sessionId);
83
89
  return;
84
90
  }
91
+ logger?.('Starting daemon...', ...mcpArgs);
85
92
  const pidFilePath = getPidFilePath(sessionId);
86
- if (fs.existsSync(pidFilePath)) {
87
- fs.unlinkSync(pidFilePath);
93
+ const logFd = openDaemonLog(sessionId);
94
+ let child;
95
+ try {
96
+ child = spawn(process.execPath, [DAEMON_SCRIPT_PATH, ...mcpArgs], {
97
+ detached: true,
98
+ // Not 'ignore' unless there was nothing to open: the daemon is detached
99
+ // with no terminal, so discarding its output leaves a daemon that dies
100
+ // silently unexplainable. The file is what the mid-command failure below
101
+ // points the user at.
102
+ stdio: logFd === 'ignore' ? 'ignore' : ['ignore', logFd, logFd],
103
+ env: { ...process.env, CHROME_DEVTOOLS_MCP_SESSION_ID: sessionId },
104
+ cwd: process.cwd(),
105
+ windowsHide: true,
106
+ });
107
+ }
108
+ finally {
109
+ // The child holds its own descriptor; the parent's copy would outlive it.
110
+ if (logFd !== 'ignore') {
111
+ fs.closeSync(logFd);
112
+ }
88
113
  }
89
- logger?.('Starting daemon...', ...mcpArgs);
90
- const child = spawn(process.execPath, [DAEMON_SCRIPT_PATH, ...mcpArgs], {
91
- detached: true,
92
- stdio: 'ignore',
93
- env: { ...process.env, CHROME_DEVTOOLS_MCP_SESSION_ID: sessionId },
94
- cwd: process.cwd(),
95
- windowsHide: true,
96
- });
97
114
  child.unref();
98
- await waitForFile(pidFilePath);
115
+ // The pid file and the spawn's own failure, whichever comes first. See
116
+ // `opera/daemonLog.ts` for why a failed spawn must not surface as a timeout.
117
+ await Promise.race([
118
+ waitForFile(pidFilePath),
119
+ nameSpawnFailure(child, sessionId),
120
+ ]);
99
121
  await waitForDaemonReady(sessionId);
100
122
  }
101
123
  const SEND_COMMAND_TIMEOUT = 60_000; // ms
102
124
  /**
103
125
  * `sendCommand` opens a socket connection sends a single command and disconnects.
126
+ *
127
+ * The frame protocol — including `onLog`, which opts the request into the
128
+ * streaming variant and hands each chunk over as it arrives — lives in
129
+ * `opera/daemonStreaming.ts`'s `requestOverSocket`.
104
130
  */
105
- export async function sendCommand(command, sessionId, timeout = SEND_COMMAND_TIMEOUT) {
131
+ export async function sendCommand(command, sessionId, timeout = SEND_COMMAND_TIMEOUT, onLog) {
106
132
  // Before connecting and sending, verify the daemon is still alive.
107
133
  if (!isDaemonRunning(sessionId)) {
108
- throw new Error('Daemon is not running.');
134
+ // A daemon that tore itself down leaves a reason behind; surfacing it is
135
+ // the difference between "Daemon is not running." and knowing why.
136
+ const reason = readExitReason(sessionId);
137
+ throw new Error(reason ? `Daemon is not running: ${reason}` : 'Daemon is not running.');
109
138
  }
110
139
  const socketPath = getSocketPath(sessionId);
111
140
  const socket = net.createConnection({
112
141
  path: socketPath,
113
142
  });
114
- return new Promise((resolve, reject) => {
115
- const timer = setTimeout(() => {
116
- socket.destroy();
117
- reject(new Error('Timeout waiting for daemon response'));
118
- }, timeout);
119
- const transport = new PipeTransport(socket, socket, puppeteerLogger);
120
- transport.onmessage = async (message) => {
121
- clearTimeout(timer);
122
- logger?.('onmessage', message);
123
- resolve(JSON.parse(message));
124
- };
125
- socket.on('error', error => {
126
- clearTimeout(timer);
127
- logger?.('Socket error:', error);
128
- reject(error);
129
- });
130
- socket.on('close', () => {
131
- clearTimeout(timer);
132
- logger?.('Socket closed:');
133
- reject(new Error('Socket closed'));
134
- });
135
- logger?.('Sending message', command);
136
- transport.send(JSON.stringify(command));
137
- });
143
+ return requestOverSocket({ socket, command, sessionId, timeout, onLog });
138
144
  }
139
145
  export async function stopDaemon(sessionId) {
140
146
  if (!isDaemonRunning(sessionId)) {