@agent360/browser-mcp 1.24.0 → 1.28.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.
@@ -36,12 +36,25 @@ function restoreSessions() {
36
36
  }
37
37
  if (validTabIds.size > 0) {
38
38
  const activeTabId = data.activeTabId && validTabIds.has(data.activeTabId) ? data.activeTabId : null;
39
+ // Samme kollisionsfejl som i getSession, og derfor samme rettelse: `size + 1`
40
+ // genbruger et nummer der allerede er i brug. Her betyder det at to gendannede
41
+ // sessioner kan komme op med samme navn efter en genstart af service-workeren.
42
+ const brugte = new Set([...sessions.values()].map((x) => x.nummer).filter((n) => typeof n === 'number'));
43
+ let nummer = typeof data.nummer === 'number' ? data.nummer : 1;
44
+ while (brugte.has(nummer)) nummer++;
39
45
  sessions.set(Number(port), {
40
46
  tabIds: validTabIds,
41
47
  activeTabId,
42
48
  groupId: data.groupId || null,
43
- color: data.color || SESSION_COLORS[sessions.size % SESSION_COLORS.length],
44
- label: data.label || `Claude ${sessions.size + 1}`,
49
+ nummer,
50
+ // MAALT 22/8: her stod `data.color || …` og `data.label || …`. Bumpede
51
+ // kollisionsloekken nummeret, fulgte navn og farve IKKE med — de blev
52
+ // gendannet ordret fra lageret. To sessioner kunne saa have hvert sit
53
+ // nummer og stadig begge hedde "Claude 1" i samme farve. Og navnet er
54
+ // praecis dét brugeren ser paa fanegruppen.
55
+ color: SESSION_COLORS[(nummer - 1) % SESSION_COLORS.length],
56
+ label: `Claude ${nummer}`,
57
+ pid: typeof data.pid === 'number' ? data.pid : null,
45
58
  });
46
59
  }
47
60
  }
@@ -49,18 +62,114 @@ function restoreSessions() {
49
62
  return restorePromise;
50
63
  }
51
64
 
