amalgm 0.1.89 → 0.1.90

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,60 +1,122 @@
1
1
  'use strict';
2
2
 
3
3
  /**
4
- * Browser engine: the verb set behind the browser/cua/recorder tools, with
5
- * backend routing.
4
+ * Browser engine: the verb set behind the browser toolbox entry, with
5
+ * deterministic backend routing by entrypoint.
6
6
  *
7
- * Backends, in order of preference:
8
- * 1. "attached" — agent-browser connected over CDP to the Amalgm desktop
9
- * app's visible in-tab webview. Requires the Electron bridge file to
10
- * advertise a cdpUrl (or AMALGM_BROWSER_CDP_URL). Full agent-browser
11
- * power against the splitscreen surface the user can see.
12
- * 2. "electron" — legacy HTTP bridge into the desktop app's webview. Covers
13
- * the core verbs; no CLI passthrough.
14
- * 3. "cli" — standalone agent-browser session (headed window on
15
- * desktop, headless on servers).
7
+ * Backends exactly two:
8
+ * 1. "attached" — agent-browser connected over CDP to a visible chromium.
9
+ * On desktop that is the Amalgm app's own webContents: the app enables
10
+ * remote debugging on localhost and advertises its CDP URL in
11
+ * electron-browser-bridge.json under the runtime state dir. The engine
12
+ * attaches, asks the UI (via a browser_surfaces state event) to open the
13
+ * in-tab split view, and selects that webview target — so commands drive
14
+ * the surface the user is watching. An explicit AMALGM_BROWSER_CDP_URL
15
+ * attaches to any other CDP endpoint the same way.
16
+ * 2. "cli" — standalone agent-browser chromium, headless. The deterministic
17
+ * path for npm-only machines, servers, and the web entrypoint.
18
+ *
19
+ * The rule: a CDP advertisement (env or fresh bridge file) ⇒ attached;
20
+ * otherwise ⇒ cli. AMALGM_BROWSER_BACKEND forces a backend for debugging.
16
21
  *
17
22
  * Sessions map to durable profiles; the default session for a tool call is
18
23
  * the caller's chat session, so one conversation keeps one browser.
19
24
  */
20
25
 
21
- const electron = require('./electron-bridge');
22
26
  const cli = require('./cli');
23
27
 
24
28
  const attachedSessions = new Set();
25
29
  const knownSessions = new Map();
26
30
 
