opera-devtools-mcp 0.6.1 → 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 (96) hide show
  1. package/README.md +1 -1
  2. package/build/src/McpContext.js +78 -41
  3. package/build/src/McpPage.js +5 -1
  4. package/build/src/ToolHandler.js +54 -76
  5. package/build/src/bin/chrome-devtools-mcp-main.js +4 -4
  6. package/build/src/bin/chrome-devtools.js +56 -124
  7. package/build/src/bin/opera-browser-cli.js +102 -0
  8. package/build/src/bin/opera-devtools-cli-options.js +1 -1
  9. package/build/src/bin/opera-devtools-mcp-cli-options.js +1 -1
  10. package/build/src/bin/opera-devtools-mcp.js +20 -1
  11. package/build/src/browser.js +23 -25
  12. package/build/src/config/browser-options.js +126 -0
  13. package/build/src/config/category-options.js +81 -0
  14. package/build/src/{bin/chrome-devtools-cli-options.js → config/cli-options.js} +368 -26
  15. package/build/src/{bin/chrome-devtools-mcp-cli-options.js → config/mcp-options.js} +143 -164
  16. package/build/src/daemon/client.js +55 -40
  17. package/build/src/daemon/daemon.js +62 -39
  18. package/build/src/daemon/utils.js +6 -0
  19. package/build/src/devtools/DevtoolsUtils.js +27 -21
  20. package/build/src/formatters/NetworkFormatter.js +5 -2
  21. package/build/src/index.js +166 -100
  22. package/build/src/opera/branding.js +4 -2
  23. package/build/src/opera/browserActivity.js +62 -0
  24. package/build/src/opera/browserCleanup.js +123 -0
  25. package/build/src/opera/browserErrors.js +66 -0
  26. package/build/src/opera/browserFlags.js +184 -38
  27. package/build/src/opera/browserTarget.js +513 -0
  28. package/build/src/opera/cdpErrors.js +391 -0
  29. package/build/src/opera/cliCommands.js +378 -0
  30. package/build/src/opera/cliOutput.js +284 -0
  31. package/build/src/opera/compactSnapshot.js +525 -0
  32. package/build/src/opera/config.js +166 -0
  33. package/build/src/opera/daemonLifecycle.js +257 -0
  34. package/build/src/opera/daemonLog.js +103 -0
  35. package/build/src/opera/daemonPidFile.js +83 -0
  36. package/build/src/opera/daemonShutdown.js +66 -0
  37. package/build/src/opera/daemonSocket.js +87 -0
  38. package/build/src/opera/daemonStreaming.js +130 -0
  39. package/build/src/opera/daemonToolCall.js +26 -0
  40. package/build/src/opera/detect.js +114 -0
  41. package/build/src/opera/doctor.js +317 -0
  42. package/build/src/opera/envConfig.js +229 -0
  43. package/build/src/opera/launcherNotice.js +116 -0
  44. package/build/src/opera/legacyBridgeCleanup.js +297 -0
  45. package/build/src/opera/logs.js +133 -0
  46. package/build/src/opera/mcpServerSupervisor.js +128 -0
  47. package/build/src/opera/migrationShared.js +164 -0
  48. package/build/src/opera/operaPages.js +56 -0
  49. package/build/src/opera/pageIdRouting.js +35 -0
  50. package/build/src/opera/pageRecovery.js +53 -0
  51. package/build/src/opera/profile.js +270 -0
  52. package/build/src/opera/refArgs.js +36 -0
  53. package/build/src/opera/serviceWorkerRetry.js +46 -4
  54. package/build/src/opera/setup.js +290 -0
  55. package/build/src/opera/skills/SKILL.md +160 -0
  56. package/build/src/opera/streamingTools.js +73 -0
  57. package/build/src/opera/suggestions.js +67 -0
  58. package/build/src/opera/toolHandlerHooks.js +25 -1
  59. package/build/src/opera/tools/opera.js +107 -38
  60. package/build/src/opera/urlResolver.js +69 -0
  61. package/build/src/opera/webStorageWarning.js +92 -0
  62. package/build/src/processors/HeapSnapshotManager.js +12 -0
  63. package/build/src/telemetry/ClearcutLogger.js +19 -6
  64. package/build/src/telemetry/transformation.js +4 -0
  65. package/build/src/telemetry/types.js +4 -0
  66. package/build/src/third_party/THIRD_PARTY_NOTICES +5 -5
  67. package/build/src/third_party/bundled-packages.json +3 -3
  68. package/build/src/third_party/devtools-formatter-worker.js +23 -0
  69. package/build/src/third_party/devtools-heap-snapshot-worker.js +101 -20
  70. package/build/src/third_party/index.js +15460 -14256
  71. package/build/src/third_party/issue-descriptions/federatedAuthRequestAccountsBlockedByConnectionAllowlist.md +1 -0
  72. package/build/src/third_party/issue-descriptions/federatedAuthRequestConfigBlockedByConnectionAllowlist.md +1 -0
  73. package/build/src/third_party/issue-descriptions/federatedAuthRequestIdTokenBlockedByConnectionAllowlist.md +1 -0
  74. package/build/src/third_party/issue-descriptions/federatedAuthRequestWellKnownBlockedByConnectionAllowlist.md +1 -0
  75. package/build/src/tools/ToolDefinition.js +15 -0
  76. package/build/src/tools/categories.js +0 -6
  77. package/build/src/tools/comments.js +90 -0
  78. package/build/src/tools/console.js +1 -1
  79. package/build/src/tools/emulation.js +1 -1
  80. package/build/src/tools/extensions.js +1 -1
  81. package/build/src/tools/memory.js +60 -6
  82. package/build/src/tools/network.js +2 -2
  83. package/build/src/tools/pages.js +26 -15
  84. package/build/src/tools/performance.js +4 -3
  85. package/build/src/tools/screencast.js +3 -2
  86. package/build/src/tools/screenshot.js +39 -8
  87. package/build/src/tools/script.js +17 -4
  88. package/build/src/tools/slim/tools.js +41 -33
  89. package/build/src/tools/snapshot.js +1 -1
  90. package/build/src/tools/tools.js +2 -0
  91. package/build/src/utils/WaitForHelper.js +12 -1
  92. package/build/src/utils/bytes.js +105 -0
  93. package/build/src/utils/url.js +79 -0
  94. package/build/src/version.js +1 -1
  95. package/package.json +29 -8
  96. package/build/src/bin/opera-devtools.js +0 -10
