@agent360/browser-mcp 1.28.1 → 1.29.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.
@@ -79,7 +79,15 @@ function getSession(port, pid) {
79
79
  // samtidige sessioner heller ikke kan faa samme farve.
80
80
  const brugte = new Set([...sessions.values()].map((s) => s.nummer).filter((n) => typeof n === 'number'));
81
81
  let nummer = 1;
82
- while (brugte.has(nummer)) nummer++;
82
+ // Havde denne chat en plads foer den slap sin port, og er den stadig ledig, saa faar
83
+ // den sin egen tilbage — se pladsPrPid. Ellers laveste ledige, som foer.
84
+ const husket = typeof pid === 'number' ? pladsPrPid.get(pid) : undefined;
85
+ if (typeof husket === 'number' && !brugte.has(husket)) {
86
+ nummer = husket;
87
+ } else {
88
+ while (brugte.has(nummer)) nummer++;
89
+ }
90
+ husketPlads(pid, nummer);
83
91
 
84
92
  sessions.set(port, {
85
93
  tabIds: new Set(),
@@ -93,7 +101,7 @@ function getSession(port, pid) {
93
101
  }
94
102
  const s = sessions.get(port);
95
103
  // 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;
104
+ if (s.pid == null && typeof pid === 'number') { s.pid = pid; husketPlads(pid, s.nummer); }
97
105
  return s;
98
106
  }
99
107
 
@@ -211,6 +219,8 @@ async function evictOldestTabs(session, justAddedTabId) {
211
219
  async function addTabToSession(port, tabId) {
212
220
  const session = getSession(port);
213
221
  session.tabIds.add(tabId);
222
+ // Sessionen lever igen — aflys en eventuel port-frigivelse (se tabs.onRemoved).
223
+ chrome.alarms.clear(`frigiv-${port}`).catch(() => {});
214
224
 
215
225
  // LRU eviction: når sessionen overstiger cap, luk de ældste tabs.
216
226
  if (session.tabIds.size > MAX_TABS_PER_SESSION) {
@@ -247,13 +257,62 @@ async function releaseSession(port) {
247
257
  const session = sessions.get(port);
248
258
  if (!session) return;
249
259
 
260
+ // ── Bad vi selv om frigivelsen? (FUNDET AF REVIEW 7/9) ────────────────────
261
+ //
262
+ // Vejen fra "sessionen er tom" til denne funktion gaar over mindst tre hop:
263
+ // sendMessage -> offscreen sender terminate og lukker WS -> ws.onclose ->
264
+ // session_disconnect. Aabner agenten en fane i de ~50 ms undervejs, lukkede
265
+ // oprydningen herunder DEN fane — paa grundlag af en beslutning der blev truffet
266
+ // foer fanen fandtes. Det er praecis "agenten holder pause og genoptager"-
267
+ // scenariet frigivelsen er bygget til at understoette.
268
+ //
269
+ // En UVENTET afbrydelse (chatten er vaek) skal stadig lukke fanerne — derfor
270
+ // skelnes der, i stedet for bare at tjekke om sessionen er tom.
271
+ // #12: sessionen forsvinder her — dens frist skal med, uanset hvilken vej vi gaar ud.
272
+ chrome.alarms.clear(`frigiv-${port}`).catch(() => {});
273
+
274
+ if (frivilligtFrigivet.delete(port)) {
275
+ if (session.tabIds.size) {
276
+ // Sessionen arbejder igen. Behold den, saa naeste binding kan adoptere den
277
+ // (adoptOrphanedSession finder donorer med samme pid OG faner).
278
+ persistSessions();
279
+ return;
280
+ }
281
+ sessions.delete(port);
282
+ persistSessions();
283
+ return;
284
+ }
285
+
250
286
  // Detach debugger + close all session tabs
251
- const tabIds = [...session.tabIds];
252
- for (const tabId of tabIds) {
287
+ //
288
+ // #13: fejlene blev slugt her, og `sessions.delete(port)` koerte alligevel. En fane der
289
+ // ikke KUNNE lukkes blev dermed foraeldreloes: stadig aaben, stadig i en farvet gruppe,
290
+ // men uden for enhver session — usynlig for list_tabs og aldrig ryddet op. Praeeksisterende,
291
+ // men v1.29 slipper porte langt oftere, saa stien koeres langt hyppigere end foer.
292
+ const stadigAabne = new Set();
293
+ for (const tabId of [...session.tabIds]) {
253
294
  debuggerForceDetach(tabId);
254
295
  try {
255
296
  await chrome.tabs.remove(tabId);
256
- } catch {} // tab may already be closed
297
+ session.tabIds.delete(tabId);
298
+ } catch {
299
+ // Fanen kan vaere lukket i forvejen — saa er den vaek, og det er fint. Findes den
300
+ // stadig, beholder vi den: bedre en session der lever lidt for laenge end en fane
301
+ // ingen ejer.
302
+ try {
303
+ await chrome.tabs.get(tabId);
304
+ stadigAabne.add(tabId);
305
+ } catch {
306
+ session.tabIds.delete(tabId);
307
+ }
308
+ }
309
+ }
310
+
311
+ if (stadigAabne.size) {
312
+ console.warn('[BG] kunne ikke lukke', [...stadigAabne], '— sessionen beholdes saa fanerne ikke strander');
313
+ session.tabIds = stadigAabne;
314
+ persistSessions();
315
+ return;
257
316
  }
258
317
 
259
318
  sessions.delete(port);
@@ -401,7 +460,13 @@ async function debuggerAttach(tabId) {
401
460
  lastMsg = 'attach resolved but Chrome shows tab not attached (ghost — page likely mid-reload)';
402
461
  try { await chrome.debugger.detach({ tabId }); } catch {}
403
462
  } catch (e) {
404
- if (e.message?.includes('Already attached')) {
463
+ // MAALT 31/8 af den nye udvidelses-test: her stod `includes('Already attached')`
464
+ // med stort A. Chromes faktiske besked er "Another debugger is already attached
465
+ // to the tab with id: N" — med lille. Tjekket ramte ALDRIG. Resultat: naar en
466
+ // anden debugger havde fanen (DevTools aabent, en anden udvidelse), blev det
467
+ // behandlet som en fejl, proevet tre gange, og kastet — i stedet for bare at
468
+ // bruge den session der allerede fandtes.
469
+ if (/already attached/i.test(e.message || '')) {
405
470
  // Chrome side has session — sync local cache
406
471
  debuggerAttached.add(tabId);
407
472
  return;
@@ -445,13 +510,26 @@ chrome.debugger.onDetach.addListener((source, reason) => {
445
510
  // Side-effectful methods (Input.*, DOM.setFileInputFiles) must NEVER auto-retry:
446
511
  // Chrome may detach AFTER processing the input (e.g., keystroke triggered navigation),
447
512
  // and a blind retry would double-type or double-click.
513
+ // MAALT 9/9-2026 (fundet af Astra, reproduceret her): `Runtime.evaluate` stod paa listen,
514
+ // fordi de fleste kald er laesninger. Men vi kan ikke afgoere paa metodenavnet om et
515
+ // udtryk MUTERER — og flere af dem goer:
516
+ // * settle-udtrykket i debuggerClick FYRER reserveloesnings-klikket
517
+ // * scroll'ens reserveloesning kalder window.scrollBy
518
+ // * fill skriver i feltet gennem evalAttached
519
+ // Reproduktion: cdpSend(1,'Runtime.evaluate',{expression:'window.tael++'}) med en
520
+ // detach-fejl koerte udtrykket FIRE gange. Paa en SPA hvor debuggeren falder af, kunne
521
+ // det altsaa lande fire klik — paa en knap der maaske bestiller noget.
522
+ //
523
+ // Vi kan ikke skelne, saa standarden skal vaere sikker. Falder debuggeren af midt i en
524
+ // evaluering, faar kalderen fejlen og kan selv beslutte om det er forsvarligt at gentage.
525
+ // Prisen er en tabt gentagelse paa anti-automatiserings-sider; alternativet er et
526
+ // dobbeltklik, og de to ting er ikke lige slemme.
448
527
  const RETRYABLE_CDP_METHODS = new Set([
449
528
  'DOM.getDocument',
450
529
  'DOM.querySelector',
451
530
  'DOM.querySelectorAll',
452
531
  'DOM.focus',
453
532
  'DOM.describeNode',
454
- 'Runtime.evaluate',
455
533
  'Runtime.enable',
456
534
  'Page.captureScreenshot',
457
535
  'Page.enable',
@@ -463,6 +541,86 @@ const RETRYABLE_CDP_METHODS = new Set([
463
541
  // CDP wrapper with auto-recovery: re-attaches on detach errors.
464
542
  // For read-only methods (whitelist above), retries once after re-attach.
465
543
  // For side-effectful methods, only re-attaches and throws — caller must decide.
544
+ // MAALT 8/9-2026: `Input.dispatchMouseEvent` med mouseWheel indfrier ALDRIG sit loefte.
545
+ // `browser_scroll` med pixels ramte derfor serverens 30-sekunders-loft 6 kald ud af 6,
546
+ // paa baade en kort og en lang side — og den `window.scrollBy` der er skrevet til netop
547
+ // det tilfaelde, ligger i et `catch` og kunne aldrig naas. En haenger er ikke en exception.
548
+ // Fristen findes for at reserveloesningen kan naas. Alt der HAR en reserveloesning skal
549
+ // kunne naas via en frist, ikke kun via en fejl.
550
+ // Foerste udgave satte 1.500 ms paa ALT. Astra fandt 9/9 at det indfoerte en ny fejlklasse:
551
+ // dialog-ventetider bruger 3.000 ms, og et Page.captureScreenshot paa en tung side kan
552
+ // lovligt tage laengere. Fristen skar dem over, og skaermbilledets reserveloesning blev
553
+ // dermed naaet i almindelig drift — se rettelse B nedenfor.
554
+ //
555
+ // Den hang der blev MAALT var Input.dispatchMouseEvent med mouseWheel, som aldrig indfrier
556
+ // sit loefte. Laesekald har i forvejen deres egne ydre frister (evaluerTaalmodigt).
557
+ // Derfor: kort frist paa input-kald, og en rundhaandet bagstopper paa resten — stadig under
558
+ // serverens 30 s, saa kalderens reserveloesning kan naas.
559
+ const CDP_FRIST_INPUT_MS = 1500;
560
+ const CDP_FRIST_MS = 8000;
561
+ // MAALT 9/9 af reviewet: ét loft paa alt braekker tre ting. Et skaermbillede paa en tung
562
+ // side bruger lovligt mere end 8 s; `execute_script` med awaitPromise venter paa BRUGERENS
563
+ // egen kode, som serveren giver 30 s; og et enkelt tastetryk paa en side med validering
564
+ // pr. anslag kan lovligt overskride input-fristen. Fristen skal derfor kende kaldet.
565
+ const CDP_FRIST_TUNG_MS = 20000; // under serverens 30 s, men over alt lovligt
566
+
567
+ function cdpFrist(method, params) {
568
+ const m = String(method);
569
+ // Brugerens egen kode maa vente: awaitPromise betyder "vent paa dette loefte".
570
+ if (m === 'Runtime.evaluate' && params && params.awaitPromise) return CDP_FRIST_TUNG_MS;
571
+ if (m === 'Page.captureScreenshot' || m === 'Network.getResponseBody') return CDP_FRIST_TUNG_MS;
572
+ return m.startsWith('Input.') ? CDP_FRIST_INPUT_MS : CDP_FRIST_MS;
573
+ }
574
+
575
+ // Skaermbilledet har et budget for HELE kaeden, ikke kun pr. kald.
576
+ // MAALT 11/9 af Astra (sign-off, R5 F8): standardoptagelsen hang og koblede foerst fra efter 19 s. cdpSend gentog den,
577
+ // fordi den staar som sikker at gentage, og billedet kom efter ca. 38 s - serveren havde opgivet ved 30 s. I 1.29.0
578
+ // sendte en frist paa 8 s kaldet videre til fromSurface:false, som svarede paa et halvt sekund.
579
+ // Standardoptagelsen faar derfor 10 s. Reserven faar resten af budgettet, saa svaret naar frem foer serverens frist.
580
+ // MAALT 11/9 af Fable (e2e-review): haenger BEGGE optagelser, haevede 1.29.0 vinduet og leverede et billede efter 16,7 s.
581
+ // En frist afskar den sidste udvej her, saa der kom intet billede. Budgettet reserverer derfor haevMs til den haevede runde.
582
+ //
583
+ // MAALT 12/9 af Astra (tredje runde paa samme sted): en gentilslutning paa 12,5 s betoed at kandidaten opgav efter 26,9 s,
584
+ // hvor 1.29.0 leverede et billede efter 29,2 s. Aarsagen var ikke logikken, men TALLET: 26 s var valgt frit, ikke udledt.
585
+ // Hver runde fandt et nyt scenarie i de 4 sekunder vi gav bort. Budgettet er derfor nu udledt af det eneste tal der
586
+ // betyder noget - serverens egen frist pr. kald (sendToExtension i mcp-server/index.js) - minus den tid svaret skal
587
+ // bruge paa at komme tilbage gennem broen. Aendrer serverens frist sig, foelger budgettet med.
588
+ const SERVER_FRIST_MS = 30000; // sendToExtension(..., timeoutMs = 30000) i mcp-server/index.js
589
+ // MAALT 12/9 af Astra: hjemrejsen for en skaermbillede-stor nyttelast over den lokale WebSocket er 84-154 ms
590
+ // (0,5 / 2 / 8 MB, tre maalinger hver). 1500 ms var 10x det - altsaa endnu et frit valgt tal i den regel der lige var
591
+ // lukket. 500 ms er tre gange det maalte og krymper baandet, hvor 1.29.0 leverer og vi ikke goer, fra ~1,35 s til ~0,35 s.
592
+ const SVARETS_HJEMREJSE_MS = 500; // udvidelse -> offscreen -> WebSocket -> server
593
+ function skaermbilledeFrister() {
594
+ return { foersteMs: 10000, samletMs: SERVER_FRIST_MS - SVARETS_HJEMREJSE_MS, haevMs: 8000 };
595
+ }
596
+
597
+ // wait_for_network har samme loft hos serveren (30 s). MAALT 11/9 af Astra (e2e-review): svaret kom efter 13 s, body-kaldet
598
+ // fik CDP_FRIST_TUNG_MS (20 s) oveni, og serveren opgav ved 30 s, hvor 1.29.0 svarede efter 21 s med body:null.
599
+ // Hele vaerktoejet har derfor ét budget, og body-kaldet faar kun det der er tilbage.
600
+ // Astra (efterproevning af c826f63): budgettet skar en body over der kom efter 27 s + 2 s, som 1.29.0 leverede efter 29 s.
601
+ // Body-kaldet faar derfor aldrig kortere tid end 1.29.0's frist (CDP_FRIST_MS) og aldrig mere end CDP_FRIST_TUNG_MS.
602
+ // MAALT 12/9 af Astra: her stod 28000 haardkodet - det SAMME frie tal som skaermbilledets budget lige var sluppet af med.
603
+ // Begge budgetter udledes nu af serverens egen frist, saa reglen gaelder hele vejen og ikke kun dér hvor den blev fundet.
604
+ function netvaerkFrister() {
605
+ return { budgetMs: SERVER_FRIST_MS - SVARETS_HJEMREJSE_MS, bodyMinMs: CDP_FRIST_MS, bodyMaxMs: CDP_FRIST_TUNG_MS };
606
+ }
607
+
608
+ function cdpMedFrist(tabId, method, params) {
609
+ let ur;
610
+ const frist = cdpFrist(method, params);
611
+ return Promise.race([
612
+ chrome.debugger.sendCommand({ tabId }, method, params),
613
+ new Promise((_, afvis) => {
614
+ // MAALT 11/9 (raa CDP-proeve i Chrome for Testing): Chrome leverer ikke Input.* til en fane i baggrunden.
615
+ // Kaldet haenger, og efter aktivering svarer det paa millisekunder. Fejlen siger det, saa agenten ved hvad den goer.
616
+ const hint = String(method).startsWith('Input.')
617
+ ? ' (fanen er sandsynligvis i baggrunden - Chrome leverer ikke mus og taster til en fane der ikke er aktiv; kald browser_switch_tab og proev igen)'
618
+ : '';
619
+ ur = setTimeout(() => afvis(new Error(`CDP svarede ikke inden ${frist} ms: ${method}${hint}`)), frist);
620
+ }),
621
+ ]).finally(() => clearTimeout(ur));
622
+ }
623
+
466
624
  async function cdpSend(tabId, method, params = {}) {
467
625
  await debuggerAttach(tabId);
468
626
  let lastMsg = '';
@@ -471,7 +629,7 @@ async function cdpSend(tabId, method, params = {}) {
471
629
  // where Chrome re-detaches between attach and command execution.
472
630
  for (let attempt = 0; attempt < 4; attempt++) {
473
631
  try {
474
- return await chrome.debugger.sendCommand({ tabId }, method, params);
632
+ return await cdpMedFrist(tabId, method, params);
475
633
  } catch (e) {
476
634
  const msg = e?.message || String(e);
477
635
  const isDetachError =
@@ -504,6 +662,27 @@ async function cdpSend(tabId, method, params = {}) {
504
662
  // Faner agenten selv lukkede via close_tab. En tom session betyder kun "arbejdet er slut"
505
663
  // hvis det var BRUGEREN der lukkede den sidste fane.
506
664
  const agentLukkedeFaner = new Set();
665
+ // Porte hvor VI selv har bedt serveren slippe porten, fordi sessionen var tom.
666
+ // Skelnen betyder alt i releaseSession: en frivillig frigivelse maa aldrig lukke
667
+ // faner, mens en uventet afbrydelse (chatten er vaek) netop skal rydde op.
668
+ const frivilligtFrigivet = new Set();
669
+ // Hvilken plads en chat sidst havde, husket paa dens pid — ikke paa porten.
670
+ //
671
+ // MAALT 8/9 (#17): siden porten slippes naar en session er faerdig, slettes sessionen.
672
+ // Kommer chatten tilbage, faar den det laveste LEDIGE nummer — saa en chat der var
673
+ // "Claude 3" vender tilbage som "Claude 1" i en anden farve, og brugeren kan ikke
674
+ // genkende sin egen fanegruppe. Identiteten hoerer til chatten, ikke til porten.
675
+ //
676
+ // Garantien er praecis "dit gamle nummer HVIS det er ledigt". Er det taget, vinder den
677
+ // nulevende session — ellers ville vi genindfoere den navnekollision som lavest-ledige-
678
+ // nummer blev indfoert for at loese.
679
+ const pladsPrPid = new Map();
680
+ function husketPlads(pid, nummer) {
681
+ if (typeof pid !== 'number') return;
682
+ pladsPrPid.set(pid, nummer);
683
+ // Kortet maa ikke vokse i det uendelige paa en langtlevende worker.
684
+ if (pladsPrPid.size > 60) pladsPrPid.delete(pladsPrPid.keys().next().value);
685
+ }
507
686
 
508
687
  // ── Armerede dialog-haandterere, pr. fane ──────────────────────────────────────
509
688
  // MAALT 22/8 af flowtesten: handle_dialog var ubrugelig som den var skrevet. Den
@@ -536,21 +715,71 @@ function afvaebnDialog(tabId, grund) {
536
715
  if (grund && a.opfyld) a.opfyld({ ok: false, error: grund });
537
716
  }
538
717
 
718
+ // Fylder sessions-kortet fra lageret UDEN at filtrere paa om fanerne stadig findes.
719
+ //
720
+ // `restoreSessions()` dropper sessioner uden GYLDIGE faner (`validTabIds.size > 0`), og i
721
+ // `tabs.onRemoved` er den fane vi lige har mistet netop den der ville goere sessionen
722
+ // ugyldig. Bruger man restoreSessions dér, forsvinder praecis den session man skal handle
723
+ // paa. Samme faelde som i alarm-lytteren, og derfor samme svar: laes lageret raat.
724
+ async function hydrerSessionerRaat() {
725
+ if (sessions.size) return;
726
+ const { sessions: gemte } = await chrome.storage.local.get({ sessions: {} });
727
+ for (const [port, d] of Object.entries(gemte)) {
728
+ const p = Number(port);
729
+ if (sessions.has(p)) continue;
730
+ sessions.set(p, {
731
+ tabIds: new Set(d.tabIds || []),
732
+ activeTabId: d.activeTabId ?? null,
733
+ groupId: d.groupId ?? null,
734
+ color: d.color,
735
+ label: d.label,
736
+ nummer: d.nummer ?? null,
737
+ pid: d.pid ?? null,
738
+ });
739
+ }
740
+ }
741
+
539
742
  chrome.tabs.onRemoved.addListener((tabId) => {
540
743
  afvaebnDialog(tabId, 'fanen blev lukket foer der kom en dialog');
541
744
  const lukketAfAgenten = agentLukkedeFaner.delete(tabId);
542
745
  debuggerAttached.delete(tabId);
746
+ // MAALT 8/9 (#15): loekken herunder loeb SYNKRONT paa `sessions`. Vaekker eventet en
747
+ // suspenderet service-worker, er kortet tomt, loekken koerer nul gange, og hverken
748
+ // nedlukningen eller fristen bliver sat — porten holdes saa til 4-timers-tomgangen.
749
+ // Agentens egen close_tab ramte det ikke (den kommer som mcp_command, der vaekker og
750
+ // gendanner foerst). Det var specifikt MENNESKET der lukkede den sidste fane, senere.
751
+ (async () => {
752
+ await hydrerSessionerRaat();
543
753
  for (const [port, session] of sessions) {
544
754
  if (!session.tabIds.has(tabId)) continue;
545
755
  session.tabIds.delete(tabId);
546
756
  if (session.tabIds.size === 0 && !lukketAfAgenten) {
547
757
  // Last tab closed — tell offscreen to terminate the MCP server.
548
758
  // Resulting WS-close triggers the existing session_disconnect → releaseSession path.
759
+ frivilligtFrigivet.add(port);
549
760
  chrome.runtime.sendMessage({ type: 'terminate_mcp_session', port }).catch(() => {});
761
+ } else if (session.tabIds.size === 0) {
762
+ // ── Agenten lukkede selv sin sidste fane (MAALT 7/9-2026) ────────────────
763
+ // Her stod der intet, og det var med vilje: en agent der lukker en fane midt i
764
+ // et forloeb skal ikke miste browseren. Men serverens EGEN instruks siger til
765
+ // hver eneste agent: "ALWAYS close tabs when done". Hver velopdragen chat endte
766
+ // altsaa med at holde sin port til 4-timers-tomgangen udloeb — den dokumenterede
767
+ // god-praksis slog oprydningen ihjel, og 20 porte kunne staa optaget af chats
768
+ // der for laengst var faerdige.
769
+ //
770
+ // Nu: fem minutters henstand. Kommer der en ny fane inden da, aflyses den
771
+ // (se getSessionTab). Sker der intet, bedes serveren slippe porten — den DOER
772
+ // ikke, saa chatten kan hente browseren tilbage naar som helst.
773
+ //
774
+ // chrome.alarms og ikke setTimeout: en MV3-service-worker suspenderes, og en
775
+ // timer ville forsvinde med den.
776
+ chrome.alarms.create(`frigiv-${port}`, { delayInMinutes: 5 });
777
+ persistSessions();
550
778
  } else {
551
779
  persistSessions();
552
780
  }
553
781
  }
782
+ })().catch(() => {});
554
783
  });
555
784
 
556
785
  // Physical-key `code` for a character, US layout. We used to build this as
@@ -585,18 +814,9 @@ async function typeCharsAttached(tabId, text) {
585
814
  for (let i = 0; i < text.length; i++) {
586
815
  const char = text[i];
587
816
  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
- });
817
+ await tastParAttached(tabId,
818
+ { text: char, key: char, ...(code ? { code } : {}), unmodifiedText: char },
819
+ { key: char, ...(code ? { code } : {}) });
600
820
  // Human-like typing: random 30-120ms, occasional longer pause
601
821
  const pause = (i > 0 && i % (7 + Math.floor(Math.random() * 5)) === 0)
602
822
  ? 150 + Math.random() * 200 // thinking pause every ~10 chars
@@ -653,15 +873,59 @@ async function evaluerTaalmodigt(tabId, params, ms = 3000) {
653
873
  let faerdig = false;
654
874
  const kald = cdpSend(tabId, 'Runtime.evaluate', params)
655
875
  .then((r) => { faerdig = true; return r; })
656
- .catch(() => { faerdig = true; });
876
+ .catch((e) => { faerdig = true; return { __fejl: String(e?.message || e) }; });
657
877
  await Promise.race([kald, new Promise((r) => setTimeout(r, ms))]);
658
878
  if (faerdig) return kald;
659
- return { result: { value: { landed: true, fallbackFired: false, rendererSvarede: false } } };
879
+ return { result: { value: { landed: true, fallbackFired: false, rendererSvarede: false, observeret: false } } };
880
+ }
881
+
882
+ // Én regel for om et klik landede - brugt af click, click_xy og select_option. Astra, tredje runde:
883
+ // select_option havde stadig den gamle ("ikke falsk" = landet), saa et uvist klik blev til ok:true.
884
+ function klikLandede(r) {
885
+ return r?.landed === true || r?.detached === true;
886
+ }
887
+
888
+ // Teksten der foelger med et uvist klik. Staar ét sted, fordi den skal vaere ens for click, click_xy og select_option -
889
+ // tre kaldesteder med den samme regel har foer drevet fra hinanden (select_option havde den gamle i to udgaver).
890
+ const UVIST_NOTE = 'Klikket blev sendt, og siden aendrede sig paa mousedown - men ikke af selve klikket. Det kan vaere en ripple, og det kan vaere en menu der aabner paa mousedown. Tjek tilstanden foer du klikker igen: et klik nummer to lukker en menu der allerede er aaben.';
891
+ const UVERIFICERET_NOTE = 'Museknappen blev sendt, men siden kunne ikke laeses bagefter (opslaget fejlede, og siden navigerede ikke). ' +
892
+ 'Klikket kan vaere landet. Tjek tilstanden foer du klikker igen.';
893
+
894
+ // MAALT 12/9 af Astra: der er TO uvisheds-kanaler - `uvist` (kun mousedown aendrede noget) og `uverificeret`
895
+ // (settle-opslaget fejlede uden navigation, men museknappen ER sendt). Rettelsen roerte kun den foerste, saa den anden
896
+ // gav stadig et bart ok:false hvor 1.29.0 gav ok:true - og et bart nej faar agenten til at klikke igen.
897
+ // Betingelsen spoerger derfor paa landed === null, ikke paa ét flag: en fremtidig tredje kanal er daekket fra dag ét.
898
+ function uvisVurdering(r) {
899
+ if (!r || r.landed !== null) return null;
900
+ if (r.uvist) return { maaske_landet: true, note: UVIST_NOTE };
901
+ if (r.uverificeret) return { maaske_landet: true, note: UVERIFICERET_NOTE };
902
+ return { maaske_landet: true, note: 'Handlingen blev sendt, men virkningen kunne ikke bekraeftes. Tjek tilstanden foer du gentager den.' };
903
+ }
904
+
905
+ // MAALT 10/9 af Astra (anden runde): et settle-opslag der FEJLEDE gav null, og null blev til ok:true.
906
+ // Men den hyppigste grund til at opslaget fejler er at klikket navigerede - det er en virkning, og agenten
907
+ // maa ikke faa at vide at den skal klikke igen.
908
+ // Tredje runde: "detached", "closed" og "Cannot find" er IKKE bevis for navigation - afkobling sker ogsaa
909
+ // naar nogen aabner DevTools. Bevis er nu: fanen er lukket, adressen er skiftet, eller JavaScript-konteksten
910
+ // blev revet ned (det sker kun naar dokumentet blev udskiftet). Alt andet er uvist og siges som uvist.
911
+ async function tolkManglendeSettle(tabId, settle, urlFoer) {
912
+ const fejl = settle?.__fejl || 'settle-opslaget gav intet svar';
913
+ const fane = await chrome.tabs.get(tabId).catch(() => null);
914
+ const urlEfter = fane?.url ?? null;
915
+ const kontekstVaek = /Execution context was destroyed|Cannot find context with specified id|Inspected target navigated/i.test(fejl);
916
+ if (!fane || (urlFoer && urlEfter && urlEfter !== urlFoer) || kontekstVaek) {
917
+ return { landed: false, fallbackFired: false, detached: true, navigerede: true };
918
+ }
919
+ return { landed: null, fallbackFired: false, uverificeret: true, fejl };
660
920
  }
661
921
 
662
922
  async function debuggerClick(tabId, x, y) {
663
923
  await debuggerAttach(tabId);
924
+ // Er museknappen sendt ned, kan klikket vaere landet - saa maa en fejl bagefter ikke fore til et klik til.
925
+ let trykSendt = false;
664
926
  try {
927
+ // Adressen foer klikket - et skift bagefter er bevis for en virkning (se tolkManglendeSettle).
928
+ const urlFoer = (await chrome.tabs.get(tabId).catch(() => null))?.url ?? null;
665
929
  // 0. Capture the DEEPEST target element under the point BEFORE dispatching.
666
930
  // Web-components (Google Ads <button-panel>, Material Web) keep their real
667
931
  // <button> inside an (open) shadow root, so we pierce shadow roots to reach
@@ -704,52 +968,135 @@ async function debuggerClick(tabId, x, y) {
704
968
  // 0 on release) plus a small press→release gap are REQUIRED for Chrome to
705
969
  // synthesize a *trusted* 'click' from the pair. Without them, web-components
706
970
  // that gate on the trusted click event (Google Ads, Material Web) never fire.
707
- await dispatchTaalmodigt(tabId, {
708
- type: 'mousePressed', x, y, button: 'left', buttons: 1, clickCount: 1,
709
- });
710
- await new Promise(r => setTimeout(r, 30));
711
- await dispatchTaalmodigt(tabId, {
712
- type: 'mouseReleased', x, y, button: 'left', buttons: 0, clickCount: 1,
713
- });
971
+ trykSendt = true;
972
+ // Astra, tredje runde: fejlede mousePressed EFTER at vaere leveret, blev mouseReleased aldrig sendt,
973
+ // og siden stod tilbage med knappen nede (drag-tilstand). Knappen slippes nu altid.
974
+ let trykFejl = null;
975
+ try {
976
+ await dispatchTaalmodigt(tabId, {
977
+ type: 'mousePressed', x, y, button: 'left', buttons: 1, clickCount: 1,
978
+ });
979
+ await new Promise(r => setTimeout(r, 30));
980
+ } catch (e) { trykFejl = e; }
981
+ try {
982
+ await dispatchTaalmodigt(tabId, {
983
+ type: 'mouseReleased', x, y, button: 'left', buttons: 0, clickCount: 1,
984
+ });
985
+ } catch (e) { trykFejl = trykFejl || e; }
986
+ if (trykFejl) throw trykFejl;
714
987
  // 3. Framework fallback — only if the captured target is STILL connected (i.e.
715
988
  // the trusted click in step 2 did not already handle it). Settle delay lets
716
989
  // SPA re-renders (Google Ads) detach the element first. Fires a full pointer
717
990
  // + mouse sequence on the shadow-pierced target, then React/Angular handlers.
718
991
  await new Promise(r => setTimeout(r, 120));
992
+ // MAALT 9/9-2026: oprydningen (removeEventListener + delete) laa FOER reserveloesningen
993
+ // fyrede. Derfor kunne intet observere om det syntetiske klik virkede, og udtrykket
994
+ // svarede landed:false som et GAET. Paa en div-baseret dropdown betoed det
995
+ // {ok:true, landed:false} og en menu der aldrig aabnede — issue #19.
996
+ // Nu staar lytteren stadig paa document mens reserveloesningen fyrer, og laeses bagefter:
997
+ // det er forskellen paa en maaling og en antagelse. Der ryddes op paa hver udgang.
998
+ // NB: ingen backticks i udtrykket herunder — det ER et template literal.
719
999
  const settle = await evaluerTaalmodigt(tabId, {
720
1000
  returnByValue: true,
721
1001
  expression: `(() => {
722
1002
  const el = window.__bmcpClickTarget;
723
1003
  const landed = window.__bmcpClicked === true;
724
- try { window.__bmcpClickListener && document.removeEventListener('click', window.__bmcpClickListener, true); } catch (e) {}
725
- try { delete window.__bmcpClickTarget; delete window.__bmcpClicked; delete window.__bmcpClickListener; } catch (e) {}
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
1004
+ const ryd = () => {
1005
+ try { window.__bmcpClickListener && document.removeEventListener('click', window.__bmcpClickListener, true); } catch (e) {}
1006
+ try { delete window.__bmcpClickTarget; delete window.__bmcpClicked; delete window.__bmcpClickListener; } catch (e) {}
1007
+ };
1008
+ // Et billigt fingeraftryk af det et klik plejer at aendre: antal noder, synlig tekst,
1009
+ // adressen, og om noget er aabnet/valgt. Bevidst groft — det skal kunne tages to gange
1010
+ // paa faa millisekunder, ikke beskrive siden.
1011
+ // MAALT 11/9 af Astra (R5 F6): tekst og feltvaerdier blev talt i LAENGDE, saa AAAA -> BBBB var
1012
+ // usynlig, og et klik der virkede blev meldt som fejl. Nu hashes indholdet (FNV-1a, 32 bit).
1013
+ const hash = (s) => {
1014
+ let x = 2166136261;
1015
+ for (let i = 0; i < s.length; i++) { x ^= s.charCodeAt(i); x = Math.imul(x, 16777619); }
1016
+ return (x >>> 0).toString(36);
1017
+ };
1018
+ const aftryk = () => {
1019
+ try {
1020
+ return document.querySelectorAll('*').length + '|' +
1021
+ hash(document.body ? String(document.body.innerText) : '') + '|' +
1022
+ location.href + '|' +
1023
+ document.querySelectorAll('[aria-expanded="true"],[aria-selected="true"],[open],.open,.active').length + '|' +
1024
+ // Astra, anden runde: en afkrydsning eller en feltvaerdi aendrer hverken noder, tekst eller
1025
+ // adresse - saa et klik der VIRKEDE blev meldt som fejl, og et nyt klik ville fortryde det.
1026
+ document.querySelectorAll('input:checked,option:checked').length + '|' +
1027
+ hash(Array.from(document.querySelectorAll('input,textarea,select')).map((e) => String(e.value || '')).join(''));
1028
+ } catch (e) { return 'aftryk-fejlede'; }
1029
+ };
1030
+ if (landed) { ryd(); return { landed: true, fallbackFired: false }; } // FIX-13: trusted click already landed — do NOT double-fire
1031
+ if (el === null) { ryd(); return { landed: false, fallbackFired: false, intetMaal: true }; } // intet element under punktet (fx uden for vinduet) - ingen virkning
1032
+ if (!el || !el.isConnected) { ryd(); return { landed: false, fallbackFired: false, detached: true }; } // already navigated/handled — don't double-fire
1033
+ const foerAftryk = aftryk();
728
1034
  const opts = { bubbles: true, cancelable: true, composed: true, view: window, clientX: ${x}, clientY: ${y} };
729
- try { el.dispatchEvent(new PointerEvent('pointerdown', opts)); } catch (e) {}
1035
+ // Samme moenster som script-klikket (Astra 11/9): en PointerEvent uden pointerType er ikke en mus.
1036
+ const mus = { ...opts, pointerType: 'mouse', isPrimary: true, pointerId: 1, button: 0 };
1037
+ try { el.dispatchEvent(new PointerEvent('pointerdown', { ...mus, buttons: 1 })); } catch (e) {}
730
1038
  el.dispatchEvent(new MouseEvent('mousedown', opts));
731
- try { el.dispatchEvent(new PointerEvent('pointerup', opts)); } catch (e) {}
1039
+ try { el.dispatchEvent(new PointerEvent('pointerup', { ...mus, buttons: 0 })); } catch (e) {}
732
1040
  el.dispatchEvent(new MouseEvent('mouseup', opts));
733
- el.dispatchEvent(new MouseEvent('click', opts));
1041
+ // MAALT 10/9 i en rigtig browser: her stod BEGGE — dispatchEvent('click') OG el.click().
1042
+ // Det er to klik-haendelser. Paa alt hvad der skifter tilstand (dropdowns, faneblade,
1043
+ // afkrydsningsfelter, harmonikaer) aabner det foerste og det andet lukker igen, saa
1044
+ // resultatet er intet. Det er aarsagen til at issue #19's div-dropdown "aldrig aabnede":
1045
+ // den aabnede og lukkede inde i den samme reserveloesning.
1046
+ // ét klik: aftryk 11|53|…|0 -> 11|69|…|1 (menuen aaben)
1047
+ // to klik: aftryk 11|53|…|0 -> 11|53|…|0 (tilbage ved start)
1048
+ // el.click() foretraekkes, fordi den ogsaa udloeser elementets aktiverings-adfaerd
1049
+ // (foelg link, skift afkrydsning) — det goer en syntetisk MouseEvent ikke paalideligt.
1050
+ // MAALT 11/9 af Astra (R5 F5): aftrykket til afgoerelsen herunder var foerAftryk fra FOER mousedown.
1051
+ // En ripple-node fra mousedown lignede derfor en virkning af el.click(), React blev sprunget over,
1052
+ // og svaret blev landed:true med nul handling. Afgoerelsen maaler nu kun hvad el.click() gjorde.
1053
+ const foerKlik = aftryk();
734
1054
  if (typeof el.click === 'function') el.click();
1055
+ else el.dispatchEvent(new MouseEvent('click', opts));
1056
+
1057
+ // MAALT 10/9 af Astra (anden runde): React-fiberens onClick blev kaldt UBETINGET efter el.click() -
1058
+ // men el.click() udloeser allerede Reacts handler. To koersler, og en toggle endte hvor den startede.
1059
+ // Framework-vejene er til elementer hvor det native klik INGEN virkning gav.
1060
+ if (aftryk() === foerKlik) {
1061
+ // React fiber fallback — find and call onClick handler directly
1062
+ const fiberKey = Object.keys(el).find(k => k.startsWith('__reactFiber') || k.startsWith('__reactInternalInstance'));
1063
+ if (fiberKey) {
1064
+ let fiber = el[fiberKey];
1065
+ for (let i = 0; i < 10 && fiber; i++) {
1066
+ if (fiber.memoizedProps?.onClick) { fiber.memoizedProps.onClick(new MouseEvent('click', {bubbles:true})); break; }
1067
+ fiber = fiber.return;
1068
+ }
1069
+ }
735
1070
 
736
- // React fiber fallback — find and call onClick handler directly
737
- const fiberKey = Object.keys(el).find(k => k.startsWith('__reactFiber') || k.startsWith('__reactInternalInstance'));
738
- if (fiberKey) {
739
- let fiber = el[fiberKey];
740
- for (let i = 0; i < 10 && fiber; i++) {
741
- if (fiber.memoizedProps?.onClick) { fiber.memoizedProps.onClick(new MouseEvent('click', {bubbles:true})); break; }
742
- fiber = fiber.return;
1071
+ // Angular Material fallback — ripple + internal handlers
1072
+ const ngKey = Object.keys(el).find(k => k.startsWith('__ng'));
1073
+ if (ngKey || el.getAttribute('ng-click') || el.getAttribute('(click)')) {
1074
+ const matRipple = el.closest && el.closest('[mat-button], [mat-raised-button], [mat-icon-button], [mat-fab], mat-checkbox, mat-slide-toggle, mat-radio-button');
1075
+ if (matRipple) matRipple.dispatchEvent(new MouseEvent('click', opts));
743
1076
  }
744
1077
  }
745
1078
 
746
- // Angular Material fallback ripple + internal handlers
747
- const ngKey = Object.keys(el).find(k => k.startsWith('__ng'));
748
- if (ngKey || el.getAttribute('ng-click') || el.getAttribute('(click)')) {
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');
750
- if (matRipple) matRipple.dispatchEvent(new MouseEvent('click', opts));
751
- }
752
- return { landed: false, fallbackFired: true };
1079
+ // Lytteren kan IKKE bruges her. Den udloeses af enhver dispatch paa maalet, og
1080
+ // reserveloesningen dispatcher netop paa maalet — saa flaget ville vaere sandt fordi
1081
+ // VI sendte noget, ikke fordi siden reagerede. Reproduceret 9/9 mod et <div> uden
1082
+ // nogen handler: foer=false, efter=true.
1083
+ // Derfor maales sidens REAKTION i stedet, med samme aftryks-greb som select_option
1084
+ // allerede bruger: aendrede noget sig af det et klik plejer at aendre?
1085
+ // MAALT 12/9 af Fable (e2e runde 3): her sammenlignedes med aftrykket fra FOER mousedown.
1086
+ // Rettelsen af R5 F5 ovenfor gjaldt kun HVILKEN vej der blev proevet, ikke hvad vi SVAREDE. Uden en framework-vej
1087
+ // at falde tilbage paa gav en ripple derfor stadig landed:true med nul handling.
1088
+ // Tre udfald, ikke to: aendrede klikket (eller framework-vejen) noget, er det landet. Aendrede kun mousedown
1089
+ // noget, ved vi det ikke - det kan vaere en ripple, men ogsaa en menu der aabner paa mousedown. Saa siges der
1090
+ // hverken ja eller nej: landed null bliver til maaske_landet hos kalderen, med den maalte tilstand.
1091
+ const efterAftryk = aftryk();
1092
+ const klikketVirkede = efterAftryk !== foerKlik;
1093
+ const kunMousedown = !klikketVirkede && foerKlik !== foerAftryk;
1094
+ ryd();
1095
+ return {
1096
+ landed: kunMousedown ? null : klikketVirkede,
1097
+ ...(kunMousedown ? { uvist: true } : {}),
1098
+ fallbackFired: true, aftrykFoer: foerAftryk, aftrykEfter: efterAftryk,
1099
+ };
753
1100
  })()`,
754
1101
  });
755
1102
  const vaerdi = settle?.result?.value ?? null;
@@ -766,7 +1113,10 @@ async function debuggerClick(tabId, x, y) {
766
1113
  dialogLoefter.delete(tabId);
767
1114
  }
768
1115
  }
769
- return vaerdi;
1116
+ return vaerdi ?? await tolkManglendeSettle(tabId, settle, urlFoer);
1117
+ } catch (e) {
1118
+ if (trykSendt && e && typeof e === 'object') e.trykSendt = true;
1119
+ throw e;
770
1120
  } finally {
771
1121
  await debuggerDetach(tabId);
772
1122
  }
@@ -798,20 +1148,21 @@ async function evalAttached(tabId, expression) {
798
1148
  return result.result?.value;
799
1149
  }
800
1150
 
1151
+ // En tast ned og op igen - og op igen UANSET hvad. MAALT 10/9 af Astra (anden runde): press_key fik
1152
+ // keyUp-altid i foerste runde, men de tre hjaelpere der ogsaa sender taster gjorde ikke. Timede et
1153
+ // keyDown ud efter at det VAR landet, forlod hjaelperen funktionen foer sit keyUp, og tasten sad fast
1154
+ // for siden. Et fastsiddende Cmd goer det naeste tastetryk til en genvej.
1155
+ async function tastParAttached(tabId, ned, op) {
1156
+ let fejl = null;
1157
+ try { await cdpSend(tabId, 'Input.dispatchKeyEvent', { type: 'keyDown', ...ned }); } catch (e) { fejl = e; }
1158
+ try { await cdpSend(tabId, 'Input.dispatchKeyEvent', { type: 'keyUp', ...op }); } catch (e) { fejl = fejl || e; }
1159
+ if (fejl) throw fejl;
1160
+ }
1161
+
801
1162
  // Select-all + Backspace. Assumes the debugger is already attached.
802
1163
  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
- });
1164
+ await tastParAttached(tabId, { key: 'a', code: 'KeyA', modifiers: SELECT_ALL_MODS }, { key: 'a', code: 'KeyA' });
1165
+ await tastParAttached(tabId, { key: 'Backspace', code: 'Backspace' }, { key: 'Backspace', code: 'Backspace' });
815
1166
  }
816
1167
 
817
1168
  async function debuggerFill(tabId, selector, value) {
@@ -847,6 +1198,37 @@ async function debuggerFill(tabId, selector, value) {
847
1198
  try {
848
1199
  await clearFieldAttached(tabId);
849
1200
 
1201
+ // ── Blev feltet FAKTISK tomt? (MAALT 8/9) ────────────────────────────────
1202
+ //
1203
+ // `clearFieldAttached` sender Cmd/Ctrl+A og Backspace som aegte tastetryk. Paa et
1204
+ // React-styret felt tommer det ikke: maalt paa forbrugeragenten.dk/penge-tilbage gav
1205
+ // to fill-kald efter hinanden vaerdien "test@example.dkanden@example.dk" — begge kald
1206
+ // svarede ok:true. Vaerktoejet meldte succes og gjorde noget andet end det lovede.
1207
+ //
1208
+ // Et fill paa et TOMT felt var rent i samme maaling, saa fejlen sidder alene her.
1209
+ // Derfor: laes tilbage, og ryd med den vej der virker paa styrede felter hvis
1210
+ // tastetrykkene ikke slog igennem. Vi gaetter ikke paa hvorfor — vi tjekker.
1211
+ const restVaerdi = await evalAttached(tabId, `
1212
+ (function() {
1213
+ const el = document.activeElement;
1214
+ return el && 'value' in el ? el.value : '';
1215
+ })()
1216
+ `);
1217
+ if (restVaerdi) {
1218
+ await evalAttached(tabId, `
1219
+ (function() {
1220
+ const el = document.activeElement;
1221
+ if (!el || !('value' in el)) return false;
1222
+ const proto = el.tagName === 'TEXTAREA' ? HTMLTextAreaElement.prototype : HTMLInputElement.prototype;
1223
+ const setter = Object.getOwnPropertyDescriptor(proto, 'value')?.set;
1224
+ if (setter) setter.call(el, ''); else el.value = '';
1225
+ el.dispatchEvent(new Event('input', { bubbles: true }));
1226
+ el.dispatchEvent(new Event('change', { bubbles: true }));
1227
+ return true;
1228
+ })()
1229
+ `);
1230
+ }
1231
+
850
1232
  // Fast path: one trusted InputEvent instead of N key events. This is the same
851
1233
  // primitive set_combobox and set_date already rely on, it avoids per-key `code`
852
1234
  // mapping entirely, and it turns a 40-character value from ~3 seconds of
@@ -918,11 +1300,41 @@ async function scriptingClick(tabId, selector) {
918
1300
  }
919
1301
  if (!el) return { ok: false, reason: 'not_found' };
920
1302
  el.scrollIntoView({ block: 'center', behavior: 'instant' });
921
- const opts = { bubbles: true, cancelable: true, view: window };
1303
+ // Sign-off 11/9 (Astra og Fable, begge reproduceret): paa en baggrundsfane blev et klik der krævede isTrusted,
1304
+ // eller kun lyttede paa pointerdown, meldt ok:true uden nogen virkning. Klikket maaler nu sidens reaktion med
1305
+ // samme aftryk og samme regel som debuggerens reserve (debuggerClick) - ingen backticks herinde.
1306
+ const hash = (s) => {
1307
+ let x = 2166136261;
1308
+ for (let i = 0; i < s.length; i++) { x ^= s.charCodeAt(i); x = Math.imul(x, 16777619); }
1309
+ return (x >>> 0).toString(36);
1310
+ };
1311
+ const aftryk = () => {
1312
+ try {
1313
+ return document.querySelectorAll('*').length + '|' +
1314
+ hash(document.body ? String(document.body.innerText) : '') + '|' +
1315
+ location.href + '|' +
1316
+ document.querySelectorAll('[aria-expanded="true"],[aria-selected="true"],[open],.open,.active').length + '|' +
1317
+ document.querySelectorAll('input:checked,option:checked').length + '|' +
1318
+ hash(Array.from(document.querySelectorAll('input,textarea,select')).map((e) => String(e.value || '')).join(' '));
1319
+ } catch (e) { return 'aftryk-fejlede'; }
1320
+ };
1321
+ const opts = { bubbles: true, cancelable: true, composed: true, view: window };
1322
+ // Et rigtigt klik sender pointerdown foer mousedown; Radix/shadcn aabner paa pointerdown.
1323
+ // MAALT 11/9 af Astra (efterproevning af 9814636): uden pointerType ("") handlede en side der reagerer paa ikke-mus-
1324
+ // pointere OG paa click to gange. En rigtig mus sender pointerType "mouse".
1325
+ const mus = { ...opts, pointerType: 'mouse', isPrimary: true, pointerId: 1, button: 0 };
1326
+ try { el.dispatchEvent(new PointerEvent('pointerdown', { ...mus, buttons: 1 })); } catch (e) {}
922
1327
  el.dispatchEvent(new MouseEvent('mousedown', opts));
1328
+ try { el.dispatchEvent(new PointerEvent('pointerup', { ...mus, buttons: 0 })); } catch (e) {}
923
1329
  el.dispatchEvent(new MouseEvent('mouseup', opts));
1330
+ // Aftrykket tages lige foer el.click(). MAALT 11/9 af Astra (e2e-review): taget foer mousedown blev en ripple klikbevis
1331
+ // (ok:true, nul handlinger; 1.29.0: fejl). En side der reagerer paa pointerdown, bliver derfor aerligt uvist.
1332
+ const foer = aftryk();
924
1333
  el.click();
925
- return { ok: true, tag: el.tagName };
1334
+ // Maales i samme oejeblik som klikket. Astra (efterproevning af c1496d4): en anden maaling 200 ms senere gjorde et
1335
+ // uafhaengigt ur til klikbevis og et klik der navigerede i ventetiden til en fejl. En sen React-opdatering giver
1336
+ // derfor landed:false og maaske_landet - aerligt, aldrig en falsk succes.
1337
+ return { ok: true, tag: el.tagName, landed: aftryk() !== foer, detached: el.isConnected === false };
926
1338
  },
927
1339
  args: [selector],
928
1340
  });
@@ -1156,6 +1568,20 @@ async function offscreenSvarer() {
1156
1568
  const MAX_OFFSCREEN_GENSKAB = 3;
1157
1569
  const OFFSCREEN_PAUSE_MS = 10 * 60 * 1000;
1158
1570
 
1571
+ // Et omraade paa mere end dette ville faa HVER brugers browser til at probe tusindvis af porte hvert andet sekund.
1572
+ const PORTE_MAX_SPAEND = 200;
1573
+ /** Laeser et alternativt portomraade fra chrome.storage.local. Ugyldigt = standarden (9876-9895) staar. */
1574
+ async function portOmraadeFraLager() {
1575
+ try {
1576
+ const { bmcpPorte } = await chrome.storage.local.get({ bmcpPorte: null });
1577
+ const m = /^(\d{4,5})-(\d{4,5})$/.exec(String(bmcpPorte ?? ''));
1578
+ if (!m) return null;
1579
+ const fra = Number(m[1]), til = Number(m[2]);
1580
+ if (fra < 1024 || til > 65535 || til < fra || til - fra >= PORTE_MAX_SPAEND) return null;
1581
+ return fra + '-' + til;
1582
+ } catch { return null; }
1583
+ }
1584
+
1159
1585
  async function ensureOffscreen() {
1160
1586
  const findes = await chrome.offscreen.hasDocument();
1161
1587
 
@@ -1213,8 +1639,15 @@ async function ensureOffscreen() {
1213
1639
  // baerer den aeldre version, hvilket er praecis den skelnen tjekket skal bruge.
1214
1640
  let minVersion = '';
1215
1641
  try { minVersion = chrome.runtime.getManifest().version; } catch {}
1642
+ // Portomraadet foelger samme vej som versionen. MAALT 12/9 af Astra: serveren sender kun til den NYESTE forbundne
1643
+ // udvidelse, saa en testbrowser paa de samme porte bliver "nyeste" for hver eneste koerende chats server. Et
1644
+ // omraade i chrome.storage.local lader én profil koere isoleret - og fordi det er en VAERDI og ikke en kodeaendring,
1645
+ // er repoets filer byte-identiske, og udgivelsens kode-aftryk er uroert. (Mit foerste forslag, at flytte portene
1646
+ // til manifestet, ville have fjernet dem fra aftrykket - Astra maalte at de ligger i offscreen.js, som hashes.)
1647
+ const porteParam = await portOmraadeFraLager();
1216
1648
  await chrome.offscreen.createDocument({
1217
- url: 'offscreen.html' + (minVersion ? '?v=' + encodeURIComponent(minVersion) : ''),
1649
+ url: 'offscreen.html' + (minVersion ? '?v=' + encodeURIComponent(minVersion) : '')
1650
+ + (porteParam ? (minVersion ? '&' : '?') + 'porte=' + porteParam : ''),
1218
1651
  reasons: ['WORKERS'],
1219
1652
  justification: 'Maintain persistent WebSocket connection to local MCP server',
1220
1653
  });
@@ -1224,22 +1657,42 @@ async function ensureOffscreen() {
1224
1657
 
1225
1658
  const SENSITIVE = new Set(['get_cookies', 'get_local_storage', 'execute_script', 'extract_token']);
1226
1659
 
1227
- function logAction(port, method, params) {
1660
+ // MAALT 11/9 (Astra, efterproevet): her stod `params: JSON.stringify(params).slice(0, 200)`.
1661
+ // De foerste 200 tegn af ALLE parametre — ogsaa vaerdien til fill (adgangskoder), cookie-
1662
+ // vaerdier og adresser med login-tokens — laa i klartekst i chrome.storage.local. Popuppen
1663
+ // viser kun tid, vaerktoej og session, saa parametrene tjente intet. De gemmes ikke laengere,
1664
+ // og poster gemt af aeldre udgaver renses ved opdatering (rensHandlingslog i onInstalled).
1665
+ async function logAction(port, method) {
1228
1666
  const category = SENSITIVE.has(method) ? 'sensitive' : 'safe';
1229
1667
  const session = sessions.get(port);
1230
1668
  const entry = {
1231
1669
  time: Date.now(),
1232
1670
  method,
1233
- params: JSON.stringify(params).slice(0, 200),
1234
1671
  category,
1235
1672
  session: session?.label || `Port ${port}`,
1236
1673
  color: session?.color || 'grey',
1237
1674
  };
1238
- chrome.storage.local.get({ actionLog: [] }, ({ actionLog }) => {
1239
- actionLog.unshift(entry);
1240
- if (actionLog.length > 50) actionLog.length = 50;
1241
- chrome.storage.local.set({ actionLog });
1242
- });
1675
+ try {
1676
+ const { actionLog = [] } = await chrome.storage.local.get({ actionLog: [] });
1677
+ // MAALT 11/9 af Astra (e2e-review): et logAction der koerte samtidig med rensHandlingslog i onInstalled, havde laest den
1678
+ // gamle log foer oprydningen skrev - og skrev saa den gamle adgangskode tilbage. Hver skrivning renser derfor selv.
1679
+ const renset = (Array.isArray(actionLog) ? actionLog : []).filter(Boolean).map(({ params, ...resten }) => resten);
1680
+ renset.unshift(entry);
1681
+ if (renset.length > 50) renset.length = 50;
1682
+ await chrome.storage.local.set({ actionLog: renset });
1683
+ } catch (e) {
1684
+ console.warn('[BG] handlingslog kunne ikke skrives:', e?.message || e);
1685
+ }
1686
+ }
1687
+
1688
+ async function rensHandlingslog() {
1689
+ try {
1690
+ const { actionLog } = await chrome.storage.local.get({ actionLog: [] });
1691
+ if (!Array.isArray(actionLog) || !actionLog.some((p) => p && 'params' in p)) return;
1692
+ await chrome.storage.local.set({ actionLog: actionLog.map(({ params, ...resten }) => resten) });
1693
+ } catch (e) {
1694
+ console.warn('[BG] gammel handlingslog kunne ikke renses:', e?.message || e);
1695
+ }
1243
1696
  }
1244
1697
 
1245
1698
  // ── Message Handler — receives commands from offscreen.js ──────────────────
@@ -1247,7 +1700,7 @@ function logAction(port, method, params) {
1247
1700
  chrome.runtime.onMessage.addListener((msg, sender, sendResponse) => {
1248
1701
  if (msg.type === 'mcp_command') {
1249
1702
  const port = msg.port;
1250
- logAction(port, msg.method, msg.params);
1703
+ logAction(port, msg.method);
1251
1704
  // Restore sessions from storage (service worker may have restarted), then take over any
1252
1705
  // session orphaned by an unclean disconnect so navigate→click keeps hitting the same tab.
1253
1706
  restoreSessions()
@@ -1314,10 +1767,35 @@ chrome.runtime.onMessage.addListener((msg, sender, sendResponse) => {
1314
1767
 
1315
1768
  const OAUTH_DOMAINS = ['accounts.google.com', 'login.microsoftonline.com', 'github.com/login/oauth', 'slack.com/oauth', 'app.hubspot.com/oauth'];
1316
1769
 
1770
+ // MAALT 10/9, reproduceret i selen: her stod kun `lastCreatedTabId = tab.id` — sat paa
1771
+ // HVER onCreated, ogsaa naar brugeren selv trykker Cmd+T eller aabner sin netbank.
1772
+ // `get_new_tab` adopterede den saa ind i agentens session, hvorefter skaermbillede,
1773
+ // sidetekst og localStorage af BRUGERENS fane var lovligt. Reproduktionen: bruger aabner
1774
+ // brugerens-netbank.example -> get_new_tab svarer med den -> fanen staar i tabIds.
1775
+ //
1776
+ // En ny fane hoerer til en session naar den er aabnet FRA en af dens faner (klik paa et
1777
+ // link med target=_blank, en OAuth-popup). Det staar i openerTabId. Er der ingen opener,
1778
+ // var det brugeren, og saa er den ikke vores.
1317
1779
  let lastCreatedTabId = null;
1780
+ // Aabneren huskes PR. FANE. En global "seneste aabner" blev 10/9 (Astra, reproduceret) laant af
1781
+ // den forkerte fane, naar to faner blev aabnet mens get_new_tab ventede paa tabs.get.
1782
+ const openerForFane = new Map();
1783
+ // MAALT 11/9 af Astra (R5 F9), reproduceret: fane 1 aabner en popup og lukkes derefter (typisk
1784
+ // "log ind i nyt vindue"). get_new_tab slog aabneren op i sessionens faner NU, hvor 1 var vaek,
1785
+ // og svarede not-ours paa sessionens egen popup. Ejeren huskes derfor i det oejeblik fanen
1786
+ // oprettes, hvor aabneren stadig staar i sin session.
1787
+ const ejerForFane = new Map();
1318
1788
 
1319
1789
  chrome.tabs.onCreated.addListener(async (tab) => {
1320
1790
  lastCreatedTabId = tab.id;
1791
+ openerForFane.set(tab.id, tab.openerTabId ?? null);
1792
+ if (openerForFane.size > 500) openerForFane.delete(openerForFane.keys().next().value);
1793
+ let ejer = null;
1794
+ if (tab.openerTabId != null) {
1795
+ for (const [p, s] of sessions) { if (s.tabIds.has(tab.openerTabId)) { ejer = p; break; } }
1796
+ }
1797
+ ejerForFane.set(tab.id, ejer);
1798
+ if (ejerForFane.size > 500) ejerForFane.delete(ejerForFane.keys().next().value);
1321
1799
 
1322
1800
  // Auto-claim OAuth popups for the session that opened them
1323
1801
  if (tab.pendingUrl || tab.url) {
@@ -1375,7 +1853,9 @@ function parsePlaceholderFormat(placeholder) {
1375
1853
  const order = parts.map(p => p.includes('Y') ? 'Y' : p.includes('M') ? 'M' : p.includes('D') ? 'D' : null);
1376
1854
  if (order.includes(null) || new Set(order).size !== 3) return null;
1377
1855
  const padded = parts.map(p => p.length >= 2);
1378
- return { sep, order, padded };
1856
+ // Fjerde runde: YY og YYYY skal kunne skelnes, ellers skrives "2026" i et felt der kun tager to cifre.
1857
+ const lengths = parts.map(p => p.length);
1858
+ return { sep, order, padded, lengths };
1379
1859
  }
1380
1860
 
1381
1861
  function isoToFormat(iso, fmt) {
@@ -1383,7 +1863,7 @@ function isoToFormat(iso, fmt) {
1383
1863
  if (!m) throw new Error('Invalid ISO date: ' + iso);
1384
1864
  const [, y, mo, d] = m;
1385
1865
  return fmt.order.map((slot, i) => {
1386
- if (slot === 'Y') return y;
1866
+ if (slot === 'Y') return fmt.lengths && fmt.lengths[i] === 2 ? y.slice(2) : y;
1387
1867
  if (slot === 'M') return fmt.padded[i] ? mo : String(parseInt(mo, 10));
1388
1868
  if (slot === 'D') return fmt.padded[i] ? d : String(parseInt(d, 10));
1389
1869
  }).join(fmt.sep);
@@ -1406,17 +1886,53 @@ function parseMonthYearText(text) {
1406
1886
  return null;
1407
1887
  }
1408
1888
 
1409
- function valueLooksLikeIso(value, iso) {
1410
- if (!value || !iso) return false;
1889
+ function valueLooksLikeIso(raaVaerdi, iso, fmt) {
1890
+ if (!raaVaerdi || !iso) return false;
1411
1891
  const [y, m, d] = iso.split('-');
1412
- const digits = value.replace(/\D/g, '');
1413
- if (digits.includes(y + m + d)) return true;
1414
- if (digits.includes(m + d + y)) return true;
1415
- if (digits.includes(d + m + y)) return true;
1416
- const hasYear = value.includes(y);
1417
- const hasMonth = value.includes(m) || value.includes(String(parseInt(m, 10)));
1418
- const hasDay = value.includes(d) || value.includes(String(parseInt(d, 10)));
1419
- return hasYear && hasMonth && hasDay;
1892
+ const Y = Number(y), M = Number(m), D = Number(d);
1893
+ // Fjerde runde: en aflaesning der BEGYNDER med den oenskede ISO-dato (fx "2026-01-02T12:00:00") er den dato,
1894
+ // uanset hvilket format placeholderen lover - ellers blev en korrekt dato afvist og kalenderen proevet oveni.
1895
+ if (raaVaerdi.trim().startsWith(iso) && !/\d/.test(raaVaerdi.trim().charAt(iso.length))) return true;
1896
+ // MAALT 11/9 af Astra (R5): klokkeslaet og tidszone er ikke en del af datoen. F2: "02/01 20:26" gav timen 20
1897
+ // som aaret 2020 og ok:true. F3: "02/01/2026 12:00 GMT" blev afvist af bogstavkontrollen paa "GMT", og
1898
+ // kalenderen blev proevet oveni. Begge fjernes foer datoen laeses.
1899
+ const value = raaVaerdi
1900
+ .replace(/\b\d{1,2}:\d{2}(:\d{2}(\.\d+)?)?(\s*[ap]\.?m\.?)?(?![\d:])/gi, ' ')
1901
+ .replace(/\b(GMT|UTC|UT|CET|CEST|EET|EEST|WET|WEST|BST|EST|EDT|CST|CDT|MST|MDT|PST|PDT)\b([+-]\d{1,2}(:?\d{2})?)?/g, ' ')
1902
+ .replace(/\s+/g, ' ')
1903
+ .trim();
1904
+ if (!value) return false;
1905
+ // Astra, anden runde: tre `value.includes(...)` hver for sig godkendte "20/12/2026" som 2026-01-02.
1906
+ // Tredje runde: tre `digits.includes(...)` koerte stadig FOER kontrollen af hele tal, saa
1907
+ // "2026-1-1 02:00" blev 2026-11-02. Nu sammenlignes cifre som én streng KUN naar vaerdien udelukkende
1908
+ // ER otte cifre - og med kendt format kun i formatets raekkefoelge ("01/12/2026" under DD/MM/YYYY er
1909
+ // 1. december, ikke 12. januar).
1910
+ const ordener = fmt?.order ? [fmt.order] : [['Y', 'M', 'D'], ['D', 'M', 'Y'], ['M', 'D', 'Y']];
1911
+ const del = { Y: y, M: m, D: d };
1912
+ if (/^\s*\d{8}\s*$/.test(value)) {
1913
+ const cifre = value.trim();
1914
+ return ordener.some((o) => o.map((slot) => del[slot]).join('') === cifre);
1915
+ }
1916
+ // Hele tal fra vaerdiens START - et klokkeslaet bagefter maa ikke levere dag eller maaned.
1917
+ const tok = value.match(/\d+/g) || [];
1918
+ const passer = (slot, s) => {
1919
+ if (s === undefined) return false;
1920
+ if (slot === 'Y') return (s.length === 4 && Number(s) === Y) || (s.length === 2 && Number(s) === Y % 100);
1921
+ return s.length <= 2 && Number(s) === (slot === 'M' ? M : D);
1922
+ };
1923
+ // Fjerde runde: rene tal kun naar vaerdien ingen bogstaver har - i "2 Jan 26 05:00" blev timen ellers et aarstal.
1924
+ if (!/\p{L}/u.test(value) && ordener.some((o) => o.every((slot, i) => passer(slot, tok[i])))) return true;
1925
+ // Maanedsnavn ("2 Jan 2026", "2. maj 2026", "Jan 2, 2026"): dagen skal staa lige foer eller lige efter navnet.
1926
+ const navne = [/jan/, /feb/, /mar/, /apr/, /ma[iyj]/, /jun/, /jul/, /aug/, /sep/, /o[ck]t/, /nov/, /de[cz]/];
1927
+ const navn = navne[M - 1];
1928
+ if (navn) {
1929
+ const lav = value.toLowerCase();
1930
+ const n = navn.source;
1931
+ const foer = new RegExp('^\\s*(\\d{1,2})\\.?\\s+' + n + '[a-zæøå]*\\.?,?\\s+(\\d{4})(?!\\d)').exec(lav);
1932
+ const efter = new RegExp('^\\s*' + n + '[a-zæøå]*\\.?\\s+(\\d{1,2}),?\\s+(\\d{4})(?!\\d)').exec(lav);
1933
+ for (const r of [foer, efter]) if (r && Number(r[1]) === D && Number(r[2]) === Y) return true;
1934
+ }
1935
+ return false;
1420
1936
  }
1421
1937
 
1422
1938
  async function getDateInputInfo(tabId, selector) {
@@ -1485,25 +2001,9 @@ async function setDateMaskedTyping(tabId, selector, iso, format) {
1485
2001
  await debuggerFocus(tabId, selector);
1486
2002
  await debuggerAttach(tabId);
1487
2003
  try {
1488
- await cdpSend(tabId, 'Input.dispatchKeyEvent', {
1489
- type: 'keyDown', key: 'a', code: 'KeyA', modifiers: SELECT_ALL_MODS,
1490
- });
1491
- await cdpSend(tabId, 'Input.dispatchKeyEvent', {
1492
- type: 'keyUp', key: 'a', code: 'KeyA',
1493
- });
1494
- await cdpSend(tabId, 'Input.dispatchKeyEvent', {
1495
- type: 'keyDown', key: 'Backspace', code: 'Backspace',
1496
- });
1497
- await cdpSend(tabId, 'Input.dispatchKeyEvent', {
1498
- type: 'keyUp', key: 'Backspace', code: 'Backspace',
1499
- });
2004
+ await clearFieldAttached(tabId);
1500
2005
  await cdpSend(tabId, 'Input.insertText', { text: formatted });
1501
- await cdpSend(tabId, 'Input.dispatchKeyEvent', {
1502
- type: 'keyDown', key: 'Tab', code: 'Tab',
1503
- });
1504
- await cdpSend(tabId, 'Input.dispatchKeyEvent', {
1505
- type: 'keyUp', key: 'Tab', code: 'Tab',
1506
- });
2006
+ await tastParAttached(tabId, { key: 'Tab', code: 'Tab' }, { key: 'Tab', code: 'Tab' });
1507
2007
  } finally {
1508
2008
  await debuggerDetach(tabId);
1509
2009
  }
@@ -1662,12 +2162,7 @@ async function setDatePicker(tabId, selector, iso) {
1662
2162
  await debuggerAttach(tabId);
1663
2163
  try {
1664
2164
  const key = delta > 0 ? 'PageDown' : 'PageUp';
1665
- await cdpSend(tabId, 'Input.dispatchKeyEvent', {
1666
- type: 'keyDown', key, code: key,
1667
- });
1668
- await cdpSend(tabId, 'Input.dispatchKeyEvent', {
1669
- type: 'keyUp', key, code: key,
1670
- });
2165
+ await tastParAttached(tabId, { key, code: key }, { key, code: key });
1671
2166
  } finally {
1672
2167
  await debuggerDetach(tabId);
1673
2168
  }
@@ -2010,6 +2505,26 @@ async function setCombobox(tabId, selector, values, opts = {}) {
2010
2505
  const waitIterations = Math.max(1, Math.ceil(waitMs / 100));
2011
2506
  const results = [];
2012
2507
 
2508
+ // MAALT 9/9-2026: paa en aegte <select> brugte den her 8,5 sekunder paa at sige nej.
2509
+ // Den klikkede feltet, forsoegte at tomme det med en input-vaerdisaetter (en <select>
2510
+ // ER ikke et input), skrev tekst ind, og pollede saa 30 gange efter en listbox der
2511
+ // aldrig kan opstaa — for saa at svare "no-options-rendered". Kapaciteten fandtes hele
2512
+ // tiden i browser_select_option, som klarer samme felt paa 9 ms. Nu siger den det
2513
+ // med det samme i stedet for at lade agenten vente og gaette.
2514
+ const erNativeSelect = await debuggerEval(tabId, `(() => {
2515
+ const el = document.querySelector(${JSON.stringify(selector)});
2516
+ return el?.tagName === 'SELECT';
2517
+ })()`).catch(() => false);
2518
+ if (erNativeSelect) {
2519
+ return {
2520
+ ok: false,
2521
+ error: 'native-select',
2522
+ hint: 'Feltet er en almindelig <select>. Brug browser_select_option i stedet — ' +
2523
+ 'set_combobox er til dropdowns bygget af div/li med en listbox.',
2524
+ selector,
2525
+ };
2526
+ }
2527
+
2013
2528
  for (const val of valueList) {
2014
2529
  try {
2015
2530
  const inputEl = await resolveElement(tabId, selector);
@@ -2416,24 +2931,82 @@ async function dispatch(port, method, params) {
2416
2931
  if (tab.url.startsWith('chrome://') || tab.url.startsWith('about:')) {
2417
2932
  throw new Error(`Cannot screenshot ${tab.url.split(':')[0]}: pages — navigate to a real page first`);
2418
2933
  }
2419
- // Capture without stealing focus: CDP Page.captureScreenshot (default → fromSurface:false
2420
- // retry) works for background/visible tabs; captureVisibleTab is the secondary.
2421
- const tryCapture = async () => {
2934
+ // MAALT 10/9, anden runde (Astra, reproduceret): reserveloesningen captureVisibleTab
2935
+ // fotograferer den fane der er SYNLIG i vinduet, ikke agentens. Et tjek foer og et efter
2936
+ // kunne ikke udelukke at brugeren skiftede A->B->A imens - og saa blev brugerens side
2937
+ // leveret. To tidspunkter beviser ikke hvad der skete imellem. Reserveloesningen er derfor
2938
+ // fjernet: CDP optager netop `tab.id`, eller der er intet billede.
2939
+ // Samme runde: en frist paa optagelsen startede alligevel en ny runde (haev vinduet, optag
2940
+ // igen), og kaeden kom over serverens 30 s. En frist markeres nu, og saa proeves der ikke igen.
2941
+ // Sign-off 11/9 (Astra, R5 F8): hele kaeden har ét budget (skaermbilledeFrister). Fristen gaelder ogsaa mens
2942
+ // cdpSend gentager et kald efter en afkobling - det var den gentagelse der bar kaeden over 30 s.
2943
+ const { foersteMs, samletMs, haevMs = 0 } = skaermbilledeFrister();
2944
+ const budgetSlut = Date.now() + samletMs;
2945
+ // MAALT 11/9 af Astra (e2e runde 2), tre fejl i den haevede runde: reservationen blev trukket fra IGEN (reserven fik
2946
+ // 0 ms), `debuggerAttach` laa uden for fristloebet (en gentilslutning paa 12,5 s bar kaeden over 30 s), og haevningen
2947
+ // startede nye optagelser og smed de igangvaerende vaek - 1.29.0 fik netop svar fra dem der allerede loeb.
2948
+ const igangvaerende = []; // alle optagelser der stadig kan svare, paa tvaers af runder
2949
+ const tryCapture = async ({ sidsteUdvej = false } = {}) => {
2950
+ const reserveret = sidsteUdvej ? 0 : haevMs; // kun foerste runde holder tid fri til haevningen
2951
+ const denneRunde = [];
2952
+ const optag = (p) => {
2953
+ const kald = cdpSend(tab.id, 'Page.captureScreenshot', p);
2954
+ kald.catch(() => {}); // et svar efter budgettet er ligegyldigt
2955
+ igangvaerende.push(kald);
2956
+ denneRunde.push(kald);
2957
+ return kald;
2958
+ };
2959
+ const medFrist = async (loefte, ms) => {
2960
+ let ur;
2961
+ try {
2962
+ return await Promise.race([
2963
+ loefte,
2964
+ new Promise((_, afvis) => {
2965
+ const frist = Math.max(0, ms);
2966
+ ur = setTimeout(() => afvis(new Error(`CDP svarede ikke inden ${frist} ms: Page.captureScreenshot`)), frist);
2967
+ }),
2968
+ ]);
2969
+ } catch (e) {
2970
+ if (e && typeof e === 'object' && /svarede ikke inden/.test(e.message || '')) e.ingenNyRunde = true;
2971
+ throw e;
2972
+ } finally {
2973
+ clearTimeout(ur);
2974
+ }
2975
+ };
2976
+ // Tilslutningen skal ogsaa ligge inde i budgettet: en gentilslutning kan tage lang tid paa et haevet vindue.
2977
+ await medFrist(debuggerAttach(tab.id), budgetSlut - reserveret - Date.now());
2978
+ const standard = optag({ format: 'png' });
2422
2979
  try {
2423
- await debuggerAttach(tab.id);
2980
+ const shot = await medFrist(standard, Math.min(foersteMs, budgetSlut - reserveret - Date.now()));
2981
+ return { image: 'data:image/png;base64,' + shot.data };
2982
+ } catch (foersteFejl) {
2983
+ // fromSurface:false proeves ogsaa efter en frist: i 1.29.0 var det netop fristen der naaede hertil, og den
2984
+ // leverede billedet. Men efter en frist startes ingen runde mere, uanset hvordan reserven fejler.
2985
+ // MAALT 11/9 af Astra (efterproevning af f084d1b): standardoptagelsen lykkedes efter 11 s, reserven fejlede, og
2986
+ // fristen paa 10 s havde kasseret standardbilledet. Standardoptagelsen loeber derfor videre efter sin frist, og
2987
+ // den af de to der lykkes foerst inden for budgettet, vinder.
2988
+ optag({ format: 'png', fromSurface: false, captureBeyondViewport: false });
2424
2989
  try {
2425
- const shot = await cdpSend(tab.id, 'Page.captureScreenshot', { format: 'png' });
2426
- return { image: 'data:image/png;base64,' + shot.data };
2427
- } catch {
2428
- const shot = await cdpSend(tab.id, 'Page.captureScreenshot', {
2429
- format: 'png', fromSurface: false, captureBeyondViewport: false,
2430
- });
2990
+ // Den haevede runde er sidste udvej paa en tildaekket skaerm og skal have tid tilbage (haevMs) - ellers naar
2991
+ // 1.29.0's eneste virkende vej aldrig frem (Fable, e2e 11/9).
2992
+ // MAALT 12/9 af Astra: alle igangvaerende optagelser laa i den SAMME Promise.any, saa et gammelt billede fra
2993
+ // foer haevningen kunne svare foerst og vinde - selv om siden havde aendret sig imens. Rundens EGNE optagelser
2994
+ // afgoer nu svaret; de aeldre taeller stadig med, men kun hvis rundens egne ikke naar frem.
2995
+ let shot;
2996
+ try {
2997
+ shot = await medFrist(Promise.any(denneRunde), budgetSlut - reserveret - Date.now());
2998
+ } catch (rundeFejl) {
2999
+ const aeldre = igangvaerende.filter((k) => !denneRunde.includes(k));
3000
+ if (!aeldre.length) throw rundeFejl;
3001
+ // Et aeldre billede der ALLEREDE er kommet, svarer i samme oejeblik - ogsaa naar budgettet er brugt op.
3002
+ shot = await medFrist(Promise.any(aeldre), budgetSlut - Date.now());
3003
+ }
2431
3004
  return { image: 'data:image/png;base64,' + shot.data };
3005
+ } catch (andenFejl) {
3006
+ const fejl = andenFejl instanceof AggregateError ? andenFejl.errors[andenFejl.errors.length - 1] : andenFejl;
3007
+ if (foersteFejl?.ingenNyRunde && fejl && typeof fejl === 'object') fejl.ingenNyRunde = true;
3008
+ throw fejl;
2432
3009
  }
2433
- } catch {
2434
- // CDP failed entirely — native tabs API (needs the tab visible in its window).
2435
- const dataUrl = await chrome.tabs.captureVisibleTab(tab.windowId, { format: 'png' });
2436
- return { image: dataUrl };
2437
3010
  }
2438
3011
  };
2439
3012
 
@@ -2441,6 +3014,13 @@ async function dispatch(port, method, params) {
2441
3014
  try {
2442
3015
  return await tryCapture();
2443
3016
  } catch (firstErr) {
3017
+ // 10/9 (Astra, R2) stod her: en frist betyder at kompositoren ikke svarede, saa en ny runde fordobler kun
3018
+ // ventetiden. MAALT 11/9 af Fable: paa en TILDAEKKET skaerm haenger begge optagelser netop indtil vinduet haeves -
3019
+ // 1.29.0 leverede dér et billede efter 16,7 s, hvor HEAD gav op. Budgettet (haevMs) holder kaeden under serverens
3020
+ // 30 s, saa den sidste udvej maa proeves uanset om fejlen var en frist.
3021
+ // Er budgettet brugt, er der ikke tid til en runde med haevet vindue foer serverens 30 s. (haevMs ovenfor er netop
3022
+ // den tid der blev holdt fri til den her runde.)
3023
+ if (budgetSlut - Date.now() <= 0) throw firstErr;
2444
3024
  // Both methods failed → the window is genuinely OCCLUDED (covered by other windows),
2445
3025
  // so Chrome's compositor produced no frames. LAST RESORT ONLY: raise the window to
2446
3026
  // de-occlude it, capture, then RESTORE the user's previously-focused window. This
@@ -2450,7 +3030,7 @@ async function dispatch(port, method, params) {
2450
3030
  await chrome.windows.update(tab.windowId, { focused: true, state: 'normal' });
2451
3031
  await chrome.tabs.update(tab.id, { active: true }).catch(() => {});
2452
3032
  await new Promise(r => setTimeout(r, 250)); // let it composite
2453
- return await tryCapture();
3033
+ return await tryCapture({ sidsteUdvej: true }); // resten af budgettet - der er ikke en runde mere efter denne
2454
3034
  } catch (secondErr) {
2455
3035
  throw new Error(
2456
3036
  `Screenshot failed after focus-neutral AND raised attempts. ` +
@@ -2481,19 +3061,43 @@ async function dispatch(port, method, params) {
2481
3061
 
2482
3062
  const diag = { tried: [] };
2483
3063
 
3064
+ // MAALT 10/9 af Astra (anden runde), reproduceret med en taeller: kode der udfoerte en
3065
+ // effekt og SAA kastede, blev koert igen via debuggeren - to effekter. `sendt` beskyttede
3066
+ // kun debugger-loekken. Er koden koert, er dens fejl svaret; den koeres ikke igen.
3067
+ // Samme runde: scripting-stierne afventede ikke et Promise ("Promise.resolve(42)" gav {}).
3068
+ // Den injicerede funktion er nu async og afventer resultatet.
3069
+ const koertOgFejlede = (r) => ({
3070
+ ok: false, error: r.message, name: r.name,
3071
+ method: r.world === 'MAIN' ? 'scripting-main' : 'scripting-isolated',
3072
+ note: 'Koden KOERTE og kastede en fejl. Den koeres ikke igen via debuggeren, fordi det den ' +
3073
+ 'naaede at goere foer fejlen saa ville ske to gange.',
3074
+ });
3075
+ // MAALT 11/9 af Astra (R5 F4), reproduceret: scriptet sendte en POST og returnerede et
3076
+ // Promise; mens scripting-stien ventede, forsvandt dokumentet, og Chrome afviste med
3077
+ // "Frame with ID 0 was removed." Handleren gik saa videre til debuggeren, som koerte koden
3078
+ // igen - to POST'er. Forsvinder siden EFTER at koden er startet, kan den have koert, og
3079
+ // den koeres ikke igen. Blev den aldrig indsproejtet, maa debuggeren stadig proeve.
3080
+ const sidenForsvandt = (m) => /was removed|execution context was destroyed|document (was )?unloaded/i.test(m);
3081
+ const maaskeKoert = (world, m) => ({
3082
+ ok: false, error: m, maybe_ran: true,
3083
+ method: world === 'MAIN' ? 'scripting-main' : 'scripting-isolated',
3084
+ note: 'Siden skiftede eller lukkede mens koden koerte. Den kan allerede have koert, saa den ' +
3085
+ 'koeres ikke igen via debuggeren. Kald igen kun hvis det er sikkert at koere to gange.',
3086
+ });
2484
3087
  // Step 1: try ISOLATED world
2485
3088
  try {
2486
3089
  const [result] = await chrome.scripting.executeScript({
2487
3090
  target: { tabId: tab.id },
2488
3091
  world: 'ISOLATED',
2489
3092
  args: [params.code],
2490
- func: (codeStr) => {
2491
- try {
2492
- const fn = new Function('return (' + codeStr + ')');
2493
- return { __ok: true, value: fn() };
2494
- } catch (e) {
2495
- return { __scriptingError: true, message: String(e?.message || e), name: e?.name, world: 'ISOLATED' };
2496
- }
3093
+ func: async (codeStr) => {
3094
+ // Oversaettelse og koersel er adskilt: fejler oversaettelsen (CSP, syntaks), koerte
3095
+ // intet, og debuggeren maa proeve. Fejler KOERSLEN, er koden allerede koert.
3096
+ let fn;
3097
+ try { fn = new Function('return (' + codeStr + ')'); }
3098
+ catch (e) { return { __kompilering: true, message: String(e?.message || e), name: e?.name, world: 'ISOLATED' }; }
3099
+ try { return { __ok: true, value: await fn() }; }
3100
+ catch (e) { return { __scriptingError: true, koerte: true, message: String(e?.message || e), name: e?.name, world: 'ISOLATED' }; }
2497
3101
  },
2498
3102
  });
2499
3103
  const r = result?.result;
@@ -2501,10 +3105,14 @@ async function dispatch(port, method, params) {
2501
3105
  if (r && typeof r === 'object' && r.__ok) {
2502
3106
  return { result: r.value, method: 'scripting-isolated' };
2503
3107
  }
2504
- if (r && typeof r === 'object' && r.__scriptingError) {
3108
+ if (r && typeof r === 'object' && r.__scriptingError && r.koerte) return koertOgFejlede(r);
3109
+ if (r && typeof r === 'object' && (r.__scriptingError || r.__kompilering)) {
2505
3110
  diag.isolated_error = r.message;
2506
3111
  }
2507
3112
  } catch (e) {
3113
+ // Ingen maybe_ran her. MAALT 11/9 i Chrome for Testing 153: `new Function` i ISOLATED afvises af udvidelsens
3114
+ // CSP ('unsafe-eval'), saa brugerens kode kan aldrig have koert i denne verden. Sign-off (Astra): en afvisning
3115
+ // med "Frame with ID 0 was removed." foer start gav maybe_ran og nul koersler, hvor 1.29.0 koerte koden via MAIN.
2508
3116
  diag.isolated_throw = String(e?.message || e);
2509
3117
  }
2510
3118
 
@@ -2514,13 +3122,14 @@ async function dispatch(port, method, params) {
2514
3122
  target: { tabId: tab.id },
2515
3123
  world: 'MAIN',
2516
3124
  args: [params.code],
2517
- func: (codeStr) => {
2518
- try {
2519
- const fn = new Function('return (' + codeStr + ')');
2520
- return { __ok: true, value: fn() };
2521
- } catch (e) {
2522
- return { __scriptingError: true, message: String(e?.message || e), name: e?.name, world: 'MAIN' };
2523
- }
3125
+ func: async (codeStr) => {
3126
+ // Oversaettelse og koersel er adskilt: fejler oversaettelsen (CSP, syntaks), koerte
3127
+ // intet, og debuggeren maa proeve. Fejler KOERSLEN, er koden allerede koert.
3128
+ let fn;
3129
+ try { fn = new Function('return (' + codeStr + ')'); }
3130
+ catch (e) { return { __kompilering: true, message: String(e?.message || e), name: e?.name, world: 'MAIN' }; }
3131
+ try { return { __ok: true, value: await fn() }; }
3132
+ catch (e) { return { __scriptingError: true, koerte: true, message: String(e?.message || e), name: e?.name, world: 'MAIN' }; }
2524
3133
  },
2525
3134
  });
2526
3135
  const r = result?.result;
@@ -2528,11 +3137,14 @@ async function dispatch(port, method, params) {
2528
3137
  if (r && typeof r === 'object' && r.__ok) {
2529
3138
  return { result: r.value, method: 'scripting-main' };
2530
3139
  }
2531
- if (r && typeof r === 'object' && r.__scriptingError) {
3140
+ if (r && typeof r === 'object' && r.__scriptingError && r.koerte) return koertOgFejlede(r);
3141
+ if (r && typeof r === 'object' && (r.__scriptingError || r.__kompilering)) {
2532
3142
  diag.main_error = r.message;
2533
3143
  }
2534
3144
  } catch (e) {
2535
- diag.main_throw = String(e?.message || e);
3145
+ const m = String(e?.message || e);
3146
+ if (sidenForsvandt(m)) return maaskeKoert('MAIN', m);
3147
+ diag.main_throw = m;
2536
3148
  }
2537
3149
 
2538
3150
  // Step 3: debugger fallback — the ONLY universal path for arbitrary STRING code
@@ -2544,10 +3156,15 @@ async function dispatch(port, method, params) {
2544
3156
  // fires and debuggerEval silently returned undefined → the caller saw a bare
2545
3157
  // `{method:"debugger"}` with no result. Also surface script exceptions + raw
2546
3158
  // diagnostics so a genuine failure is never mistaken for an empty success.
2547
- let rawDbg, dbgErr = '';
3159
+ let rawDbg, dbgErr = '', sendt = false;
2548
3160
  for (let attempt = 0; attempt < 4; attempt++) {
2549
3161
  try {
2550
3162
  await debuggerAttach(tab.id);
3163
+ // MAALT 10/9 af Astra: faldt debuggeren af EFTER at scriptet var sendt, loeb loekken
3164
+ // videre og koerte BRUGERENS kode igen — op til fire gange. Vi kan ikke se om et
3165
+ // vilkaarligt script muterer. Samme regel som for Runtime.evaluate i cdpSend: er det
3166
+ // sendt, gentages det ikke automatisk. Kun en fejl FOER afsendelse proeves igen.
3167
+ sendt = true;
2551
3168
  rawDbg = await cdpSend(tab.id, 'Runtime.evaluate', {
2552
3169
  expression: '(' + params.code + '\n)',
2553
3170
  returnByValue: true,
@@ -2563,12 +3180,14 @@ async function dispatch(port, method, params) {
2563
3180
  return { result: rawDbg.result.value, method: 'debugger' };
2564
3181
  }
2565
3182
  dbgErr = 'empty/undefined CDP response: ' + JSON.stringify(rawDbg);
3183
+ break; // tomt svar efter afsendelse: scriptet kan have koert — gentag ikke
2566
3184
  } catch (e) {
2567
3185
  const m = String(e?.message || e);
2568
3186
  if (m.startsWith('__SCRIPT_EX__')) {
2569
3187
  throw new Error(m.slice('__SCRIPT_EX__'.length) + ' | scripting-diag: ' + JSON.stringify(diag));
2570
3188
  }
2571
3189
  dbgErr = m;
3190
+ if (sendt) break; // sendt = maaske koert — se kommentaren ved afsendelsen
2572
3191
  if (!/detach|attach|empty|gone|given id|not attached/i.test(m)) break;
2573
3192
  }
2574
3193
  await debuggerDetach(tab.id).catch(() => {});
@@ -2576,6 +3195,8 @@ async function dispatch(port, method, params) {
2576
3195
  }
2577
3196
  throw new Error(
2578
3197
  'execute_script failed on all paths. debugger: ' + dbgErr +
3198
+ (sendt ? ' | NB: scriptet blev sendt til siden foer det fejlede og KAN allerede have koert. ' +
3199
+ 'Det gentages ikke automatisk — kald igen kun hvis det er sikkert at koere to gange.' : '') +
2579
3200
  ' | raw: ' + JSON.stringify(rawDbg) +
2580
3201
  ' | scripting-diag: ' + JSON.stringify(diag)
2581
3202
  );
@@ -2606,7 +3227,6 @@ async function dispatch(port, method, params) {
2606
3227
  // Primary path: debugger mouse events (isTrusted=true, works on React/Angular SPAs)
2607
3228
  const clickResult = await debuggerClick(tab.id, el.x, el.y);
2608
3229
  return {
2609
- ok: true,
2610
3230
  method: el.method || 'debugger',
2611
3231
  tag: el.tag,
2612
3232
  text: el.text,
@@ -2614,6 +3234,15 @@ async function dispatch(port, method, params) {
2614
3234
  // saa `click` svarede ok:true selv naar siden slet ikke reagerede. Nu foelger den med:
2615
3235
  // landed=false betyder "eventet blev sendt, men intet handler tog imod det".
2616
3236
  ...(clickResult || {}),
3237
+ // MAALT 9/9 (issue #19, fjerde gang samme fejlklasse efter select_option og fill):
3238
+ // `ok: true` stod hardkodet, og `landed` blev spredt ind bagefter. Klikket svarede
3239
+ // altsaa ja og nej i samme aandedrag, og en agent laeser `ok`.
3240
+ // Et element der forsvandt ER en virkning — derfor tæller `detached` som landet.
3241
+ // MAALT 12/9 af Astra (N1): `ok` stod FOER settle-vaerdien her og i click_xy, hvor select_option var immun -
3242
+ // saa et svar fra siden kunne bestemme vaerktoejets egen dom. Ikke naabart i dag, men det er samme klasse som
3243
+ // lige blev lukket for noten, og de tre steder var indbyrdes uens. Vaerktoejets vurdering staar nu sidst.
3244
+ ok: klikLandede(clickResult),
3245
+ ...(uvisVurdering(clickResult) || {}),
2617
3246
  };
2618
3247
  } catch (e) {
2619
3248
  // Fallback: synthetic click via chrome.scripting for anti-automation sites
@@ -2633,9 +3262,46 @@ async function dispatch(port, method, params) {
2633
3262
  //
2634
3263
  // Prisen ved fallbacken er at klikket mister isTrusted=true. Det tjekker de færreste
2635
3264
  // 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 || '')) {
3265
+ // MAALT 10/9 af Astra (anden runde), reproduceret: museknappen var sendt ned og op, CDP meldte
3266
+ // derefter afkobling - og reserveloesningen klikkede EN GANG TIL. To effekter, ok:true. Er
3267
+ // trykket sendt, kan klikket vaere landet; saa klikkes der ikke igen.
3268
+ if (e?.trykSendt) {
3269
+ return {
3270
+ ok: false, error: e.message, maaske_landet: true, method: 'debugger',
3271
+ note: 'Museklikket blev sendt, men debuggeren koblede fra bagefter. Klikket KAN vaere landet, ' +
3272
+ 'saa det gentages ikke. Tjek siden foer du klikker igen.',
3273
+ };
3274
+ }
3275
+ // MAALT 11/9 i Chrome for Testing: en fane i baggrunden faar ikke Input.* - musebevaegelsen udloeber FOER
3276
+ // trykket er sendt. Intet klik kan vaere landet (trykSendt er falsk, se ovenfor), saa script-klikket er sikkert.
3277
+ const inputFristFoerTryk = /svarede ikke inden \d+ ms: Input\./.test(e?.message || '');
3278
+ if (inputFristFoerTryk || /Debugger detached|Debugger attach failed|not attached/i.test(e?.message || '')) {
3279
+ // Ingen "ny adresse = klikket navigerede"-regel her. Astra (efterproevning af c1496d4): en UAFHAENGIG navigation
3280
+ // fjernede rammen foer scriptet koerte, og reglen svarede ok:true med nul handlinger. En afvisning beviser ikke at
3281
+ // scriptet koerte, og en ny adresse beviser ikke at det var klikket.
2637
3282
  const r = await scriptingClick(tab.id, params.selector);
2638
- if (r.ok) return { ok: true, method: 'scripting-fallback', tag: r.tag };
3283
+ if (r.ok && !inputFristFoerTryk) {
3284
+ // Debuggeren var blokeret eller afkoblet: samme svar som 1.29.0. landed vedlaegges kun naar klikket er bevist - Astra
3285
+ // (efterproevning af c826f63): en menu der aabnede paa mousedown, blev ellers meldt som landed:false.
3286
+ return { ok: true, method: 'scripting-fallback', tag: r.tag, ...(klikLandede(r) ? { landed: true } : {}) };
3287
+ }
3288
+ if (r.ok) {
3289
+ // Sign-off 11/9 (Astra og Fable): paa en baggrundsfane svarede reserven ok:true, ogsaa naar siden intet gjorde
3290
+ // (handler der kraever isTrusted). 1.29.0 svarede med en fejl. Nu kraever ok samme bevis som de andre klikveje.
3291
+ // Viste siden ingen reaktion, er klikket alligevel sendt - saa det meldes som "kan vaere landet", ikke gentag blindt.
3292
+ if (klikLandede(r)) {
3293
+ return {
3294
+ ok: true, method: 'scripting-fallback', tag: r.tag, landed: true,
3295
+ note: 'Fanen var i baggrunden, saa musehaendelser naaede ikke frem. Klikket blev udfoert med et script i stedet.',
3296
+ };
3297
+ }
3298
+ return {
3299
+ ok: false, method: 'scripting-fallback', tag: r.tag, landed: false, maaske_landet: true, error: e.message,
3300
+ note: 'Fanen var i baggrunden, saa musehaendelser naaede ikke frem. Et script-klik blev sendt, men klikket selv ' +
3301
+ 'gav ingen synlig virkning: enten kraever siden et aegte klik, eller klikket virkede uden synlig aendring. Tjek siden, ' +
3302
+ 'og kald browser_switch_tab og klik igen kun hvis intet skete.',
3303
+ };
3304
+ }
2639
3305
  }
2640
3306
  throw e;
2641
3307
  }
@@ -2661,6 +3327,27 @@ async function dispatch(port, method, params) {
2661
3327
  await debuggerFill(tab.id, parsed.selector, params.value);
2662
3328
  return { ok: true, method: 'debugger' };
2663
3329
  } catch (e) {
3330
+ // MAALT 10/9 af Astra: debugger-vejen timede ud, og reserveloesningen skrev saa hele
3331
+ // vaerdien med den native setter. Men Promise.race afbryder ikke — tastetrykkene fra
3332
+ // debugger-forsoeget kan lande BAGEFTER. Reproduceret: "X" blev til "XX", og kaldet
3333
+ // svarede ok:true. To regler: er vaerdien der allerede efter en frist, er vi faerdige;
3334
+ // og efter reserveloesningen laeses feltet igen, saa en fordobling ses i stedet for
3335
+ // at blive meldt som succes.
3336
+ const laesFelt = async () => {
3337
+ const r = await safeExecuteScript(tab.id, (sel) => {
3338
+ const el = document.querySelector(sel);
3339
+ return el && 'value' in el ? el.value : null;
3340
+ }, [parsed.selector]).catch(() => null);
3341
+ return r && !r.cspBlocked ? r.result : null;
3342
+ };
3343
+ const fristUdloeb = /svarede ikke inden/.test(e?.message || '');
3344
+ if (fristUdloeb) await new Promise((r) => setTimeout(r, 400));
3345
+ // MAALT 11/9 af Astra (R5 F1): feltet laeses FOER reserveloesningen skriver. Det er det der skiller en
3346
+ // side der afviste vaerdien (feltet stod stille) fra en side der formaterede den (feltet aendrede sig).
3347
+ const foer = await laesFelt();
3348
+ if (fristUdloeb && foer === params.value) {
3349
+ return { ok: true, method: 'debugger', note: 'landede trods fristen' };
3350
+ }
2664
3351
  // Fallback to executeScript if debugger fails
2665
3352
  const scriptResult = await safeExecuteScript(tab.id, (sel, val) => {
2666
3353
  const el = document.querySelector(sel);
@@ -2675,8 +3362,59 @@ async function dispatch(port, method, params) {
2675
3362
  el.dispatchEvent(new Event('change', { bubbles: true }));
2676
3363
  return { ok: true };
2677
3364
  }, [parsed.selector, params.value]);
2678
- if (!scriptResult.cspBlocked) return scriptResult.result;
2679
- return { ok: false, error: e.message, method: 'debugger' };
3365
+ if (scriptResult.cspBlocked) return { ok: false, error: e.message, method: 'debugger' };
3366
+ if (!scriptResult.result?.ok) return scriptResult.result;
3367
+ await new Promise((r) => setTimeout(r, 300));
3368
+ const endelig = await laesFelt();
3369
+ // Kun to sluttilstande er fejl. FORDOBLET: et forsinket Input.insertText landede efter
3370
+ // setteren ("X" -> "XX", reproduceret). TOEMT: et forsinket Cmd+A/Backspace ryddede feltet
3371
+ // igen. Alt andet — "5" der bliver til "5,00 kr", et telefonnummer med mellemrum — er
3372
+ // feltets egen formatering og maa ikke meldes som fejl.
3373
+ const v = String(params.value ?? '');
3374
+ if (typeof endelig === 'string' && endelig !== v) {
3375
+ const fordoblet = endelig === v + v || (v.length >= 3 && endelig.includes(v + v));
3376
+ if (fordoblet || (v && endelig === '')) {
3377
+ return {
3378
+ ok: false, method: 'fallback', error: fordoblet ? 'feltet-fordoblet' : 'feltet-toemt',
3379
+ forventet: v, faktisk: endelig,
3380
+ note: 'Et forsinket tastetryk fra debugger-forsoeget landede efter reserveloesningen.',
3381
+ };
3382
+ }
3383
+ // Anden runde: "OLD" efter fill("NEW") blev kaldt formatering. Tredje og fjerde runde: hver regel for
3384
+ // "det er bare formatering" havde huller ("5" -> "15", "1.5" -> "15", Unicode-minus, "+45" -> "+1 45").
3385
+ // Femte runde (R5 F1): at melde ENHVER afvigelse som fejl gjorde korrekt formatering ("1.234,50 kr",
3386
+ // "+45 12 34 56 78") til ok:false, hvor 1.29.0 sagde ok. Skellet maales nu i stedet for at gaettes:
3387
+ // stod feltet stille (foer === efter) -> uvist, og det siges: afviger + uaendret (se nedenfor)
3388
+ // aendrede det sig til noget andet -> ok:true, men afviger:true med den faktiske vaerdi
3389
+ // Kalderen faar altsaa aldrig en tavs succes paa en anden vaerdi end den der blev skrevet.
3390
+ // Sign-off 11/9 (Astra og Fable, begge reproduceret): feltet viste allerede "1.234,50 kr", fill("1234.5")
3391
+ // blev formateret tilbage til praecis det samme, og svaret var "siden tog ikke imod vaerdien" (1.29.0: ok).
3392
+ // Foer = efter kan ikke skelne en afvisning fra en vaerdi der allerede stod der i sidens format.
3393
+ // Kun en toemning der ikke skete er entydig: intet format goer "" til noget andet.
3394
+ if (typeof foer === 'string' && endelig === foer) {
3395
+ if (!v) {
3396
+ return {
3397
+ ok: false, method: 'fallback', error: 'feltet-viser-andet', forventet: v, faktisk: endelig,
3398
+ note: 'Feltet skulle toemmes, men viser det samme som foer.',
3399
+ };
3400
+ }
3401
+ return {
3402
+ ok: true, method: 'fallback', value: endelig, afviger: true, uaendret: true, forventet: v, faktisk: endelig,
3403
+ note: 'Feltet viste det samme foer og efter skrivningen. Enten stod vaerdien der allerede i sidens eget ' +
3404
+ 'format, eller siden tog ikke imod den. Tjek `faktisk` foer du gaar videre.',
3405
+ };
3406
+ }
3407
+ return {
3408
+ ok: true, method: 'fallback', value: endelig, afviger: true, forventet: v, faktisk: endelig,
3409
+ ...(typeof foer === 'string' ? {} : { foer_ukendt: true }),
3410
+ note: 'Feltet aendrede sig, men viser en anden tekst end den der blev skrevet - fx formatering ' +
3411
+ '("5,00 kr", "+45 12 34 56 78") eller en afkortning. Tjek `faktisk`, hvis den praecise vaerdi betyder noget.',
3412
+ };
3413
+ }
3414
+ return {
3415
+ ok: true, method: 'fallback', value: endelig ?? v,
3416
+ ...(typeof endelig === 'string' ? {} : { verificeret: false }),
3417
+ };
2680
3418
  }
2681
3419
  }
2682
3420
 
@@ -2711,9 +3449,16 @@ async function dispatch(port, method, params) {
2711
3449
  await new Promise(r => setTimeout(r, 250));
2712
3450
  const v = await readBackValue(tab.id, params.selector);
2713
3451
  tried.push({ path: 'masked', format: fmt.order.join(fmt.sep), value: v });
2714
- if (valueLooksLikeIso(v, iso)) return { ok: true, method: 'masked', value: v, format: fmt.order.join(fmt.sep) };
3452
+ if (valueLooksLikeIso(v, iso, fmt)) return { ok: true, method: 'masked', value: v, format: fmt.order.join(fmt.sep) };
2715
3453
  } catch (e) {
2716
- tried.push({ path: 'masked', error: e.message });
3454
+ // MAALT 10/9 af Astra: en frist her betyder ikke at intet skete. Tastetrykkene kan
3455
+ // allerede staa i feltet, og saa ville kalender-vejen nedenfor saette datoen EN GANG
3456
+ // TIL. Laes feltet foer vi proever noget andet.
3457
+ const v = await readBackValue(tab.id, params.selector).catch(() => null);
3458
+ tried.push({ path: 'masked', error: e.message, value: v });
3459
+ if (valueLooksLikeIso(v, iso, fmt)) {
3460
+ return { ok: true, method: 'masked', value: v, format: fmt.order.join(fmt.sep), note: 'landede trods fejl i afsendelsen' };
3461
+ }
2717
3462
  }
2718
3463
  } else {
2719
3464
  tried.push({
@@ -2734,11 +3479,13 @@ async function dispatch(port, method, params) {
2734
3479
 
2735
3480
  // Path C: calendar-picker navigation
2736
3481
  if (!params.skip_picker) {
3482
+ // Fjerde runde (Astra): kalender-grenen glemte feltets format, saa 01/12/2026 blev godkendt som 12. januar.
3483
+ const kendtFormat = parsePlaceholderFormat(info.placeholder) || parsePlaceholderFormat(info.ariaLabel);
2737
3484
  const r = await setDatePicker(tab.id, params.selector, iso);
2738
3485
  await new Promise(r2 => setTimeout(r2, 200));
2739
3486
  const v = await readBackValue(tab.id, params.selector);
2740
3487
  tried.push({ path: 'picker', ...r, value: v });
2741
- if (r.ok && valueLooksLikeIso(v, iso)) return { ok: true, method: 'picker', value: v, navAttempts: r.navAttempts };
3488
+ if (r.ok && valueLooksLikeIso(v, iso, kendtFormat)) return { ok: true, method: 'picker', value: v, navAttempts: r.navAttempts };
2742
3489
  }
2743
3490
 
2744
3491
  const visibleErrors = await collectVisibleErrors(tab.id, params.selector);
@@ -2835,26 +3582,44 @@ async function dispatch(port, method, params) {
2835
3582
  const vkCode = VK_CODES[key];
2836
3583
  const vkParams = vkCode ? { windowsVirtualKeyCode: vkCode, nativeVirtualKeyCode: vkCode } : {};
2837
3584
 
3585
+ // MAALT 10/9 af Astra: keyDown og keyUp stod i samme try. Timede keyDown ud — og det
3586
+ // kan det, selv naar tasten LANDEDE (Enter der sender en formular) — blev keyUp aldrig
3587
+ // sendt. En tast der kun er trykket ned, er en tast der haenger. Nu sendes keyUp altid,
3588
+ // og svaret siger om nedtrykket fejlede i stedet for at kaste raat.
2838
3589
  await debuggerAttach(tab.id);
3590
+ let tastFejl = null;
2839
3591
  try {
2840
- await cdpSend(tab.id, 'Input.dispatchKeyEvent', {
2841
- type: 'keyDown',
2842
- key,
2843
- code: params.code || key,
2844
- modifiers,
2845
- text: key.length === 1 ? key : '',
2846
- ...vkParams,
2847
- });
2848
- await cdpSend(tab.id, 'Input.dispatchKeyEvent', {
2849
- type: 'keyUp',
2850
- key,
2851
- code: params.code || key,
2852
- modifiers,
2853
- ...vkParams,
2854
- });
3592
+ try {
3593
+ await cdpSend(tab.id, 'Input.dispatchKeyEvent', {
3594
+ type: 'keyDown',
3595
+ key,
3596
+ code: params.code || key,
3597
+ modifiers,
3598
+ text: key.length === 1 ? key : '',
3599
+ ...vkParams,
3600
+ });
3601
+ } catch (e) { tastFejl = e; }
3602
+ try {
3603
+ await cdpSend(tab.id, 'Input.dispatchKeyEvent', {
3604
+ type: 'keyUp',
3605
+ key,
3606
+ code: params.code || key,
3607
+ modifiers,
3608
+ ...vkParams,
3609
+ });
3610
+ } catch (e) { if (!tastFejl) tastFejl = e; }
2855
3611
  } finally {
2856
3612
  await debuggerDetach(tab.id);
2857
3613
  }
3614
+ if (tastFejl) {
3615
+ const frist = /svarede ikke inden/.test(tastFejl.message || '');
3616
+ return {
3617
+ ok: false, key, error: tastFejl.message,
3618
+ ...(frist ? { maaske_landet: true,
3619
+ note: 'Chrome kvitterede ikke inden fristen. Tasten kan alligevel have virket ' +
3620
+ '(fx en formular der blev sendt) — tjek siden foer du trykker igen.' } : {}),
3621
+ };
3622
+ }
2858
3623
  return { ok: true, key };
2859
3624
  }
2860
3625
 
@@ -2873,6 +3638,14 @@ async function dispatch(port, method, params) {
2873
3638
  // FB/Twitter/IG only trigger lazy-load on continuous wheel events, not a single large delta.
2874
3639
  const dx = params.x || 0;
2875
3640
  const dy = params.y || 0;
3641
+ // Hvor stod siden FOER vi roerte den? Uden det tal kan reserveloesningen ikke vide
3642
+ // hvor meget hjulet naaede, og ender med at rulle for langt.
3643
+ // MAALT 10/9: her stod `.catch(() => ({x:0,y:0}))`. Et opdigtet nulpunkt er vaerre end
3644
+ // ingen: stod siden paa 500 og laesningen fejlede, ville reserveloesningen rulle OP.
3645
+ const start = await debuggerEval(tab.id, '({x: window.scrollX, y: window.scrollY})')
3646
+ .catch(() => null);
3647
+ const startKendt = !!start && typeof start.y === 'number';
3648
+ const startX = startKendt ? start.x : 0, startY = startKendt ? start.y : 0;
2876
3649
  try {
2877
3650
  await debuggerAttach(tab.id);
2878
3651
  const STEP_SIZE = 300; // pixels per wheel-event (matches a typical mouse-wheel notch)
@@ -2890,9 +3663,70 @@ async function dispatch(port, method, params) {
2890
3663
  // before any subsequent commands run (caller often scrapes immediately after)
2891
3664
  await new Promise(r => setTimeout(r, 600));
2892
3665
  } catch (e) {
2893
- // Fallback to window.scrollBy for simple pages (synthetic but works on non-anti-scrape sites)
2894
- await debuggerEval(tab.id, `window.scrollBy(${dx}, ${dy})`);
2895
- return { ok: true, scrolled: { x: dx, y: dy }, method: 'fallback', fallback_reason: e.message };
3666
+ // MAALT 9/9 af reviewet: her stod `window.scrollBy(dx, dy)` altsaa "rul det HELE igen".
3667
+ // Promise.race afbryder ikke det kald den opgiver, saa hjultrin der allerede virkede
3668
+ // bliver liggende. Reproduceret: scroll({y:600}), foerste trin flyttede 300 uden at
3669
+ // kvittere, fallbacken lagde 600 oveni = 900 faktisk, 600 rapporteret.
3670
+ // scrollTo mod en beregnet MAAL-position er idempotent: har hjulet allerede rullet
3671
+ // halvdelen, ruller vi kun resten.
3672
+ // MAALT 10/9 af Astra, i MIN egen rettelse fra fire timer foer: her stod
3673
+ // `.catch(() => null)` og derefter `ok: true` ubetinget. Fejlede ogsaa
3674
+ // reserveloesningen, svarede vaerktoejet succes med nul rullede pixels.
3675
+ // Femte gang samme fejlklasse paa én dag — og den her var min.
3676
+ // MAALT 10/9 af Astra (anden runde), reproduceret: kunne starten ikke laeses, blev der
3677
+ // rullet RELATIVT - og hjultrin der allerede var landet blev lagt oveni (500 -> 1400 ved
3678
+ // y:600). Flaget start_ukendt dokumenterede risikoen uden at forhindre den. Uden kendt
3679
+ // start findes ingen rulning der kan gentages uden at rulle dobbelt, saa der rulles ikke.
3680
+ if (!startKendt) {
3681
+ return {
3682
+ ok: false, method: 'fallback', error: 'scroll-uvist', start_ukendt: true, hjul_fejl: e.message,
3683
+ hint: 'Hjul-kaldet svarede ikke, og startpositionen kunne ikke laeses, saa siden KAN have ' +
3684
+ 'rullet. Laes window.scrollY med browser_execute_script, og rul derefter det der mangler.',
3685
+ };
3686
+ }
3687
+ const landede = await debuggerEval(tab.id, `(() => {
3688
+ const foer = { x: window.scrollX, y: window.scrollY };
3689
+ window.scrollTo(${startX} + ${dx}, ${startY} + ${dy});
3690
+ return { foer, efter: { x: window.scrollX, y: window.scrollY } };
3691
+ })()`).catch((fejl) => ({ fejl: fejl?.message || String(fejl) }));
3692
+
3693
+ if (!landede || landede.fejl) {
3694
+ return {
3695
+ ok: false, method: 'fallback', error: 'scroll-mislykkedes',
3696
+ hjul_fejl: e.message,
3697
+ fallback_fejl: landede?.fejl || 'reserveloesningen svarede ikke',
3698
+ };
3699
+ }
3700
+ // MAALT 11/9 af Astra (e2e-review): med blød rulning (scroll-behavior: smooth) naar siden foerst maalet over de naeste
3701
+ // billeder. Laest i samme oejeblik blev en rulning der lykkedes meldt som "bunden er maaske naaet" (1.29.0: ok).
3702
+ // Positionen laeses igen hvert 100 ms, til maalet er naaet eller siden staar stille - hoejst ca. 1 s.
3703
+ // Astra (efterproevning af c826f63): bundet af et ANTAL forsoeg kom en side med langsomme opslag over serverens 30 s
3704
+ // (1.29.0: svar efter 3 s). Genlaesningen er bundet af tid.
3705
+ const roSlut = Date.now() + 1000;
3706
+ while (Date.now() < roSlut && !(landede.efter.x === startX + dx && landede.efter.y === startY + dy)) {
3707
+ await new Promise((r) => setTimeout(r, 100));
3708
+ const nu = await debuggerEval(tab.id, '({x: window.scrollX, y: window.scrollY})').catch(() => null);
3709
+ if (!nu || typeof nu.y !== 'number') break;
3710
+ const stille = nu.x === landede.efter.x && nu.y === landede.efter.y;
3711
+ landede.efter = nu;
3712
+ if (stille) break;
3713
+ }
3714
+ const flyttede = landede.efter.x !== landede.foer.x || landede.efter.y !== landede.foer.y;
3715
+ const alleredeFremme = !flyttede && startKendt &&
3716
+ landede.efter.x === startX + dx && landede.efter.y === startY + dy;
3717
+ // Astra (efterproevning af cf5b98a): en animation der gik frem, tilbage og foerst naaede maalet efter 1,6 s, fik
3718
+ // svaret ok:false "siden flyttede sig ikke" (1.29.0: ok:true). En rulning der ER sendt, meldes aldrig som fiasko.
3719
+ // Kan bevaegelsen ikke ses inden for ventetiden, er svaret uvist - samme aerlighed som maaske_landet paa klik.
3720
+ return {
3721
+ ok: true,
3722
+ method: 'fallback', fallback_reason: e.message,
3723
+ position: landede.efter, foer: landede.foer,
3724
+ ...(flyttede || alleredeFremme ? {} : {
3725
+ uvist: true,
3726
+ note: 'Rulningen blev sendt, men positionen var uaendret da vi svarede: enten er en blød rulning stadig i gang, ' +
3727
+ 'eller bunden er naaet. Laes window.scrollY med browser_execute_script hvis den praecise position betyder noget.',
3728
+ }),
3729
+ };
2896
3730
  }
2897
3731
  return { ok: true, scrolled: { x: dx, y: dy }, method: 'mouseWheel-stepped' };
2898
3732
  }
@@ -2943,8 +3777,30 @@ async function dispatch(port, method, params) {
2943
3777
  if (typeof params.x !== 'number' || typeof params.y !== 'number') {
2944
3778
  return { ok: false, error: 'x and y (numbers, CSS pixels in viewport) are required' };
2945
3779
  }
2946
- await debuggerClick(tab.id, params.x, params.y);
2947
- return { ok: true, clicked_at: { x: params.x, y: params.y } };
3780
+ // MAALT 10/9: debuggerClick maaler om klikket landede, men svaret blev smidt vaek og
3781
+ // `ok: true` stod hardkodet samme fejl som `click` havde (issue #19). Samme regel her.
3782
+ let klik;
3783
+ try {
3784
+ klik = await debuggerClick(tab.id, params.x, params.y);
3785
+ } catch (e) {
3786
+ // Samme regel som click (Astra, tredje runde): er museknappen sendt, kan klikket vaere landet -
3787
+ // og en kastet fejl mister markeringen over forbindelsen.
3788
+ if (e?.trykSendt) {
3789
+ return {
3790
+ ok: false, error: e.message, maaske_landet: true, clicked_at: { x: params.x, y: params.y },
3791
+ note: 'Museklikket blev sendt, men debuggeren fejlede bagefter. Klikket KAN vaere landet, ' +
3792
+ 'saa det gentages ikke. Tjek siden foer du klikker igen.',
3793
+ };
3794
+ }
3795
+ throw e;
3796
+ }
3797
+ return {
3798
+ clicked_at: { x: params.x, y: params.y },
3799
+ ...(klik || {}),
3800
+ // Samme som click: vaerktoejets egen dom og et uvist klik staar sidst, saa siden ikke kan overskrive dem.
3801
+ ok: klikLandede(klik),
3802
+ ...(uvisVurdering(klik) || {}),
3803
+ };
2948
3804
  }
2949
3805
 
2950
3806
  case 'reattach_debugger': {
@@ -2999,6 +3855,20 @@ async function dispatch(port, method, params) {
2999
3855
  // INTET, og svaret sagde stadig at det var lykkedes. Samme fejlklasse som klikket
3000
3856
  // der svarede ok:true uden at siden reagerede. Nu laeses svaret, og der laeses
3001
3857
  // TILBAGE fra feltet bagefter, saa "valgt" betyder at vaerdien faktisk staar der.
3858
+ // MAALT 8/9 paa forbrugeragenten.dk/penge-tilbage: her laa en anden fejl af samme
3859
+ // familie. Vagten laeste `sel.value` SYNKRONT lige efter dispatch og kaldte enhver
3860
+ // afvigelse "rullet tilbage". Men et React-styret felt der ARBEJDER ser praecis
3861
+ // saadan ud: onChange koerer, komponenten gemmer valget et andet sted og nulstiller
3862
+ // sin egen `value`. Vi maalte altsaa succes som fiasko — og sagde ok:false om et
3863
+ // valg der faktisk landede (chippen "Norlys Energi ×" stod paa siden bagefter).
3864
+ //
3865
+ // Det er den omvendte udgave af issue #19, og rettelsen er den samme som issuet
3866
+ // beder om: maal EFFEKTEN, ikke feltet. Aendrede resten af siden sig, gjorde
3867
+ // komponenten sit arbejde — uanset hvad feltet staar paa nu.
3868
+ const aftryk = `(function(el){
3869
+ return el.options.length + '|' + (el.form ? el.form.innerText.length : document.body.innerText.length);
3870
+ })(document.querySelector(${JSON.stringify(params.selector)}))`;
3871
+
3002
3872
  const valg = await debuggerEval(tab.id, `
3003
3873
  (function() {
3004
3874
  const sel = document.querySelector(${JSON.stringify(params.selector)});
@@ -3011,20 +3881,44 @@ async function dispatch(port, method, params) {
3011
3881
  return JSON.stringify({ found: false, error: 'Ingen mulighed matchede: ' + oensket,
3012
3882
  available: Array.from(sel.options).map(o => o.text.trim()).slice(0, 25) });
3013
3883
  }
3884
+ const foer = ${aftryk};
3014
3885
  sel.value = opt.value;
3015
3886
  sel.dispatchEvent(new Event('input', { bubbles: true }));
3016
3887
  sel.dispatchEvent(new Event('change', { bubbles: true }));
3017
- return JSON.stringify({ found: true, wanted: opt.value, actual: sel.value, text: opt.text.trim() });
3888
+ return JSON.stringify({ found: true, wanted: opt.value, actual: sel.value, text: opt.text.trim(), foer });
3018
3889
  })()
3019
3890
  `);
3020
3891
  let r; try { r = JSON.parse(valg); } catch { r = null; }
3021
3892
  if (!r) return { ok: false, type: 'native_select', error: 'Kunne ikke laese resultatet af valget' };
3022
3893
  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}"` };
3894
+
3895
+ if (r.actual === r.wanted) return { ok: true, type: 'native_select', selected: r.text, value: r.actual };
3896
+
3897
+ // Feltet holder ikke vaerdien. Giv rammen tid til at gen-rendere, og se saa efter
3898
+ // om NOGET andet aendrede sig. Gjorde det det, blev valget taget imod.
3899
+ await new Promise((res) => setTimeout(res, 150));
3900
+ const efter = await debuggerEval(tab.id, `
3901
+ (function() {
3902
+ const sel = document.querySelector(${JSON.stringify(params.selector)});
3903
+ if (!sel) return JSON.stringify({ vaerdi: null, aftryk: null });
3904
+ return JSON.stringify({ vaerdi: sel.value, aftryk: ${aftryk} });
3905
+ })()
3906
+ `);
3907
+ let e; try { e = JSON.parse(efter); } catch { e = null; }
3908
+
3909
+ if (e && e.vaerdi === r.wanted) {
3910
+ return { ok: true, type: 'native_select', selected: r.text, value: e.vaerdi };
3911
+ }
3912
+ if (e && e.aftryk && r.foer && e.aftryk !== r.foer) {
3913
+ return {
3914
+ ok: true, type: 'native_select', selected: r.text, value: e.vaerdi,
3915
+ note: `Feltet nulstillede sig selv til "${e.vaerdi}", men siden reagerede — ` +
3916
+ 'et styret felt der gemmer valget et andet sted. Valget landede.',
3917
+ };
3026
3918
  }
3027
- return { ok: true, type: 'native_select', selected: r.text, value: r.actual };
3919
+ return { ok: false, type: 'native_select',
3920
+ error: `Valget blev rullet tilbage: satte "${r.wanted}", feltet staar paa "${e ? e.vaerdi : r.actual}", ` +
3921
+ 'og intet andet paa siden aendrede sig.' };
3028
3922
  }
3029
3923
 
3030
3924
  // Custom dropdown (Angular Material, React Select, etc.)
@@ -3052,12 +3946,15 @@ async function dispatch(port, method, params) {
3052
3946
  // ordret "it never reports success without the field actually changing".
3053
3947
  return {
3054
3948
  ...(valgKlik || {}),
3055
- ok: valgKlik?.landed !== false,
3949
+ ok: klikLandede(valgKlik),
3056
3950
  type: 'custom_dropdown',
3057
3951
  selected: oensket,
3058
- ...(valgKlik?.landed === false
3059
- ? { error: 'Klikket paa muligheden blev ikke taget imod af siden: ' + oensket }
3060
- : {}),
3952
+ // MAALT 12/9 af Astra: her stod fejlteksten paa ALT der ikke var bevist landet - ogsaa naar koden lige havde
3953
+ // regnet ud at den IKKE ved det. En skarp benaegtelse oven paa en uvished er mindre aerlig end ingen tekst.
3954
+ ...(uvisVurdering(valgKlik)
3955
+ || (!klikLandede(valgKlik)
3956
+ ? { error: 'Klikket paa muligheden blev ikke taget imod af siden: ' + oensket }
3957
+ : {})),
3061
3958
  };
3062
3959
  }
3063
3960
 
@@ -3113,6 +4010,8 @@ async function dispatch(port, method, params) {
3113
4010
  if (tab.url.startsWith('chrome://')) throw new Error('Cannot interact with chrome:// pages');
3114
4011
  const urlPattern = params.url_pattern || '';
3115
4012
  const timeout = params.timeout || 15000;
4013
+ const netFrister = netvaerkFrister();
4014
+ const budgetSlut = Date.now() + netFrister.budgetMs;
3116
4015
 
3117
4016
  await debuggerAttach(tab.id);
3118
4017
  try {
@@ -3134,10 +4033,18 @@ async function dispatch(port, method, params) {
3134
4033
  if (!urlPattern || url.includes(urlPattern)) {
3135
4034
  chrome.debugger.onEvent.removeListener(listener);
3136
4035
  clearTimeout(timer);
3137
- // Try to get response body
3138
- cdpSend(tab.id, 'Network.getResponseBody', {
3139
- requestId: eventParams.requestId,
3140
- }).then(bodyResult => {
4036
+ // Try to get response body - kun inden for vaerktoejets budget (netvaerkBudgetMs). Naar den ikke frem, er
4037
+ // svaret body:null som i 1.29.0, i stedet for at serverens 30 s loeber ud.
4038
+ const bodyKald = cdpSend(tab.id, 'Network.getResponseBody', { requestId: eventParams.requestId });
4039
+ bodyKald.catch(() => {});
4040
+ let bodyUr;
4041
+ Promise.race([
4042
+ bodyKald,
4043
+ new Promise((ok) => {
4044
+ const bodyFrist = Math.min(netFrister.bodyMaxMs, Math.max(netFrister.bodyMinMs, budgetSlut - Date.now()));
4045
+ bodyUr = setTimeout(() => ok(null), bodyFrist);
4046
+ }),
4047
+ ]).finally(() => clearTimeout(bodyUr)).then(bodyResult => {
3141
4048
  resolve({
3142
4049
  ok: true,
3143
4050
  url,
@@ -3201,8 +4108,101 @@ async function dispatch(port, method, params) {
3201
4108
  }
3202
4109
 
3203
4110
  case 'get_cookies': {
3204
- const cookies = await chrome.cookies.getAll({ domain: params.domain });
3205
- return { cookies: cookies.map(c => ({ name: c.name, value: c.value, domain: c.domain, path: c.path })) };
4111
+ // MAALT 10/9, reproduceret i selen: uden `domain` blev filteret {} — altsaa INTET
4112
+ // filter, altsaa hver eneste cookie i profilen, inklusive httpOnly-sessionscookies
4113
+ // som sidens eget JS ikke maa se. Skemaet siger required: ['domain'], men serveren
4114
+ // videresender argumenter uvalideret, saa skemaet var en henstilling.
4115
+ if (!params.domain || typeof params.domain !== 'string' || !params.domain.trim()) {
4116
+ return {
4117
+ ok: false,
4118
+ error: 'domain-mangler',
4119
+ hint: 'Angiv `domain`. Uden det ville kaldet returnere HVER cookie i profilen — ' +
4120
+ 'ogsaa fra sider der intet har med opgaven at goere.',
4121
+ };
4122
+ }
4123
+ // MAALT 10/9, to runder. Foerste udgave gaettede domaeneslaegtskab ud fra fanernes
4124
+ // vaertsnavne ("a.example.com ligger under com"). Astra omgik det tre veje: en fane paa
4125
+ // https://com/ aabnede hele .com, en file://bank.example/-fane gav bankens cookies uden at
4126
+ // nogen side var aabnet, og et tomt vaertsnavn (about:blank) lod "bank.example." slippe
4127
+ // igennem. Et domaene kan man ikke raesonnere sig til uden public suffix-listen. Saa nu
4128
+ // spoerges Chrome i stedet: hvilke cookies ville du SENDE til de http(s)-sider sessionen har
4129
+ // aabne? Kun dem - og kun dem der passer paa det domaene der blev bedt om.
4130
+ const session = getSession(port);
4131
+ // Astra, tredje runde: en inkognito-fane har sit EGET cookie-lager. Uden storeId blev den
4132
+ // almindelige profils cookies laest for en inkognito-fane.
4133
+ let lagre = null;
4134
+ try { lagre = await chrome.cookies.getAllCookieStores(); } catch {}
4135
+ const lagerFor = (tabId) => (lagre || []).find((l) => (l.tabIds || []).includes(tabId))?.id;
4136
+ const sider = [];
4137
+ let ukendtLager = false;
4138
+ for (const id of session.tabIds) {
4139
+ const t = await chrome.tabs.get(id).catch(() => null);
4140
+ try {
4141
+ const u = new URL(t?.url || '');
4142
+ if ((u.protocol === 'https:' || u.protocol === 'http:') && u.hostname) {
4143
+ const storeId = lagerFor(id);
4144
+ // Astra (sign-off 11/9): fejlede getAllCookieStores for en inkognitofane, blev storeId udeladt, og Chrome
4145
+ // laeste den almindelige profils lager. Kan en inkognitofanes lager ikke findes, laeses intet for den.
4146
+ if (t.incognito && !storeId) { ukendtLager = true; continue; }
4147
+ // Fanens vaertsnavn er altid ASCII (punycode). Et afsluttende punktum er en ANDEN cookie-vaert i
4148
+ // Chromium (Astra, fjerde runde: x.example. fik cookies fra x.example) - saa det bevares.
4149
+ sider.push({ u, vaert: u.hostname.toLowerCase(), storeId });
4150
+ }
4151
+ } catch {}
4152
+ }
4153
+ if (!sider.length && ukendtLager) {
4154
+ return {
4155
+ ok: false, error: 'cookie-lager-ukendt',
4156
+ hint: 'Fanen er et inkognitovindue, og Chrome oplyste ikke dens cookie-lager. Intet blev laest - ellers ville ' +
4157
+ 'den almindelige profils cookies blive leveret i stedet.',
4158
+ };
4159
+ }
4160
+ const vaertsnavne = sider.map((x) => x.vaert);
4161
+ // Argumentet normaliseres som fanens adresse - "bücher.example" ER xn--bcher-kva.example.
4162
+ let d = params.domain.trim().toLowerCase().replace(/^\.+/, '');
4163
+ try { if (d) d = new URL('http://' + d + '/').hostname; } catch {}
4164
+ const slaegt = (a, b) => a === b || a.endsWith('.' + b) || b.endsWith('.' + a);
4165
+ if (!d || !vaertsnavne.some((h) => slaegt(h, d))) {
4166
+ return {
4167
+ ok: false, error: 'domaene-ikke-i-sessionen', domain: d, aabne: vaertsnavne,
4168
+ hint: 'Cookies kan kun laeses for http(s)-sider denne session har aabne. Naviger til ' +
4169
+ 'siden foerst - saa kan agenten ikke laese cookies fra noget den ikke arbejder med.',
4170
+ };
4171
+ }
4172
+ // Noeglen er et JSON-array, saa "a|b" i sti og navn ikke kan laegge to cookies sammen til én.
4173
+ const fundne = new Map();
4174
+ const med = (c, storeId) => {
4175
+ const cd = String(c.domain || '').toLowerCase().replace(/^\./, '');
4176
+ if (slaegt(cd, d)) fundne.set(JSON.stringify([storeId ?? '', c.domain, c.path, c.name]), c);
4177
+ };
4178
+ for (const side of sider) {
4179
+ const lager = side.storeId ? { storeId: side.storeId } : {};
4180
+ // Fable (sign-off 11/9): en session paa http:// fik en Secure-cookie fra overdomaenet. {url} udelader dem selv,
4181
+ // men opslagene paa {domain} kender ikke sidens protokol. Chrome sender aldrig en Secure-cookie til http.
4182
+ // Astra (efterproevning af c1496d4): Chromium regner localhost for sikker og sender Secure-cookies dertil over http.
4183
+ const sikkerVaert = side.u.protocol === 'https:' || side.vaert === 'localhost' || side.vaert.endsWith('.localhost') ||
4184
+ /^127(\.\d{1,3}){3}$/.test(side.vaert) || side.vaert === '[::1]';
4185
+ const sendesOverProtokollen = (c) => !c.secure || sikkerVaert;
4186
+ // De cookies Chrome ville SENDE til siden - inklusive overdomaenets ...
4187
+ for (const c of await chrome.cookies.getAll({ url: side.u.href, ...lager })) med(c, side.storeId);
4188
+ // ... plus sidens EGNE cookies paa alle stier (Path=/api kom ikke med ovenfor). {domain} giver
4189
+ // ogsaa underdomaener og, for et public suffix, hele suffixet - saa kun cookies hvis domaene ER
4190
+ // vaertsnavnet.
4191
+ for (const c of await chrome.cookies.getAll({ domain: side.vaert, ...lager })) {
4192
+ if (String(c.domain || '').toLowerCase().replace(/^\./, '') === side.vaert && sendesOverProtokollen(c)) med(c, side.storeId);
4193
+ }
4194
+ // MAALT 11/9 af Astra (R5 F7): overdomaenets cookie paa en anden sti (Domain=.example.com; Path=/api) kom
4195
+ // med i 1.29.0, men ikke her: {url} giver kun sidens egen sti, og {domain: vaert} holdt kun cookies hvis
4196
+ // domaene ER vaertsnavnet. Chrome spoerges nu ogsaa om det domaene der blev bedt om, og kun cookies som
4197
+ // DENNE vaert ville faa tilsendt paa en eller anden sti beholdes. En host-only-cookie sendes kun til sin
4198
+ // egen vaert, saa den skal passe praecist.
4199
+ for (const c of await chrome.cookies.getAll({ domain: d, ...lager })) {
4200
+ const cd = String(c.domain || '').toLowerCase().replace(/^\./, '');
4201
+ const sendesTilVaerten = c.hostOnly ? side.vaert === cd : (side.vaert === cd || side.vaert.endsWith('.' + cd));
4202
+ if (sendesTilVaerten && sendesOverProtokollen(c)) med(c, side.storeId);
4203
+ }
4204
+ }
4205
+ return { cookies: [...fundne.values()].map(c => ({ name: c.name, value: c.value, domain: c.domain, path: c.path })) };
3206
4206
  }
3207
4207
 
3208
4208
  case 'get_local_storage': {
@@ -3220,12 +4220,64 @@ async function dispatch(port, method, params) {
3220
4220
  }
3221
4221
 
3222
4222
  case 'set_cookies': {
4223
+ // MAALT 11/9 af Opus og Fable (e2e-review): laesningen er begraenset til sessionens egne sider, men skrivningen satte
4224
+ // cookies paa ETHVERT domaene - ogsaa sider sessionen aldrig har aabnet (og butikkens begrundelse lovede det modsatte).
4225
+ // Samme regel begge veje: kun de http(s)-vaerter sessionen har aabne.
4226
+ // MAALT 11/9 (e2e runde 2): slaegtskabet blev laest BEGGE veje, saa en cookie til et uaabnet UNDERdomaene slap
4227
+ // igennem (Astra), adressen kom fra kalderen i stedet for sessionens side (Opus), og en inkognitofane skrev i den
4228
+ // almindelige profils lager (Opus). Reglen er nu laesningens: cookiens domaene skal vaere vaerten selv eller et
4229
+ // overdomaene vaerten faar cookies fra, adressen bygges af sidens egen oprindelse (saa Chrome haandhaever bl.a.
4230
+ // public suffix), og lageret foelger fanen.
4231
+ const saetSession = getSession(port);
4232
+ let saetUkendtLager = false;
4233
+ let saetLagre = null;
4234
+ try { saetLagre = await chrome.cookies.getAllCookieStores(); } catch {}
4235
+ const saetSider = [];
4236
+ for (const id of saetSession.tabIds) {
4237
+ const t = await chrome.tabs.get(id).catch(() => null);
4238
+ try {
4239
+ const u = new URL(t?.url || '');
4240
+ if ((u.protocol !== 'https:' && u.protocol !== 'http:') || !u.hostname) continue;
4241
+ const storeId = (saetLagre || []).find((l) => (l.tabIds || []).includes(id))?.id;
4242
+ if (t.incognito && !storeId) { saetUkendtLager = true; continue; }
4243
+ saetSider.push({ u, vaert: u.hostname.toLowerCase(), storeId });
4244
+ } catch {}
4245
+ }
4246
+ if (!saetSider.length && saetUkendtLager) {
4247
+ return {
4248
+ ok: false, error: 'cookie-lager-ukendt',
4249
+ hint: 'Fanen er et inkognitovindue, og Chrome oplyste ikke dens cookie-lager. Intet blev skrevet - ellers ville ' +
4250
+ 'cookien lande i den almindelige profil i stedet.',
4251
+ };
4252
+ }
4253
+ // Sidens vaert faar cookies fra sig selv og fra sine overdomaener - ikke fra et underdomaene den ikke har aabnet.
4254
+ // MAALT 12/9 af Astra: `find` tog den FOERSTE fane der endte paa domaenet, saa en aaben a.example.com vandt over en
4255
+ // lige saa aaben example.com - og adressen blev derefter bygget af den forkerte vaert. Den noejagtige vaert vinder nu.
4256
+ // Slaegtskabs-stien (sidens vaert faar cookies fra sit overdomaene) kraever at kalderen SELV har sagt `domain`:
4257
+ // uden `domain` er cookien host-only, og saa kan oensket ikke opfyldes fra et underdomaene.
4258
+ const saetSideFor = (cd, eksplicitDomaene) =>
4259
+ saetSider.find((s) => s.vaert === cd)
4260
+ || (eksplicitDomaene ? saetSider.find((s) => s.vaert.endsWith('.' + cd)) : undefined);
3223
4261
  const results = [];
3224
4262
  const cookieList = Array.isArray(params.cookies) ? params.cookies : [params];
3225
4263
  for (const c of cookieList) {
4264
+ let cd = String(c.domain || '').trim().toLowerCase().replace(/^\.+/, '');
4265
+ if (!cd && c.url) { try { cd = new URL(c.url).hostname.toLowerCase(); } catch {} }
4266
+ try { if (cd) cd = new URL('http://' + cd + '/').hostname; } catch {}
4267
+ const side = cd ? saetSideFor(cd, Boolean(String(c.domain || '').trim())) : null;
4268
+ if (!side) {
4269
+ results.push({
4270
+ ok: false, name: c.name, error: 'domaene-ikke-i-sessionen', domain: cd || null,
4271
+ aabne: saetSider.map((s) => s.vaert),
4272
+ hint: 'Cookies kan kun saettes for http(s)-sider denne session har aabne, og kun for sidens eget domaene ' +
4273
+ 'eller - hvis du selv angiver domain - et overdomaene den faar cookies fra. Naviger til siden foerst.',
4274
+ });
4275
+ continue;
4276
+ }
3226
4277
  try {
3227
4278
  const cookie = await chrome.cookies.set({
3228
- url: c.url || `https://${c.domain}`,
4279
+ // Adressen bygges af sessionens egen side - saa Chrome selv haandhaever sine domaeneregler (public suffix osv.).
4280
+ url: side.u.origin + (typeof c.path === 'string' && c.path.startsWith('/') ? c.path : '/'),
3229
4281
  name: c.name,
3230
4282
  value: c.value,
3231
4283
  domain: c.domain,
@@ -3233,6 +4285,7 @@ async function dispatch(port, method, params) {
3233
4285
  secure: c.secure !== false,
3234
4286
  httpOnly: c.httpOnly || false,
3235
4287
  sameSite: c.sameSite || 'lax',
4288
+ ...(side.storeId ? { storeId: side.storeId } : {}),
3236
4289
  });
3237
4290
  results.push({ ok: true, name: c.name });
3238
4291
  } catch (e) {
@@ -3317,7 +4370,7 @@ async function dispatch(port, method, params) {
3317
4370
  chrome.notifications.create(notifId, {
3318
4371
  type: 'basic',
3319
4372
  iconUrl: 'icons/icon-128.png',
3320
- title: `${session.label} Action Required`,
4373
+ title: `${session.label} - Action Required`,
3321
4374
  message: params.message,
3322
4375
  requireInteraction: true,
3323
4376
  silent: false,
@@ -3378,7 +4431,7 @@ async function dispatch(port, method, params) {
3378
4431
 
3379
4432
  const h = document.createElement('div');
3380
4433
  h.style.cssText = 'font-size:14px;font-weight:600;color:#3b82f6;margin-bottom:4px';
3381
- h.textContent = title || 'Agent360 Action Required';
4434
+ h.textContent = title || 'Agent360 - Action Required';
3382
4435
  card.appendChild(h);
3383
4436
  const badge = document.createElement('div');
3384
4437
  badge.style.cssText = 'font-size:10px;color:#94a3b8;margin-bottom:12px';
@@ -3432,7 +4485,7 @@ async function dispatch(port, method, params) {
3432
4485
  });
3433
4486
  },
3434
4487
  // 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
4488
+ // med standarden "Agent360 - Action Required", men udelades den, er vaerdien
3436
4489
  // undefined — og chrome.scripting.executeScript afviser hele kaldet med
3437
4490
  // "Error at property 'args': Error at index 1: Value is unserializable".
3438
4491
  // Altsaa styrtede human-in-the-loop-vaerktoejet hver gang en agent fulgte sit
@@ -3443,7 +4496,7 @@ async function dispatch(port, method, params) {
3443
4496
  // anvendes der hvor den er lovet.
3444
4497
  args: [
3445
4498
  String(params.message ?? ''),
3446
- String(params.title ?? 'Agent360 Action Required'),
4499
+ String(params.title ?? 'Agent360 - Action Required'),
3447
4500
  Array.isArray(fields) ? fields : [],
3448
4501
  Boolean(hasFields),
3449
4502
  Number(timeout) || 120000,
@@ -3510,7 +4563,23 @@ async function dispatch(port, method, params) {
3510
4563
  if (!lastCreatedTabId) return { error: 'No new tab detected' };
3511
4564
  try {
3512
4565
  const tab = await chrome.tabs.get(lastCreatedTabId);
3513
- // Claim the new tab for this session
4566
+ // Kun faner der er aabnet FRA en af sessionens egne faner. Uden det her overtog
4567
+ // agenten enhver fane brugeren selv havde aabnet — se kommentaren ved onCreated.
4568
+ const session = getSession(port);
4569
+ const opener = tab.openerTabId ?? openerForFane.get(tab.id) ?? null;
4570
+ // Aabneren i sessionen NU, eller sessionen der ejede aabneren da fanen blev oprettet
4571
+ // (aabneren kan vaere lukket siden - se ejerForFane ved onCreated).
4572
+ const voresNu = opener != null && session.tabIds.has(opener);
4573
+ const voresDaDenBlevAabnet = opener != null && ejerForFane.get(tab.id) === port;
4574
+ if (!voresNu && !voresDaDenBlevAabnet) {
4575
+ return {
4576
+ error: 'not-ours',
4577
+ hint: 'Den seneste nye fane blev ikke aabnet fra en af dine egne faner, saa den ' +
4578
+ 'tilhoerer brugeren. Brug browser_navigate(new_tab: true) hvis du selv skal ' +
4579
+ 'have en ny fane.',
4580
+ tab_id: tab.id,
4581
+ };
4582
+ }
3514
4583
  await addTabToSession(port, tab.id);
3515
4584
  return { id: tab.id, url: tab.url, title: tab.title };
3516
4585
  } catch {
@@ -3524,9 +4593,24 @@ async function dispatch(port, method, params) {
3524
4593
  throw new Error(`Tab ${params.tab_id} does not belong to this session (${session.label})`);
3525
4594
  }
3526
4595
  const tab = await chrome.tabs.update(params.tab_id, { active: true });
4596
+ // Gør ogsaa VINDUET forrest. Uden det bliver fanen aktiv inde i sit vindue —
4597
+ // document.hasFocus() bliver sand — men document.visibilityState forbliver
4598
+ // 'hidden' fordi vinduet ligger bagved. Chrome struber timere i skjulte
4599
+ // faner, saa Angular-apps (Google Ads, GA4, Search Console) renderer aldrig
4600
+ // faerdigt: man laeser en halvt bygget side og drager forkerte konklusioner.
4601
+ // Kostede to opgaver og en forkert konklusion 31/8-2026.
4602
+ let vinduesFokus = null;
4603
+ try {
4604
+ await chrome.windows.update(tab.windowId, { focused: true });
4605
+ vinduesFokus = true;
4606
+ } catch (e) {
4607
+ // Vinduet kan vaere lukket eller paa et andet Space. Fanen er stadig
4608
+ // aktiv; vi siger bare aerligt at synligheden ikke kunne sikres.
4609
+ vinduesFokus = false;
4610
+ }
3527
4611
  session.activeTabId = tab.id;
3528
4612
  persistSessions();
3529
- return { id: tab.id, url: tab.url, title: tab.title };
4613
+ return { id: tab.id, url: tab.url, title: tab.title, windowFocused: vinduesFokus };
3530
4614
  }
3531
4615
 
3532
4616
  case 'close_tab': {
@@ -3590,7 +4674,7 @@ async function dispatch(port, method, params) {
3590
4674
  const { result: nodeResult } = await cdpSend(tab.id, 'Runtime.evaluate', {
3591
4675
  expression: `(() => {
3592
4676
  const el = document.querySelector(${JSON.stringify(selector)});
3593
- if (!el) return JSON.stringify({ found: false, error: 'File input not found: ${selector}' });
4677
+ if (!el) return JSON.stringify({ found: false, error: 'File input not found: ' + ${JSON.stringify(selector)} });
3594
4678
  return JSON.stringify({ found: true, tag: el.tagName, type: el.type, accept: el.accept, multiple: el.multiple });
3595
4679
  })()`,
3596
4680
  returnByValue: true,
@@ -3864,6 +4948,8 @@ chrome.runtime.onStartup.addListener(() => ensureOffscreen().catch(console.error
3864
4948
  // MAALT 22/8: uden tvangen slog en genindlaesning aldrig igennem til broen, og
3865
4949
  // udvikling krævede en fuld genstart af Chrome hver gang.
3866
4950
  chrome.runtime.onInstalled.addListener(async () => {
4951
+ // Foerst: aeldre udgaver gemte parametre (adgangskoder, cookie-vaerdier) i historikken.
4952
+ await rensHandlingslog();
3867
4953
  try {
3868
4954
  if (await chrome.offscreen.hasDocument()) await chrome.offscreen.closeDocument();
3869
4955
  } catch (e) {
@@ -3898,4 +4984,33 @@ chrome.alarms.onAlarm.addListener((alarm) => {
3898
4984
  if (alarm.name === 'ensure-offscreen') {
3899
4985
  ensureOffscreen().catch(console.error);
3900
4986
  }
4987
+ if (alarm.name.startsWith('frigiv-')) {
4988
+ const port = Number(alarm.name.slice('frigiv-'.length));
4989
+ // ── Hvorfor lageret laeses DIREKTE og ikke via restoreSessions() ─────────
4990
+ //
4991
+ // FUNDET AF REVIEW 7/9. Her stod `restoreSessions().then(...)`, og kommentaren
4992
+ // sagde at det var vaernet mod en genstartet service-worker. Den gjorde det
4993
+ // modsatte: `restoreSessions()` gendanner kun sessioner der har GYLDIGE FANER
4994
+ // (`if (validTabIds.size > 0)`) — og en tom session er praecis den her alarm
4995
+ // handler om. En MV3-worker suspenderes efter ~30 sekunder; fristen er paa fem
4996
+ // minutter, saa workeren er naesten altid frisk naar alarmen fyrer. Sessionen
4997
+ // blev derfor aldrig fundet, `if (!s) return` ramte, og porten blev holdt til
4998
+ // 4-timers-tomgangen — altsaa praecis den fejl frigivelsen skulle fjerne.
4999
+ //
5000
+ // restoreSessions() har god grund til ikke at genoplive doede sessioner (andre
5001
+ // kaldere vil ikke arve faner der ikke findes). Derfor rettes det HER.
5002
+ (async () => {
5003
+ const iHukommelsen = sessions.get(port);
5004
+ if (iHukommelsen) {
5005
+ if (iHukommelsen.tabIds.size > 0) return; // den arbejder igen — lad den vaere
5006
+ } else {
5007
+ const { sessions: gemte } = await chrome.storage.local.get({ sessions: {} });
5008
+ const gemt = gemte[String(port)];
5009
+ if (!gemt) return; // sessionen er reelt vaek
5010
+ if ((gemt.tabIds || []).length > 0) return; // den arbejder igen
5011
+ }
5012
+ frivilligtFrigivet.add(port);
5013
+ chrome.runtime.sendMessage({ type: 'terminate_mcp_session', port }).catch(() => {});
5014
+ })().catch(() => {});
5015
+ }
3901
5016
  });