52
- function getSession(port) {
65
+ function getSession(port, pid) {
53
66
  if (!sessions.has(port)) {
54
- const idx = sessions.size % SESSION_COLORS.length;
67
+ // ── Hvorfor det laveste LEDIGE nummer, og ikke sessions.size + 1 (MAALT 22/8) ──
68
+ // Med `size + 1` genbruges et nummer der allerede er i brug, saa snart en chat
69
+ // lukker: tre chats hedder 1, 2, 3 · chat 1 lukker · size er nu 2 · naeste chat
70
+ // faar "Claude 3" — som chat 3 stadig hedder. To chats deler navn OG farve, og
71
+ // brugeren kan ikke se hvilken fanegruppe der hoerer til hvad.
72
+ //
73
+ // Det er en TREDJE mekanisme bag "alt hedder Claude 1", uafhaengig af de to andre
74
+ // (to udvidelser om samme socket, og adoption uden live-port-gate). Den her
75
+ // rammer ogsaa naar alt andet er rigtigt — man skal bare lukke en chat.
76
+ //
77
+ // Laveste ledige nummer genbruger frigivne pladser uden at kollidere, saa numrene
78
+ // bliver ved med at vaere smaa og laesbare. Farven foelger nummeret, saa to
79
+ // samtidige sessioner heller ikke kan faa samme farve.
80
+ const brugte = new Set([...sessions.values()].map((s) => s.nummer).filter((n) => typeof n === 'number'));
81
+ let nummer = 1;
82
+ while (brugte.has(nummer)) nummer++;
83
+
55
84
  sessions.set(port, {
56
85
  tabIds: new Set(),
57
86
  activeTabId: null,
58
87
  groupId: null,
59
- color: SESSION_COLORS[idx],
60
- label: `Claude ${sessions.size + 1}`,
88
+ nummer,
89
+ color: SESSION_COLORS[(nummer - 1) % SESSION_COLORS.length],
90
+ label: `Claude ${nummer}`,
91
+ pid: typeof pid === 'number' ? pid : null,
61
92
  });
62
93
  }
63
- return sessions.get(port);
94
+ const s = sessions.get(port);
95
+ // Foerste kald fra en genstartet server kan baere pid'en foer sessionen har den.
96
+ if (s.pid == null && typeof pid === 'number') s.pid = pid;
97
+ return s;
98
+ }
99
+
100
+ // Adopt tabs from a session whose MCP connection is gone (FIX-18: reconnect orphaning).
101
+ //
102
+ // Sessions are keyed on the MCP port. A clean shutdown sends session_disconnect and the
103
+ // tabs are closed. But an UNCLEAN drop (server restart, network blip, laptop sleep) leaves
104
+ // the session in storage while the client reconnects on a NEW port — so getSession() hands
105
+ // back an empty session. Observed damage: navigate() returns tab X while click() fails on
106
+ // tab Y, list_tabs() comes back empty, a fresh about:blank spawns per call, and the session
107
+ // label walks Claude 1 → 2 → 3 → 4. Every navigate→click pair breaks.
108
+ //
109
+ // Fix: before serving an unknown port, hand over the tabs of the largest session whose port
110
+ // is no longer in mcpPorts (the live-connection list the offscreen bridge keeps in storage).
111
+ // Keeps label/color so the user sees continuity instead of a renumbered session.
112
+ async function adoptOrphanedSession(port, pid) {
113
+ // Only ever fires for a session that OWNS NOTHING — a session with tabs is left alone.
114
+ if (sessions.has(port) && sessions.get(port).tabIds.size) return null;
115
+
116
+ // ── Hvorfor pid'en er afgoerende (regression fundet 16/8) ────────────────────
117
+ // Den oprindelige version adopterede den STOERSTE session uanset hvem den tilhoerte,
118
+ // med den begrundelse at Claude Code spreder kald over flere forbindelser. Men en
119
+ // helt NY chat ejer ogsaa ingenting — saa hver ny chat stjal den aktive chats faner
120
+ // OG dens identitet (linje: sessions.delete(best.port)). Donorens port forsvandt fra
121
+ // kortet, saa dens naeste kald adopterede tilbage. To chats byttede den samme ene
122
+ // session frem og tilbage: alt hed "Claude 1", og kun én ting kunne koere ad gangen.
123
+ //
124
+ // Det aegte behov er smallere: naar EN chats MCP-server genstarter, faar den en ny
125
+ // port og skal genfinde sine egne faner. Den situation kan skelnes praecist, fordi
126
+ // begge porte hoerer til den samme Claude Code-proces. Derfor: adoptér kun fra en
127
+ // session med samme pid. Mangler pid'en (gammel server mod ny udvidelse), adopteres
128
+ // slet ikke — hellere en frisk session end en stjaalet.
129
+ if (typeof pid !== 'number') return null;
130
+
131
+ // ── Anden halvdel af gaten (MAALT 21/8) ─────────────────────────────────────
132
+ // Pid'en alene raekker ikke. Foraelder-processen er IKKE en unik identitet: starter
133
+ // en klient flere MCP-servere fra den samme proces, har de alle samme pid — og saa
134
+ // adopterede de hinandens faner paa stribe. Maalt med fire samtidige sessioner:
135
+ // alle fik navnet "Claude 3", de tre aeldste mistede deres fane, og en session
136
+ // kunne skifte til en andens. Altsaa "alt hedder Claude 1", i sin rene form, med
137
+ // kun én udvidelse indlaest.
138
+ //
139
+ // Det manglende tjek staar allerede beskrevet oeverst i denne funktion: adoptér kun
140
+ // fra en session hvis port IKKE laengere er forbundet. Er donorens port stadig i
141
+ // live, er det en anden chat der arbejder lige nu — ikke en genstartet server.
142
+ // Listen vedligeholdes af broen (ws_status → mcpPorts) ved hver til- og frakobling.
143
+ const { mcpPorts = [] } = await chrome.storage.local.get({ mcpPorts: [] });
144
+ const levendePorte = new Set(mcpPorts.map(Number));
145
+
146
+ let best = null;
147
+ for (const [p, session] of sessions) {
148
+ if (p === port || !session.tabIds.size) continue;
149
+ if (session.pid !== pid) continue; // en anden chat — lad den vaere
150
+ if (levendePorte.has(Number(p))) continue; // donoren arbejder stadig — hænderne væk
151
+ if (!best || session.tabIds.size > best.session.tabIds.size) best = { port: p, session };
152
+ }
153
+ if (!best) return null;
154
+
155
+ // Verify at least one tab survives — an orphan whose tabs the user already closed is
156
+ // worthless, and adopting it would mask a genuinely fresh start.
157
+ const alive = new Set();
158
+ for (const tabId of best.session.tabIds) {
159
+ try { await chrome.tabs.get(tabId); alive.add(tabId); } catch {}
160
+ }
161
+ if (!alive.size) {
162
+ sessions.delete(best.port);
163
+ persistSessions();
164
+ return null;
165
+ }
166
+
167
+ best.session.tabIds = alive;
168
+ if (!alive.has(best.session.activeTabId)) best.session.activeTabId = null;
169
+ sessions.delete(best.port);
170
+ sessions.set(port, best.session);
171
+ persistSessions();
172
+ return best.session;
64
173
  }
65
174
 
66
175
  // LRU eviction cap: hver session må højst have N åbne tabs samtidigt.
@@ -69,7 +178,13 @@ function getSession(port) {
69
178
  // Begrundelse: Claude Code-flows kan åbne 20+ navigate(new_tab=true) per session
70
179
  // over en længere conversation. Uden eviction akkumulerer disse i Chrome som
71
180
  // orphan-tabs der spiser RAM + giver "extension localhost 19+" tab-noise.
72
- const MAX_TABS_PER_SESSION = 10;
181
+ //
182
+ // Hævet 10 → 20 (21/8). Ti var for lavt til reelle flows: en jagt der åbner en
183
+ // fane pr. udbyder ramte loftet midtvejs, og evictionen lukkede de faner arbejdet
184
+ // stadig byggede på — tavst, for eviction rapporterer ikke noget. Tyve matcher
185
+ // portspændet (9876-9895), så en session kan holde lige så mange faner som der
186
+ // kan køre samtidige sessioner.
187
+ const MAX_TABS_PER_SESSION = 20;
73
188
 
74
189
  async function evictOldestTabs(session, justAddedTabId) {
75
190
  // Drop dead tab-ids først (user manually closed dem)
@@ -154,6 +269,8 @@ function persistSessions() {
154
269
  groupId: session.groupId,
155
270
  color: session.color,
156
271
  label: session.label,
272
+ nummer: session.nummer ?? null, // uden denne mister en gendannet session sin plads og kan kollidere
273
+ pid: session.pid ?? null, // uden denne adopterer en genstartet service worker paa tvaers af chats igen
157
274
  };
158
275
  }
159
276
  chrome.storage.local.set({ sessions: data });
@@ -384,12 +501,49 @@ async function cdpSend(tabId, method, params = {}) {
384
501
  }
385
502
 
386
503
  // Clean up debugger + session refs when tabs close
504
+ // Faner agenten selv lukkede via close_tab. En tom session betyder kun "arbejdet er slut"
505
+ // hvis det var BRUGEREN der lukkede den sidste fane.
506
+ const agentLukkedeFaner = new Set();
507
+
508
+ // ── Armerede dialog-haandterere, pr. fane ──────────────────────────────────────
509
+ // MAALT 22/8 af flowtesten: handle_dialog var ubrugelig som den var skrevet. Den
510
+ // BLOKEREDE i op til 10 sekunder mens den ventede paa en dialog — men en alert(),
511
+ // confirm() eller prompt() dukker foerst op naar man klikker paa noget, og klikket
512
+ // kan ikke ske mens kaldet blokerer. Man kunne altsaa hverken arme den foerst eller
513
+ // kalde den bagefter: naar dialogen foerst staar der, er hele fanen laast, og
514
+ // klik-vaerktoejet naar ikke frem. Vaerktoejet kunne kun lykkes hvis en ANDEN aabnede
515
+ // dialogen paa praecis det rigtige tidspunkt.
516
+ //
517
+ // Nu armerer den og vender tilbage med det samme. Lytteren bliver siddende og tager
518
+ // den naeste dialog paa fanen. `wait: true` giver den gamle blokerende adfaerd for
519
+ // de tilfaelde hvor dialogen allerede er undervejs.
520
+ const armeredeDialoger = new Map();
521
+ // Loeftet for den senest armerede dialog pr. fane. Ligger UDEN for armeredeDialoger,
522
+ // fordi lytteren afvaebner i samme oejeblik dialogen aabner — og klikket skal kunne
523
+ // vente paa svaret BAGEFTER.
524
+ const dialogLoefter = new Map(); // tabId -> Promise // tabId → { listener, timer, action }
525
+
526
+ function afvaebnDialog(tabId, grund) {
527
+ const a = armeredeDialoger.get(tabId);
528
+ if (!a) return;
529
+ try { chrome.debugger.onEvent.removeListener(a.listener); } catch {}
530
+ clearTimeout(a.timer);
531
+ armeredeDialoger.delete(tabId);
532
+ // MAALT 22/8: her stoppede funktionen. Loeftet blev ALDRIG opfyldt, saa en kalder
533
+ // med `wait: true` haengte for evigt ad to helt almindelige veje — fanen blev
534
+ // lukket, eller et andet handle_dialog armerede paa samme fane. Ingen fejl, intet
535
+ // svar, bare stilhed. Nu faar kalderen altid et svar.
536
+ if (grund && a.opfyld) a.opfyld({ ok: false, error: grund });
537
+ }
538
+
387
539
  chrome.tabs.onRemoved.addListener((tabId) => {
540
+ afvaebnDialog(tabId, 'fanen blev lukket foer der kom en dialog');
541
+ const lukketAfAgenten = agentLukkedeFaner.delete(tabId);
388
542
  debuggerAttached.delete(tabId);
389
543
  for (const [port, session] of sessions) {
390
544
  if (!session.tabIds.has(tabId)) continue;
391
545
  session.tabIds.delete(tabId);
392
- if (session.tabIds.size === 0) {
546
+ if (session.tabIds.size === 0 && !lukketAfAgenten) {
393
547
  // Last tab closed — tell offscreen to terminate the MCP server.
394
548
  // Resulting WS-close triggers the existing session_disconnect → releaseSession path.
395
549
  chrome.runtime.sendMessage({ type: 'terminate_mcp_session', port }).catch(() => {});
@@ -399,34 +553,112 @@ chrome.tabs.onRemoved.addListener((tabId) => {
399
553
  }
400
554
  });
401
555
 
556
+ // Physical-key `code` for a character, US layout. We used to build this as
557
+ // `Key${char.toUpperCase()}`, which is only correct for letters: "1" became "Key1",
558
+ // "@" became "Key@", " " became "Key ". Frameworks that branch on event.code —
559
+ // masked inputs, shortcut handlers, several React form libraries — see an unknown
560
+ // code and drop the keystroke, so typing an email or URL misbehaved on strict SPAs.
561
+ // Shifted symbols report the code of the physical key they sit on ("@" is Digit2).
562
+ const CDP_CHAR_CODES = {
563
+ ' ': 'Space', '\n': 'Enter', '\t': 'Tab',
564
+ '-': 'Minus', '_': 'Minus', '=': 'Equal', '+': 'Equal',
565
+ '[': 'BracketLeft', '{': 'BracketLeft', ']': 'BracketRight', '}': 'BracketRight',
566
+ '\\': 'Backslash', '|': 'Backslash', ';': 'Semicolon', ':': 'Semicolon',
567
+ "'": 'Quote', '"': 'Quote', ',': 'Comma', '<': 'Comma',
568
+ '.': 'Period', '>': 'Period', '/': 'Slash', '?': 'Slash',
569
+ '`': 'Backquote', '~': 'Backquote',
570
+ '!': 'Digit1', '@': 'Digit2', '#': 'Digit3', '$': 'Digit4', '%': 'Digit5',
571
+ '^': 'Digit6', '&': 'Digit7', '*': 'Digit8', '(': 'Digit9', ')': 'Digit0',
572
+ };
573
+ function cdpCodeForChar(ch) {
574
+ if (ch >= 'a' && ch <= 'z') return `Key${ch.toUpperCase()}`;
575
+ if (ch >= 'A' && ch <= 'Z') return `Key${ch}`;
576
+ if (ch >= '0' && ch <= '9') return `Digit${ch}`;
577
+ // Unknown (accented letters, CJK, emoji): omit it. CDP accepts a missing code,
578
+ // and an omitted code is honest where a fabricated one is actively misleading.
579
+ return CDP_CHAR_CODES[ch] || '';
580
+ }
581
+
582
+ // Types text as individual key events. Assumes the debugger is already attached —
583
+ // debuggerType() is the public wrapper that manages attach/detach.
584
+ async function typeCharsAttached(tabId, text) {
585
+ for (let i = 0; i < text.length; i++) {
586
+ const char = text[i];
587
+ const code = cdpCodeForChar(char);
588
+ await cdpSend(tabId, 'Input.dispatchKeyEvent', {
589
+ type: 'keyDown',
590
+ text: char,
591
+ key: char,
592
+ ...(code ? { code } : {}),
593
+ unmodifiedText: char,
594
+ });
595
+ await cdpSend(tabId, 'Input.dispatchKeyEvent', {
596
+ type: 'keyUp',
597
+ key: char,
598
+ ...(code ? { code } : {}),
599
+ });
600
+ // Human-like typing: random 30-120ms, occasional longer pause
601
+ const pause = (i > 0 && i % (7 + Math.floor(Math.random() * 5)) === 0)
602
+ ? 150 + Math.random() * 200 // thinking pause every ~10 chars
603
+ : 30 + Math.random() * 90; // normal keystroke
604
+ await new Promise(r => setTimeout(r, pause));
605
+ }
606
+ }
607
+
402
608
  async function debuggerType(tabId, text) {
403
609
  await debuggerAttach(tabId);
404
610
  try {
405
- for (let i = 0; i < text.length; i++) {
406
- const char = text[i];
407
- await cdpSend(tabId, 'Input.dispatchKeyEvent', {
408
- type: 'keyDown',
409
- text: char,
410
- key: char,
411
- code: `Key${char.toUpperCase()}`,
412
- unmodifiedText: char,
413
- });
414
- await cdpSend(tabId, 'Input.dispatchKeyEvent', {
415
- type: 'keyUp',
416
- key: char,
417
- code: `Key${char.toUpperCase()}`,
418
- });
419
- // Human-like typing: random 30-120ms, occasional longer pause
420
- const pause = (i > 0 && i % (7 + Math.floor(Math.random() * 5)) === 0)
421
- ? 150 + Math.random() * 200 // thinking pause every ~10 chars
422
- : 30 + Math.random() * 90; // normal keystroke
423
- await new Promise(r => setTimeout(r, pause));
424
- }
611
+ await typeCharsAttached(tabId, text);
425
612
  } finally {
426
613
  await debuggerDetach(tabId);
427
614
  }
428
615
  }
429
616
 
617
+ // ── Naar en dialog kan aabne midt i klikket (MAALT 24/8) ─────────────────────
618
+ //
619
+ // Et confirm() fryser rendereren, og Chrome svarer ALDRIG paa den CDP-kommando der
620
+ // udloeste det. Maalt: browser_click haengte i 25,9 sekunder og gav aldrig svar —
621
+ // mens dialogen faktisk BLEV besvaret af den armerede haandtering (siden rapporterede
622
+ // window.__svar === true). Klikket var altsaa leveret; kun svaret var vaek.
623
+ //
624
+ // Er der armeret en dialog paa fanen, venter vi derfor kun kort. Kommer der intet
625
+ // svar, ER klikket leveret — det er jo netop dét der aabnede dialogen.
626
+ async function dispatchTaalmodigt(tabId, params) {
627
+ if (!armeredeDialoger.has(tabId)) {
628
+ return cdpSend(tabId, 'Input.dispatchMouseEvent', params);
629
+ }
630
+ let faerdig = false;
631
+ const kald = cdpSend(tabId, 'Input.dispatchMouseEvent', params)
632
+ .then((r) => { faerdig = true; return r; })
633
+ .catch(() => { faerdig = true; });
634
+ await Promise.race([kald, new Promise((r) => setTimeout(r, 1200))]);
635
+ return faerdig ? kald : { __dialogBlokerede: true };
636
+ }
637
+
638
+ // MAALT 28/8: `dispatchTaalmodigt` ovenfor lukkede deadlocken paa museklikket, men
639
+ // settle-opslaget i step 3 er OGSAA et renderer-kald — og maalingen viste at det er
640
+ // PRAECIS der klikket haenger (HAENGER@step3-settle). Deadlocken var kun lukket paa
641
+ // hovedstien.
642
+ //
643
+ // Foerste forsoeg gjorde kuren betinget af at vi kunne SE en dialog (armeret, eller en
644
+ // dispatch der blokerede). Den betingelse holder ikke: dispatchen kan naa at blive
645
+ // kvitteret, og lytteren kan naa at afvaebne, FOER rendereren gaar i staa. Saa faldt vi
646
+ // tilbage i det ubeskyttede kald og hang alligevel — maalt.
647
+ //
648
+ // Derfor er fristen nu betingelsesloes. Opslaget er et par linjers synkron JS: svarer
649
+ // rendereren, er den tilbage paa millisekunder. Bruger den over tre sekunder, er den
650
+ // blokeret — ikke langsom. Saa svarer vi aerligt i stedet for at vente i 30.
651
+ // Et klik der aabnede en dialog ER landet, saa framework-fallbacken skal ikke fyre oveni.
652
+ async function evaluerTaalmodigt(tabId, params, ms = 3000) {
653
+ let faerdig = false;
654
+ const kald = cdpSend(tabId, 'Runtime.evaluate', params)
655
+ .then((r) => { faerdig = true; return r; })
656
+ .catch(() => { faerdig = true; });
657
+ await Promise.race([kald, new Promise((r) => setTimeout(r, ms))]);
658
+ if (faerdig) return kald;
659
+ return { result: { value: { landed: true, fallbackFired: false, rendererSvarede: false } } };
660
+ }
661
+
430
662
  async function debuggerClick(tabId, x, y) {
431
663
  await debuggerAttach(tabId);
432
664
  try {
@@ -464,7 +696,7 @@ async function debuggerClick(tabId, x, y) {
464
696
  })()`,
465
697
  });
466
698
  // 1. mouseMoved first (triggers hover state, required by some frameworks)
467
- await cdpSend(tabId, 'Input.dispatchMouseEvent', {
699
+ await dispatchTaalmodigt(tabId, {
468
700
  type: 'mouseMoved', x, y,
469
701
  });
470
702
  await new Promise(r => setTimeout(r, 30));
@@ -472,11 +704,11 @@ async function debuggerClick(tabId, x, y) {
472
704
  // 0 on release) plus a small press→release gap are REQUIRED for Chrome to
473
705
  // synthesize a *trusted* 'click' from the pair. Without them, web-components
474
706
  // that gate on the trusted click event (Google Ads, Material Web) never fire.
475
- await cdpSend(tabId, 'Input.dispatchMouseEvent', {
707
+ await dispatchTaalmodigt(tabId, {
476
708
  type: 'mousePressed', x, y, button: 'left', buttons: 1, clickCount: 1,
477
709
  });
478
710
  await new Promise(r => setTimeout(r, 30));
479
- await cdpSend(tabId, 'Input.dispatchMouseEvent', {
711
+ await dispatchTaalmodigt(tabId, {
480
712
  type: 'mouseReleased', x, y, button: 'left', buttons: 0, clickCount: 1,
481
713
  });
482
714
  // 3. Framework fallback — only if the captured target is STILL connected (i.e.
@@ -484,14 +716,15 @@ async function debuggerClick(tabId, x, y) {
484
716
  // SPA re-renders (Google Ads) detach the element first. Fires a full pointer
485
717
  // + mouse sequence on the shadow-pierced target, then React/Angular handlers.
486
718
  await new Promise(r => setTimeout(r, 120));
487
- await cdpSend(tabId, 'Runtime.evaluate', {
719
+ const settle = await evaluerTaalmodigt(tabId, {
720
+ returnByValue: true,
488
721
  expression: `(() => {
489
722
  const el = window.__bmcpClickTarget;
490
723
  const landed = window.__bmcpClicked === true;
491
724
  try { window.__bmcpClickListener && document.removeEventListener('click', window.__bmcpClickListener, true); } catch (e) {}
492
725
  try { delete window.__bmcpClickTarget; delete window.__bmcpClicked; delete window.__bmcpClickListener; } catch (e) {}
493
- if (landed) return; // FIX-13: trusted click already landed — do NOT double-fire
494
- if (!el || !el.isConnected) return; // already navigated/handled — don't double-fire
726
+ if (landed) return { landed: true, fallbackFired: false }; // FIX-13: trusted click already landed — do NOT double-fire
727
+ if (!el || !el.isConnected) return { landed: false, fallbackFired: false, detached: true }; // already navigated/handled — don't double-fire
495
728
  const opts = { bubbles: true, cancelable: true, composed: true, view: window, clientX: ${x}, clientY: ${y} };
496
729
  try { el.dispatchEvent(new PointerEvent('pointerdown', opts)); } catch (e) {}
497
730
  el.dispatchEvent(new MouseEvent('mousedown', opts));
@@ -516,8 +749,24 @@ async function debuggerClick(tabId, x, y) {
516
749
  const matRipple = el.closest && el.closest('[mat-button], [mat-raised-button], [mat-icon-button], [mat-fab], mat-checkbox, mat-slide-toggle, mat-radio-button');
517
750
  if (matRipple) matRipple.dispatchEvent(new MouseEvent('click', opts));
518
751
  }
752
+ return { landed: false, fallbackFired: true };
519
753
  })()`,
520
754
  });
755
+ const vaerdi = settle?.result?.value ?? null;
756
+
757
+ // MAALT 28/8: uden det her svarede klikket 5,5 sek FOER dialogen var besvaret, saa
758
+ // den NAESTE kommando ramte en stadig frossen side og ventede 15 sek forgaeves.
759
+ // Svarede rendereren ikke, ER der en dialog i vejen — saa vent til den er ude af
760
+ // verden, foer vi melder klikket faerdigt. Sidebonus: kalderen faar en side der
761
+ // rent faktisk er klar til naeste skridt.
762
+ if (vaerdi && vaerdi.rendererSvarede === false) {
763
+ const loefte = dialogLoefter.get(tabId);
764
+ if (loefte) {
765
+ await Promise.race([loefte, new Promise((r) => setTimeout(r, 8000))]);
766
+ dialogLoefter.delete(tabId);
767
+ }
768
+ }
769
+ return vaerdi;
521
770
  } finally {
522
771
  await debuggerDetach(tabId);
523
772
  }
@@ -539,6 +788,32 @@ async function debuggerFocus(tabId, selector) {
539
788
  }
540
789
  }
541
790
 
791
+ // Runtime.evaluate that leaves attach state alone. debuggerEval() detaches in its
792
+ // finally, which would pull the debugger out from under a fill that is mid-flight.
793
+ async function evalAttached(tabId, expression) {
794
+ const result = await cdpSend(tabId, 'Runtime.evaluate', { expression, returnByValue: true });
795
+ if (result.exceptionDetails) {
796
+ throw new Error(result.exceptionDetails.text || 'Script execution failed');
797
+ }
798
+ return result.result?.value;
799
+ }
800
+
801
+ // Select-all + Backspace. Assumes the debugger is already attached.
802
+ async function clearFieldAttached(tabId) {
803
+ await cdpSend(tabId, 'Input.dispatchKeyEvent', {
804
+ type: 'keyDown', key: 'a', code: 'KeyA', modifiers: SELECT_ALL_MODS,
805
+ });
806
+ await cdpSend(tabId, 'Input.dispatchKeyEvent', {
807
+ type: 'keyUp', key: 'a', code: 'KeyA',
808
+ });
809
+ await cdpSend(tabId, 'Input.dispatchKeyEvent', {
810
+ type: 'keyDown', key: 'Backspace', code: 'Backspace',
811
+ });
812
+ await cdpSend(tabId, 'Input.dispatchKeyEvent', {
813
+ type: 'keyUp', key: 'Backspace', code: 'Backspace',
814
+ });
815
+ }
816
+
542
817
  async function debuggerFill(tabId, selector, value) {
543
818
  // Check if element is contenteditable (rich text editors: LinkedIn, Slack)
544
819
  const isContentEditable = await debuggerEval(tabId, `
@@ -566,27 +841,39 @@ async function debuggerFill(tabId, selector, value) {
566
841
  return;
567
842
  }
568
843
 
569
- // Standard input/textarea — focus, clear, type
844
+ // Standard input/textarea — focus, clear, fill
570
845
  await debuggerFocus(tabId, selector);
571
846
  await debuggerAttach(tabId);
572
847
  try {
573
- // Select-all (Cmd+A on macOS, Ctrl+A elsewhere), then Backspace to clear
574
- await cdpSend(tabId, 'Input.dispatchKeyEvent', {
575
- type: 'keyDown', key: 'a', code: 'KeyA', modifiers: SELECT_ALL_MODS,
576
- });
577
- await cdpSend(tabId, 'Input.dispatchKeyEvent', {
578
- type: 'keyUp', key: 'a', code: 'KeyA',
579
- });
580
- await cdpSend(tabId, 'Input.dispatchKeyEvent', {
581
- type: 'keyDown', key: 'Backspace', code: 'Backspace',
582
- });
583
- await cdpSend(tabId, 'Input.dispatchKeyEvent', {
584
- type: 'keyUp', key: 'Backspace', code: 'Backspace',
585
- });
848
+ await clearFieldAttached(tabId);
849
+
850
+ // Fast path: one trusted InputEvent instead of N key events. This is the same
851
+ // primitive set_combobox and set_date already rely on, it avoids per-key `code`
852
+ // mapping entirely, and it turns a 40-character value from ~3 seconds of
853
+ // keystrokes into a single call — which also shrinks the window in which the
854
+ // debugger can detach mid-fill.
855
+ await cdpSend(tabId, 'Input.insertText', { text: value });
856
+
857
+ // Verify something actually landed. Masked inputs, maxlength enforcement and
858
+ // autocompletes that filter per keydown can swallow an inserted string, and
859
+ // until now that failed silently: the caller got "ok" and the field stayed
860
+ // empty. Only an EMPTY field triggers the fallback — a field that transformed
861
+ // the text (phone/date masks reformatting it) did accept the input, and
862
+ // retyping it per character would produce the same transform for no gain.
863
+ const landed = await evalAttached(tabId, `
864
+ (function() {
865
+ const el = document.querySelector(${JSON.stringify(selector)});
866
+ if (!el) return null;
867
+ return ('value' in el) ? el.value : el.textContent;
868
+ })()
869
+ `);
870
+ if (!landed) {
871
+ await clearFieldAttached(tabId);
872
+ await typeCharsAttached(tabId, value);
873
+ }
586
874
  } finally {
587
875
  await debuggerDetach(tabId);
588
876
  }
589
- await debuggerType(tabId, value);
590
877
  }
591
878
 
592
879
  async function debuggerEval(tabId, expression) {
@@ -758,6 +1045,13 @@ async function resolveElement(tabId, selectorStr) {
758
1045
  if (!el) return null;
759
1046
  el.scrollIntoView({ block: 'center', behavior: 'instant' });
760
1047
  const r = el.getBoundingClientRect();
1048
+ // MAALT 21/8: et skjult element har rect 0x0 ved (0,0), saa midtpunktet blev (0,0)
1049
+ // og debuggerClick sendte et AEGTE museklik i sidens oeverste venstre hjoerne —
1050
+ // paa hvad der nu laa der (logo, menu, link) — og svarede ok:true. Det er ikke en
1051
+ // rapporteringsfejl men en handlingsfejl: vi klikker et andet sted end der blev bedt om.
1052
+ if (r.width <= 0 || r.height <= 0) {
1053
+ return { found: false, hidden: true, tag: el.tagName, rect: { w: r.width, h: r.height } };
1054
+ }
761
1055
  return { x: r.x + r.width / 2, y: r.y + r.height / 2, tag: el.tagName, found: true };
762
1056
  };
763
1057
 
@@ -779,6 +1073,9 @@ async function resolveElement(tabId, selectorStr) {
779
1073
  if (!el) return null;
780
1074
  el.scrollIntoView({ block: 'center', behavior: 'instant' });
781
1075
  const r = el.getBoundingClientRect();
1076
+ if (r.width <= 0 || r.height <= 0) {
1077
+ return { found: false, hidden: true, tag: el.tagName, rect: { w: r.width, h: r.height } };
1078
+ }
782
1079
  return { x: r.x + r.width/2, y: r.y + r.height/2, tag: el.tagName, found: true };
783
1080
  })()
784
1081
  `);
@@ -795,6 +1092,9 @@ async function resolveElement(tabId, selectorStr) {
795
1092
  if (!el) return null;
796
1093
  el.scrollIntoView({ block: 'center', behavior: 'instant' });
797
1094
  const r = el.getBoundingClientRect();
1095
+ if (r.width <= 0 || r.height <= 0) {
1096
+ return { found: false, hidden: true, tag: el.tagName, text: el.textContent?.trim().slice(0, 80), rect: { w: r.width, h: r.height } };
1097
+ }
798
1098
  return { x: r.x + r.width/2, y: r.y + r.height/2, tag: el.tagName, text: el.textContent?.trim().slice(0, 80), found: true };
799
1099
  })()
800
1100
  `);
@@ -803,15 +1103,121 @@ async function resolveElement(tabId, selectorStr) {
803
1103
 
804
1104
  // ── Offscreen Document Setup ───────────────────────────────────────────────
805
1105
 
1106
+ // MAALT 21/8: her stod kun `if (!await chrome.offscreen.hasDocument()) createDocument()`.
1107
+ // Den spurgte om dokumentet FANDTES — aldrig om det SVAREDE. Et dokument hvis script
1108
+ // aldrig blev indlaest (en CSP-afvisning raekker) taeller stadig som eksisterende, saa
1109
+ // hjerteslaget hvert minut gjorde ingenting, for evigt. Udvidelsen saa levende ud i
1110
+ // chrome://extensions, men havde ingen WebSocket og kunne ikke naas af noget — heller
1111
+ // ikke af reload_extension, som netop kraever den forbindelse den mangler. Eneste vej
1112
+ // ud var ↻ i haanden.
1113
+ //
1114
+ // Nu spoerges dokumentet om det er der. Svarer det ikke, erstattes det.
1115
+ async function offscreenSvarer() {
1116
+ try {
1117
+ const svar = await Promise.race([
1118
+ chrome.runtime.sendMessage({ type: 'bmcp_ping' }),
1119
+ new Promise((_, afvis) => setTimeout(() => afvis(new Error('intet svar')), 1500)),
1120
+ ]);
1121
+ if (svar?.ok !== true) return false;
1122
+ // "Svarer den?" er ikke nok — den skal ogsaa vaere den udgave vi koerer nu.
1123
+ // En bro fra en aeldre udgave svarer lige saa villigt, og saa blev den aldrig
1124
+ // udskiftet. Oplyser den ingen version, er den fra foer 1.27.1 og altsaa gammel.
1125
+ let vores = null;
1126
+ try { vores = chrome.runtime.getManifest().version; } catch {}
1127
+ if (!vores) return true; // kan vi ikke sammenligne, saa lad den vaere
1128
+ return svar.version === vores;
1129
+ } catch {
1130
+ return false; // ingen modtager, eller den svarede ikke i tide
1131
+ }
1132
+ }
1133
+
1134
+ // Genskabelsen er BEGRAENSET, og det er ikke pynt.
1135
+ //
1136
+ // "Svarer ikke" betyder ikke altid "doed". En AELDRE offscreen.js — fra foer
1137
+ // ping-lytteren fandtes — svarer heller ikke, og Chrome kan servere den fra cache
1138
+ // hen over en genindlaesning (maalt 21/8). Uden en graense ville hjerteslaget saa
1139
+ // lukke og genskabe en fuldt fungerende bro hvert minut, for evigt, og rive
1140
+ // WebSocket-forbindelsen ned hver gang. Kuren ville vaere vaerre end sygdommen.
1141
+ //
1142
+ // Derfor: hoejst tre forsoeg i traek. Er dokumentet aegte doedt, er ét nok. Er det
1143
+ // bare gammelt, koster det tre korte afbrydelser og saa faar det fred et stykke tid.
1144
+ //
1145
+ // MAALT 22/8 — og det var en fejl i denne blok: graensen var PERMANENT. Naaede
1146
+ // taelleren tre, blev der aldrig forsoegt igen, og taelleren nulstilles kun naar en
1147
+ // ping lykkes — hvilket en doed bro pr. definition aldrig goer. Resultatet var en
1148
+ // doed bro der laa doed for evigt, hvor symptomet for brugeren er "browser-mcp
1149
+ // virker ikke", og hvor den eneste udvej var at genindlaese udvidelsen i haanden.
1150
+ // Praecis den tilstand vaernet skulle forhindre.
1151
+ //
1152
+ // Rettelsen: graensen er nu tidsbestemt, ikke endelig. Efter tre forsoeg holder vi
1153
+ // pause — og naar pausen er ovre, proever vi igen. En gammel-men-fungerende bro
1154
+ // faar altsaa ro i pausen i stedet for at blive revet ned hvert minut, og en aegte
1155
+ // doed bro er hoejst én pause fra at blive erstattet. Begge hensyn er i behold.
1156
+ const MAX_OFFSCREEN_GENSKAB = 3;
1157
+ const OFFSCREEN_PAUSE_MS = 10 * 60 * 1000;
1158
+
806
1159
  async function ensureOffscreen() {
807
- const existing = await chrome.offscreen.hasDocument();
808
- if (!existing) {
809
- await chrome.offscreen.createDocument({
810
- url: 'offscreen.html',
811
- reasons: ['WORKERS'],
812
- justification: 'Maintain persistent WebSocket connection to local MCP server',
813
- });
1160
+ const findes = await chrome.offscreen.hasDocument();
1161
+
1162
+ if (findes) {
1163
+ if (await offscreenSvarer()) {
1164
+ await chrome.storage.local.set({ offscreenGenskabt: 0 }); // levende — nulstil
1165
+ return;
1166
+ }
1167
+ // Taelleren skal ligge i storage, ikke i en modul-variabel: service-workeren
1168
+ // genstartes hele tiden, og en variabel ville nulstilles ved hver genstart —
1169
+ // altsaa ingen graense i praksis.
1170
+ let { offscreenGenskabt = 0 } =
1171
+ await chrome.storage.local.get({ offscreenGenskabt: 0, offscreenPauseTil: 0 });
1172
+ const { offscreenPauseTil = 0 } =
1173
+ await chrome.storage.local.get({ offscreenPauseTil: 0 });
1174
+
1175
+ const nu = Date.now();
1176
+ if (offscreenPauseTil > nu) return; // midt i pausen — lad broen vaere
1177
+
1178
+ if (offscreenPauseTil > 0) {
1179
+ // Pausen er udloebet. Taelleren SKAL nulstilles her, foer graensen tjekkes —
1180
+ // ellers rammer vi graensen igen med det samme, saetter endnu en pause, og
1181
+ // graensen er i praksis permanent alligevel, bare med et ekstra skridt.
1182
+ // (Maalt 22/8: det var praecis den fejl den foerste udgave af rettelsen havde.
1183
+ // Testen "naar pausen er ovre, proeves der igen" fangede den.)
1184
+ offscreenGenskabt = 0;
1185
+ await chrome.storage.local.set({ offscreenGenskabt: 0, offscreenPauseTil: 0 });
1186
+ }
1187
+
1188
+ if (offscreenGenskabt >= MAX_OFFSCREEN_GENSKAB) {
1189
+ // Pause i stedet for at give op. Taelleren nulstilles samtidig, saa naeste
1190
+ // runde faar sine egne tre forsoeg — ellers ville graensen vaere permanent
1191
+ // alligevel, bare med et ekstra skridt.
1192
+ console.warn(`[BG] offscreen-dokumentet svarer stadig ikke efter ${MAX_OFFSCREEN_GENSKAB} ` +
1193
+ `forsoeg — holder pause i ${OFFSCREEN_PAUSE_MS / 60000} min og proever saa igen. ` +
1194
+ 'Haster det: chrome://extensions → slaa udvidelsen fra og til.');
1195
+ await chrome.storage.local.set({ offscreenGenskabt: 0, offscreenPauseTil: nu + OFFSCREEN_PAUSE_MS });
1196
+ return;
1197
+ }
1198
+ console.warn(`[BG] offscreen-dokumentet svarer ikke — erstatter det (forsoeg ${offscreenGenskabt + 1}/${MAX_OFFSCREEN_GENSKAB})`);
1199
+ await chrome.storage.local.set({ offscreenGenskabt: offscreenGenskabt + 1 });
1200
+ try { await chrome.offscreen.closeDocument(); } catch (e) {
1201
+ console.warn('[BG] kunne ikke lukke det doede dokument:', e?.message || e);
1202
+ return; // proev igen ved naeste hjerteslag
1203
+ }
814
1204
  }
1205
+
1206
+ // Versionen foelger med i URL'en. Et offscreen-dokument har IKKE
1207
+ // chrome.runtime.getManifest() — kaldet kaster "is not a function", saa dokumentet
1208
+ // kunne aldrig oplyse sin version, `offscreenSvarer()` sammenlignede null mod vores
1209
+ // og fik altid falsk, og broen blev revet ned tre gange hvert tiende minut for evigt.
1210
+ // (Symptomet var "Offscreen document closed before fully loading" — nedrivningen
1211
+ // ramte dokumentet mens det stadig startede op.)
1212
+ // URL'en er baaret af dokumentet selv: et dokument oprettet af en aeldre udgave
1213
+ // baerer den aeldre version, hvilket er praecis den skelnen tjekket skal bruge.
1214
+ let minVersion = '';
1215
+ try { minVersion = chrome.runtime.getManifest().version; } catch {}
1216
+ await chrome.offscreen.createDocument({
1217
+ url: 'offscreen.html' + (minVersion ? '?v=' + encodeURIComponent(minVersion) : ''),
1218
+ reasons: ['WORKERS'],
1219
+ justification: 'Maintain persistent WebSocket connection to local MCP server',
1220
+ });
815
1221
  }
816
1222
 
817
1223
  // ── Action Logging ─────────────────────────────────────────────────────────
@@ -842,12 +1248,19 @@ chrome.runtime.onMessage.addListener((msg, sender, sendResponse) => {
842
1248
  if (msg.type === 'mcp_command') {
843
1249
  const port = msg.port;
844
1250
  logAction(port, msg.method, msg.params);
845
- // Restore sessions from storage (service worker may have restarted)
846
- restoreSessions().then(() => {
847
- dispatch(port, msg.method, msg.params)
848
- .then(result => sendResponse(result))
849
- .catch(err => sendResponse({ __error: err.message || String(err) }));
850
- }).catch(err => sendResponse({ __error: err.message || String(err) })); // else a storage-restore reject hangs the caller
1251
+ // Restore sessions from storage (service worker may have restarted), then take over any
1252
+ // session orphaned by an unclean disconnect so navigate→click keeps hitting the same tab.
1253
+ restoreSessions()
1254
+ .then(() => adoptOrphanedSession(port, msg.pid).catch(() => null)) // adoption is best-effort, never fatal
1255
+ .then(() => {
1256
+ // Stempl pid'en ÉT sted, foer dispatch. De fem nedstroems getSession(port)-kald
1257
+ // arver den derfra, saa ingen af dem behoever at kende til pid-begrebet.
1258
+ getSession(port, msg.pid);
1259
+ dispatch(port, msg.method, msg.params)
1260
+ .then(result => sendResponse(result))
1261
+ .catch(err => sendResponse({ __error: err.message || String(err) }));
1262
+ })
1263
+ .catch(err => sendResponse({ __error: err.message || String(err) })); // else a storage-restore reject hangs the caller
851
1264
  return true; // async response
852
1265
  }
853
1266
 
@@ -857,10 +1270,28 @@ chrome.runtime.onMessage.addListener((msg, sender, sendResponse) => {
857
1270
  }
858
1271
 
859
1272
  if (msg.type === 'reconnect') {
860
- chrome.offscreen.hasDocument().then(exists => {
861
- if (exists) chrome.offscreen.closeDocument().then(() => ensureOffscreen());
862
- else ensureOffscreen();
863
- });
1273
+ // FEJL RETTET 19/8: der var ingen fangst her. Lykkedes closeDocument()
1274
+ // men fejlede ensureOffscreen() fx fordi dokumentet stadig var ved at
1275
+ // lukke — blev afvisningen slugt, og extensionen stod tilbage UDEN
1276
+ // offscreen-dokument. Ingen WebSocket, ingen genopretning, og kun en
1277
+ // manuel genindlaesning kunne redde den.
1278
+ (async () => {
1279
+ try {
1280
+ if (await chrome.offscreen.hasDocument()) {
1281
+ await chrome.offscreen.closeDocument();
1282
+ }
1283
+ } catch (e) {
1284
+ console.warn('[BG] kunne ikke lukke offscreen:', e?.message || e);
1285
+ }
1286
+ // Proev at genskabe. Fejler det, tager hjerteslags-alarmen den
1287
+ // inden for et minut — men kun fordi vi IKKE lader fejlen forsvinde.
1288
+ try {
1289
+ await ensureOffscreen();
1290
+ } catch (e) {
1291
+ console.error('[BG] kunne ikke genskabe offscreen:', e?.message || e);
1292
+ setTimeout(() => ensureOffscreen().catch(console.error), 2000);
1293
+ }
1294
+ })();
864
1295
  return;
865
1296
  }
866
1297
 
@@ -1351,26 +1782,73 @@ async function dismissOverlays(tabId, scope = 'non_critical', maxPasses = 3) {
1351
1782
  ];
1352
1783
  // "Ambiguous" texts MAY revert partial form data ("Cancel" usually reverts state)
1353
1784
  // — only used when overlay has no editable form fields, or in aggressive scope
1785
+ // ── VETO-LISTE (MAALT 23/8 — det dyreste fund i hele auditten) ────────────
1786
+ //
1787
+ // Koert med den ORDRETTE kode mod knapper i en rigtig browser trykkede
1788
+ // dismiss_overlays paa "Close account", "Close and delete everything",
1789
+ // "Cancel subscription" og "Afvis betalingen permanent" — og paa enhver knap
1790
+ // med aria-label="Close account" eller "Luk kontoen". Det skete i DEFAULT-scope,
1791
+ // ikke kun aggressive.
1792
+ //
1793
+ // Aarsagen: "close" og "luk" er lovlige luk-ord, og matchningen havde ingen
1794
+ // ord-graense. "Close account" indeholder "close". Og instruktionerne beder
1795
+ // agenten kalde dismiss_overlays FOER hvert stoerre skridt, saa det ville ske
1796
+ // paa hver eneste side hvor saadan en knap findes.
1797
+ //
1798
+ // Et luk-ord er derfor ikke laengere nok: findes ET af disse ord i teksten eller
1799
+ // aria-labelen, klikkes der ALDRIG — uanset hvor godt resten matcher. Det er en
1800
+ // veto, ikke en vaegtning. Et overlay der ikke bliver lukket koster et ekstra
1801
+ // skridt; en lukket konto koster brugeren penge eller adgang.
1802
+ const VETO = [
1803
+ 'account', 'konto', 'subscription', 'abonnement', 'membership', 'medlemskab',
1804
+ 'payment', 'betaling', 'kort', 'card', 'billing', 'faktura', 'invoice',
1805
+ 'delete', 'slet', 'remove', 'fjern', 'erase', 'wipe', 'destroy',
1806
+ 'permanent', 'permanently', 'forever', 'for evigt', 'irreversibl',
1807
+ 'unsubscribe', 'opsig', 'afmeld', 'terminate', 'opheav',
1808
+ 'deactivate', 'deaktiver', 'disable', 'deaktivér',
1809
+ 'sign out', 'log out', 'log ud', 'logout', 'sign-out',
1810
+ 'order', 'ordre', 'purchase', 'koeb', 'køb', 'refund', 'refunder',
1811
+ ];
1812
+ const erFarlig = (tekst) => {
1813
+ const t = (tekst || '').toLowerCase();
1814
+ return VETO.some((v) => t.includes(v));
1815
+ };
1816
+
1354
1817
  const ambiguousTexts = [
1355
1818
  "skip", "cancel", "afvis", "spring over",
1356
1819
  ];
1357
1820
  const xChars = ['×', '✕', '✖', '⨯'];
1358
1821
 
1822
+ // MAALT 21/8: her stod `if (!el || !el.offsetParent && el.tagName !== 'BODY') return false`.
1823
+ // offsetParent er ALTID null for et position:fixed-element — det er ikke en fejl i
1824
+ // browseren, det er definitionen. Saa hele overlay-fjerneren var blind for praecis
1825
+ // den slags elementer som cookie-bannere, samtykke-bjaelker og modaler ER. Et
1826
+ // synligt fixed-banner med <button aria-label="Close"> blev hverken fundet som
1827
+ // overlay eller som luk-knap: dismissed:[], skipped:[], count:0 — tavst intet.
1828
+ //
1829
+ // Rigtig synlighed laeses af layout og stil, ikke af offsetParent.
1359
1830
  const isVisible = (el) => {
1360
- if (!el || !el.offsetParent && el.tagName !== 'BODY') return false;
1831
+ if (!el) return false;
1361
1832
  const rect = el.getBoundingClientRect();
1362
- return rect.width > 0 && rect.height > 0;
1833
+ if (rect.width <= 0 || rect.height <= 0) return false;
1834
+ const st = getComputedStyle(el);
1835
+ if (st.display === 'none' || st.visibility === 'hidden' || st.visibility === 'collapse') return false;
1836
+ if (parseFloat(st.opacity) === 0) return false;
1837
+ return true;
1363
1838
  };
1364
1839
 
1365
1840
  const findCloseAffordance = (overlay, allowAmbiguous) => {
1366
1841
  const all = [...overlay.querySelectorAll('button, [role="button"], a[href="#"], [aria-label]')];
1367
1842
  const allTexts = allowAmbiguous ? [...safeTexts, ...ambiguousTexts] : safeTexts;
1368
1843
 
1369
- // Priority 1: aria-label match (close/dismiss/luk/afvis are always safe)
1844
+ // Priority 1: aria-label match. MAALT 23/8: "always safe" var FORKERT — aria-label
1845
+ // "Close account" indeholder "close", saa knappen blev trykket. Vetoet nedenfor
1846
+ // er derfor det foerste der koeres, foer nogen match overhovedet forsoeges.
1370
1847
  for (const c of all) {
1371
1848
  if (!isVisible(c)) continue;
1372
1849
  const label = (c.getAttribute('aria-label') || '').toLowerCase();
1373
1850
  if (!label) continue;
1851
+ if (erFarlig(label) || erFarlig(c.textContent)) continue; // veto — se listen ovenfor
1374
1852
  if (label.includes('close') || label.includes('dismiss') || label.includes('luk')) {
1375
1853
  return { el: c, method: 'aria-label', label };
1376
1854
  }
@@ -1384,6 +1862,7 @@ async function dismissOverlays(tabId, scope = 'non_critical', maxPasses = 3) {
1384
1862
  if (!isVisible(c)) continue;
1385
1863
  const text = (c.textContent || '').trim().toLowerCase();
1386
1864
  if (!text || text.length > 30) continue;
1865
+ if (erFarlig(text) || erFarlig(c.getAttribute('aria-label'))) continue; // veto
1387
1866
  if (allTexts.some(t => text === t || text === t + '!' || text === t + '.')) {
1388
1867
  return { el: c, method: 'text-exact', label: text };
1389
1868
  }
@@ -1393,7 +1872,11 @@ async function dismissOverlays(tabId, scope = 'non_critical', maxPasses = 3) {
1393
1872
  if (!isVisible(c)) continue;
1394
1873
  const text = (c.textContent || '').trim().toLowerCase();
1395
1874
  if (!text || text.length > 40) continue;
1396
- if (allTexts.some(t => text.includes(t))) {
1875
+ // MAALT 22/8: contains-passet gjorde "ok" til en delstreng-traeffer, saa
1876
+ // "Book a demo", "Unlock account" og "Cookie settings" blev klikbare — i
1877
+ // DEFAULT-scope. Korte ord maa kun matche eksakt (prioritet 2 ovenfor).
1878
+ if (erFarlig(text) || erFarlig(c.getAttribute('aria-label'))) continue; // veto
1879
+ if (allTexts.filter(t => t.length >= 5).some(t => text.includes(t))) {
1397
1880
  return { el: c, method: 'text-contains', label: text };
1398
1881
  }
1399
1882
  }
@@ -1402,6 +1885,7 @@ async function dismissOverlays(tabId, scope = 'non_critical', maxPasses = 3) {
1402
1885
  for (const c of all) {
1403
1886
  if (!isVisible(c)) continue;
1404
1887
  const text = (c.textContent || '').trim();
1888
+ if (erFarlig(c.getAttribute('aria-label'))) continue; // et × med farlig aria-label
1405
1889
  if (xChars.includes(text)) {
1406
1890
  return { el: c, method: 'x-char', label: text };
1407
1891
  }
@@ -1423,6 +1907,21 @@ async function dismissOverlays(tabId, scope = 'non_critical', maxPasses = 3) {
1423
1907
  '[class*="banner" i]:not([class*="-hidden"]):not(input):not(button)',
1424
1908
  '[data-testid*="dialog" i]',
1425
1909
  '[data-testid*="modal" i]',
1910
+ // MAALT 22/8 af flowtesten: listen matchede kun paa class, aldrig paa id. Et
1911
+ // helt almindeligt <div id="banner"> blev derfor ALDRIG set som et overlay —
1912
+ // og cookie-bannere skrives lige saa ofte med id som med class. Hullet var
1913
+ // skjult indtil i dag, fordi det strukturelle fixed/sticky-spor fangede dem
1914
+ // alligevel; det spor blev skaaret efter sikkerhedsreview, og saa stod hullet
1915
+ // bart. De samme fem ord, samme regler — bare paa id.
1916
+ //
1917
+ // Det her er IKKE det skaarne spor i ny form: her har sideforfatteren selv
1918
+ // kaldt elementet en dialog/modal/banner. Det er en eksplicit erklaering, ikke
1919
+ // et gaet ud fra placering, saa faren ved delstrengs-matchning gaelder ikke.
1920
+ '[id*="modal" i]:not(input):not(button)',
1921
+ '[id*="overlay" i]:not(input):not(button)',
1922
+ '[id*="popover" i]:not(input):not(button)',
1923
+ '[id*="banner" i]:not(input):not(button)',
1924
+ '[id*="dialog" i]:not(input):not(button)',
1426
1925
  ];
1427
1926
  for (const sel of selectors) {
1428
1927
  try {
@@ -1434,6 +1933,15 @@ async function dismissOverlays(tabId, scope = 'non_critical', maxPasses = 3) {
1434
1933
  } catch {}
1435
1934
  }
1436
1935
 
1936
+ // Et strukturelt spor (alt fixed/sticky over 40x20px som overlay-kandidat) blev
1937
+ // proevet 21/8 og SKAARET 22/8 efter sikkerhedsreview: findCloseAffordance matcher
1938
+ // paa delstrenge, saa "Cancel subscription", "Close account" og "Book now" (via "ok")
1939
+ // alle blev klikkbare — paa hver eneste side, og instruktionerne beder agenten kalde
1940
+ // dismiss_overlays foer hvert stoerre skridt. Den maalte fejl var offsetParent-
1941
+ // blindheden i isVisible ovenfor; den er rettet. Sporet var ny adfaerd uden bevist
1942
+ // behov. Genindfoeres kun med eksakt tekstmatch og et krav om luk-affordance som
1943
+ // direkte barn.
1944
+
1437
1945
  for (const overlay of overlays) {
1438
1946
  const role = overlay.getAttribute('role') || (overlay.className || '').split(' ')[0] || 'unknown';
1439
1947
 
@@ -1690,13 +2198,86 @@ async function dropFileOnTarget(tabId, selector, files) {
1690
2198
  }
1691
2199
  }
1692
2200
 
2201
+ // Strategy 2 (v1.27): intercept the NATIVE OS file chooser.
2202
+ //
2203
+ // Sites like Google Ads never put an <input type="file"> in the DOM — clicking
2204
+ // their "choose a file" control opens the OS dialog directly, which no browser
2205
+ // automation can reach. Page.setInterceptFileChooserDialog makes Chrome fire
2206
+ // Page.fileChooserOpened instead of showing that dialog, and the event carries
2207
+ // the backendNodeId of the element that requested it. DOM.setFileInputFiles
2208
+ // accepts a backendNodeId, so we can satisfy the request programmatically.
2209
+ //
2210
+ // Order matters: interception must be armed BEFORE the click that opens the
2211
+ // chooser, otherwise the OS dialog is already up and the event never fires.
2212
+ const chooserResult = await interceptFileChooser(tabId, selector, fileList);
2213
+ if (chooserResult) return chooserResult;
2214
+
1693
2215
  return {
1694
2216
  ok: false,
1695
2217
  error: 'no-file-input-found',
1696
- hint: 'No <input type="file"> found in target subtree, parent, or page. Pure drag-drop zones (without backing input) require synthesizing File objects from disk content via mcp-server, which is not yet implemented. Try selecting a more specific selector, or fall back to manual upload via browser_ask_user.',
2218
+ hint: 'No <input type="file"> found, and the native file-chooser interception did not fire. The trigger element may not open a file dialog at all check the selector.',
1697
2219
  };
1698
2220
  }
1699
2221
 
2222
+ // Arms Page.fileChooserOpened, clicks the trigger, and fulfils the chooser with
2223
+ // the given files. Returns null if no chooser opened (so the caller can fall
2224
+ // through to its own error), or a result object on success/explicit failure.
2225
+ async function interceptFileChooser(tabId, selector, fileList) {
2226
+ try {
2227
+ await debuggerAttach(tabId);
2228
+ await cdpSend(tabId, 'Page.enable', {});
2229
+ await cdpSend(tabId, 'DOM.enable', {});
2230
+ await cdpSend(tabId, 'Page.setInterceptFileChooserDialog', { enabled: true });
2231
+ } catch (e) {
2232
+ return null; // interception unavailable — let caller report the original error
2233
+ }
2234
+
2235
+ try {
2236
+ return await new Promise((resolve) => {
2237
+ let settled = false;
2238
+ const finish = (value) => {
2239
+ if (settled) return;
2240
+ settled = true;
2241
+ chrome.debugger.onEvent.removeListener(listener);
2242
+ clearTimeout(timer);
2243
+ resolve(value);
2244
+ };
2245
+
2246
+ const listener = (source, method, eventParams) => {
2247
+ if (source.tabId !== tabId || method !== 'Page.fileChooserOpened') return;
2248
+ const backendNodeId = eventParams?.backendNodeId;
2249
+ if (!backendNodeId) {
2250
+ finish({ ok: false, error: 'file-chooser-without-node', detail: 'Chrome fired fileChooserOpened but supplied no backendNodeId.' });
2251
+ return;
2252
+ }
2253
+ cdpSend(tabId, 'DOM.setFileInputFiles', { backendNodeId, files: fileList })
2254
+ .then(() => finish({
2255
+ ok: true,
2256
+ method: 'native-chooser-intercepted',
2257
+ files: fileList,
2258
+ mode: eventParams.mode,
2259
+ }))
2260
+ .catch(e => finish({ ok: false, error: 'setFileInputFiles-failed', detail: e?.message || String(e) }));
2261
+ };
2262
+ chrome.debugger.onEvent.addListener(listener);
2263
+
2264
+ // 8s: the click is local, so the chooser opens within a frame or two.
2265
+ // A longer wait would just stall the caller when the element opens no dialog.
2266
+ const timer = setTimeout(() => finish(null), 8000);
2267
+
2268
+ // Click the trigger so the page asks for the chooser. Real mouse events —
2269
+ // a synthetic .click() does not always reach the file-picker code path.
2270
+ (async () => {
2271
+ const el = await resolveElement(tabId, selector);
2272
+ if (!el) { finish({ ok: false, error: 'trigger-not-found', detail: selector }); return; }
2273
+ await debuggerClick(tabId, el.x, el.y);
2274
+ })().catch(e => finish({ ok: false, error: 'trigger-click-failed', detail: e?.message || String(e) }));
2275
+ });
2276
+ } finally {
2277
+ try { await cdpSend(tabId, 'Page.setInterceptFileChooserDialog', { enabled: false }); } catch {}
2278
+ }
2279
+ }
2280
+
1700
2281
  // ── Command Dispatcher ──────────────────────────────────────────────────────
1701
2282
 
1702
2283
  async function dispatch(port, method, params) {
@@ -1754,11 +2335,84 @@ async function dispatch(port, method, params) {
1754
2335
  return { content, url: tab.url, title: tab.title, method: 'debugger' };
1755
2336
  }
1756
2337
 
2338
+ // Read EVERY row of a virtualised list by scrolling its container until the set stops
2339
+ // growing. Outlook, Gmail and most mail/table UIs keep only ~7 rows in the DOM, so a
2340
+ // single get_page_content sees a sliver — this walks the whole list instead.
2341
+ case 'extract_list': {
2342
+ const tab = await getSessionTab(port);
2343
+ if (tab.url.startsWith('chrome://')) throw new Error('Cannot access chrome:// pages');
2344
+ const rowSel = params.selector;
2345
+ if (!rowSel) throw new Error('extract_list requires `selector` (the repeating row element)');
2346
+ const containerSel = params.container || null;
2347
+ const maxRows = Math.min(params.max_rows || 500, 5000);
2348
+ const stableNeeded = params.stable_rounds || 3;
2349
+ const waitMs = params.wait_ms || 350;
2350
+
2351
+ const seen = new Set();
2352
+ let stable = 0, rounds = 0, atEnd = false;
2353
+ const MAX_ROUNDS = 300; // backstop: a list that never stabilises must not spin forever
2354
+
2355
+ while (seen.size < maxRows && stable < stableNeeded && rounds < MAX_ROUNDS) {
2356
+ rounds++;
2357
+ const r = await safeExecuteScript(tab.id, (rs, cs, keep) => {
2358
+ const rows = Array.from(document.querySelectorAll(rs));
2359
+ const texts = rows
2360
+ .map(el => (el.innerText || el.textContent || '').replace(/\s+/g, ' ').trim())
2361
+ .filter(t => t.length > 0);
2362
+
2363
+ // Scroll the row's own scrollable ancestor — scrolling window does nothing when the
2364
+ // list lives in an inner overflow container (the normal case in webmail).
2365
+ let c = cs ? document.querySelector(cs) : null;
2366
+ if (!c && rows.length) {
2367
+ let p = rows[0].parentElement;
2368
+ while (p && p !== document.documentElement) {
2369
+ const s = getComputedStyle(p);
2370
+ if (/(auto|scroll)/.test(s.overflowY) && p.scrollHeight > p.clientHeight + 20) { c = p; break; }
2371
+ p = p.parentElement;
2372
+ }
2373
+ }
2374
+ const step = keep || (c ? c.clientHeight : window.innerHeight) * 0.85;
2375
+ const before = c ? c.scrollTop : window.scrollY;
2376
+ if (c) c.scrollTop = before + step; else window.scrollBy(0, step);
2377
+ const after = c ? c.scrollTop : window.scrollY;
2378
+ const done = c
2379
+ ? c.scrollTop + c.clientHeight >= c.scrollHeight - 4
2380
+ : window.scrollY + window.innerHeight >= document.documentElement.scrollHeight - 4;
2381
+ return { texts, moved: after - before, done, container: !!c };
2382
+ }, [rowSel, containerSel, params.scroll_step || 0]);
2383
+
2384
+ if (r.cspBlocked) throw new Error('extract_list: this page blocks script injection — use screenshots instead');
2385
+ const data = r.result || { texts: [] };
2386
+ const before = seen.size;
2387
+ for (const t of data.texts) seen.add(t);
2388
+ // Two independent stop signals: nothing new appeared, or the container hit its end.
2389
+ if (seen.size === before) stable++; else stable = 0;
2390
+ if (data.done) { atEnd = true; stable++; }
2391
+ if (!data.texts.length && rounds > 2) break; // selector matches nothing — fail fast
2392
+ await new Promise(res => setTimeout(res, waitMs));
2393
+ }
2394
+
2395
+ return {
2396
+ rows: [...seen],
2397
+ count: seen.size,
2398
+ rounds,
2399
+ reached_end: atEnd,
2400
+ truncated: seen.size >= maxRows,
2401
+ };
2402
+ }
2403
+
1757
2404
  case 'screenshot': {
1758
- // getSessionTab(…, true) is focus-NEUTRAL now: it un-minimizes + activates the tab
1759
- // but does NOT steal window focus (FIX-1). Screenshots run constantly, so the common
1760
- // path must never yank Chrome to the foreground.
1761
- const tab = await getSessionTab(port, true);
2405
+ // EKSPERIMENT 21/8: fanen aktiveres IKKE laengere.
2406
+ //
2407
+ // getSessionTab(…, true) stjal ikke VINDUES-fokus (FIX-1), men den kaldte stadig
2408
+ // chrome.tabs.update({active:true}) altsaa et fane-skift INDE i vinduet. Sidder
2409
+ // brugeren i det samme Chrome-vindue paa sin egen fane, bliver den revet vaek hver
2410
+ // eneste gang agenten tager et billede. Og billeder tages konstant.
2411
+ //
2412
+ // Aktiveringen er formentlig unoedvendig: CDP Page.captureScreenshot nedenfor
2413
+ // fotograferer en fane der ikke er forrest. Den okkluderede sidste-udvej loefter
2414
+ // stadig vinduet hvis CDP virkelig ikke kan producere en frame.
2415
+ const tab = await getSessionTab(port, false);
1762
2416
  if (tab.url.startsWith('chrome://') || tab.url.startsWith('about:')) {
1763
2417
  throw new Error(`Cannot screenshot ${tab.url.split(':')[0]}: pages — navigate to a real page first`);
1764
2418
  }
@@ -1816,6 +2470,12 @@ async function dispatch(port, method, params) {
1816
2470
 
1817
2471
  case 'execute_script': {
1818
2472
  // v1.22.2 (DIAGNOSTIC): Try scripting paths but log all errors so we can see WHY they fail
2473
+ // v1.26: accept `script` as alias for `code` — the historic param-name mismatch caused
2474
+ // silent "unserializable"/undefined failures that read as "execute_script is broken".
2475
+ if (params.code == null && typeof params.script === 'string') params.code = params.script;
2476
+ if (typeof params.code !== 'string' || !params.code.trim()) {
2477
+ return { ok: false, error: 'Missing code. Pass a JavaScript EXPRESSION in `code` (e.g. an IIFE: (() => {...; return x;})()). `return ...` at top level is invalid — the handler wraps code in parentheses.' };
2478
+ }
1819
2479
  const tab = await getSessionTab(port);
1820
2480
  if (tab.url.startsWith('chrome://')) throw new Error('Cannot execute scripts on chrome:// pages');
1821
2481
 
@@ -1932,14 +2592,48 @@ async function dispatch(port, method, params) {
1932
2592
  try {
1933
2593
  const el = await resolveElement(tab.id, params.selector);
1934
2594
  if (!el) return { ok: false, error: 'Element not found: ' + params.selector };
2595
+ // Elementet findes, men har ingen udstraekning — at klikke ville ramme (0,0),
2596
+ // altsaa et HELT andet element end det der blev bedt om. Sig det i stedet.
2597
+ if (el.hidden) {
2598
+ return {
2599
+ ok: false,
2600
+ error: 'Element fundet men ikke synligt (0x0) — klik ville ramme sidens hjoerne: ' + params.selector,
2601
+ hidden: true,
2602
+ tag: el.tag,
2603
+ };
2604
+ }
1935
2605
 
1936
2606
  // Primary path: debugger mouse events (isTrusted=true, works on React/Angular SPAs)
1937
- await debuggerClick(tab.id, el.x, el.y);
1938
- return { ok: true, method: el.method || 'debugger', tag: el.tag, text: el.text };
2607
+ const clickResult = await debuggerClick(tab.id, el.x, el.y);
2608
+ return {
2609
+ ok: true,
2610
+ method: el.method || 'debugger',
2611
+ tag: el.tag,
2612
+ text: el.text,
2613
+ // MAALT 21/8: `landed` blev allerede beregnet inde i debuggerClick og smidt vaek,
2614
+ // saa `click` svarede ok:true selv naar siden slet ikke reagerede. Nu foelger den med:
2615
+ // landed=false betyder "eventet blev sendt, men intet handler tog imod det".
2616
+ ...(clickResult || {}),
2617
+ };
1939
2618
  } catch (e) {
1940
2619
  // Fallback: synthetic click via chrome.scripting for anti-automation sites
1941
2620
  // (Apple ASC etc.) OR user-blocked-debugger scenarios.
1942
- if (/Debugger detached/.test(e?.message || '')) {
2621
+ //
2622
+ // MÅLT 29/7: betingelsen var kun /Debugger detached/, men den HYPPIGSTE fejl hedder
2623
+ // "Debugger attach failed after 3 attempts" (kastes l.298) — altså når Chrome nægter
2624
+ // at koble debuggeren på overhovedet. De to strenge ligner hinanden og betyder næsten
2625
+ // det samme, men regexet ramte kun den ene, så fallbacken fyrede aldrig i det tilfælde
2626
+ // den var skrevet til: "user-blocked-debugger scenarios" står ordret i kommentaren
2627
+ // ovenfor, og det var netop dét den ikke dækkede.
2628
+ //
2629
+ // Konsekvens i praksis: klikker brugeren Cancel på Chromes debugger-banner ÉN gang,
2630
+ // husker Chrome det på tværs af extension-reloads, og hvert eneste klik fejler
2631
+ // permanent — selvom scriptingClick ville have virket hele tiden. Den bruger `func:`
2632
+ // og ikke en kode-streng, så den rammes ikke af sidens CSP.
2633
+ //
2634
+ // Prisen ved fallbacken er at klikket mister isTrusted=true. Det tjekker de færreste
2635
+ // sider, og et klik der virker på 95% af nettet slår et klik der aldrig virker.
2636
+ if (/Debugger detached|Debugger attach failed|not attached/i.test(e?.message || '')) {
1943
2637
  const r = await scriptingClick(tab.id, params.selector);
1944
2638
  if (r.ok) return { ok: true, method: 'scripting-fallback', tag: r.tag };
1945
2639
  }
@@ -2077,6 +2771,11 @@ async function dispatch(port, method, params) {
2077
2771
  const r = await setCombobox(tab.id, params.selector, values, {
2078
2772
  multi: !!params.multi,
2079
2773
  query_chars: params.query_chars,
2774
+ // MAALT 22/8: wait_ms blev tavst kasseret her. Skemaet lover parameteren, og
2775
+ // setCombobox laeser opts.wait_ms — men handleren videregav den ikke, saa
2776
+ // ventetiden stod altid paa standarden 3000 ms. En agent der bad om laengere
2777
+ // tid til en langsom liste fik den ikke, og fik ingen besked om det.
2778
+ wait_ms: params.wait_ms,
2080
2779
  });
2081
2780
  const visibleErrors = r.ok ? [] : await collectVisibleErrors(tab.id, params.selector);
2082
2781
  return r.ok ? r : { ...r, visible_errors: visibleErrors };
@@ -2085,7 +2784,8 @@ async function dispatch(port, method, params) {
2085
2784
  case 'drop_file': {
2086
2785
  const tab = await getSessionTab(port);
2087
2786
  if (tab.url.startsWith('chrome://')) throw new Error('Cannot interact with chrome:// pages');
2088
- const files = Array.isArray(params.files) ? params.files : [params.files || params.file];
2787
+ const files = Array.isArray(params.files) ? params.files
2788
+ : [params.files || params.file || params.file_path].filter(Boolean);
2089
2789
  if (!files[0]) return { ok: false, error: 'files or file required' };
2090
2790
  return await dropFileOnTarget(tab.id, params.selector || 'body', files);
2091
2791
  }
@@ -2116,8 +2816,9 @@ async function dispatch(port, method, params) {
2116
2816
  }
2117
2817
 
2118
2818
  case 'press_key': {
2119
- // v1.22: activate tab so key-event lands in foreground (otherwise Chrome routes to active tab)
2120
- const tab = await getSessionTab(port, true);
2819
+ // EKSPERIMENT 21/8: aktiverer IKKE fanen. Kommentaren her sagde at Chrome ellers
2820
+ // sender tastetrykket til den aktive fane — det maales nu i stedet for at antages.
2821
+ const tab = await getSessionTab(port, false);
2121
2822
  if (tab.url.startsWith('chrome://')) throw new Error('Cannot interact with chrome:// pages');
2122
2823
  const key = params.key; // e.g. "Enter", "Tab", "Escape", "ArrowDown"
2123
2824
  const modifiers = (params.ctrl ? 2 : 0) | (params.alt ? 1 : 0) | (params.shift ? 8 : 0) | (params.meta ? 4 : 0);
@@ -2196,6 +2897,65 @@ async function dispatch(port, method, params) {
2196
2897
  return { ok: true, scrolled: { x: dx, y: dy }, method: 'mouseWheel-stepped' };
2197
2898
  }
2198
2899
 
2900
+ // ── v1.26 "superior" tools ──────────────────────────────────────────────
2901
+ // to the MCP server — only lengths and shape booleans. Born from the 2026-07-27
2902
+ // Azure-secret night: the agent must be able to move a credential from page to
2903
+ // field without the value ever entering the LLM context or transcript.
2904
+
2905
+ case 'double_click': {
2906
+ const tab = await getSessionTab(port);
2907
+ if (tab.url.startsWith('chrome://')) throw new Error('Cannot interact with chrome:// pages');
2908
+ const el = await resolveElement(tab.id, params.selector);
2909
+ if (!el) return { ok: false, error: 'Element not found: ' + params.selector };
2910
+ await debuggerAttach(tab.id);
2911
+ const { x, y } = el;
2912
+ await cdpSend(tab.id, 'Input.dispatchMouseEvent', { type: 'mouseMoved', x, y });
2913
+ await new Promise(r => setTimeout(r, 30));
2914
+ // Proper dblclick: two press/release pairs with escalating clickCount.
2915
+ await dispatchTaalmodigt(tab.id, { type: 'mousePressed', x, y, button: 'left', buttons: 1, clickCount: 1 });
2916
+ await dispatchTaalmodigt(tab.id, { type: 'mouseReleased', x, y, button: 'left', buttons: 0, clickCount: 1 });
2917
+ await new Promise(r => setTimeout(r, 40));
2918
+ await dispatchTaalmodigt(tab.id, { type: 'mousePressed', x, y, button: 'left', buttons: 1, clickCount: 2 });
2919
+ await dispatchTaalmodigt(tab.id, { type: 'mouseReleased', x, y, button: 'left', buttons: 0, clickCount: 2 });
2920
+ return { ok: true, double_clicked: true, tag: el.tag, text: el.text };
2921
+ }
2922
+
2923
+ case 'right_click': {
2924
+ const tab = await getSessionTab(port);
2925
+ if (tab.url.startsWith('chrome://')) throw new Error('Cannot interact with chrome:// pages');
2926
+ const el = await resolveElement(tab.id, params.selector);
2927
+ if (!el) return { ok: false, error: 'Element not found: ' + params.selector };
2928
+ await debuggerAttach(tab.id);
2929
+ const { x, y } = el;
2930
+ await cdpSend(tab.id, 'Input.dispatchMouseEvent', { type: 'mouseMoved', x, y });
2931
+ await new Promise(r => setTimeout(r, 30));
2932
+ await dispatchTaalmodigt(tab.id, { type: 'mousePressed', x, y, button: 'right', buttons: 2, clickCount: 1 });
2933
+ await dispatchTaalmodigt(tab.id, { type: 'mouseReleased', x, y, button: 'right', buttons: 0, clickCount: 1 });
2934
+ return { ok: true, right_clicked: true, tag: el.tag, text: el.text, note: 'contextmenu event fired; native Chrome menu does not open via CDP — page-level menus (OWA, web apps) do' };
2935
+ }
2936
+
2937
+ case 'click_xy': {
2938
+ // Raw coordinate click — the escape hatch for custom widgets whose buttons
2939
+ // resist every selector strategy (Azure portal dialogs, KO-bound divs).
2940
+ // Coordinates come from the caller's own screenshot analysis.
2941
+ const tab = await getSessionTab(port);
2942
+ if (tab.url.startsWith('chrome://')) throw new Error('Cannot interact with chrome:// pages');
2943
+ if (typeof params.x !== 'number' || typeof params.y !== 'number') {
2944
+ return { ok: false, error: 'x and y (numbers, CSS pixels in viewport) are required' };
2945
+ }
2946
+ await debuggerClick(tab.id, params.x, params.y);
2947
+ return { ok: true, clicked_at: { x: params.x, y: params.y } };
2948
+ }
2949
+
2950
+ case 'reattach_debugger': {
2951
+ // Ghost-attach recovery without full extension reload: force detach + fresh attach.
2952
+ const tab = await getSessionTab(port);
2953
+ try { await chrome.debugger.detach({ tabId: tab.id }); } catch {}
2954
+ await new Promise(r => setTimeout(r, 150));
2955
+ await debuggerAttach(tab.id);
2956
+ return { ok: true, reattached: true, tab_id: tab.id };
2957
+ }
2958
+
2199
2959
  case 'hover': {
2200
2960
  const tab = await getSessionTab(port);
2201
2961
  if (tab.url.startsWith('chrome://')) throw new Error('Cannot interact with chrome:// pages');
@@ -2226,21 +2986,45 @@ async function dispatch(port, method, params) {
2226
2986
  })()
2227
2987
  `);
2228
2988
 
2989
+ // `value` og `label` accepteres som alias for `option`. Uden dem gav et forkert
2990
+ // navn "undefined" som soegetekst — og vaerktoejet svarede alligevel ok:true.
2991
+ const oensket = params.option ?? params.value ?? params.label;
2992
+ if (typeof oensket !== 'string' || !oensket) {
2993
+ return { ok: false, error: 'Manglende `option` (teksten eller vaerdien paa den mulighed der skal vaelges).' };
2994
+ }
2995
+
2229
2996
  if (isNativeSelect) {
2230
- // Native <select>set value directly
2231
- await debuggerEval(tab.id, `
2997
+ // MAALT 21/8: her blev resultatet af evalueringen `return !!opt` — kastet vaek,
2998
+ // og handleren svarede ubetinget ok:true. Blev muligheden ikke fundet, skete der
2999
+ // INTET, og svaret sagde stadig at det var lykkedes. Samme fejlklasse som klikket
3000
+ // der svarede ok:true uden at siden reagerede. Nu laeses svaret, og der laeses
3001
+ // TILBAGE fra feltet bagefter, saa "valgt" betyder at vaerdien faktisk staar der.
3002
+ const valg = await debuggerEval(tab.id, `
2232
3003
  (function() {
2233
3004
  const sel = document.querySelector(${JSON.stringify(params.selector)});
2234
- const opt = Array.from(sel.options).find(o => o.text.includes(${JSON.stringify(params.option)}) || o.value === ${JSON.stringify(params.option)});
2235
- if (opt) {
2236
- sel.value = opt.value;
2237
- sel.dispatchEvent(new Event('change', { bubbles: true }));
2238
- sel.dispatchEvent(new Event('input', { bubbles: true }));
3005
+ if (!sel) return JSON.stringify({ found: false, error: 'select ikke fundet' });
3006
+ const oensket = ${JSON.stringify(oensket)};
3007
+ const opt = Array.from(sel.options).find(o => o.value === oensket)
3008
+ || Array.from(sel.options).find(o => o.text.trim() === oensket)
3009
+ || Array.from(sel.options).find(o => o.text.includes(oensket));
3010
+ if (!opt) {
3011
+ return JSON.stringify({ found: false, error: 'Ingen mulighed matchede: ' + oensket,
3012
+ available: Array.from(sel.options).map(o => o.text.trim()).slice(0, 25) });
2239
3013
  }
2240
- return !!opt;
3014
+ sel.value = opt.value;
3015
+ sel.dispatchEvent(new Event('input', { bubbles: true }));
3016
+ sel.dispatchEvent(new Event('change', { bubbles: true }));
3017
+ return JSON.stringify({ found: true, wanted: opt.value, actual: sel.value, text: opt.text.trim() });
2241
3018
  })()
2242
3019
  `);
2243
- return { ok: true, type: 'native_select' };
3020
+ let r; try { r = JSON.parse(valg); } catch { r = null; }
3021
+ if (!r) return { ok: false, type: 'native_select', error: 'Kunne ikke laese resultatet af valget' };
3022
+ if (!r.found) return { ok: false, type: 'native_select', error: r.error, available: r.available };
3023
+ if (r.actual !== r.wanted) {
3024
+ // Reagerede siden ved at rulle valget tilbage (React-styret select), skal det siges.
3025
+ return { ok: false, type: 'native_select', error: `Valget blev rullet tilbage: satte "${r.wanted}", feltet staar paa "${r.actual}"` };
3026
+ }
3027
+ return { ok: true, type: 'native_select', selected: r.text, value: r.actual };
2244
3028
  }
2245
3029
 
2246
3030
  // Custom dropdown (Angular Material, React Select, etc.)
@@ -2253,57 +3037,75 @@ async function dispatch(port, method, params) {
2253
3037
  await new Promise(r => setTimeout(r, params.wait || 300));
2254
3038
 
2255
3039
  // Step 3: Find and click the option by text
2256
- const option = await resolveElement(tab.id, `text=${params.option}`);
2257
- if (!option) return { ok: false, error: 'Option not found: ' + params.option };
2258
- await debuggerClick(tab.id, option.x, option.y);
2259
-
2260
- return { ok: true, type: 'custom_dropdown', selected: params.option };
3040
+ const option = await resolveElement(tab.id, `text=${oensket}`);
3041
+ if (!option) return { ok: false, error: 'Option not found: ' + oensket };
3042
+ const valgKlik = await debuggerClick(tab.id, option.x, option.y);
3043
+
3044
+ // MAALT 22/8 ved review: aerlighedsfixet blev kun anvendt paa native-grenen
3045
+ // ovenfor. Her stod stadig `return { ok: true }` ubetinget, selv om resultatet
3046
+ // af klikket var beregnet og smidt vaek — praecis den fejl der blev lukket to
3047
+ // gange andre steder samme dag. En brugerdefineret dropdown hvor klikket ikke
3048
+ // blev taget imod, meldte altsaa stadig succes.
3049
+ // MAALT 22/8 (tredje gang samme fejlklasse): `ok: true` stod hardkodet, og `landed`
3050
+ // blev blot spredt ind ved siden af. En dropdown hvor klikket ikke blev taget imod
3051
+ // svarede altsaa {ok:true, landed:false} — og agenten laeser ok. Beskrivelsen lover
3052
+ // ordret "it never reports success without the field actually changing".
3053
+ return {
3054
+ ...(valgKlik || {}),
3055
+ ok: valgKlik?.landed !== false,
3056
+ type: 'custom_dropdown',
3057
+ selected: oensket,
3058
+ ...(valgKlik?.landed === false
3059
+ ? { error: 'Klikket paa muligheden blev ikke taget imod af siden: ' + oensket }
3060
+ : {}),
3061
+ };
2261
3062
  }
2262
3063
 
2263
3064
  case 'handle_dialog': {
2264
- // Auto-handle JS alert/confirm/prompt dialogs
2265
- // Must be set up BEFORE the dialog appears
2266
3065
  const tab = await getSessionTab(port);
2267
3066
  if (tab.url.startsWith('chrome://')) throw new Error('Cannot interact with chrome:// pages');
2268
- const action = params.action || 'accept'; // accept, dismiss
3067
+ const action = params.action === 'dismiss' ? 'dismiss' : 'accept';
2269
3068
  const promptText = params.text || '';
3069
+ const vent = params.wait === true; // gammel, blokerende adfaerd
3070
+ const levetid = params.timeout || 60000;
2270
3071
 
2271
3072
  await debuggerAttach(tab.id);
2272
- try {
2273
- // Enable page events to catch dialogs
2274
- await cdpSend(tab.id, 'Page.enable', {});
3073
+ await cdpSend(tab.id, 'Page.enable', {});
3074
+ afvaebnDialog(tab.id, 'en ny armering overtog denne fane'); // kun én ad gangen
3075
+
3076
+ let opfyld;
3077
+ const svar = new Promise((resolve) => { opfyld = resolve; });
3078
+
3079
+ const listener = (source, method, eventParams) => {
3080
+ if (source.tabId !== tab.id || method !== 'Page.javascriptDialogOpening') return;
3081
+ afvaebnDialog(tab.id); // uden grund: vi svarer selv lige nedenfor
3082
+ cdpSend(tab.id, 'Page.handleJavaScriptDialog', {
3083
+ accept: action === 'accept',
3084
+ promptText,
3085
+ })
3086
+ .then(() => opfyld({ ok: true, dialog_type: eventParams.type, message: eventParams.message, action }))
3087
+ .catch((e) => opfyld({ ok: false, error: e.message }));
3088
+ };
2275
3089
 
2276
- // Wait for dialog to appear (or handle existing one)
2277
- const result = await new Promise((resolve) => {
2278
- const timeout = setTimeout(() => {
2279
- chrome.debugger.onEvent.removeListener(listener);
2280
- resolve({ ok: false, error: 'No dialog appeared within timeout' });
2281
- }, params.timeout || 10000);
3090
+ const timer = setTimeout(() => {
3091
+ afvaebnDialog(tab.id);
3092
+ opfyld({ ok: false, error: `Ingen dialog dukkede op inden for ${levetid} ms` });
3093
+ }, levetid);
2282
3094
 
2283
- const listener = (source, method, eventParams) => {
2284
- if (source.tabId !== tab.id || method !== 'Page.javascriptDialogOpening') return;
2285
- chrome.debugger.onEvent.removeListener(listener);
2286
- clearTimeout(timeout);
2287
-
2288
- cdpSend(tab.id, 'Page.handleJavaScriptDialog', {
2289
- accept: action === 'accept',
2290
- promptText: promptText,
2291
- }).then(() => {
2292
- resolve({
2293
- ok: true,
2294
- dialog_type: eventParams.type,
2295
- message: eventParams.message,
2296
- action,
2297
- });
2298
- }).catch(e => resolve({ ok: false, error: e.message }));
2299
- };
2300
- chrome.debugger.onEvent.addListener(listener);
2301
- });
3095
+ armeredeDialoger.set(tab.id, { listener, timer, action, opfyld });
3096
+ dialogLoefter.set(tab.id, svar);
3097
+ chrome.debugger.onEvent.addListener(listener);
2302
3098
 
2303
- return result;
2304
- } finally {
2305
- await debuggerDetach(tab.id);
2306
- }
3099
+ if (vent) return await svar;
3100
+
3101
+ // Armeret. Debuggeren bliver siddende — frakobler vi her, doer lytteren med den.
3102
+ return {
3103
+ ok: true,
3104
+ armed: true,
3105
+ action,
3106
+ expires_in_ms: levetid,
3107
+ note: 'Naeste dialog paa denne fane haandteres automatisk. Klik nu paa det der aabner den.',
3108
+ };
2307
3109
  }
2308
3110
 
2309
3111
  case 'wait_for_network': {
@@ -2629,7 +3431,24 @@ async function dispatch(port, method, params) {
2629
3431
  setTimeout(() => { if (document.getElementById('a360-overlay')) { overlay.remove(); resolve({ acknowledged: false, action: 'timeout', values: {} }); } }, timeout);
2630
3432
  });
2631
3433
  },
2632
- args: [params.message, params.title, fields, hasFields, timeout, session.label],
3434
+ // MAALT 21/8: her stod `params.title` raat. Skemaet siger at title er VALGFRI
3435
+ // med standarden "Agent360 — Action Required", men udelades den, er vaerdien
3436
+ // undefined — og chrome.scripting.executeScript afviser hele kaldet med
3437
+ // "Error at property 'args': Error at index 1: Value is unserializable".
3438
+ // Altsaa styrtede human-in-the-loop-vaerktoejet hver gang en agent fulgte sit
3439
+ // eget skema. Det blev aldrig fanget, fordi ask_user stod som "springes over"
3440
+ // i flowtesten — den eneste der kunne have set det.
3441
+ //
3442
+ // Alle argumenter tvinges nu til serialiserbare vaerdier, og standarden
3443
+ // anvendes der hvor den er lovet.
3444
+ args: [
3445
+ String(params.message ?? ''),
3446
+ String(params.title ?? 'Agent360 — Action Required'),
3447
+ Array.isArray(fields) ? fields : [],
3448
+ Boolean(hasFields),
3449
+ Number(timeout) || 120000,
3450
+ String(session.label ?? 'Claude'),
3451
+ ],
2633
3452
  world: 'MAIN',
2634
3453
  });
2635
3454
 
@@ -2650,13 +3469,35 @@ async function dispatch(port, method, params) {
2650
3469
  return { error: `Frame ${frameIndex} not found. Available: ${frames?.length || 0} frames`, frames: frames?.map((f, i) => ({ index: i, url: f.url })) };
2651
3470
  }
2652
3471
  const frameId = frames[frameIndex].frameId;
3472
+ // MAALT 21/8: her stod `func: new Function('return (' + code + ')')`. Den byggede
3473
+ // funktionen i SERVICE-WORKEREN, hvor udvidelsens egen CSP forbyder eval — saa
3474
+ // vaerktoejet fejlede paa hver eneste side, ogsaa med koden '1+1':
3475
+ // "Evaluating a string as JavaScript violates ... 'unsafe-eval' is not allowed".
3476
+ // execute_script loeser det samme problem korrekt: send koden med som ARGUMENT og
3477
+ // byg funktionen INDE i den injicerede func, hvor sidens egen CSP gaelder. Samme
3478
+ // vej her.
3479
+ //
3480
+ // Og som i execute_script (v1.26): accepter `script` som alias for `code`. Samme
3481
+ // navne-uoverensstemmelse har foer faaet vaerktoejer til at se brudte ud i tavshed.
3482
+ if (params.code == null && typeof params.script === 'string') params.code = params.script;
2653
3483
  const code = params.code || 'document.body.innerText.slice(0, 5000)';
2654
3484
  const [result] = await chrome.scripting.executeScript({
2655
3485
  target: { tabId: tab.id, frameIds: [frameId] },
2656
- func: new Function('return (' + code + ')'),
2657
3486
  world: 'MAIN',
3487
+ args: [code],
3488
+ func: (codeStr) => {
3489
+ try {
3490
+ return { __ok: true, value: new Function('return (' + codeStr + ')')() };
3491
+ } catch (e) {
3492
+ return { __scriptingError: true, message: String(e?.message || e) };
3493
+ }
3494
+ },
2658
3495
  });
2659
- return { result: result.result, frame_url: frames[frameIndex].url };
3496
+ const r = result?.result;
3497
+ if (r && r.__scriptingError) {
3498
+ return { ok: false, error: r.message, frame_url: frames[frameIndex].url };
3499
+ }
3500
+ return { result: r?.value, frame_url: frames[frameIndex].url };
2660
3501
  }
2661
3502
 
2662
3503
  case 'list_frames': {
@@ -2694,6 +3535,9 @@ async function dispatch(port, method, params) {
2694
3535
  if (!session.tabIds.has(tabId)) {
2695
3536
  throw new Error(`Tab ${tabId} does not belong to this session (${session.label})`);
2696
3537
  }
3538
+ // Maerk lukningen som agentens egen. Ellers laeser onRemoved den tomme session som
3539
+ // "brugeren er faerdig" og lukker serveren ned midt i samtalen (MAALT 22/8).
3540
+ agentLukkedeFaner.add(tabId);
2697
3541
  await chrome.tabs.remove(tabId);
2698
3542
  session.tabIds.delete(tabId);
2699
3543
  if (session.activeTabId === tabId) session.activeTabId = null;
@@ -2757,8 +3601,19 @@ async function dispatch(port, method, params) {
2757
3601
  return info;
2758
3602
  }
2759
3603
 
2760
- // Get the DOM node ID for the file input
2761
- const { result: docResult } = await cdpSend(tab.id, 'DOM.getDocument', {});
3604
+ // Get the DOM node ID for the file input.
3605
+ //
3606
+ // MAALT 21/8: her stod `const { result: docResult } = await cdpSend(...)`.
3607
+ // Runtime.evaluate ovenfor svarer {result:{...}}, men DOM.getDocument svarer
3608
+ // {root:{...}} — moenstret var kopieret fra det ene kald til det andet. Saa
3609
+ // docResult var undefined, og vaerktoejet doede paa
3610
+ // "Cannot read properties of undefined (reading 'root')" ved HVERT eneste kald.
3611
+ // browser_upload_file kunne ikke uploade en fil paa nogen side overhovedet.
3612
+ const docResult = await cdpSend(tab.id, 'DOM.getDocument', {});
3613
+ if (!docResult?.root?.nodeId) {
3614
+ await debuggerDetach(tab.id);
3615
+ return { ok: false, error: 'DOM.getDocument gav intet rod-element' };
3616
+ }
2762
3617
  const { nodeId } = await cdpSend(tab.id, 'DOM.querySelector', {
2763
3618
  nodeId: docResult.root.nodeId,
2764
3619
  selector: selector,
@@ -2769,8 +3624,16 @@ async function dispatch(port, method, params) {
2769
3624
  return { found: false, error: 'Could not get DOM node for file input' };
2770
3625
  }
2771
3626
 
2772
- // Set files on the input using CDP
2773
- const files = Array.isArray(params.files) ? params.files : [params.files || params.file];
3627
+ // Set files on the input using CDP.
3628
+ // `file_path` accepteres som alias for `file`/`files` praecis samme navne-faelde
3629
+ // som execute_script fik lukket i v1.26. Et forkert navn gav [undefined] og en
3630
+ // upload der saa ud til at lykkes.
3631
+ const files = Array.isArray(params.files) ? params.files
3632
+ : [params.files || params.file || params.file_path].filter(Boolean);
3633
+ if (!files.length) {
3634
+ await debuggerDetach(tab.id);
3635
+ return { ok: false, error: 'Ingen fil angivet. Brug `files` (array) eller `file` (enkelt sti).' };
3636
+ }
2774
3637
  await cdpSend(tab.id, 'DOM.setFileInputFiles', {
2775
3638
  nodeId: nodeId,
2776
3639
  files: files,
@@ -2904,10 +3767,10 @@ async function clickRecaptchaCheckbox(tabId) {
2904
3767
  type: 'mouseMoved', x: pos.x, y: pos.y,
2905
3768
  });
2906
3769
  await new Promise(r => setTimeout(r, 100 + Math.random() * 200));
2907
- await cdpSend(tabId, 'Input.dispatchMouseEvent', {
3770
+ await dispatchTaalmodigt(tabId, {
2908
3771
  type: 'mousePressed', x: pos.x, y: pos.y, button: 'left', clickCount: 1,
2909
3772
  });
2910
- await cdpSend(tabId, 'Input.dispatchMouseEvent', {
3773
+ await dispatchTaalmodigt(tabId, {
2911
3774
  type: 'mouseReleased', x: pos.x, y: pos.y, button: 'left', clickCount: 1,
2912
3775
  });
2913
3776
  await debuggerDetach(tabId);
@@ -2968,10 +3831,10 @@ async function clickCaptchaGridCells(tabId, cells) {
2968
3831
  type: 'mouseMoved', x: ox, y: oy,
2969
3832
  });
2970
3833
  await new Promise(r => setTimeout(r, 150 + Math.random() * 300));
2971
- await cdpSend(tabId, 'Input.dispatchMouseEvent', {
3834
+ await dispatchTaalmodigt(tabId, {
2972
3835
  type: 'mousePressed', x: ox, y: oy, button: 'left', clickCount: 1,
2973
3836
  });
2974
- await cdpSend(tabId, 'Input.dispatchMouseEvent', {
3837
+ await dispatchTaalmodigt(tabId, {
2975
3838
  type: 'mouseReleased', x: ox, y: oy, button: 'left', clickCount: 1,
2976
3839
  });
2977
3840
  await new Promise(r => setTimeout(r, 200 + Math.random() * 400));
@@ -2995,9 +3858,42 @@ async function clickCaptchaGridCells(tabId, cells) {
2995
3858
  ensureOffscreen().catch(console.error);
2996
3859
 
2997
3860
  chrome.runtime.onStartup.addListener(() => ensureOffscreen().catch(console.error));
2998
- chrome.runtime.onInstalled.addListener(() => ensureOffscreen().catch(console.error));
3861
+ // onInstalled fyrer ved installation, opdatering OG ved "Genindlaes" paa
3862
+ // chrome://extensions. I alle tre tilfaelde er koden aendret pr. definition, saa en
3863
+ // overlevende bro er per definition forældet — uanset hvor villigt den svarer paa ping.
3864
+ // MAALT 22/8: uden tvangen slog en genindlaesning aldrig igennem til broen, og
3865
+ // udvikling krævede en fuld genstart af Chrome hver gang.
3866
+ chrome.runtime.onInstalled.addListener(async () => {
3867
+ try {
3868
+ if (await chrome.offscreen.hasDocument()) await chrome.offscreen.closeDocument();
3869
+ } catch (e) {
3870
+ console.warn('[BG] kunne ikke lukke broen ved genindlaesning:', e?.message || e);
3871
+ }
3872
+ await chrome.storage.local.set({ offscreenGenskabt: 0, offscreenPauseTil: 0 });
3873
+ ensureOffscreen().catch(console.error);
3874
+ });
3875
+
3876
+ // Hjerteslag der genskaber offscreen-dokumentet hvis Chrome har ryddet det.
3877
+ //
3878
+ // FEJL RETTET 19/8: alarmen blev oprettet paa oeverste niveau ved HVER
3879
+ // service-worker-opstart. chrome.alarms.create() med et navn der allerede
3880
+ // findes NULSTILLER nedtaellingen — saa hvis workeren vaagnede oftere end
3881
+ // hvert minut (hvilket den goer ved tab-events, beskeder, navigation),
3882
+ // naaede alarmen aldrig at fyre. Resultat: offscreen-dokumentet doede, intet
3883
+ // genskabte det, og forbindelsen til MCP-serveren kom aldrig tilbage foer
3884
+ // nogen genindlaeste extensionen i haanden.
3885
+ //
3886
+ // Nu oprettes den kun hvis den ikke findes, saa nedtaellingen faar lov at
3887
+ // loebe faerdig.
3888
+ chrome.alarms.get('ensure-offscreen', (eksisterende) => {
3889
+ if (!eksisterende) {
3890
+ chrome.alarms.create('ensure-offscreen', {
3891
+ periodInMinutes: 1,
3892
+ delayInMinutes: 1,
3893
+ });
3894
+ }
3895
+ });
2999
3896
 
3000
- chrome.alarms.create('ensure-offscreen', { periodInMinutes: 1 });
3001
3897
  chrome.alarms.onAlarm.addListener((alarm) => {
3002
3898
  if (alarm.name === 'ensure-offscreen') {
3003
3899
  ensureOffscreen().catch(console.error);