@jobshimo/browser-link 0.11.0 → 0.13.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.
@@ -19,9 +19,15 @@ function isRuntimeMessage(msg) {
19
19
  if (typeof msg !== 'object' || msg === null)
20
20
  return false;
21
21
  const m = msg;
22
+ if (m.action === 'versionCheck')
23
+ return true;
22
24
  if (typeof m.tabId !== 'number')
23
25
  return false;
24
- return m.action === 'connect' || m.action === 'disconnect' || m.action === 'status';
26
+ return (m.action === 'connect' ||
27
+ m.action === 'disconnect' ||
28
+ m.action === 'status' ||
29
+ m.action === 'pendingDialog' ||
30
+ m.action === 'respondDialog');
25
31
  }
26
32
  const tabStates = new Map();
27
33
  function send(ws, msg) {
@@ -38,6 +44,43 @@ function safeParse(raw) {
38
44
  function cdp(tabId, method, params = {}) {
39
45
  return chrome.debugger.sendCommand({ tabId }, method, params);
40
46
  }
47
+ /**
48
+ * Mapping from the CDP permission name (the contract `browser.set_permission`
49
+ * accepts on the wire) to the `chrome.contentSettings` setting key. Only the
50
+ * names that actually have a contentSettings surface in MV3 are listed —
51
+ * `Browser.setPermission` covers more, but that domain is not reachable from
52
+ * `chrome.debugger` attached to a tab.
53
+ */
54
+ const CONTENT_SETTING_BY_PERMISSION = {
55
+ geolocation: 'location',
56
+ notifications: 'notifications',
57
+ camera: 'camera',
58
+ microphone: 'microphone',
59
+ clipboardReadWrite: 'clipboard',
60
+ clipboardSanitizedWrite: 'clipboard',
61
+ sensors: 'sensors',
62
+ };
63
+ const STATE_TO_CONTENT_SETTING = {
64
+ granted: 'allow',
65
+ denied: 'block',
66
+ prompt: 'ask',
67
+ };
68
+ /**
69
+ * Apply a `chrome.contentSettings.<setting>.set({ primaryPattern, setting })`
70
+ * call without statically referencing each key — the `contentSettings`
71
+ * surface is dynamic at the API level. We narrow `unknown` carefully so the
72
+ * cast is the smallest possible.
73
+ */
74
+ async function applyContentSetting(settingKey, primaryPattern, setting) {
75
+ const root = chrome.contentSettings;
76
+ if (!root)
77
+ throw new Error('chrome.contentSettings not available in this build');
78
+ const setter = root[settingKey];
79
+ if (!setter || typeof setter.set !== 'function') {
80
+ throw new Error(`chrome.contentSettings.${settingKey} is not exposed in this Chrome build`);
81
+ }
82
+ await setter.set({ primaryPattern, setting });
83
+ }
41
84
  /**
42
85
  * Defensive caps applied to every duration that ends up in a setTimeout
43
86
  * driven by tool params. The MCP request payload is technically untrusted
@@ -283,11 +326,67 @@ function attachDebuggerListener(state) {
283
326
  entry.failed = p.errorText ?? 'failed';
284
327
  return;
285
328
  }
329
+ if (method === 'Page.javascriptDialogOpening') {
330
+ // The page JS thread is now frozen until something calls
331
+ // Page.handleJavaScriptDialog. We do NOT respond automatically —
332
+ // the agent reads dialog-opening from browser.events and decides
333
+ // via browser.dialog_respond. The popup also exposes accept/dismiss
334
+ // buttons as a manual escape hatch.
335
+ const p = params;
336
+ const info = {
337
+ type: p.type ?? 'alert',
338
+ message: p.message ?? '',
339
+ };
340
+ if (typeof p.defaultPrompt === 'string')
341
+ info.default_prompt = p.defaultPrompt;
342
+ if (typeof p.url === 'string')
343
+ info.url = p.url;
344
+ state.pendingDialog = info;
345
+ if (state.ws && state.ws.readyState === WebSocket.OPEN && state.serverTabId) {
346
+ const data = {
347
+ type: info.type,
348
+ message: info.message,
349
+ };
350
+ if (info.default_prompt !== undefined)
351
+ data.default_prompt = info.default_prompt;
352
+ if (info.url !== undefined)
353
+ data.url = info.url;
354
+ send(state.ws, {
355
+ kind: 'bridge.event',
356
+ eventKind: 'dialog-opening',
357
+ tabId: state.serverTabId,
358
+ data,
359
+ });
360
+ }
361
+ return;
362
+ }
363
+ if (method === 'Page.javascriptDialogClosed') {
364
+ const p = params;
365
+ state.pendingDialog = undefined;
366
+ if (state.ws && state.ws.readyState === WebSocket.OPEN && state.serverTabId) {
367
+ const data = { accept: p.result === true };
368
+ if (typeof p.userInput === 'string')
369
+ data.user_input = p.userInput;
370
+ send(state.ws, {
371
+ kind: 'bridge.event',
372
+ eventKind: 'dialog-closed',
373
+ tabId: state.serverTabId,
374
+ data,
375
+ });
376
+ }
377
+ return;
378
+ }
286
379
  };
287
380
  state.debuggerListener = listener;
288
381
  }
289
- const SNAPSHOT_JS = `
290
- (() => {
382
+ /**
383
+ * Shared in-page DOM helpers, injected verbatim into any JS template that
384
+ * needs them (`buildSnapshotJs`, `buildFindJs`). Defining them once here
385
+ * keeps the heuristics (visibility, selector generation, accessible text)
386
+ * identical across tools so a selector returned by `snapshot` and a selector
387
+ * returned by `find` follow the same rules.
388
+ */
389
+ const DOM_HELPERS_JS = `
291
390
  function isVisible(el) {
292
391
  if (!(el instanceof HTMLElement)) return true;
293
392
  const style = getComputedStyle(el);
@@ -330,39 +429,168 @@ const SNAPSHOT_JS = `
330
429
  }
331
430
  return parts.join(' > ');
332
431
  }
