dsh-builtin-browser 0.3.1 → 0.4.1

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 (34) hide show
  1. package/README.en.md +84 -39
  2. package/README.md +78 -38
  3. package/client.js +2 -3
  4. package/cordis.patch.yml +13 -7
  5. package/desktop-bridge/install.mjs +274 -12
  6. package/desktop-bridge/plugin-browser-bridge.js +55 -15
  7. package/docs/architecture.md +1 -1
  8. package/docs/tool-reference.md +8 -8
  9. package/docs/user-guide.md +4 -2
  10. package/docs/why-browser.md +1 -1
  11. package/lib/browser-electron/desktop-bridge-host.d.ts +10 -1
  12. package/lib/browser-electron/desktop-bridge-host.js +10 -2
  13. package/lib/browser-electron/entry.js +22 -2
  14. package/lib/browser-electron/history-store.js +5 -1
  15. package/lib/browser-electron/missing-system-browser.d.ts +54 -0
  16. package/lib/browser-electron/missing-system-browser.js +89 -0
  17. package/lib/browser-electron/provider.d.ts +54 -1
  18. package/lib/browser-electron/provider.js +241 -37
  19. package/lib/browser-electron/remote-host.js +6 -3
  20. package/lib/browser-electron/settings-store.js +10 -4
  21. package/lib/browser-electron/system-browser.d.ts +27 -6
  22. package/lib/browser-electron/system-browser.js +274 -37
  23. package/lib/tool-browser/index.d.ts +14 -0
  24. package/lib/tool-browser/index.js +143 -58
  25. package/package.json +1 -1
  26. package/src/browser-electron/desktop-bridge-host.ts +11 -2
  27. package/src/browser-electron/entry.ts +22 -2
  28. package/src/browser-electron/history-store.ts +5 -1
  29. package/src/browser-electron/missing-system-browser.ts +113 -0
  30. package/src/browser-electron/provider.ts +267 -36
  31. package/src/browser-electron/remote-host.ts +6 -3
  32. package/src/browser-electron/settings-store.ts +11 -4
  33. package/src/browser-electron/system-browser.ts +256 -31
  34. package/src/tool-browser/index.ts +149 -63
@@ -868,9 +868,12 @@ export class RemoteElectronViewHost {
868
868
  }
869
869
  /** The child died: tear down so the next use starts a fresh child. */
870
870
  onChildExit() {
871
- if (this.disposed)
872
- return;
873
- process.stderr.write('[dsh-browser host] browser host gone; will restart on next use\n');
871
+ // A disposed host still has to drop these: returning here left a child that had
872
+ // already been replaced during a dispose race in no map at all, so nothing would
873
+ // ever kill it. Only the restart is suppressed, not the cleanup.
874
+ const wasDisposed = this.disposed;
875
+ if (!wasDisposed)
876
+ process.stderr.write('[dsh-browser host] browser host gone; will restart on next use\n');
874
877
  this.client = undefined;
875
878
  this.server?.close();
876
879
  this.server = undefined;
@@ -157,11 +157,17 @@ export class SettingsStore {
157
157
  mkdirSync(dirname(this.file), { recursive: true });
158
158
  writeFileSync(this.file, `${JSON.stringify(merged, null, 2)}\n`);
159
159
  }
160
- catch {
161
- // Keep the merged value in memory even when the write fails.
160
+ catch (error) {
161
+ // Do NOT publish the new value as cached, and do not pretend the file changed: a
162
+ // reader compares the file's mtime against this stamp, so stamping "now" on a failed
163
+ // write made the next get() re-read the file and silently revert the user's change.
164
+ // Clear the cache before failing: the value on disk is still the old one, so leaving
165
+ // the merged document cached would hand the next reader something that does not exist —
166
+ // and the stamp would make it look fresh until the mtime comparison rolled it back.
167
+ this.cached = undefined;
168
+ this.cachedMtimeMs = -1;
169
+ throw new Error(`dsh-builtin-browser: settings could not be saved: ${error instanceof Error ? error.message : String(error)}`);
162
170
  }
163
- this.cached = merged;
164
- this.cachedMtimeMs = Date.now();
165
171
  return merged;
166
172
  }
167
173
  }