@@ -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
@@ -5,14 +5,22 @@
5
5
  * This file is an original work developed by Opera.
6
6
  */
7
7
  import { closeBrowserIfOpen, ensureBrowserLaunched, getCurrentBrowser, } from '../browser.js';
8
- import { buildLaunchOptions } from './browserLaunch.js';
9
8
  import { logger } from '../utils/logger.js';
9
+ import { otherBrowserUsers } from './browserActivity.js';
10
+ import { buildLaunchOptions } from './browserLaunch.js';
10
11
  /**
11
12
  * Opera's AI features refuse to run when the page reports itself as
12
13
  * automation-controlled, so the browser has to be launched with this flag for
13
14
  * them. It is deliberately NOT applied to every launch: it changes observable
14
15
  * page behaviour, which would silently alter results for ordinary DevTools
15
16
  * tools.
17
+ *
18
+ * The flags are therefore acquired the first time an Opera AI tool needs them
19
+ * and then kept for the life of that browser — see `ensureBrowserFlagsForTool`.
20
+ * A browser carrying them is never relaunched to take them away again: the
21
+ * daemon serves several terminals at once, and the tool that used to trigger
22
+ * that relaunch was an ordinary one (`take_snapshot` in another terminal), which
23
+ * closed the browser a running `opera_do` was streaming from.
16
24
  */