432
+ function accessibleText(el) {
433
+ const aria = el.getAttribute && el.getAttribute('aria-label');
434
+ if (aria) return aria;
435
+ const txt = (el.innerText || el.textContent || '').trim();
436
+ if (txt) return txt;
437
+ if ('value' in el && el.value) return String(el.value);
438
+ const ph = el.getAttribute && el.getAttribute('placeholder');
439
+ if (ph) return ph;
440
+ const title = el.getAttribute && el.getAttribute('title');
441
+ if (title) return title;
442
+ return '';
443
+ }
444
+ `;
445
+ function buildSnapshotJs(opts = {}) {
446
+ const optsJson = JSON.stringify({
447
+ withinSelector: typeof opts.within_selector === 'string' ? opts.within_selector : null,
448
+ onlyInteractive: opts.only_interactive === true,
449
+ exclude: Array.isArray(opts.exclude) ? opts.exclude : [],
450
+ maxInteractive: typeof opts.max_interactive === 'number' && opts.max_interactive > 0
451
+ ? Math.min(opts.max_interactive, 500)
452
+ : 120,
453
+ });
454
+ return `
455
+ (() => {
456
+ ${DOM_HELPERS_JS}
457
+ const opts = ${optsJson};
458
+ let root = document;
459
+ let notice = '';
460
+ if (opts.withinSelector) {
461
+ const sub = document.querySelector(opts.withinSelector);
462
+ if (!sub) {
463
+ return {
464
+ title: document.title,
465
+ url: location.href,
466
+ interactive: [],
467
+ notice: 'within_selector did not match any element',
468
+ };
469
+ }
470
+ root = sub;
471
+ }
472
+ const excludeSet = new Set((opts.exclude || []).map(s => String(s).toLowerCase()));
473
+ function inExcludedLandmark(el) {
474
+ if (excludeSet.size === 0) return false;
475
+ let cur = el.parentElement;
476
+ while (cur && cur !== document.body) {
477
+ const tag = cur.tagName ? cur.tagName.toLowerCase() : '';
478
+ if (excludeSet.has(tag)) return true;
479
+ if (cur === root) return false;
480
+ cur = cur.parentElement;
481
+ }
482
+ return false;
483
+ }
333
484
  const sel = 'a[href], button, input, select, textarea, [role=button], [role=link], [role=checkbox], [role=tab], [role=menuitem], [contenteditable=true]';
334
485
  const interactive = [];
335
- document.querySelectorAll(sel).forEach((el) => {
486
+ const scope = (root.querySelectorAll ? root : document);
487
+ scope.querySelectorAll(sel).forEach((el) => {
336
488
  if (!isVisible(el)) return;
337
- interactive.push({
338
- tag: el.tagName.toLowerCase(),
339
- role: el.getAttribute('role') || el.tagName.toLowerCase(),
340
- text: shortText(el),
341
- value: 'value' in el ? (el.value || '') : '',
342
- placeholder: el.getAttribute('placeholder') || '',
343
- aria_label: el.getAttribute('aria-label') || '',
344
- name: el.getAttribute('name') || '',
345
- type: el.getAttribute('type') || '',
346
- href: el.getAttribute('href') || '',
347
- disabled: 'disabled' in el ? !!el.disabled : false,
348
- selector: genSelector(el),
349
- });
350
- });
351
- const headings = [];
352
- document.querySelectorAll('h1, h2, h3').forEach((h) => {
353
- if (!isVisible(h)) return;
354
- headings.push({ level: h.tagName, text: shortText(h) });
489
+ if (inExcludedLandmark(el)) return;
490
+ const tag = el.tagName.toLowerCase();
491
+ const role = el.getAttribute('role') || tag;
492
+ const entry = { tag, role, selector: genSelector(el) };
493
+ const txt = shortText(el);
494
+ if (txt) entry.text = txt;
495
+ if ('value' in el && el.value) entry.value = String(el.value);
496
+ const placeholder = el.getAttribute('placeholder');
497
+ if (placeholder) entry.placeholder = placeholder;
498
+ const aria_label = el.getAttribute('aria-label');
499
+ if (aria_label) entry.aria_label = aria_label;
500
+ const name = el.getAttribute('name');
501
+ if (name) entry.name = name;
502
+ const type = el.getAttribute('type');
503
+ if (type) entry.type = type;
504
+ const href = el.getAttribute('href');
505
+ if (href) entry.href = href;
506
+ if ('disabled' in el && el.disabled) entry.disabled = true;
507
+ interactive.push(entry);
355
508
  });
356
- const visibleText = (document.body && document.body.innerText) ? document.body.innerText.slice(0, 4000) : '';
357
- return {
509
+ const result = {
358
510
  title: document.title,
359
511
  url: location.href,
360
- headings: headings.slice(0, 30),
361
- text: visibleText,
362
- interactive: interactive.slice(0, 120),
512
+ interactive: interactive.slice(0, opts.maxInteractive),
363
513
  };
514
+ if (!opts.onlyInteractive) {
515
+ const headings = [];
516
+ scope.querySelectorAll('h1, h2, h3').forEach((h) => {
517
+ if (!isVisible(h)) return;
518
+ if (inExcludedLandmark(h)) return;
519
+ const t = shortText(h);
520
+ if (t) headings.push({ level: h.tagName, text: t });
521
+ });
522
+ result.headings = headings.slice(0, 30);
523
+ const textRoot = (root === document) ? (document.body || document) : root;
524
+ const visibleText = (textRoot && textRoot.innerText) ? textRoot.innerText.slice(0, 4000) : '';
525
+ if (visibleText) result.text = visibleText;
526
+ }
527
+ if (notice) result.notice = notice;
528
+ return result;
364
529
  })()
365
530
  `;
531
+ }
532
+ function buildFindJs(opts) {
533
+ const optsJson = JSON.stringify({
534
+ text: opts.text,
535
+ role: typeof opts.role === 'string' && opts.role.length > 0 ? opts.role : null,
536
+ exact: opts.exact === true,
537
+ candidateLimit: 5,
538
+ });
539
+ return `
540
+ (() => {
541
+ ${DOM_HELPERS_JS}
542
+ const opts = ${optsJson};
543
+ const needle = opts.text.toLowerCase();
544
+ const ROLE_SELECTORS = {
545
+ button: 'button, [role="button"], input[type="button"], input[type="submit"], input[type="reset"]',
546
+ link: 'a[href], [role="link"]',
547
+ textbox: 'input[type="text"], input[type="email"], input[type="password"], input[type="search"], input[type="url"], input[type="tel"], input:not([type]), textarea, [role="textbox"], [contenteditable="true"]',
548
+ checkbox: 'input[type="checkbox"], [role="checkbox"]',
549
+ tab: '[role="tab"]',
550
+ menuitem: '[role="menuitem"]',
551
+ };
552
+ const selectorSet = opts.role && ROLE_SELECTORS[opts.role]
553
+ ? ROLE_SELECTORS[opts.role]
554
+ : 'button, a, input, textarea, select, [role], [onclick], [contenteditable="true"], [tabindex]';
555
+ const all = Array.from(document.querySelectorAll(selectorSet));
556
+ const matches = [];
557
+ for (const el of all) {
558
+ if (!isVisible(el)) continue;
559
+ const text = accessibleText(el).trim();
560
+ if (text.length === 0) continue;
561
+ const lower = text.toLowerCase();
562
+ const ok = opts.exact ? lower === needle : lower.includes(needle);
563
+ if (ok) matches.push(el);
564
+ }
565
+ if (matches.length === 0) {
566
+ return { matched: false, reason: 'not-found' };
567
+ }
568
+ if (matches.length > 1) {
569
+ return {
570
+ matched: false,
571
+ reason: 'multiple-matches',
572
+ candidates: matches.slice(0, opts.candidateLimit).map((el) => ({
573
+ selector: genSelector(el),
574
+ text: shortText(el),
575
+ tag: el.tagName.toLowerCase(),
576
+ })),
577
+ };
578
+ }
579
+ const el = matches[0];
580
+ const rect = el.getBoundingClientRect();
581
+ return {
582
+ matched: true,
583
+ selector: genSelector(el),
584
+ coords: {
585
+ x: Math.round(rect.left + rect.width / 2),
586
+ y: Math.round(rect.top + rect.height / 2),
587
+ },
588
+ tag: el.tagName.toLowerCase(),
589
+ text: shortText(el),
590
+ };
591
+ })()
592
+ `;
593
+ }
366
594
  async function evaluateInTab(tabId, expression) {
367
595
  const result = await cdp(tabId, 'Runtime.evaluate', {
368
596
  expression,
@@ -426,7 +654,31 @@ async function handleTool(state, msg) {
426
654
  };
427
655
  }
428
656
  case 'snapshot': {
429
- const value = await evaluateInTab(tabId, SNAPSHOT_JS);
657
+ const value = await evaluateInTab(tabId, buildSnapshotJs({
658
+ within_selector: optStr('within_selector'),
659
+ only_interactive: p.only_interactive === true,
660
+ exclude: Array.isArray(p.exclude)
661
+ ? p.exclude.filter((x) => typeof x === 'string')
662
+ : undefined,
663
+ max_interactive: typeof p.max_interactive === 'number' ? p.max_interactive : undefined,
664
+ }));
665
+ return { kind: 'tool.response', id: msg.id, ok: true, result: value };
666
+ }
667
+ case 'find': {
668
+ const text = str('text');
669
+ if (!text) {
670
+ return {
671
+ kind: 'tool.response',
672
+ id: msg.id,
673
+ ok: false,
674
+ error: 'find: text required',
675
+ };
676
+ }
677
+ const value = await evaluateInTab(tabId, buildFindJs({
678
+ text,
679
+ role: optStr('role'),
680
+ exact: p.exact === true,
681
+ }));
430
682
  return { kind: 'tool.response', id: msg.id, ok: true, result: value };
431
683
  }
432
684
  case 'console': {
@@ -901,6 +1153,73 @@ async function handleTool(state, msg) {
901
1153
  : { matched: false, elapsed_ms: elapsedMs, checks, reason: 'timeout' },
902
1154
  };
903
1155
  }
1156
+ case 'dialog_respond': {
1157
+ const accept = p.accept === true;
1158
+ const promptText = optStr('prompt_text');
1159
+ // No probing — if there is no dialog open, CDP returns an error.
1160
+ // We propagate it; the caller decides if "no dialog to respond to"
1161
+ // is a problem or a race they can ignore.
1162
+ const params = { accept };
1163
+ if (promptText !== undefined)
1164
+ params.promptText = promptText;
1165
+ await cdp(tabId, 'Page.handleJavaScriptDialog', params);
1166
+ return {
1167
+ kind: 'tool.response',
1168
+ id: msg.id,
1169
+ ok: true,
1170
+ result: { accepted: accept },
1171
+ };
1172
+ }
1173
+ case 'set_permission': {
1174
+ const origin = str('origin');
1175
+ const name = str('name');
1176
+ const state_ = str('state');
1177
+ // CDP `Browser.setPermission` requires a browser-level target,
1178
+ // which `chrome.debugger.attach({ tabId })` does NOT give us in
1179
+ // MV3. We use `chrome.contentSettings` instead — that surface IS
1180
+ // available to extensions and covers the realistic permissions
1181
+ // an agent needs to pre-set (geolocation, notifications, camera,
1182
+ // microphone, clipboard, sensors).
1183
+ const settingKey = CONTENT_SETTING_BY_PERMISSION[name];
1184
+ if (!settingKey) {
1185
+ return {
1186
+ kind: 'tool.response',
1187
+ id: msg.id,
1188
+ ok: false,
1189
+ error: `set_permission: '${name}' is not supported by chrome.contentSettings in MV3. Supported names: ${Object.keys(CONTENT_SETTING_BY_PERMISSION).join(', ')}.`,
1190
+ };
1191
+ }
1192
+ const setting = STATE_TO_CONTENT_SETTING[state_];
1193
+ if (!setting) {
1194
+ return {
1195
+ kind: 'tool.response',
1196
+ id: msg.id,
1197
+ ok: false,
1198
+ error: `set_permission: unknown state '${state_}' (expected granted | denied | prompt).`,
1199
+ };
1200
+ }
1201
+ // `chrome.contentSettings.<name>.set({ primaryPattern, setting })`
1202
+ // requires a URL pattern, not a bare origin. Append `/*` so the
1203
+ // pattern covers every path on the origin.
1204
+ const primaryPattern = origin.endsWith('/*') ? origin : `${origin}/*`;
1205
+ try {
1206
+ await applyContentSetting(settingKey, primaryPattern, setting);
1207
+ }
1208
+ catch (err) {
1209
+ return {
1210
+ kind: 'tool.response',
1211
+ id: msg.id,
1212
+ ok: false,
1213
+ error: `set_permission: ${err instanceof Error ? err.message : String(err)}`,
1214
+ };
1215
+ }
1216
+ return {
1217
+ kind: 'tool.response',
1218
+ id: msg.id,
1219
+ ok: true,
1220
+ result: { origin, name, state: state_, applied_as: settingKey },
1221
+ };
1222
+ }
904
1223
  default:
905
1224
  return { kind: 'tool.response', id: msg.id, ok: false, error: `Unknown tool: ${msg.tool}` };
906
1225
  }
@@ -1050,6 +1369,11 @@ async function connectTab(tabId) {
1050
1369
  return;
1051
1370
  if (msg.kind === 'tab.registered') {
1052
1371
  state.serverTabId = msg.payload.tabId;
1372
+ // serverVersion is optional on the wire so older servers that
1373
+ // predate the field still parse — narrow on `typeof string`.
1374
+ if (typeof msg.payload.serverVersion === 'string') {
1375
+ state.serverVersion = msg.payload.serverVersion;
1376
+ }
1053
1377
  // Remember this id so the next reconnect (after a primary swap)
1054
1378
  // asks the new primary to honour it. The primary emits
1055
1379
  // `tab-renamed` in the bridge event log if it can't.
@@ -1094,6 +1418,40 @@ function getTabStatus(tabId) {
1094
1418
  return { connected: false };
1095
1419
  return { connected: true, serverTabId: state.serverTabId };
1096
1420
  }
1421
+ /**
1422
+ * Compute the version-mismatch summary for the popup. Picks the
1423
+ * `serverVersion` reported by ANY currently-connected tab — they all come
1424
+ * from the same primary, so the first one is enough. When no tab has
1425
+ * registered yet (e.g. user just installed and hasn't pressed Connect),
1426
+ * `server` stays null and the popup hides the banner.
1427
+ */
1428
+ function getVersionCheck() {
1429
+ const extension = chrome.runtime.getManifest().version;
1430
+ let server = null;
1431
+ for (const s of tabStates.values()) {
1432
+ if (typeof s.serverVersion === 'string' && s.serverVersion.length > 0) {
1433
+ server = s.serverVersion;
1434
+ break;
1435
+ }
1436
+ }
1437
+ return {
1438
+ extension,
1439
+ server,
1440
+ aligned: server === null ? null : server === extension,
1441
+ };
1442
+ }
1443
+ async function respondToDialogFromPopup(tabId, accept, promptText) {
1444
+ try {
1445
+ const params = { accept };
1446
+ if (promptText !== undefined)
1447
+ params.promptText = promptText;
1448
+ await cdp(tabId, 'Page.handleJavaScriptDialog', params);
1449
+ return { ok: true };
1450
+ }
1451
+ catch (err) {
1452
+ return { ok: false, error: err instanceof Error ? err.message : String(err) };
1453
+ }
1454
+ }
1097
1455
  chrome.runtime.onMessage.addListener((msg, _sender, sendResponse) => {
1098
1456
  if (!isRuntimeMessage(msg))
1099
1457
  return false;
@@ -1105,6 +1463,19 @@ chrome.runtime.onMessage.addListener((msg, _sender, sendResponse) => {
1105
1463
  void disconnectTab(msg.tabId).then(sendResponse);
1106
1464
  return true;
1107
1465
  }
1466
+ if (msg.action === 'pendingDialog') {
1467
+ const state = tabStates.get(msg.tabId);
1468
+ sendResponse({ pending: state?.pendingDialog ?? null });
1469
+ return false;
1470
+ }
1471
+ if (msg.action === 'respondDialog') {
1472
+ void respondToDialogFromPopup(msg.tabId, msg.accept, msg.promptText).then(sendResponse);
1473
+ return true;
1474
+ }
1475
+ if (msg.action === 'versionCheck') {
1476
+ sendResponse(getVersionCheck());
1477
+ return false;
1478
+ }
1108
1479
  // 'status'
1109
1480
  sendResponse(getTabStatus(msg.tabId));
1110
1481
  return false;
@@ -1116,6 +1487,87 @@ chrome.tabs.onRemoved.addListener((tabId) => {
1116
1487
  // The Chrome tab is gone — drop any cached browser-link id for it.
1117
1488
  void forgetTabId(tabId);
1118
1489
  });
1490
+ /* Auto-connect tabs spawned by a connected tab.
1491
+ *
1492
+ * When a tab the user has connected via the popup opens a new tab
1493
+ * (window.open, target="_blank", a click handler doing window.open, etc.),
1494
+ * Chrome reports it via tabs.onCreated with `openerTabId` pointing at the
1495
+ * source. We use that link to:
1496
+ * 1. Wait for the new tab to settle on a real URL (the first onCreated
1497
+ * callback often reports `about:blank` even when a destination is set).
1498
+ * 2. Auto-connect the new tab through the same flow as the popup
1499
+ * "Connect" button — attach debugger, register with the server.
1500
+ * 3. Emit a `tab-created` bridge.event tagged with `opened_from = the
1501
+ * opener's server tab id`. The MCP `browser.wait_for_tab` tool reads
1502
+ * that event and auto-claims the new tab for the waiting agent.
1503
+ *
1504
+ * If the opener is not connected (regular browsing), we do nothing —
1505
+ * background tabs the user opens for themselves stay out of the bridge.
1506
+ */
1507
+ chrome.tabs.onCreated.addListener((tab) => {
1508
+ const newTabId = tab.id;
1509
+ const openerTabId = tab.openerTabId;
1510
+ if (typeof newTabId !== 'number')
1511
+ return;
1512
+ if (typeof openerTabId !== 'number')
1513
+ return;
1514
+ const openerState = tabStates.get(openerTabId);
1515
+ if (!openerState)
1516
+ return;
1517
+ const openedFrom = openerState.serverTabId;
1518
+ if (!openedFrom)
1519
+ return;
1520
+ void (async () => {
1521
+ // Wait until chrome.tabs.get returns a real `url`. `pendingUrl` alone
1522
+ // is NOT enough — connectTab's guard short-circuits on `!tab.url`.
1523
+ // Bound at 5 s so a never-navigating tab doesn't park us forever.
1524
+ const deadline = Date.now() + 5_000;
1525
+ let ready = false;
1526
+ while (Date.now() < deadline) {
1527
+ try {
1528
+ const t = await chrome.tabs.get(newTabId);
1529
+ if (t.url && t.url.length > 0) {
1530
+ ready = true;
1531
+ break;
1532
+ }
1533
+ }
1534
+ catch {
1535
+ return;
1536
+ }
1537
+ await sleep(50);
1538
+ }
1539
+ if (!ready)
1540
+ return;
1541
+ try {
1542
+ const result = await connectTab(newTabId);
1543
+ if (!result.ok || !result.serverTabId)
1544
+ return;
1545
+ const newState = tabStates.get(newTabId);
1546
+ if (!newState?.ws || newState.ws.readyState !== WebSocket.OPEN)
1547
+ return;
1548
+ let resolvedUrl = '';
1549
+ try {
1550
+ const settled = await chrome.tabs.get(newTabId);
1551
+ resolvedUrl = settled.url ?? settled.pendingUrl ?? '';
1552
+ }
1553
+ catch {
1554
+ // Tab vanished between attach and read.
1555
+ }
1556
+ send(newState.ws, {
1557
+ kind: 'bridge.event',
1558
+ eventKind: 'tab-created',
1559
+ tabId: newState.serverTabId,
1560
+ data: {
1561
+ opened_from: openedFrom,
1562
+ url: resolvedUrl,
1563
+ },
1564
+ });
1565
+ }
1566
+ catch {
1567
+ // Auto-connect is best-effort.
1568
+ }
1569
+ })();
1570
+ });
1119
1571
  chrome.debugger.onDetach.addListener((source) => {
1120
1572
  if (source.tabId === undefined)
1121
1573
  return;