@@ -38,12 +38,28 @@ export interface DetectedBrowser {
38
38
  * Find an installed Chromium browser.
39
39
  *
40
40
  * `auto` prefers Chrome, then Edge, then Brave — the order reflects how likely each
41
- * is to be the browser a user actually chose rather than one Windows shipped.
41
+ * is to be the browser a user actually chose rather than one the system shipped.
42
+ * Each candidate is looked up in three places, most specific first: an explicit
43
+ * environment override, then PATH, then the platform's conventional install
44
+ * locations. PATH comes before the fixed paths because it is what actually reflects
45
+ * how the browser was installed.
42
46
  * @param channel - the configured choice; `bundled` never resolves to a system browser.
43
47
  * @param env - environment lookup, injected so tests need no real machine.
48
+ * @param platform - the platform to use conventions for; injected for the same reason.
44
49
  * @returns the detected browser, or undefined when the choice is unavailable.
45
50
  */
46
- export declare function detectBrowser(channel: BrowserChannel, env?: NodeJS.ProcessEnv): DetectedBrowser | undefined;
51
+ export declare function detectBrowser(channel: BrowserChannel, env?: NodeJS.ProcessEnv, platform?: NodeJS.Platform): DetectedBrowser | undefined;
52
+ /**
53
+ * Everything that was checked for one product, for reporting a miss.
54
+ *
55
+ * Detection returns undefined with no explanation, which left the caller able to say
56
+ * only "not found". This gives the user the actual list so they can see whether the
57
+ * plugin looked somewhere their browser is not.
58
+ * @param kind - the product to describe.
59
+ * @param platform - the platform whose names and locations apply.
60
+ * @returns the launcher names (as PATH lookups) and the fixed locations checked.
61
+ */
62
+ export declare function searchSummary(kind: DetectedBrowser['kind'], platform?: NodeJS.Platform): string[];
47
63
  /** A system browser driven over CDP, presented as a browser view host. */
48
64
  export declare class SystemBrowserViewHost implements ElectronBrowserViewHost {
49
65
  private readonly browser;
@@ -106,11 +122,16 @@ export declare class SystemBrowserViewHost implements ElectronBrowserViewHost {
106
122
  /** The browser owns window grouping. */
107
123
  groupView(): void;
108
124
  /**
109
- * Bring the browser's window forward.
125
+ * Best effort at bringing the browser's window forward.
110
126
  *
111
- * The browser is a separate application, so there is no view to raise; focusing
112
- * the process is the closest honest equivalent, and failing to do so (a minimized
113
- * window, a locked session) is never worth an error.
127
+ * The browser is a separate application, so there is no view to raise. What this can
128
+ * actually do is ask its process to continue from a stopped state — which is a no-op on
129
+ * Windows, where `SIGCONT` is not a supported signal and the call throws. It is kept
130
+ * because it costs nothing and does help on POSIX (a process stopped with SIGSTOP
131
+ * resumes), but it must not be read as a promise that the window comes to the front;
132
+ * importantly, it does NOT raise a window that is merely behind another. Raising the
133
+ * page properly would need `Page.bringToFront` on a specific target session, which is a
134
+ * view-level concern and not available to the host.
114
135
  */
115
136
  focus(): Promise<void>;
116
137
  /** The browser reports its own window lifecycle. */
@@ -30,34 +30,121 @@ import { join } from 'node:path';
30
30
  import { spawn } from 'node:child_process';
31
31
  import { randomUUID } from 'node:crypto';
32
32
  /**
33
- * Where each Chromium product installs on Windows, plus the registry paths that
34
- * survive a non-default install location. Only paths that exist are returned, so a
35
- * caller can tell "not installed" from "installed elsewhere".
33
+ * Launcher names to look for on PATH, per product and per platform.
34
+ *
35
+ * The names genuinely differ: Linux distributions package the launcher under a
36
+ * versioned name (`microsoft-edge-stable`, `google-chrome-stable`), Windows puts
37
+ * `chrome.exe` in an Application directory that may or may not be on PATH, and macOS
38
+ * ships the executable inside the .app bundle. PATH is still consulted first on every
39
+ * platform — it is what reflects how the user actually installed the browser.
40
+ */
41
+ const PATH_NAMES = {
42
+ chrome: {
43
+ win32: ['chrome'],
44
+ darwin: ['Google Chrome', 'Chromium'],
45
+ linux: ['google-chrome-stable', 'google-chrome', 'chromium', 'chromium-browser'],
46
+ },
47
+ edge: {
48
+ win32: ['msedge'],
49
+ darwin: ['Microsoft Edge'],
50
+ linux: ['microsoft-edge-stable', 'microsoft-edge', 'microsoft-edge-dev'],
51
+ },
52
+ brave: {
53
+ win32: ['brave'],
54
+ darwin: ['Brave Browser'],
55
+ linux: ['brave-browser', 'brave'],
56
+ },
57
+ };
58
+ /**
59
+ * Where each product installs when its launcher is not on PATH.
60
+ *
61
+ * Windows keeps the executable inside a version-less Application directory; macOS
62
+ * keeps it inside the .app bundle; Linux distributions that do not use a launcher
63
+ * put it under /usr/bin or /opt. All are checked, and anything missed can be named
64
+ * explicitly with DSH_BROWSER_<KIND>_PATH.
36
65
  */
37
- const WINDOWS_LOCATIONS = {
38
- chrome: [
39
- String.raw `C:\Program Files\Google\Chrome\Application\chrome.exe`,
40
- String.raw `C:\Program Files (x86)\Google\Chrome\Application\chrome.exe`,
41
- ],
42
- edge: [
43
- String.raw `C:\Program Files (x86)\Microsoft\Edge\Application\msedge.exe`,
44
- String.raw `C:\Program Files\Microsoft\Edge\Application\msedge.exe`,
45
- ],
46
- brave: [
47
- String.raw `C:\Program Files\BraveSoftware\Brave-Browser\Application\brave.exe`,
48
- String.raw `C:\Program Files (x86)\BraveSoftware\Brave-Browser\Application\brave.exe`,
49
- ],
66
+ const INSTALL_LOCATIONS = {
67
+ chrome: {
68
+ win32: [
69
+ String.raw `C:\Program Files\Google\Chrome\Application\chrome.exe`,
70
+ String.raw `C:\Program Files (x86)\Google\Chrome\Application\chrome.exe`,
71
+ ],
72
+ darwin: [
73
+ '/Applications/Google Chrome.app/Contents/MacOS/Google Chrome',
74
+ '/Applications/Chromium.app/Contents/MacOS/Chromium',
75
+ ],
76
+ linux: [
77
+ '/usr/bin/google-chrome-stable',
78
+ '/usr/bin/google-chrome',
79
+ '/usr/bin/chromium',
80
+ '/opt/google/chrome/chrome',
81
+ '/snap/bin/chromium',
82
+ ],
83
+ },
84
+ edge: {
85
+ win32: [
86
+ String.raw `C:\Program Files (x86)\Microsoft\Edge\Application\msedge.exe`,
87
+ String.raw `C:\Program Files\Microsoft\Edge\Application\msedge.exe`,
88
+ ],
89
+ darwin: ['/Applications/Microsoft Edge.app/Contents/MacOS/Microsoft Edge'],
90
+ linux: [
91
+ '/usr/bin/microsoft-edge-stable',
92
+ '/usr/bin/microsoft-edge',
93
+ '/usr/bin/microsoft-edge-dev',
94
+ ],
95
+ },
96
+ brave: {
97
+ win32: [
98
+ String.raw `C:\Program Files\BraveSoftware\Brave-Browser\Application\brave.exe`,
99
+ String.raw `C:\Program Files (x86)\BraveSoftware\Brave-Browser\Application\brave.exe`,
100
+ ],
101
+ darwin: ['/Applications/Brave Browser.app/Contents/MacOS/Brave Browser'],
102
+ linux: ['/usr/bin/brave-browser', '/usr/bin/brave', '/snap/bin/brave'],
103
+ },
50
104
  };
105
+ /**
106
+ * Find an executable by name on PATH.
107
+ *
108
+ * PATH is read from the supplied environment rather than the process's own, so the
109
+ * detection stays testable. On Windows a bare name needs its extension; elsewhere
110
+ * the launcher is the file itself.
111
+ * @param names - candidate launcher names, in preference order.
112
+ * @param env - the environment to read PATH from.
113
+ * @param platform - the platform whose PATH and extension conventions apply.
114
+ * @returns the first existing absolute path, or undefined.
115
+ */
116
+ function findOnPath(names, env, platform) {
117
+ // Windows spells it Path as often as PATH; the separator differs too.
118
+ const raw = env.PATH ?? env.Path ?? env.path ?? '';
119
+ const separator = platform === 'win32' ? ';' : ':';
120
+ const directories = raw.split(separator).map(entry => entry.trim()).filter(entry => entry !== '');
121
+ const suffixes = platform === 'win32' ? ['.exe', '.cmd', ''] : [''];
122
+ for (const name of names) {
123
+ for (const directory of directories) {
124
+ for (const suffix of suffixes) {
125
+ const candidate = join(directory, `${name}${suffix}`);
126
+ if (existsSync(candidate))
127
+ return candidate;
128
+ }
129
+ }
130
+ }
131
+ return undefined;
132
+ }
51
133
  /**
52
134
  * Find an installed Chromium browser.
53
135
  *
54
136
  * `auto` prefers Chrome, then Edge, then Brave — the order reflects how likely each
55
- * is to be the browser a user actually chose rather than one Windows shipped.
137
+ * is to be the browser a user actually chose rather than one the system shipped.
138
+ * Each candidate is looked up in three places, most specific first: an explicit
139
+ * environment override, then PATH, then the platform's conventional install
140
+ * locations. PATH comes before the fixed paths because it is what actually reflects
141
+ * how the browser was installed.
56
142
  * @param channel - the configured choice; `bundled` never resolves to a system browser.
57
143
  * @param env - environment lookup, injected so tests need no real machine.
144
+ * @param platform - the platform to use conventions for; injected for the same reason.
58
145
  * @returns the detected browser, or undefined when the choice is unavailable.
59
146
  */
60
- export function detectBrowser(channel, env = process.env) {
147
+ export function detectBrowser(channel, env = process.env, platform = process.platform) {
61
148
  if (channel === 'bundled')
62
149
  return undefined;
63
150
  const order = channel === 'auto' ? ['chrome', 'edge', 'brave'] : [channel];
@@ -65,16 +152,33 @@ export function detectBrowser(channel, env = process.env) {
65
152
  const fromEnv = (kind === 'chrome' ? env.DSH_BROWSER_CHROME_PATH : kind === 'edge' ? env.DSH_BROWSER_EDGE_PATH : undefined);
66
153
  if (fromEnv !== undefined && fromEnv !== '' && existsSync(fromEnv))
67
154
  return { kind, path: fromEnv };
68
- for (const candidate of WINDOWS_LOCATIONS[kind] ?? []) {
155
+ const onPath = findOnPath(PATH_NAMES[kind]?.[platform] ?? [], env, platform);
156
+ if (onPath !== undefined)
157
+ return { kind, path: onPath };
158
+ for (const candidate of INSTALL_LOCATIONS[kind]?.[platform] ?? []) {
69
159
  if (existsSync(candidate))
70
160
  return { kind, path: candidate };
71
161
  }
72
162
  }
73
163
  return undefined;
74
164
  }
165
+ /**
166
+ * Everything that was checked for one product, for reporting a miss.
167
+ *
168
+ * Detection returns undefined with no explanation, which left the caller able to say
169
+ * only "not found". This gives the user the actual list so they can see whether the
170
+ * plugin looked somewhere their browser is not.
171
+ * @param kind - the product to describe.
172
+ * @param platform - the platform whose names and locations apply.
173
+ * @returns the launcher names (as PATH lookups) and the fixed locations checked.
174
+ */
175
+ export function searchSummary(kind, platform = process.platform) {
176
+ const onPath = (PATH_NAMES[kind]?.[platform] ?? []).map(name => `${name} (on PATH)`);
177
+ const located = INSTALL_LOCATIONS[kind]?.[platform] ?? [];
178
+ return [...onPath, ...located];
179
+ }
75
180
  /** One CDP connection to a browser-level WebSocket, with flattened sessions. */
76
181
  class CdpClient {
77
- url;
78
182
  nextId = 1;
79
183
  pending = new Map();
80
184
  ready;
@@ -82,7 +186,6 @@ class CdpClient {
82
186
  * @param url - the browser's `webSocketDebuggerUrl`.
83
187
  */
84
188
  constructor(url) {
85
- this.url = url;
86
189
  // Node 22 provides WebSocket globally; no dependency is added for this.
87
190
  const socket = new WebSocket(url);
88
191
  this.socket = socket;
@@ -94,9 +197,42 @@ class CdpClient {
94
197
  socket.addEventListener('close', () => this.fail(new Error('dsh-builtin-browser: the browser closed the CDP connection')));
95
198
  }
96
199
  socket;
97
- /** Resolve once the socket is open. */
200
+ /**
201
+ * Resolve once the socket is open.
202
+ *
203
+ * Bounded on purpose. `ready` settles on the socket's own `open` or `error` events, and
204
+ * a connection that is dropped by something in between may deliver neither — in which
205
+ * case an unbounded await here would hang the tool call forever, because the caller's
206
+ * own deadline is only checked between polls and this await happens inside one of them.
207
+ * @returns when the handshake completed.
208
+ */
209
+ /**
210
+ * Whether the socket is still usable.
211
+ *
212
+ * A dropped connection is not reported to the caller of send(); the pending command
213
+ * would simply never complete. Asking the socket directly is what lets a cached client
214
+ * be discarded instead of reused, which is the difference between a restart and a 30s
215
+ * hang on every subsequent call.
216
+ * @returns true while the socket is open.
217
+ */
218
+ isAlive() {
219
+ return this.socket.readyState === WebSocket.OPEN;
220
+ }
98
221
  async whenReady() {
99
- await this.ready;
222
+ const timeoutMs = 30_000;
223
+ let timer;
224
+ try {
225
+ await Promise.race([
226
+ this.ready,
227
+ new Promise((_resolve, reject) => {
228
+ timer = setTimeout(() => reject(new Error(`dsh-builtin-browser: the browser did not complete the CDP handshake within ${timeoutMs / 1000}s`)), timeoutMs);
229
+ }),
230
+ ]);
231
+ }
232
+ finally {
233
+ if (timer !== undefined)
234
+ clearTimeout(timer);
235
+ }
100
236
  }
101
237
  /**
102
238
  * Send one CDP command, optionally on an attached session.
@@ -106,6 +242,11 @@ class CdpClient {
106
242
  * @returns the CDP result.
107
243
  */
108
244
  async send(method, params, sessionId) {
245
+ // Fail fast and say why. Waiting out the timeout on a closed socket produced
246
+ // only "timed out", which reads as a slow page and hides the real cause.
247
+ if (!this.isAlive()) {
248
+ throw new Error('dsh-builtin-browser: the browser connection is closed (the browser window was probably closed); the next command will start it again');
249
+ }
109
250
  await this.ready;
110
251
  const id = this.nextId++;
111
252
  const message = { id, method, ...params !== undefined ? { params } : {}, ...sessionId !== undefined ? { sessionId } : {} };
@@ -198,8 +339,19 @@ export class SystemBrowserViewHost {
198
339
  * @returns the connected client.
199
340
  */
200
341
  async ensureClient() {
201
- if (this.client !== undefined)
202
- return this.client;
342
+ // A disposed host must never start a browser: doing so would spawn a process
343
+ // nobody owns and nobody will kill.
344
+ if (this.disposed)
345
+ throw new Error('dsh-builtin-browser: this browser host was released');
346
+ // The exit listener clears this, but a socket can also die without the process doing so
347
+ // (the browser closing its debugging endpoint, a dropped connection). Checking the
348
+ // socket's own state costs nothing and turns a 30s hang into a restart.
349
+ if (this.client !== undefined) {
350
+ if (this.client.isAlive())
351
+ return this.client;
352
+ this.client.close();
353
+ this.client = undefined;
354
+ }
203
355
  // Concurrent first calls share one startup rather than racing two browsers.
204
356
  this.starting ??= this.start().finally(() => { this.starting = undefined; });
205
357
  return await this.starting;
@@ -228,16 +380,63 @@ export class SystemBrowserViewHost {
228
380
  ];
229
381
  const child = spawn(this.browser.path, args, { stdio: ['ignore', 'ignore', 'ignore'], windowsHide: false });
230
382
  this.child = child;
383
+ /**
384
+ * A launch that fails (ENOENT, no permission, a broken binary) is reported by an
385
+ * asynchronous 'error' event on the child, NOT by a throw here and NOT by exitCode.
386
+ * Without this listener Node treats it as unhandled and takes the whole host process
387
+ * down — one bad browser path would kill DSH, not just fail a tool call.
388
+ */
389
+ let launchError;
390
+ child.on('error', error => { launchError = error; });
391
+ // A browser that exits later is as unusable as one that never started, and the failure
392
+ // has to reach the NEXT call rather than being swallowed: clearing the client here is
393
+ // what makes ensureClient() start a fresh browser instead of returning a socket to a
394
+ // dead process and letting every command time out.
395
+ child.on('exit', () => {
396
+ if (this.child !== child)
397
+ return;
398
+ this.client?.close();
399
+ this.client = undefined;
400
+ this.child = undefined;
401
+ });
231
402
  const portFile = join(this.profileDir, 'DevToolsActivePort');
232
403
  const deadline = Date.now() + 30_000;
404
+ /**
405
+ * Why the loop stopped, so the failure can say what actually happened. Reporting
406
+ * every ending as "did not expose CDP within 30s" was wrong three ways: the cause,
407
+ * the words, and the elapsed time — a browser that cannot be launched says so in
408
+ * half a second, and the caller then looks for a CDP problem that does not exist.
409
+ */
410
+ let stopped = 'timeout';
233
411
  for (;;) {
412
+ // Released while we were starting: stop here rather than keep polling for a
413
+ // browser that dispose() has already killed, which would leave a connection
414
+ // to a dead process behind. The child is killed here too — dispose() may have
415
+ // run before this child was recorded, leaving nobody else to do it.
416
+ if (this.disposed) {
417
+ child.kill();
418
+ if (this.child === child)
419
+ this.child = undefined;
420
+ stopped = 'released';
421
+ break;
422
+ }
423
+ if (launchError !== undefined) {
424
+ if (this.child === child)
425
+ this.child = undefined;
426
+ stopped = 'launch-failed';
427
+ break;
428
+ }
234
429
  if (Date.now() > deadline) {
235
430
  child.kill();
236
- this.child = undefined;
431
+ if (this.child === child)
432
+ this.child = undefined;
433
+ stopped = 'timeout';
237
434
  break;
238
435
  }
239
436
  if (child.exitCode !== null) {
240
- this.child = undefined;
437
+ if (this.child === child)
438
+ this.child = undefined;
439
+ stopped = 'exited';
241
440
  break;
242
441
  }
243
442
  if (existsSync(portFile)) {
@@ -248,6 +447,12 @@ export class SystemBrowserViewHost {
248
447
  if (typeof version.webSocketDebuggerUrl === 'string') {
249
448
  const client = new CdpClient(version.webSocketDebuggerUrl);
250
449
  await client.whenReady();
450
+ // Still wanted? A dispose() during the handshake has already killed the
451
+ // process, so publishing the client would leave a dangling connection.
452
+ if (this.disposed) {
453
+ client.close();
454
+ break;
455
+ }
251
456
  this.client = client;
252
457
  return client;
253
458
  }
@@ -259,7 +464,20 @@ export class SystemBrowserViewHost {
259
464
  }
260
465
  await new Promise(resolve => setTimeout(resolve, 250));
261
466
  }
262
- throw new Error(`dsh-builtin-browser: ${this.browser.kind} did not expose CDP within 30s (${this.browser.path})`);
467
+ // Each ending gets its own message. The old single message blamed a CDP timeout for
468
+ // all of them, which sent the reader looking for a port problem when the browser had
469
+ // simply failed to launch.
470
+ const target = `${this.browser.kind} (${this.browser.path})`;
471
+ if (stopped === 'launch-failed') {
472
+ throw new Error(`dsh-builtin-browser: could not launch ${target}: ${launchError?.message ?? 'unknown error'}`);
473
+ }
474
+ if (stopped === 'exited') {
475
+ throw new Error(`dsh-builtin-browser: ${target} exited immediately after launch (exit code ${child.exitCode}); check that the path is a runnable browser`);
476
+ }
477
+ if (stopped === 'released') {
478
+ throw new Error(`dsh-builtin-browser: ${target} was released while it was starting`);
479
+ }
480
+ throw new Error(`dsh-builtin-browser: ${target} did not expose CDP within 30s`);
263
481
  }
264
482
  /**
265
483
  * Whether this host can back views.
@@ -275,11 +493,25 @@ export class SystemBrowserViewHost {
275
493
  const viewId = randomUUID();
276
494
  return {
277
495
  id: viewId,
496
+ // This is the real browser, not Electron: CDP's JPEG encoder works here, so the
497
+ // provider may pass format and quality through instead of falling back to PNG.
498
+ supportsCdpJpeg: true,
278
499
  sendCommand: async (method, params) => {
279
- const client = await this.ensureClient();
280
- const session = this.views.get(viewId) ?? await this.ensureSession(viewId);
281
- const result = await client.send(method, params, session);
282
- return (result ?? {});
500
+ // A command that never answered is the only signal that a client has wedged — the
501
+ // socket can stay OPEN while nothing comes back, which is the form the reporter saw with
502
+ // a live, healthy-looking Edge. Dropping the client here means the next call starts a new
503
+ // browser rather than hanging again, and the error names the cause instead of a timeout.
504
+ try {
505
+ const client = await this.ensureClient();
506
+ const session = this.views.get(viewId) ?? await this.ensureSession(viewId);
507
+ const result = await client.send(method, params, session);
508
+ return (result ?? {});
509
+ }
510
+ catch (error) {
511
+ this.client?.close();
512
+ this.client = undefined;
513
+ throw error;
514
+ }
283
515
  },
284
516
  };
285
517
  }
@@ -305,17 +537,22 @@ export class SystemBrowserViewHost {
305
537
  /** The browser owns window grouping. */
306
538
  groupView() { }
307
539
  /**
308
- * Bring the browser's window forward.
540
+ * Best effort at bringing the browser's window forward.
309
541
  *
310
- * The browser is a separate application, so there is no view to raise; focusing
311
- * the process is the closest honest equivalent, and failing to do so (a minimized
312
- * window, a locked session) is never worth an error.
542
+ * The browser is a separate application, so there is no view to raise. What this can
543
+ * actually do is ask its process to continue from a stopped state — which is a no-op on
544
+ * Windows, where `SIGCONT` is not a supported signal and the call throws. It is kept
545
+ * because it costs nothing and does help on POSIX (a process stopped with SIGSTOP
546
+ * resumes), but it must not be read as a promise that the window comes to the front;
547
+ * importantly, it does NOT raise a window that is merely behind another. Raising the
548
+ * page properly would need `Page.bringToFront` on a specific target session, which is a
549
+ * view-level concern and not available to the host.
313
550
  */
314
551
  async focus() {
315
552
  try {
316
553
  this.child?.kill('SIGCONT');
317
554
  }
318
- catch { /* not supported on Windows; harmless */ }
555
+ catch { /* Windows has no SIGCONT; nothing to do */ }
319
556
  }
320
557
  /** The browser reports its own window lifecycle. */
321
558
  onUserAction() { }
@@ -28,6 +28,20 @@ export interface Config {
28
28
  /** Optional initial allow-list of browser tool names; other tools are refused. */
29
29
  readonly allowedActions?: readonly string[];
30
30
  }
31
+ /**
32
+ * Guard one browser tool call against the active restriction. Refuses calls
33
+ * not on the allow-list when a restriction is in effect.
34
+ * @param state - the calling context's tool state.
35
+ * @param toolName - the browser tool about to run.
36
+ */
37
+ /**
38
+ * Tools that only observe, plus the ones that undo a restriction.
39
+ *
40
+ * Never subject to an allow-list: restricting what the agent may *do* must not also
41
+ * blind it or trap it, and the tool descriptions promise as much. browser_restrict
42
+ * is in here so a task that restricted everything can still lift it.
43
+ */
44
+ export declare const READ_ONLY_TOOLS: ReadonlySet<string>;
31
45
  /** Register all browser tools with `ctx.tools`. */
32
46
  export declare function apply(ctx: Context, config?: Config): void;
33
47
  /** Test hook: inspect and reset session mappings across every live plugin apply. */