17
25
  export const OPERA_AUTOMATION_FLAGS = [
18
26
  '--disable-blink-features=AutomationControlled',
@@ -27,59 +35,197 @@ export function toolRequiresOperaFlags(toolName) {
27
35
  return TOOLS_REQUIRING_OPERA_FLAGS.has(toolName);
28
36
  }
29
37
  /**
30
- * Tracks whether the currently running browser was launched with
31
- * {@link OPERA_AUTOMATION_FLAGS}. Module-level rather than per-server because
32
- * the browser itself is a module-level singleton in `../browser.ts`.
38
+ * The browser this server launched with {@link OPERA_AUTOMATION_FLAGS}, held by
39
+ * identity rather than as a flag that outlives it: a browser that died and was
40
+ * relaunched by the next call carries none of them (`index.ts` launches without
41
+ * the Opera flags), and the tools that need them must not be told otherwise.
42
+ * Module-level rather than per-server because the browser itself is a
43
+ * module-level singleton in `../browser.ts`.
33
44
  */
34
- let browserHasOperaFlags = false;
35
- export function browserWasLaunchedWithOperaFlags() {
36
- return browserHasOperaFlags;
37
- }
38
- /** Test seam: forget what we believe about the current browser. */
45
+ let flagsBrowser;
46
+ /** Test seam: forget which browser was launched with the Opera flags. */
39
47
  export function resetOperaFlagState() {
40
- browserHasOperaFlags = false;
48
+ flagsBrowser = undefined;
41
49
  }
50
+ /**
51
+ * One source of truth for "did we launch the browser, or attach to one that was
52
+ * already running". `index.ts` picks the branch with the same three options at
53
+ * invocation time, and the CLI reports the mode from the daemon's stored argv,
54
+ * so the rule must not be re-implemented per caller.
55
+ */
56
+ const ATTACH_OPTIONS = ['browserUrl', 'wsEndpoint', 'autoConnect'];
42
57
  /**
43
58
  * True when this server launched the browser itself. When the user attached to
44
59
  * an existing browser we must never kill and relaunch it.
45
60
  */
46
- function isLaunchMode(serverArgs) {
47
- return (!serverArgs.browserUrl && !serverArgs.wsEndpoint && !serverArgs.autoConnect);
61
+ export function isLaunchMode(serverArgs) {
62
+ return !ATTACH_OPTIONS.some(option => Boolean(serverArgs[option]));
48
63
  }
49
64
  /**
50
- * Makes sure the running browser has (or lacks) the Opera automation flags to
51
- * match what `toolName` needs, relaunching it if not. No-op when we did not
52
- * launch the browser ourselves, or when the flags already match.
65
+ * One canonical spelling for a raw flag name. yargs expands `--browser-url` and
66
+ * `--browserUrl` to the same option, and the daemon stores the argv verbatim, so
67
+ * a read-back that does not normalize would call an attached session "launched".
53
68
  */
54
- export async function ensureBrowserFlagsForTool(toolName, serverArgs, logFile, control,
55
- // Injected so tests do not need a real browser singleton.
56
- deps = { closeBrowserIfOpen, ensureBrowserLaunched }) {
57
- if (!isLaunchMode(serverArgs)) {
58
- return;
69
+ function canonicalOptionName(name) {
70
+ return name.replace(/-([a-z])/g, (_, letter) => letter.toUpperCase());
71
+ }
72
+ /**
73
+ * The flags of a stored argv in one pass: canonical name → value, `true` for a
74
+ * value-less flag. `--flag=value`, `--flag value`, `--no-flag` and
75
+ * `--flag=false` all arrive on a real command line, and all four have to read
76
+ * the same as `isLaunchMode` reads the parsed form.
77
+ */
78
+ function readFlags(args) {
79
+ const flags = new Map();
80
+ for (let index = 0; index < args.length; index++) {
81
+ const arg = args[index];
82
+ if (!arg.startsWith('--')) {
83
+ continue;
84
+ }
85
+ const body = arg.slice(2);
86
+ const equals = body.indexOf('=');
87
+ if (equals !== -1) {
88
+ flags.set(canonicalOptionName(body.slice(0, equals)), body.slice(equals + 1));
89
+ continue;
90
+ }
91
+ if (body.startsWith('no-')) {
92
+ flags.set(canonicalOptionName(body.slice(3)), false);
93
+ continue;
94
+ }
95
+ const next = args[index + 1];
96
+ if (next !== undefined && !next.startsWith('-')) {
97
+ flags.set(canonicalOptionName(body), next);
98
+ index++;
99
+ continue;
100
+ }
101
+ flags.set(canonicalOptionName(body), true);
59
102
  }
60
- const needsOperaFlags = toolRequiresOperaFlags(toolName);
61
- const browserConnected = getCurrentBrowser()?.connected ?? false;
62
- // A disconnected browser tells us nothing about the flags of the next one.
63
- if (needsOperaFlags && browserHasOperaFlags && browserConnected) {
103
+ return flags;
104
+ }
105
+ /**
106
+ * How a serialized CLI argv (`opera-browser-cli status`) describes the browser
107
+ * it drives: `launched (owned by this daemon)` or `attached to <target>`.
108
+ *
109
+ * The daemon stores the MCP argv it was started with, so this needs no new
110
+ * channel between the two processes — and it is the same predicate the server
111
+ * itself applies, read back from the flags that decided it.
112
+ */
113
+ export function describeBrowserMode(args) {
114
+ const flags = readFlags(args);
115
+ const attachFlag = ATTACH_OPTIONS.find(option => {
116
+ const value = flags.get(option);
117
+ // `--flag=false` and `--no-flag` are how yargs spells "not set", and
118
+ // `isLaunchMode` reads them the same way.
119
+ return value !== undefined && value !== false && value !== 'false';
120
+ });
121
+ if (!attachFlag) {
122
+ return 'launched (owned by this daemon)';
123
+ }
124
+ const value = flags.get(attachFlag);
125
+ return `attached to ${typeof value === 'string' && value ? value : 'an external browser'}`;
126
+ }
127
+ /**
128
+ * The profile a stored argv's launched browser was pointed at, if any.
129
+ *
130
+ * Read back for the same reason `describeBrowserMode` is: a daemon fixes its
131
+ * browser at startup, so its argv is the only record of which profile that
132
+ * browser holds. The CLI uses it to tell its own browser — which never
133
+ * advertises a debug port, because it is launched over a pipe — from the one a
134
+ * user opened, which is the difference between "nothing to do" and "ask".
135
+ */
136
+ export function launchedUserDataDir(args) {
137
+ const value = readFlags(args).get('userDataDir');
138
+ return typeof value === 'string' && value.length > 0 ? value : undefined;
139
+ }
140
+ /**
141
+ * The `--browser-url` a stored argv carries, if it carries one.
142
+ *
143
+ * That is the form the CLI's own endpoint discovery sets (`browserTarget.ts`
144
+ * finds a live `DevToolsActivePort` and passes the URL on), which makes it the
145
+ * one attach target the CLI may re-derive when the browser behind it is gone: a
146
+ * `--wsEndpoint` or `--autoConnect` was typed by the user, and their browser is
147
+ * theirs to start again.
148
+ */
149
+ export function storedBrowserUrl(args) {
150
+ const value = readFlags(args).get('browserUrl');
151
+ return typeof value === 'string' && value.length > 0 ? value : undefined;
152
+ }
153
+ /**
154
+ * How long a relaunch waits for the browser to fall idle before it gives up and
155
+ * reports why, and how often it looks. A mutable object so tests can drive the
156
+ * wait without waiting: faking timers is not workable here (sinon's fake clock
157
+ * replaces the globals `node:test` schedules subtests with).
158
+ */
159
+ export const browserIdleWaitPolicy = {
160
+ timeoutMs: 10_000,
161
+ pollMs: 100,
162
+ };
163
+ function sleep(ms) {
164
+ const { promise, resolve } = Promise.withResolvers();
165
+ setTimeout(resolve, ms);
166
+ return promise;
167
+ }
168
+ const defaultFlagRelaunchDeps = {
169
+ closeBrowserIfOpen,
170
+ ensureBrowserLaunched,
171
+ getCurrentBrowser,
172
+ sleep,
173
+ };
174
+ /**
175
+ * Wait until nothing else is inside a tool invocation, or refuse.
176
+ *
177
+ * A relaunch closes every page in the browser, which is defensible only when
178
+ * nothing is using it: the daemon answers several terminals at once, so the
179
+ * alternative is destroying the pages of an invocation that is still running —
180
+ * the failure this whole module exists to stop.
181
+ */
182
+ async function waitForBrowserIdle(toolName, sleep) {
183
+ const deadline = Date.now() + browserIdleWaitPolicy.timeoutMs;
184
+ for (;;) {
185
+ const users = otherBrowserUsers(toolName);
186
+ if (users.length === 0) {
187
+ return;
188
+ }
189
+ if (Date.now() >= deadline) {
190
+ throw new Error(`${toolName} needs the browser relaunched with Opera's automation flags, and it is in use by ${users.join(', ')} — relaunching it now would close that work. Timed out waiting for the browser to be free; retry when it is.`);
191
+ }
192
+ await sleep(browserIdleWaitPolicy.pollMs);
193
+ }
194
+ }
195
+ /**
196
+ * Make sure the running browser has the Opera automation flags `toolName` needs.
197
+ *
198
+ * One direction only, and that is the point. A browser this daemon launched
199
+ * *gains* the flags when an Opera AI tool first needs them and keeps them for the
200
+ * rest of its life; a tool that needs no flags is not this module's business at
201
+ * all. The reverse relaunch — taking the flags away again for an ordinary
202
+ * DevTools tool — was what closed the browser under a running `opera_do` when a
203
+ * second terminal ran `take_snapshot`, failing both streams with the AI
204
+ * dispatcher's "no target" error, and it is gone rather than made conditional:
205
+ * the flags are a property of the browser, not of the tool that happens to be
206
+ * running.
207
+ *
208
+ * The one relaunch that remains — the acquisition — waits for the browser to fall
209
+ * idle first, and reports why instead of closing anything if it does not.
210
+ */
211
+ export async function ensureBrowserFlagsForTool(toolName, serverArgs, logFile, control, deps = defaultFlagRelaunchDeps) {
212
+ if (!isLaunchMode(serverArgs) || !toolRequiresOperaFlags(toolName)) {
64
213
  return;
65
214
  }
66
- if (!needsOperaFlags && !(browserHasOperaFlags && browserConnected)) {
215
+ const current = deps.getCurrentBrowser();
216
+ if (current?.connected && current === flagsBrowser) {
67
217
  return;
68
218
  }
69
- logger?.(`Relaunching browser ${needsOperaFlags ? 'with' : 'without'} Opera automation flags for ${toolName}`);
219
+ await waitForBrowserIdle(toolName, deps.sleep);
220
+ logger?.(`Relaunching browser with Opera's automation flags for ${toolName}`);
70
221
  control.resetContext();
71
- browserHasOperaFlags = false;
222
+ flagsBrowser = undefined;
72
223
  await deps.closeBrowserIfOpen();
73
- if (needsOperaFlags) {
74
- // Launch options come from the shared `buildLaunchOptions` so the relaunch
75
- // applies exactly the same flags (including blocklist/allowlist and proxy)
76
- // as the normal launch in `index.ts`, plus the automation flags.
77
- await deps.ensureBrowserLaunched(buildLaunchOptions(serverArgs, logFile, {
78
- extraChromeArgs: OPERA_AUTOMATION_FLAGS,
79
- }));
80
- browserHasOperaFlags = true;
81
- }
82
- // Otherwise leave the browser closed: getContext() relaunches it without the
83
- // Opera flags on the next call.
224
+ // Launch options come from the shared `buildLaunchOptions` so the relaunch
225
+ // applies exactly the same flags (including blocklist/allowlist and proxy)
226
+ // as the normal launch in `index.ts`, plus the automation flags.
227
+ flagsBrowser = await deps.ensureBrowserLaunched(buildLaunchOptions(serverArgs, logFile, {
228
+ extraChromeArgs: OPERA_AUTOMATION_FLAGS,
229
+ }));
84
230
  }
85
231
  //# sourceMappingURL=browserFlags.js.map