27
- function isPackagedDesktopRuntime() {
28
- const execPath = process.execPath || '';
29
- return process.env.ELECTRON_RUN_AS_NODE === '1' && execPath.includes('.app/Contents/MacOS/');
31
+ const SURFACE_EVENT_RESOURCE = 'browser_surfaces';
32
+ const WEBVIEW_MARKER_PREFIX = 'amalgm-bsid-';
33
+ const WEBVIEW_WAIT_MS = 15_000;
34
+ const WEBVIEW_POLL_MS = 500;
35
+
36
+ // ---------------------------------------------------------------------------
37
+ // Backend routing
38
+ // ---------------------------------------------------------------------------
39
+
40
+ function pidIsRunning(pid) {
41
+ if (!Number.isInteger(pid) || pid <= 0) return false;
42
+ try {
43
+ process.kill(pid, 0);
44
+ return true;
45
+ } catch (err) {
46
+ return Boolean(err && err.code === 'EPERM');
47
+ }
30
48
  }
31
49
 
32
- function cdpUrl() {
33
- if (process.env.AMALGM_BROWSER_CDP_URL) return process.env.AMALGM_BROWSER_CDP_URL;
50
+ /**
51
+ * Bridge advertisement candidates, most specific first. Every runtime
52
+ * generation can find the file written by the desktop app:
53
+ * - Electron-spawned and LaunchAgent runtimes have AMALGM_RUNTIME_STATE_DIR;
54
+ * - runtimes with only AMALGM_DIR (+ label) derive the same path;
55
+ * - bare dev runtimes fall back to the unscoped roots.
56
+ */
57
+ function bridgeFileCandidates() {
58
+ const path = require('path');
59
+ const os = require('os');
60
+ const dirs = [];
61
+ if (process.env.AMALGM_RUNTIME_STATE_DIR) dirs.push(process.env.AMALGM_RUNTIME_STATE_DIR);
62
+ const amalgmDir = process.env.AMALGM_DIR;
63
+ const label = process.env.AMALGM_RUNTIME_LABEL || process.env.AMALGM_BRANCH;
64
+ if (amalgmDir && label) dirs.push(path.join(amalgmDir, 'runtimes', label));
65
+ if (amalgmDir) dirs.push(amalgmDir);
66
+ dirs.push(path.join(os.homedir(), '.amalgm'));
67
+ return [...new Set(dirs)].map((dir) => path.join(dir, 'electron-browser-bridge.json'));
68
+ }
69
+
70
+ function readBridgeAdvertisement() {
71
+ const fs = require('fs');
72
+ for (const file of bridgeFileCandidates()) {
73
+ try {
74
+ const data = JSON.parse(fs.readFileSync(file, 'utf8'));
75
+ if (typeof data?.cdpUrl !== 'string' || !data.cdpUrl) continue;
76
+ if (typeof data?.pid === 'number' && !pidIsRunning(data.pid)) continue;
77
+ return { cdpUrl: data.cdpUrl, cdpPort: data.cdpPort, label: data.label, file };
78
+ } catch {}
79
+ }
80
+ return null;
81
+ }
82
+
83
+ /** Reduce a CDP url/port advertisement to the value agent-browser's --cdp flag takes. */
84
+ function cdpFlagValue(input) {
85
+ const raw = String(input || '').trim();
86
+ if (!raw) return null;
87
+ if (/^\d+$/.test(raw)) return raw;
34
88
  try {
35
- const fs = require('fs');
36
- const path = require('path');
37
- const file = path.join(cli.amalgmDir(), 'electron-browser-bridge.json');
38
- const data = JSON.parse(fs.readFileSync(file, 'utf8'));
39
- if (typeof data?.cdpUrl === 'string' && data.cdpUrl) return data.cdpUrl;
40
- if (Number.isInteger(data?.cdpPort) && data.cdpPort > 0) return String(data.cdpPort);
89
+ const url = new URL(raw);
90
+ if (url.port) return url.port;
41
91
  } catch {}
42
- return null;
92
+ return raw;
93
+ }
94
+
95
+ /**
96
+ * The CDP endpoint for attached mode, as a --cdp flag value. agent-browser's
97
+ * `connect` command launches a managed browser; raw --cdp attach is the mode
98
+ * that discovers Electron <webview> targets, so that is all we use.
99
+ */
100
+ function cdpEndpoint() {
101
+ if (process.env.AMALGM_BROWSER_CDP_URL) return cdpFlagValue(process.env.AMALGM_BROWSER_CDP_URL);
102
+ const advertisement = readBridgeAdvertisement();
103
+ if (!advertisement) return null;
104
+ if (advertisement.cdpPort) return String(advertisement.cdpPort);
105
+ return cdpFlagValue(advertisement.cdpUrl);
43
106
  }
44
107
 
45
108
  function mode() {
46
109
  const requested = (process.env.AMALGM_BROWSER_BACKEND || '').toLowerCase();
47
- if (requested === 'agent-browser' || requested === 'external' || requested === 'cli') return 'cli';
48
- if (requested === 'electron') return 'electron';
49
-
50
- if (electron.isConfigured() || isPackagedDesktopRuntime()) {
51
- return cdpUrl() ? 'attached' : 'electron';
52
- }
53
- return 'cli';
110
+ if (['agent-browser', 'external', 'cli', 'headless'].includes(requested)) return 'cli';
111
+ if (['attached', 'cdp', 'electron'].includes(requested)) return 'attached';
112
+ return cdpEndpoint() ? 'attached' : 'cli';
54
113
  }
55
114
 
56
- function usesElectronBridge() {
57
- return mode() === 'electron';
115
+ /** Options every cli call needs for the current backend. */
116
+ function cliOptions(session, extra = {}) {
117
+ const options = { session, ...extra };
118
+ if (mode() === 'attached') options.cdp = cdpEndpoint();
119
+ return options;
58
120
  }
59
121
 
60
122
  function sessionFor(args = {}, context = {}) {
@@ -94,34 +156,154 @@ function sessionInfo(session, extra = {}) {
94
156
  return info;
95
157
  }
96
158
 
159
+ // ---------------------------------------------------------------------------
160
+ // Attached mode: surface open + webview target selection
161
+ // ---------------------------------------------------------------------------
162
+
163
+ /**
164
+ * Ask the UI to open the in-tab browser surface for this session. Published
165
+ * on the runtime state-event bus; ChatView subscribes to browser_surfaces
166
+ * and mounts the split-view webview with a marker URL the target scan below
167
+ * can recognize.
168
+ */
169
+ function requestSurfaceOpen(session, context) {
170
+ try {
171
+ const { appendStateEvent } = require('../state/events');
172
+ appendStateEvent({
173
+ resource: SURFACE_EVENT_RESOURCE,
174
+ op: 'insert',
175
+ id: session,
176
+ value: {
177
+ browserSessionId: session,
178
+ callerSessionId: context?.callerSessionId || null,
179
+ marker: `${WEBVIEW_MARKER_PREFIX}${session}`,
180
+ requestedAt: new Date().toISOString(),
181
+ },
182
+ source: 'browser-engine',
183
+ });
184
+ return true;
185
+ } catch {
186
+ return false;
187
+ }
188
+ }
189
+
190
+ async function listWebviewTabs(session) {
191
+ const tabs = await cli.runCli(['tab'], cliOptions(session, { timeoutMs: 15_000 }));
192
+ const list = tabs?.data?.tabs || tabs?.tabs || [];
193
+ return list.filter((tab) => tab.type === 'webview');
194
+ }
195
+
196
+ function matchWebview(webviews, session) {
197
+ const marker = `${WEBVIEW_MARKER_PREFIX}${session}`;
198
+ const byMarker = webviews.find((tab) => String(tab.url || '').includes(marker)
199
+ || String(tab.title || '').includes(marker));
200
+ if (byMarker) return byMarker;
201
+ const previous = knownSessions.get(session)?.webviewTabId;
202
+ if (previous) {
203
+ const byPrevious = webviews.find((tab) => tab.tabId === previous);
204
+ if (byPrevious) return byPrevious;
205
+ }
206
+ if (webviews.length === 1) return webviews[0];
207
+ return null;
208
+ }
209
+
97
210
  /**
98
- * In attached mode, connect the session to the desktop app's chromium on
99
- * first use and select the in-tab webview target so commands drive the
100
- * surface the user is looking at.
211
+ * In attached mode, connect the session to the advertised chromium on first
212
+ * use and select the in-tab webview target so commands drive the surface the
213
+ * user is looking at. Fails closed: if no webview target can be found we
214
+ * error with instructions instead of silently driving the outer app window.
101
215
  */
102
- async function ensureReady(session) {
216
+ async function ensureReady(session, context) {
103
217
  if (mode() !== 'attached' || attachedSessions.has(session)) return;
104
- const target = cdpUrl();
105
- await cli.runCli(['connect', target], { session });
218
+
219
+ let webviews = await listWebviewTabs(session);
220
+ let selected = matchWebview(webviews, session);
221
+
222
+ if (!selected) {
223
+ requestSurfaceOpen(session, context);
224
+ const deadline = Date.now() + WEBVIEW_WAIT_MS;
225
+ while (!selected && Date.now() < deadline) {
226
+ await new Promise((resolve) => setTimeout(resolve, WEBVIEW_POLL_MS));
227
+ webviews = await listWebviewTabs(session);
228
+ selected = matchWebview(webviews, session);
229
+ }
230
+ }
231
+
232
+ if (!selected && webviews.length === 0 && process.env.AMALGM_BROWSER_CDP_URL) {
233
+ // Explicit external CDP endpoints (plain chromium) have page targets,
234
+ // not webviews — drive whatever the daemon selected by default.
235
+ attachedSessions.add(session);
236
+ return;
237
+ }
238
+
239
+ if (!selected) {
240
+ throw new Error(
241
+ 'Browser surface did not open in the desktop app. '
242
+ + 'Retry, or force a headless browser with AMALGM_BROWSER_BACKEND=cli.',
243
+ );
244
+ }
245
+
246
+ await cli.runCli(['tab', selected.tabId], cliOptions(session, { timeoutMs: 15_000 }));
247
+ await focusWebviewHost(session, selected);
248
+ knownSessions.set(session, { ...knownSessions.get(session), webviewTabId: selected.tabId });
249
+ attachedSessions.add(session);
250
+ }
251
+
252
+ /**
253
+ * Chromium drops CDP Input events to an Electron <webview> guest until the
254
+ * guest widget has focus (verified live: clicks report success but never
255
+ * land). One focus pass after target selection fixes input for the session:
256
+ * focus the <webview> element from the embedder page, then self-focus the
257
+ * guest. Best-effort — failures leave snapshot/eval working regardless.
258
+ */
259
+ async function focusWebviewHost(session, selected) {
260
+ const marker = `${WEBVIEW_MARKER_PREFIX}${session}`;
106
261
  try {
107
- const tabs = await cli.runCli(['tab'], { session, timeoutMs: 15_000 });
262
+ const tabs = await cli.runCli(['tab'], cliOptions(session, { timeoutMs: 15_000 }));
108
263
  const list = tabs?.data?.tabs || tabs?.tabs || [];
109
- const webview = list.find((tab) => tab.type === 'webview');
110
- if (webview?.tabId) await cli.runCli(['tab', webview.tabId], { session, timeoutMs: 15_000 });
264
+ const outer = list.find((tab) => tab.type === 'page');
265
+ if (outer) {
266
+ const script = `(() => {
267
+ const views = [...document.querySelectorAll('webview')];
268
+ const target = views.find((w) => (w.getAttribute('src') || '').includes(${JSON.stringify(marker)}))
269
+ || views.find((w) => (w.src || '') === ${JSON.stringify(String(selected.url || ''))})
270
+ || views[views.length - 1];
271
+ if (target) target.focus();
272
+ return views.length;
273
+ })()`;
274
+ await cli.runCli(['tab', outer.tabId], cliOptions(session, { timeoutMs: 15_000 }));
275
+ await cli.runCli(['eval', script], cliOptions(session, { timeoutMs: 15_000 }));
276
+ await cli.runCli(['tab', selected.tabId], cliOptions(session, { timeoutMs: 15_000 }));
277
+ }
278
+ await cli.runCli(['eval', 'window.focus()'], cliOptions(session, { timeoutMs: 15_000 }));
111
279
  } catch {
112
- // No webview target yet the outer page target still works.
280
+ // Snapshot/eval still work without focus; input may need a retry.
113
281
  }
114
- attachedSessions.add(session);
282
+ }
283
+
284
+ function looksLikeLostConnection(err) {
285
+ return /clos|connect|target|socket|detach/i.test(String(err?.message || err || ''));
115
286
  }
116
287
 
117
288
  async function run(session, commandArgs, options = {}) {
118
- await ensureReady(session);
119
- return cli.runCli(commandArgs, { session, ...options });
289
+ const { context, ...cliExtra } = options;
290
+ await ensureReady(session, context);
291
+ try {
292
+ return await cli.runCli(commandArgs, cliOptions(session, cliExtra));
293
+ } catch (err) {
294
+ if (mode() === 'attached' && attachedSessions.has(session) && looksLikeLostConnection(err)) {
295
+ // The app restarted or the webview remounted; re-attach once.
296
+ attachedSessions.delete(session);
297
+ await ensureReady(session, context);
298
+ return cli.runCli(commandArgs, cliOptions(session, cliExtra));
299
+ }
300
+ throw err;
301
+ }
120
302
  }
121
303
 
122
304
  async function readText(session, commandArgs, timeoutMs) {
123
305
  await ensureReady(session);
124
- return cli.readText(commandArgs, session, timeoutMs);
306
+ return cli.readText(commandArgs, session, timeoutMs, mode() === 'attached' ? cdpEndpoint() : null);
125
307
  }
126
308
 
127
309
  /**
@@ -141,20 +323,12 @@ function findArgs(target, action, value) {
141
323
  return command;
142
324
  }
143
325
 
144
- function electronLocator(target) {
145
- if (target.startsWith('@')) return { ref: target };
146
- if (target.startsWith('text=')) return { text: target.slice('text='.length) };
147
- return { selector: target };
148
- }
149
-
150
326
  // ---------------------------------------------------------------------------
151
327
  // Core verbs
152
328
  // ---------------------------------------------------------------------------
153
329
 
154
330
  async function state(args = {}, context) {
155
331
  const session = sessionFor(args, context);
156
- if (usesElectronBridge()) return electron.stateBrowser({ browserSessionId: session }, context);
157
-
158
332
  let url = '';
159
333
  let title = '';
160
334
  try { url = await readText(session, ['get', 'url']); } catch {}
@@ -164,39 +338,24 @@ async function state(args = {}, context) {
164
338
 
165
339
  async function open(args = {}, context) {
166
340
  const session = sessionFor(args, context);
167
- if (usesElectronBridge()) {
168
- const result = await electron.navigateBrowser({ url: args.url, browserSessionId: session }, context);
169
- return sessionInfo(session, { url: result.url || args.url, title: result.title || '' });
170
- }
171
- await run(session, ['open', args.url]);
341
+ await run(session, ['open', args.url], { context });
172
342
  return state(args, context);
173
343
  }
174
344
 
175
345
  async function snapshot(args = {}, context) {
176
346
  const session = sessionFor(args, context);
177
- if (usesElectronBridge()) {
178
- const result = await electron.snapshotBrowser({ browserSessionId: session }, context);
179
- return sessionInfo(session, {
180
- url: result.url,
181
- title: result.title,
182
- snapshotText: result.snapshotText || result.snapshot || '',
183
- });
184
- }
185
- const result = await run(session, ['snapshot', '-i'], { timeoutMs: 30_000 });
347
+ const result = await run(session, ['snapshot', '-i'], { timeoutMs: 30_000, context });
186
348
  const current = await state(args, context);
187
349
  return { ...current, snapshotText: cli.asText(result) };
188
350
  }
189
351
 
190
352
  async function screenshot(args = {}, context) {
191
353
  const session = sessionFor(args, context);
192
- if (usesElectronBridge()) {
193
- return electron.screenshotBrowser({ browserSessionId: session, fullPage: args.fullPage }, context);
194
- }
195
354
  const fs = require('fs');
196
355
  const path = require('path');
197
356
  const crypto = require('crypto');
198
357
  const file = path.join(cli.screenshotDir(), `${session}-${Date.now()}-${crypto.randomUUID()}.png`);
199
- await run(session, ['screenshot', ...(args.fullPage ? ['--full'] : []), file]);
358
+ await run(session, ['screenshot', ...(args.fullPage ? ['--full'] : []), file], { context });
200
359
  const buffer = fs.readFileSync(file);
201
360
  return {
202
361
  ...sessionInfo(session),
@@ -210,13 +369,9 @@ async function screenshot(args = {}, context) {
210
369
  async function click(args = {}, context) {
211
370
  const session = sessionFor(args, context);
212
371
  const target = String(args.target || '');
213
- if (usesElectronBridge()) {
214
- await electron.clickBrowser({ ...electronLocator(target), browserSessionId: session }, context);
215
- return sessionInfo(session, { clicked: target });
216
- }
217
372
  await run(session, target.startsWith('text=')
218
373
  ? findArgs(target, 'click')
219
- : ['click', target]);
374
+ : ['click', target], { context });
220
375
  return sessionInfo(session, { clicked: target });
221
376
  }
222
377
 
@@ -224,35 +379,22 @@ async function fill(args = {}, context) {
224
379
  const session = sessionFor(args, context);
225
380
  const target = String(args.target || '');
226
381
  const text = String(args.text ?? '');
227
- if (usesElectronBridge()) {
228
- await electron.typeBrowser({ ...electronLocator(target), text, browserSessionId: session }, context);
229
- if (args.submit) await electron.pressBrowser({ key: 'Enter', browserSessionId: session }, context);
230
- return sessionInfo(session, { filled: target, submitted: Boolean(args.submit) });
231
- }
232
382
  await run(session, target.startsWith('text=')
233
383
  ? findArgs(target, 'fill', text)
234
- : ['fill', target, text]);
235
- if (args.submit) await run(session, ['press', 'Enter']);
384
+ : ['fill', target, text], { context });
385
+ if (args.submit) await run(session, ['press', 'Enter'], { context });
236
386
  return sessionInfo(session, { filled: target, submitted: Boolean(args.submit) });
237
387
  }
238
388
 
239
389
  async function press(args = {}, context) {
240
390
  const session = sessionFor(args, context);
241
- if (usesElectronBridge()) {
242
- await electron.pressBrowser({ key: args.key, browserSessionId: session }, context);
243
- return sessionInfo(session, { pressed: args.key });
244
- }
245
- await run(session, ['press', args.key]);
391
+ await run(session, ['press', args.key], { context });
246
392
  return sessionInfo(session, { pressed: args.key });
247
393
  }
248
394
 
249
395
  async function evaluate(args = {}, context) {
250
396
  const session = sessionFor(args, context);
251
- if (usesElectronBridge()) {
252
- const { result } = await electron.evaluateBrowser({ script: args.script, browserSessionId: session }, context);
253
- return { ...sessionInfo(session), result };
254
- }
255
- const result = await run(session, ['eval', args.script || '']);
397
+ const result = await run(session, ['eval', args.script || ''], { context });
256
398
  const data = result?.data ?? result;
257
399
  // agent-browser wraps eval results as { origin, result }.
258
400
  const value = data && typeof data === 'object' && 'result' in data ? data.result : data;
@@ -268,10 +410,6 @@ async function wait(args = {}, context) {
268
410
  return sessionInfo(session, { waited: `${args.ms}ms` });
269
411
  }
270
412
  if (args.url) {
271
- if (usesElectronBridge()) {
272
- await electron.waitForURLBrowser({ url: args.url, timeoutMs, browserSessionId: session }, context);
273
- return sessionInfo(session, { matched: args.url });
274
- }
275
413
  const deadline = Date.now() + timeoutMs;
276
414
  while (Date.now() < deadline) {
277
415
  const current = await readText(session, ['get', 'url']);
@@ -281,11 +419,7 @@ async function wait(args = {}, context) {
281
419
  throw new Error(`Timed out waiting for URL ${args.url}`);
282
420
  }
283
421
  if (args.selector) {
284
- if (usesElectronBridge()) {
285
- await electron.waitForSelectorBrowser({ selector: args.selector, timeoutMs, browserSessionId: session }, context);
286
- return sessionInfo(session, { matched: args.selector });
287
- }
288
- await run(session, ['wait', args.selector], { timeoutMs });
422
+ await run(session, ['wait', args.selector], { timeoutMs, context });
289
423
  return sessionInfo(session, { matched: args.selector });
290
424
  }
291
425
  throw new Error('wait requires one of: selector, url, ms');
@@ -293,19 +427,13 @@ async function wait(args = {}, context) {
293
427
 
294
428
  async function tabs(args = {}, context) {
295
429
  const session = sessionFor(args, context);
296
- if (usesElectronBridge()) {
297
- if (args.action === 'select' && args.tab) {
298
- return electron.tabsSelectBrowser({ tabId: args.tab, browserSessionId: session }, context);
299
- }
300
- return electron.tabsListBrowser(context);
301
- }
302
430
  if (args.action === 'select' && args.tab) {
303
- return run(session, ['tab', args.tab]);
431
+ return run(session, ['tab', args.tab], { context });
304
432
  }
305
433
  if (args.action === 'new') {
306
- return run(session, ['tab', 'new', ...(args.url ? [args.url] : [])]);
434
+ return run(session, ['tab', 'new', ...(args.url ? [args.url] : [])], { context });
307
435
  }
308
- const result = await run(session, ['tab']);
436
+ const result = await run(session, ['tab'], { context });
309
437
  return { ...sessionInfo(session), tabs: result?.data?.tabs || result?.tabs || [] };
310
438
  }
311
439
 
@@ -316,47 +444,65 @@ async function tabs(args = {}, context) {
316
444
  */
317
445
  async function passthrough(args = {}, context) {
318
446
  const session = sessionFor(args, context);
319
- if (usesElectronBridge()) {
320
- throw new Error(
321
- 'browser_cli needs the agent-browser backend. The desktop bridge does not expose it; '
322
- + 'set AMALGM_BROWSER_BACKEND=agent-browser or enable the CDP bridge (cdpUrl).',
323
- );
324
- }
325
447
  const commandArgs = (args.args || []).map(String);
326
448
  if (!commandArgs.length) throw new Error('args is required, e.g. ["scroll", "down"] or ["get", "text", "@e1"]');
327
- const result = await run(session, commandArgs, { timeoutMs: args.timeoutMs || 60_000 });
449
+ const result = await run(session, commandArgs, { timeoutMs: args.timeoutMs || 60_000, context });
328
450
  return { ...sessionInfo(session), result: result?.data ?? result };
329
451
  }
330
452
 
453
+ /** Schema convention is 1 left / 2 middle / 3 right; agent-browser's mouse
454
+ * commands take left|middle|right strings. */
455
+ function mouseButton(value) {
456
+ const raw = String(value ?? '').toLowerCase();
457
+ if (raw === 'left' || raw === 'middle' || raw === 'right') return raw;
458
+ if (raw === '2') return 'middle';
459
+ if (raw === '3') return 'right';
460
+ return 'left';
461
+ }
462
+
331
463
  async function cua(action, args = {}, context) {
332
464
  const session = sessionFor(args, context);
333
- if (usesElectronBridge()) return electron.cuaBrowser(action, { ...args, browserSessionId: session }, context);
465
+ const options = { context };
334
466
 
335
467
  const x = Math.round(Number(args.x || 0));
336
468
  const y = Math.round(Number(args.y || 0));
337
- if (action === 'cua_move') await run(session, ['mouse', 'move', x, y]);
469
+ if (action === 'cua_move') await run(session, ['mouse', 'move', x, y], options);
338
470
  if (action === 'cua_click') {
339
- await run(session, ['mouse', 'move', x, y]);
340
- await run(session, ['mouse', 'down', args.button || 1]);
341
- await run(session, ['mouse', 'up', args.button || 1]);
471
+ const button = mouseButton(args.button);
472
+ await run(session, ['mouse', 'move', x, y], options);
473
+ await run(session, ['mouse', 'down', button], options);
474
+ await run(session, ['mouse', 'up', button], options);
342
475
  }
343
476
  if (action === 'cua_double_click') {
344
- await run(session, ['mouse', 'move', x, y]);
477
+ await run(session, ['mouse', 'move', x, y], options);
345
478
  for (let i = 0; i < 2; i += 1) {
346
- await run(session, ['mouse', 'down', 1]);
347
- await run(session, ['mouse', 'up', 1]);
479
+ await run(session, ['mouse', 'down', 'left'], options);
480
+ await run(session, ['mouse', 'up', 'left'], options);
348
481
  }
482
+ // agent-browser's mouse events always carry clickCount:1, so the renderer
483
+ // never coalesces the pair above into a native dblclick. Dispatch the
484
+ // dblclick event directly at the point; the two real clicks before it
485
+ // keep focus/selection side effects honest.
486
+ await run(session, ['eval', `(() => {
487
+ const el = document.elementFromPoint(${x}, ${y});
488
+ if (!el) return false;
489
+ el.dispatchEvent(new MouseEvent('dblclick', {
490
+ bubbles: true, cancelable: true, view: window,
491
+ clientX: ${x}, clientY: ${y}, button: 0, detail: 2,
492
+ }));
493
+ return true;
494
+ })()`], options);
349
495
  }
350
- if (action === 'cua_scroll') await run(session, ['mouse', 'wheel', args.scrollY || 0, args.scrollX || 0]);
351
- if (action === 'cua_type') await run(session, ['keyboard', 'type', args.text || '']);
352
- if (action === 'cua_keypress') await run(session, ['press', (args.keys || []).join('+')]);
496
+ if (action === 'cua_scroll') await run(session, ['mouse', 'wheel', args.scrollY || 0, args.scrollX || 0], options);
497
+ if (action === 'cua_type') await run(session, ['keyboard', 'type', args.text || ''], options);
498
+ if (action === 'cua_keypress') await run(session, ['press', (args.keys || []).join('+')], options);
353
499
  if (action === 'cua_drag') {
354
500
  const points = Array.isArray(args.path) ? args.path : [];
355
501
  if (points.length < 2) throw new Error('drag path must contain at least two points');
356
- await run(session, ['mouse', 'move', points[0].x, points[0].y]);
357
- await run(session, ['mouse', 'down', 1]);
358
- for (const point of points.slice(1)) await run(session, ['mouse', 'move', point.x, point.y]);
359
- await run(session, ['mouse', 'up', 1]);
502
+ await run(session, ['mouse', 'move', points[0].x, points[0].y], options);
503
+ await run(session, ['mouse', 'down', 'left'], options);
504
+ for (const point of points.slice(1)) await run(session, ['mouse', 'move', point.x, point.y], options);
505
+ await run(session, ['mouse', 'up', 'left'], options);
360
506
  }
361
507
  return sessionInfo(session, { ok: true });
362
508
  }
@@ -367,14 +513,12 @@ async function cua(action, args = {}, context) {
367
513
 
368
514
  async function saveState(args = {}, context) {
369
515
  const session = sessionFor(args, context);
370
- if (usesElectronBridge()) return electron.saveStateBrowser({ ...args, browserSessionId: session }, context);
371
-
372
516
  const fs = require('fs');
373
517
  const os = require('os');
374
518
  const path = require('path');
375
519
  const crypto = require('crypto');
376
520
  const file = args.path || path.join(os.tmpdir(), `amalgm-browser-state-${session}-${Date.now()}-${crypto.randomUUID()}.json`);
377
- await run(session, ['state', 'save', file]);
521
+ await run(session, ['state', 'save', file], { context });
378
522
  const stateData = JSON.parse(fs.readFileSync(file, 'utf8'));
379
523
  if (!args.path) {
380
524
  try { fs.rmSync(file, { force: true }); } catch {}
@@ -390,8 +534,6 @@ async function saveState(args = {}, context) {
390
534
 
391
535
  async function loadState(args = {}, context) {
392
536
  const session = sessionFor(args, context);
393
- if (usesElectronBridge()) return electron.loadStateBrowser({ ...args, browserSessionId: session }, context);
394
-
395
537
  const fs = require('fs');
396
538
  const os = require('os');
397
539
  const path = require('path');
@@ -404,7 +546,7 @@ async function loadState(args = {}, context) {
404
546
  fs.writeFileSync(file, `${JSON.stringify(args.state, null, 2)}\n`, { mode: 0o600 });
405
547
  temporary = true;
406
548
  }
407
- await run(session, ['state', 'load', file]);
549
+ await run(session, ['state', 'load', file], { context });
408
550
  if (temporary) {
409
551
  try { fs.rmSync(file, { force: true }); } catch {}
410
552
  }
@@ -413,9 +555,9 @@ async function loadState(args = {}, context) {
413
555
 
414
556
  async function close(args = {}, context) {
415
557
  const session = sessionFor(args, context);
416
- if (usesElectronBridge()) {
417
- await electron.closeBrowser({ browserSessionId: session }, context);
418
- } else {
558
+ // Attached mode drives the user's visible surface — never close it from
559
+ // the tool; just forget the session bookkeeping.
560
+ if (mode() !== 'attached') {
419
561
  await cli.runCli(['close'], { session });
420
562
  }
421
563
  attachedSessions.delete(session);
@@ -425,11 +567,13 @@ async function close(args = {}, context) {
425
567
  /** Resolve the CDP websocket URL for a session — recorder needs it. */
426
568
  async function cdpUrlForSession(session) {
427
569
  await ensureReady(session);
428
- const result = await cli.runCli(['get', 'cdp-url'], { session, timeoutMs: 15_000 });
570
+ const result = await cli.runCli(['get', 'cdp-url'], cliOptions(session, { timeoutMs: 15_000 }));
429
571
  return result?.data?.cdpUrl || result?.cdpUrl || null;
430
572
  }
431
573
 
432
574
  module.exports = {
575
+ bridgeFileCandidates,
576
+ cdpEndpoint,
433
577
  cdpUrlForSession,
434
578
  click,
435
579
  close,
@@ -442,6 +586,7 @@ module.exports = {
442
586
  open,
443
587
  passthrough,
444
588
  press,
589
+ readBridgeAdvertisement,
445
590
  saveState,
446
591
  screenshot,
447
592
  sessionFor,
@@ -449,6 +594,5 @@ module.exports = {
449
594
  snapshot,
450
595
  state,
451
596
  tabs,
452
- usesElectronBridge,
453
597
  wait,
454
598
  };