pawbrowse 0.6.1 → 0.6.3

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.
package/README.md CHANGED
@@ -19,6 +19,16 @@
19
19
  <a href="https://modelcontextprotocol.io"><img src="https://img.shields.io/badge/MCP-server-blue.svg" alt="MCP"></a>
20
20
  </p>
21
21
 
22
+ <p align="center">
23
+ <a href="assets/demo.mp4"><img src="assets/demo.gif" alt="A real Google Flights run at 1× speed: Zürich to London, the cheapest nonstop flight opened in 6.8 seconds, each targeted element highlighted with its ref" width="100%"></a>
24
+ </p>
25
+
26
+ <p align="center"><sub>
27
+ A real run on live Google Flights at <b>1× speed</b> — every frame is the original screencast, the result is verified from the page itself.
28
+ The plan is scripted, so this is PawBrowse's browser time only; your agent's thinking time comes on top.
29
+ <a href="assets/demo.mp4">MP4</a> · reproduce with <code>node scripts/demo/record.mjs rec && python3 scripts/demo/render.py rec</code>
30
+ </sub></p>
31
+
22
32
  PawBrowse is a **Chrome MV3 extension + a tiny zero-dependency MCP server** that lets your local
23
33
  AI coding agent (like **Claude Code**) read and act on your **actual, logged-in browser tabs** —
24
34
  your profile, your sessions, your open pages — with **no remote-debug port, no browser relaunch,
@@ -126,30 +136,27 @@ Tips:
126
136
  ## Benchmark
127
137
 
128
138
  <p align="center">
129
- <img src="assets/pawbrowse-vs-claude-in-chrome.gif" alt="Real side-by-side recording: PawBrowse vs Claude-in-Chrome on the same task" width="100%">
139
+ <a href="assets/benchmark.mp4"><img src="assets/benchmark.gif" alt="Side-by-side recording on live Booking.com: the same agent searches a Lisbon hotel through PawBrowse (102.5 s, 8 tool calls) and through Claude in Chrome (112.9 s, 11 tool calls)" width="100%"></a>
130
140
  </p>
131
141
 
132
- <p align="center"><em>Real side-by-side screen recording — same task (3 Wikipedia section jumps), same brain (Claude). PawBrowse acts in one call per click and finishes first; Claude-in-Chrome perceives-then-clicks. Measured numbers below.</em></p>
133
-
134
- Because PawBrowse keeps **stable element refs** and its `navigate`/`act` already return the fresh
135
- table, the agent clicks a known target in **one** round trip. Screenshot/accessibility-tree drivers
136
- do **perceive-then-act** — a read (or screenshot) *then* a click — paying an extra agent round trip
137
- and a larger payload every action.
138
-
139
- Measured task: click 5 different section links on the same Wikipedia page, averaged, same machine,
140
- same agent (Claude):
142
+ <p align="center"><sub>
143
+ Same task on live Booking.com (Lisbon, Oct 20–22, free cancellation, open the first hotel), same agent (Claude), same real Chrome.
144
+ Each run filmed from its own tab and timed from its first action; played at 8×, clocks in real time. <a href="assets/benchmark.mp4">MP4</a>
145
+ </sub></p>
141
146
 
142
- | | PawBrowse | Claude-in-Chrome |
147
+ | | PawBrowse | Claude in Chrome |
143
148
  | --- | --- | --- |
144
- | Calls per click | **1** (`act` by stable ref) | 2 (`read_page` → click) |
145
- | Avg wall-clock per click | **~7.6 s** | ~17.0 s |
146
- | Perception payload | compact, viewport-only | full a11y tree w/ URLs (up to 50 KB) |
147
-
148
- > **Honest caveat:** with Claude as the shared brain, absolute wall-clock is dominated by agent
149
- > latency and is noisy — treat the **~2.2× ratio** as the signal, not the exact seconds. The win is
150
- > *structural* (fewer round trips + smaller payloads), which also means fewer tokens per step. It's
151
- > **not** the sub-second speed of a small, dedicated click-picking model — PawBrowse trades that
152
- > raw speed for a smart, general brain (Claude) with no keys and no per-click cost.
149
+ | Wall-clock (incl. the agent's thinking) | **102.5 s** | 112.9 s |
150
+ | Tool calls | **8** (12 actions) | 11 (19 actions) |
151
+ | Screenshots needed to check state | **0** — every call returns the page's fresh element table | 3 |
152
+ | Surprises handled | sign-in popup reported ("element is disabled") and dismissed; new tab followed automatically | a filter click that silently didn't apply, caught from a screenshot and retried |
153
+
154
+ > **Honest caveats:** most of the time on both sides is the agent thinking between calls, so the
155
+ > wall-clock gap is modest. The structural signal is **fewer calls and no screenshots** — PawBrowse
156
+ > returns the page's state with every action, so the agent never has to "look again". Run 2 (Claude in
157
+ > Chrome) also started with Booking remembering run 1's destination and dates. One run each; treat it
158
+ > as an illustration, not a statistic. Reproduce: `scripts/demo/peek-record.mjs` films a tab,
159
+ > `scripts/demo/render_compare.py` renders the comparison.
153
160
 
154
161
  ## How it compares
155
162
 
@@ -18,7 +18,8 @@ let ws = null;
18
18
  let reconnectTimer = null;
19
19
 
20
20
  // Per-session state. Each session drives its own tab(s) inside its own tab group.
21
- const attachedTabs = new Set(); // tabIds we currently hold a debugger on
21
+ const attachedTabs = new Set();
22
+ const peekOnly = new Set(); // tabs attached ONLY for dev frame capture (peek) // tabIds we currently hold a debugger on
22
23
  const sessions = new Map(); // sessionId -> { activeTabId, createdTabs:Set, groupId, num, color }
23
24
  const tabOwner = new Map(); // tabId -> sessionId (so sessions don't steal each other's tabs)
24
25
  const chains = new Map();
@@ -288,7 +289,9 @@ chrome.debugger.onEvent.addListener((source, method, params) => {
288
289
  if (method === 'Page.javascriptDialogClosed') { openDialogs.delete(tabId); return; }
289
290
  if (method !== 'Page.javascriptDialogOpening') return;
290
291
  const pol = acting.get(tabId);
291
- const where = source.sessionId ? { tabId, sessionId: source.sessionId } : tabId; // dialogs can come from frames
292
+ // Dialogs from any frame (incl. out-of-process iframes) are raised on — and answered via — the
293
+ // root session (Chromium routes them through the main frame's Page domain).
294
+ const where = tabId;
292
295
  if (!pol) { openDialogs.set(tabId, { type: params.type, message: params.message, where }); return; }
293
296
  const accept = pol.accept != null ? pol.accept : params.type === 'alert';
294
297
  const promptText = pol.text != null ? String(pol.text) : (params.defaultPrompt || '');
@@ -302,7 +305,16 @@ chrome.debugger.onEvent.addListener((source, method, params) => {
302
305
  async function whileActing(tabId, fn, policy) {
303
306
  const mine = { ...(policy || {}) };
304
307
  acting.set(tabId, mine);
305
- try { return await fn(); } finally { if (acting.get(tabId) === mine) acting.delete(tabId); }
308
+ try { return await fn(); } finally {
309
+ // A dialog that pops up just AFTER the action (a follow-up alert once a confirm is answered) is
310
+ // still its consequence: keep answering with the default policy for a short while and report it
311
+ // with the next result, instead of leaving the page frozen for the next call.
312
+ if (acting.get(tabId) === mine) {
313
+ const linger = { linger: true };
314
+ acting.set(tabId, linger);
315
+ setTimeout(() => { if (acting.get(tabId) === linger) acting.delete(tabId); }, 1500);
316
+ }
317
+ }
306
318
  }
307
319
 
308
320
  function takeDialogLog(tabId) {
@@ -322,6 +334,7 @@ function assertNoOpenDialog(tabId) {
322
334
  chrome.tabs.onRemoved.addListener((tabId) => {
323
335
  attachedTabs.delete(tabId);
324
336
  forgetTab(tabId, true);
337
+ openedBy.delete(tabId);
325
338
  const owner = tabOwner.get(tabId);
326
339
  tabOwner.delete(tabId);
327
340
  if (owner != null) {
@@ -422,7 +435,11 @@ async function endSession(session) {
422
435
  const MO_INSTALL = `var M=window.__pawmo; if(!M){ M=window.__pawmo={last:performance.now(),times:[],roots:new WeakSet()};
423
436
  M.mo=new MutationObserver(function(){ var t=performance.now(); M.last=t; M.times.push(t); if(M.times.length>64) M.times.shift(); });
424
437
  M.watch=function(r){ if(!M.roots.has(r)){ M.roots.add(r); try{ M.mo.observe(r,{subtree:true,childList:true,attributes:true,characterData:true}); }catch(_){} } };
425
- M.watch(document); }`;
438
+ M.watch(document);
439
+ // A busy main thread (parsing/running JS, rendering) is not "quiet" even when the DOM is still:
440
+ // SPA routes often mutate nothing for a few hundred ms and then render everything at once.
441
+ try{ new PerformanceObserver(function(l){ l.getEntries().forEach(function(e){ var end=e.startTime+e.duration; if(end>M.last) M.last=end; }); }).observe({type:'longtask', buffered:false}); }catch(_){}
442
+ }`;
426
443
  const SNAPSHOT = `(function(seed, opts){
427
444
  opts=opts||{};
428
445
  try{
@@ -472,7 +489,9 @@ const SNAPSHOT = `(function(seed, opts){
472
489
  // Visible descendants only: display:none / hidden children (tooltips, menus) must not leak in.
473
490
  // A rich-text editor's text is its VALUE, never its name (else typing into it renames it and
474
491
  // its ref is refused on the next action).
475
- var txt = (tag==='INPUT'||tag==='SELECT'||tag==='TEXTAREA'||e.isContentEditable) ? '' : [].map.call(e.childNodes,function(n){ return n.nodeType===3 ? n.textContent : (n.nodeType===1 && visible(n) ? name(n,seen) : ''); }).join(' ').trim();
492
+ // A <slot> shows the nodes ASSIGNED to it (a web component's light-DOM label), not its children.
493
+ var kids = tag==='SLOT' ? ((e.assignedNodes && e.assignedNodes({flatten:true}).length) ? e.assignedNodes({flatten:true}) : e.childNodes) : e.childNodes;
494
+ var txt = (tag==='INPUT'||tag==='SELECT'||tag==='TEXTAREA'||e.isContentEditable) ? '' : [].map.call(kids,function(n){ return n.nodeType===3 ? n.textContent : (n.nodeType===1 && visible(n) ? name(n,seen) : ''); }).join(' ').trim();
476
495
  if(txt) return txt;
477
496
  return e.getAttribute('title')||e.getAttribute('placeholder')||e.getAttribute('aria-placeholder')||'';
478
497
  }
@@ -506,7 +525,7 @@ const SNAPSHOT = `(function(seed, opts){
506
525
  // Semantic controls + custom clickables: any contenteditable, an inline onclick, or a
507
526
  // keyboard-focusable [tabindex] (framework buttons — React-Native-Web Pressables, design-system
508
527
  // divs — often expose only these). cursor:pointer clickables are added separately in collect().
509
- var selector='a[href],button,input,textarea,select,summary,[contenteditable]:not([contenteditable="false"]),[onclick],[tabindex]:not([tabindex="-1"]),[draggable="true"],'+roles.map(function(r){return '[role="'+r+'"]';}).join(',');
528
+ var selector='a[href],button,input,textarea,select,summary,[contenteditable]:not([contenteditable="false"]),[onclick],[onmousedown],[onmouseup],[onpointerdown],[onpointerup],[ondblclick],[tabindex]:not([tabindex="-1"]),[draggable="true"],'+roles.map(function(r){return '[role="'+r+'"]';}).join(',');
510
529
  // Inputs whose value is SET (not typed): typing into these is unreliable, so type() routes them
511
530
  // through a value setter. The hint tells the agent the expected format.
512
531
  var SETTABLE={date:'YYYY-MM-DD',time:'HH:MM','datetime-local':'YYYY-MM-DDTHH:MM',month:'YYYY-MM',week:'YYYY-Www',color:'#rrggbb',range:''};
@@ -529,13 +548,47 @@ const SNAPSHOT = `(function(seed, opts){
529
548
  }
530
549
  // Hit-test a frame-local point in the element's own root, descending into nested open shadow
531
550
  // roots, so a covered control (overlay, modal backdrop, pointer-events:none) is flagged.
551
+ // Is f (what is actually under the pointer) just the target's own visible content? Common
552
+ // pattern (Google results, cards): the accessible element sits UNDER a sibling that renders the
553
+ // row. Clicking there is what a person does. Not so for an empty overlay, a modal/fixed layer, a
554
+ // different control, or an ancestor of the target.
555
+ cache.sameWidget=function(t, f){
556
+ try{
557
+ if(!f || f===t || f.contains(t)) return false;
558
+ var p=t.parentElement, d=0; while(p && d<2 && !p.contains(f)){ p=p.parentElement; d++; }
559
+ if(!p || !p.contains(f) || p.tagName==='BODY' || p.tagName==='HTML') return false;
560
+ for(var a=f; a && a!==p; a=a.parentElement){
561
+ var cs=a.ownerDocument.defaultView.getComputedStyle(a);
562
+ if(cs.position==='fixed' || cs.position==='sticky') return false;
563
+ if(a.matches('a[href],button,input,select,textarea,[role=button],[role=link],[role=checkbox],[role=menuitem],[role=option],[role=tab]')) return false;
564
+ }
565
+ // A layer that CONTAINS other controls (a promo with its own button) is a different widget.
566
+ if(f.querySelector('a[href],button,input,select,textarea,[role=button],[role=link],[role=checkbox],[role=menuitem],[role=option],[role=tab]')) return false;
567
+ return !!((f.innerText||'').trim() || f.querySelector('img,svg') || /^(IMG|SVG)$/i.test(f.tagName));
568
+ }catch(_){ return false; }
569
+ };
570
+ // Where to point at an element: the centre of its box — except for an inline element that WRAPS
571
+ // (a link across two lines), whose box centre falls between the lines, on the surrounding text.
572
+ // Then use the centre of its first visible line box.
573
+ cache.pt=function(el){
574
+ var r=el.getBoundingClientRect(), rs=el.getClientRects(), v=el.ownerDocument.defaultView;
575
+ if(rs.length>1){ for(var i=0;i<rs.length;i++){ var q=rs[i]; if(q.width>=2 && q.height>=2 && q.bottom>0 && q.top<v.innerHeight) return {x:q.x+q.width/2, y:q.y+q.height/2, r:r}; } }
576
+ return {x:r.x+r.width/2, y:r.y+r.height/2, r:r};
577
+ };
578
+ // Pointing at a web component's SLOTTED text (its light-DOM label) makes the shadow root's
579
+ // elementFromPoint answer with the host: that's the control's own content, not a cover.
580
+ function slotted(t, f){ var rt=t.getRootNode(); return !!(rt && rt.host && f===rt.host && t.querySelector && t.querySelector('slot')); }
581
+ cache.slotted=slotted;
582
+ // Does a pointer event landing on f count as hitting t? (t itself or inside it, its label's control,
583
+ // its slotted content, or its own row content.) Used before AND during the click.
584
+ cache.accepts=function(t, f){ return !!f && (t===f || (f.nodeType===1 && t.contains(f)) || (t.control && t.control===f) || slotted(t, f) || cache.sameWidget(t, f)); };
532
585
  cache.hits=function(t, lx, ly){
533
586
  try{
534
- if(lx==null){ var hr=t.getBoundingClientRect(); lx=hr.x+hr.width/2; ly=hr.y+hr.height/2; }
587
+ if(lx==null){ var hp=cache.pt(t); lx=hp.x; ly=hp.y; }
535
588
  var root=t.getRootNode(); if(!root.elementFromPoint) root=t.ownerDocument;
536
589
  var f=root.elementFromPoint(lx,ly), g=0;
537
590
  while(f && sroot(f) && g++<16){ var inner=sroot(f).elementFromPoint(lx,ly); if(!inner||inner===f) break; f=inner; }
538
- return !!f && (t===f || t.contains(f) || (t.control && t.control===f));
591
+ return !!f && (t===f || t.contains(f) || (t.control && t.control===f) || slotted(t, f) || cache.sameWidget(t, f));
539
592
  }catch(_){ return true; }
540
593
  };
541
594
  var hits=cache.hits;
@@ -584,6 +637,8 @@ const SNAPSHOT = `(function(seed, opts){
584
637
  // systems) render as role-less <div>s but get cursor:pointer. Include the ROOT of each
585
638
  // pointer region (its parent is NOT pointer) so we capture the pressable itself, not its
586
639
  // inherited-cursor text children. Bounded scan so huge DOMs stay fast.
640
+ // <a> without href but with inline JS handlers (onmouseenter/onmousedown/...): JS-driven links.
641
+ if(!seen.has(n) && n.tagName==='A' && !n.hasAttribute('href')){ for(var ai=0;ai<n.attributes.length;ai++){ if(/^on(mouse|pointer|touch|click|dblclick|key)/.test(n.attributes[ai].name)){ add(n, dx, dy, true); break; } } }
587
642
  if(!seen.has(n) && scanned<8000){
588
643
  scanned++;
589
644
  try{
@@ -614,6 +669,23 @@ const SNAPSHOT = `(function(seed, opts){
614
669
  walk(document, 0, 0, 0);
615
670
  return out;
616
671
  }
672
+ // Not hittable, but only because a sticky/fixed bar (not a modal) or something OUTSIDE its own
673
+ // scroll box sits on top of it — e.g. a sidebar list scrolled under the sidebar's filter header.
674
+ // Acting scrolls it into the open, so it's "scrolled out", not "covered".
675
+ function reachable(t, lx, ly){
676
+ try{
677
+ var root=t.getRootNode(); if(!root.elementFromPoint) root=t.ownerDocument;
678
+ var f=root.elementFromPoint(lx,ly); if(!f || f.contains(t)) return false;
679
+ var view=t.ownerDocument.defaultView;
680
+ for(var a=f; a && a.nodeType===1; a=a.parentElement){
681
+ if(a.matches('dialog,[role=dialog],[role=alertdialog],[aria-modal=true]')) return false;
682
+ var cs=view.getComputedStyle(a);
683
+ if(cs.position==='fixed' || cs.position==='sticky'){ var q=a.getBoundingClientRect(); return q.width*q.height < 0.4*view.innerWidth*view.innerHeight; }
684
+ }
685
+ var sc=t.parentElement; while(sc && !(sc.scrollHeight>sc.clientHeight+2 && /(auto|scroll)/.test(view.getComputedStyle(sc).overflowY))) sc=sc.parentElement;
686
+ return !!sc && sc!==t.ownerDocument.body && sc!==t.ownerDocument.documentElement && !sc.contains(f);
687
+ }catch(_){ return false; }
688
+ }
617
689
  var scrollerCache=new Map(); // element -> does it clip its overflow? (one style read per ancestor per snapshot)
618
690
  function clipper(a, view){ var v=scrollerCache.get(a); if(v===undefined){ var cs=view.getComputedStyle(a); v=!(cs.overflowX==='visible' && cs.overflowY==='visible'); scrollerCache.set(a,v); } return v; }
619
691
  function clipped(t){
@@ -643,14 +715,14 @@ const SNAPSHOT = `(function(seed, opts){
643
715
  // Only accept it if it has a real label and isn't just a wrapper around an actual control
644
716
  // (or a <label> standing in for one), so we don't flood the table with layout containers.
645
717
  var ti=e.getAttribute('tabindex');
646
- if(clk || e.hasAttribute('onclick') || (ti!==null && ti!=='-1') || e.getAttribute('draggable')==='true'){
718
+ if(clk || e.hasAttribute('onclick') || e.hasAttribute('onmousedown') || e.hasAttribute('onmouseup') || e.hasAttribute('onpointerdown') || e.hasAttribute('onpointerup') || e.hasAttribute('ondblclick') || (ti!==null && ti!=='-1') || e.getAttribute('draggable')==='true'){
647
719
  if(e.tagName==='LABEL' && e.control) continue;
648
720
  if(!(name(e)||'').trim() || e.querySelector(selector)) continue;
649
721
  rname='button';
650
722
  }
651
723
  }
652
724
  if(!rname) continue;
653
- var r=surf.getBoundingClientRect(), lx=r.x+r.width/2, ly=r.y+r.height/2, x=lx+ox, y=ly+oy;
725
+ var sp=cache.pt(surf), r=sp.r, lx=sp.x, ly=sp.y, x=lx+ox, y=ly+oy;
654
726
  if(x<0||x>=VW) continue;
655
727
  if(rname==='gridcell' && e.querySelector('button,[role="button"]')) continue;
656
728
  var base={node:identity(e), role:rname, label:clean(fieldLabel(e)||hint(e)||rname), x:Math.round(x), y:Math.round(y), w:Math.round(r.width), h:Math.round(r.height)};
@@ -661,6 +733,18 @@ const SNAPSHOT = `(function(seed, opts){
661
733
  var asel=e.getAttribute('aria-selected'); if(asel!=null) base.selected=(asel==='true');
662
734
  if(e.required || e.getAttribute('aria-required')==='true') base.required=true;
663
735
  if(e.getAttribute('draggable')==='true') base.draggable=true;
736
+ // Site chrome (global header/nav — "Home", language picker, sign-in...) is real and stays
737
+ // clickable, just demoted to the end of the in-view rows so task content comes first.
738
+ try{ if(e.closest('nav,[role="navigation"]')) base.chrome=true; }catch(_){}
739
+ // Repeated links to the exact same destination (a card's photo, title, and "Opens X info"
740
+ // overlay often all point at the same URL): dedup key, hash dropped, bare "#"/javascript: skipped.
741
+ if(e.tagName==='A'){
742
+ // Same-PAGE hash-only hrefs ("#", "#1", "#panel-a") are cheap and collide across totally
743
+ // unrelated widgets (a sidebar's "#1" and a panel's "#1" resolve to the identical URL) — never
744
+ // dedup those. Only a link to a genuinely different page/resource is a safe, real duplicate.
745
+ var hattr=e.getAttribute('href')||'';
746
+ if(hattr && hattr.charAt(0)!=='#' && !/^javascript:/i.test(hattr)) base._dk=e.href;
747
+ }
664
748
  // Only surface validation errors on fields the user (or agent) has put a value in, or that the
665
749
  // page itself flags, so an untouched required form isn't a wall of warnings.
666
750
  if(e.getAttribute('aria-invalid')==='true') base.invalid='invalid';
@@ -675,6 +759,8 @@ const SNAPSHOT = `(function(seed, opts){
675
759
  base.off=y<0?'up':'down'; base.dist=y<0?-y:y-VH;
676
760
  } else if((function(){ var fv=surf.ownerDocument.defaultView; return fv!==window && (lx<0||ly<0||lx>=fv.innerWidth||ly>=fv.innerHeight); })()){
677
761
  base.off='scroll'; // scrolled out of its (same-origin) iframe's viewport
762
+ } else if(!hits(surf, lx, ly) && reachable(surf, lx, ly)){
763
+ base.off='scroll'; base.dist=1; // hidden under a sticky bar / a panel header: right here, scrolling brings it out
678
764
  } else if(!hits(surf, lx, ly)){
679
765
  // Not hittable: either scrolled out of an overflow container (reachable — acting scrolls it
680
766
  // in) or genuinely covered by an overlay/modal (needs dismissing first).
@@ -709,11 +795,77 @@ const SNAPSHOT = `(function(seed, opts){
709
795
  var omitted=Math.max(0, inView.length-250); inView.splice(250);
710
796
  // Keep the 25 off-screen controls NEAREST the visible area (not the first 25 in DOM order, which
711
797
  // for a scrolled list are the rows furthest behind), then restore page order.
712
- var OFFCAP=opts.all?400:25;
798
+ var OFFCAP=opts.all?400:12; // default trimmed further: the nearest few off-screen rows are rarely the next target
713
799
  var offMore=Math.max(0, offView.length-OFFCAP)+farOff;
714
800
  if(offView.length>OFFCAP){ offView.forEach(function(a,k){ a.ord=k; }); offView.sort(function(a,b){ return a.dist-b.dist; }); offView.splice(OFFCAP); offView.sort(function(a,b){ return a.ord-b.ord; }); }
801
+ inView.sort(function(a,b){ return (a.chrome?1:0)-(b.chrome?1:0); }); // stable: keeps relative order within each group
715
802
  var actions=inView.concat(offView);
803
+ // Two rows with the IDENTICAL displayed label where one element contains the other, sitting at
804
+ // essentially the same spot, are the same control seen twice by our own heuristics — an outer
805
+ // clickable "cell" (aria-label="Tuesday, October 20, 2026") wrapping the real checkbox/radio/link
806
+ // with the same name inside it (a calendar day, a selectable list row). A small pixel tolerance
807
+ // absorbs the cell's own border/padding around an absolutely-positioned inner control. Keep the
808
+ // one with richer state (checked/selected/expanded) when only one side has it, else the innermost.
809
+ try{
810
+ var byLbl={};
811
+ actions.forEach(function(a){ var k=a.kind+'|'+a.label; (byLbl[k]=byLbl[k]||[]).push(a); });
812
+ var dropR=new Set();
813
+ Object.keys(byLbl).forEach(function(key){
814
+ var grp=byLbl[key]; if(grp.length<2) return;
815
+ for(var gi=0; gi<grp.length; gi++){
816
+ for(var gj=0; gj<grp.length; gj++){
817
+ if(gi===gj) continue;
818
+ var A=grp[gi], B=grp[gj]; if(dropR.has(A)||dropR.has(B)) continue;
819
+ var ea=cache.nodes.get(A.node), eb=cache.nodes.get(B.node);
820
+ if(!ea||!eb||ea===eb) continue;
821
+ var contains; try{ contains=eb.contains(ea); }catch(_){ contains=false; }
822
+ if(!contains) continue; // B is an ancestor of A, same label
823
+ if(Math.abs(A.x-B.x)>8 || Math.abs(A.y-B.y)>8) continue; // not the same visual spot: leave both
824
+ var richA=A.checked!=null||A.selected!=null||A.expanded!=null;
825
+ var richB=B.checked!=null||B.selected!=null||B.expanded!=null;
826
+ if(richB && !richA) dropR.add(A); else dropR.add(B); // prefer the innermost, unless only the ancestor carries state
827
+ }
828
+ }
829
+ });
830
+ if(dropR.size) actions=actions.filter(function(a){ return !dropR.has(a); });
831
+ }catch(_){}
832
+ // Repeated links to the same destination (see _dk above): keep the clearest copy (in view, not
833
+ // covered, shortest label), drop the rest.
834
+ try{
835
+ var byHref={};
836
+ actions.forEach(function(a){ if(a._dk) (byHref[a._dk]=byHref[a._dk]||[]).push(a); });
837
+ var dropH=new Set();
838
+ Object.keys(byHref).forEach(function(href){
839
+ var grp=byHref[href]; if(grp.length<2) return;
840
+ // Labels get combined below, so there's no information reason to prefer the shorter one —
841
+ // prefer the LARGER, more prominent element as the surviving click target's geometry instead
842
+ // (a tiny trailing ")" fragment next to a wide title link would otherwise become the anchor).
843
+ var score=function(a){ return (a.off?2:0)+(a.covered?2:0)-Math.min((a.w||0)*(a.h||0),40000)/40000; };
844
+ // Cluster by PROXIMITY first (a navbar shortcut and a hero CTA can legitimately share a
845
+ // destination while being two different, intentionally separate controls); only within the
846
+ // same small visual area is one of them a genuine repeat of the other.
847
+ var used=new Array(grp.length).fill(false);
848
+ for(var gi=0; gi<grp.length; gi++){
849
+ if(used[gi]) continue;
850
+ var cluster=[grp[gi]]; used[gi]=true;
851
+ for(var gj=gi+1; gj<grp.length; gj++){
852
+ if(used[gj]) continue;
853
+ if(Math.abs(grp[gi].x-grp[gj].x)<=200 && Math.abs(grp[gi].y-grp[gj].y)<=200){ cluster.push(grp[gj]); used[gj]=true; }
854
+ }
855
+ if(cluster.length<2) continue;
856
+ var keep=cluster.reduce(function(b,a){ return score(a)<score(b) ? a : b; });
857
+ // Different labels on the same destination ("7 hours ago" and "197 comments" both open the
858
+ // same story) each carry real information — combine them into the surviving row instead of
859
+ // silently discarding one, so the agent never loses "197 comments" for "7 hours ago".
860
+ var uniq=[]; cluster.forEach(function(a){ if(uniq.indexOf(a.label)<0) uniq.push(a.label); });
861
+ if(uniq.length>1) keep.label=clean(uniq.join(' · '),140);
862
+ cluster.forEach(function(a){ if(a!==keep) dropH.add(a); });
863
+ }
864
+ });
865
+ if(dropH.size) actions=actions.filter(function(a){ return !dropH.has(a); });
866
+ }catch(_){}
716
867
  // The nearest ancestor text that isn't just the label itself: which row/item a control is in.
868
+ cache.ctxOf=function(el, lab){ return ctxOf(el, lab); };
717
869
  function ctxOf(el, lab){
718
870
  for(var p=el&&el.parentElement, g=0; p && g<6 && p.tagName!=='BODY'; p=p.parentElement, g++){
719
871
  var tx=clean(p.innerText,400); if(!tx || tx===lab) continue;
@@ -1125,49 +1277,81 @@ async function observe(tabId, opts) {
1125
1277
 
1126
1278
  // Re-resolve a ref to its live element, re-check it, and hit-test the center
1127
1279
  // (elementFromPoint containment) so we never click a stale/covered/wrong target.
1128
- async function resolveHit(tabId, ref, opts) {
1280
+ async function resolveHit(tabId, ref, opts, _retries) {
1281
+ _retries = _retries || 0;
1129
1282
  const forFill = opts && opts.fill ? 'true' : 'false';
1130
1283
  const noScroll = opts && opts.noScroll ? 'true' : 'false', noHit = opts && opts.noHit ? 'true' : 'false';
1284
+ const TXT = opts && opts.text != null ? JSON.stringify(String(opts.text)) : 'null';
1131
1285
  const R = JSON.stringify(String(ref));
1132
- return evaluate(tabId, `(function(){
1286
+ const result = await evaluate(tabId, `(function(){
1133
1287
  var c=window.__pawbrowse; if(!c||!c.byId) return {error:'no snapshot yet; observe first'};
1134
1288
  if(c.byId[${R}]==null) return {error:'unknown ref (observe again)'};
1135
1289
  var e=c.get?c.get(${R}):null;
1136
1290
  if(!e||!e.isConnected) return {error:'element no longer on page (observe again)'};
1137
1291
  if(c.guard && c.guards && c.guards[${R}]!=null && c.guard(e)!==c.guards[${R}]) return {error:'element changed since observe (observe again)'};
1292
+ // Same node, same label — but a different ROW? Virtualized lists recycle row elements for other
1293
+ // items: "Delete" may now belong to someone else. The row context it was listed with must hold.
1294
+ var fp=c.fps&&c.fps[${R}]; if(fp && fp.ctx && c.ctxOf && c.ctxOf(e, fp.label)!==fp.ctx) return {error:'the row this control belongs to changed since observe (now in "'+c.ctxOf(e, fp.label)+'"); observe again'};
1138
1295
  if(e.matches(':disabled')||e.closest('[aria-disabled="true"],[inert]')) return {error:'element is disabled'};
1139
1296
  if(${forFill} && (e.readOnly||e.getAttribute('aria-readonly')==='true')) return {error:'field is read-only'};
1140
1297
  if(${forFill} && !('value' in e) && !e.isContentEditable) return {error:'not an editable field (observe again)'};
1298
+ // Typed text that the field would reject (email/number/url/pattern/maxlength) is refused up front:
1299
+ // nothing is typed, instead of a value silently dropped or half-entered.
1300
+ if(${forFill} && ${TXT}!==null && ${TXT}!=='' && e.tagName==='INPUT'){
1301
+ var probe=e.cloneNode(); probe.value=${TXT};
1302
+ if(['email','number','url'].indexOf(e.type)>=0 && (probe.value!==${TXT} || probe.validity.typeMismatch || probe.validity.badInput)) return {error:'"'+${TXT}+'" is not a valid '+e.type+' for this field (nothing typed)'};
1303
+ if(e.hasAttribute('pattern') && probe.validity.patternMismatch) return {error:'the field requires a specific format ('+(e.title||e.getAttribute('pattern'))+'); "'+${TXT}+'" does not match (nothing typed)'};
1304
+ }
1141
1305
  // Value-set inputs (date/time/range/color...) take the setter path, not click+type.
1142
1306
  if(${forFill} && e.tagName==='INPUT' && ['date','time','datetime-local','month','week','color','range'].indexOf(e.type)>=0) return {set:true};
1143
1307
  // Click the control's visible SURFACE: a styled checkbox's <label>, or the element itself.
1144
1308
  var s=c.surface?c.surface(e):e;
1145
1309
  if(!s) return {error:'element not visible'};
1146
- if(!${noScroll}) s.scrollIntoView({block:'center',inline:'center'});
1147
- var r=s.getBoundingClientRect(); if(!r.width||!r.height) return {error:'element has no size'};
1148
- // Frame-local center (in the element's own frame viewport)...
1149
- var lx=r.x+r.width/2, ly=r.y+r.height/2;
1150
- // ...plus the offset chain of any ancestor iframes, giving the TOP-LEVEL click point for CDP.
1151
- var dx=0, dy=0, w=(s.ownerDocument&&s.ownerDocument.defaultView), g=0;
1152
- while(w && w.frameElement && g++<12){
1153
- var fe=w.frameElement, fr=fe.getBoundingClientRect(), fcs=fe.ownerDocument.defaultView.getComputedStyle(fe);
1154
- dx+=fr.left+fe.clientLeft+(parseFloat(fcs.paddingLeft)||0);
1155
- dy+=fr.top+fe.clientTop+(parseFloat(fcs.paddingTop)||0);
1156
- w=fe.ownerDocument.defaultView;
1157
- }
1158
- var x=Math.round(lx+dx), y=Math.round(ly+dy);
1159
- if(x<0||y<0||x>=innerWidth||y>=innerHeight) return {error:'element off-screen after scroll'};
1160
1310
  // Hit-test in the surface's OWN root (document / shadow root / iframe doc) with frame-local
1161
- // coords, descending through nested open shadow roots, so shadow-DOM and iframe elements
1162
- // aren't falsely reported as covered.
1163
- if(${noHit}) return {x:x, y:y};
1164
- var root=s.getRootNode(); if(!root||!root.elementFromPoint) root=s.ownerDocument;
1165
- var f=root.elementFromPoint(lx,ly), k=0;
1311
+ // coords, descending through nested open/closed shadow roots.
1166
1312
  var sroot=function(n){ return n.shadowRoot || (c.closed && c.closed.get(n)) || null; };
1167
- while(f && sroot(f) && k++<16){ var inner=sroot(f).elementFromPoint(lx,ly); if(!inner||inner===f) break; f=inner; }
1168
- if(!f || !(s===f || s.contains(f) || (s.control && s.control===f))) return {error:'element is covered by another element (dismiss the overlay/dialog first)'};
1169
- return {x:x, y:y};
1313
+ var at=function(){
1314
+ var r=s.getBoundingClientRect(); if(!r.width||!r.height) return {error:'element has no size'};
1315
+ var fw=s.ownerDocument.defaultView;
1316
+ var sp=c.pt?c.pt(s):{x:r.x+r.width/2, y:r.y+r.height/2}, lx=sp.x, ly=sp.y;
1317
+ // ...plus the offset chain of any ancestor iframes, giving the TOP-LEVEL click point for CDP.
1318
+ var dx=0, dy=0, w=fw, g=0;
1319
+ while(w && w.frameElement && g++<12){
1320
+ var fe=w.frameElement, fr=fe.getBoundingClientRect(), fcs=fe.ownerDocument.defaultView.getComputedStyle(fe);
1321
+ dx+=fr.left+fe.clientLeft+(parseFloat(fcs.paddingLeft)||0);
1322
+ dy+=fr.top+fe.clientTop+(parseFloat(fcs.paddingTop)||0);
1323
+ w=fe.ownerDocument.defaultView;
1324
+ }
1325
+ var inView=ly>=0 && lx>=0 && ly<fw.innerHeight && lx<fw.innerWidth && (r.height>fw.innerHeight || (r.top>=0 && r.bottom<=fw.innerHeight));
1326
+ var x=Math.round(lx+dx), y=Math.round(ly+dy);
1327
+ var root=s.getRootNode(); if(!root||!root.elementFromPoint) root=s.ownerDocument;
1328
+ var f=root.elementFromPoint(lx,ly), k=0;
1329
+ while(f && sroot(f) && k++<16){ var inner=sroot(f).elementFromPoint(lx,ly); if(!inner||inner===f) break; f=inner; }
1330
+ var hit=!!f && (s===f || s.contains(f) || (s.control && s.control===f) || (c.slotted && c.slotted(s, f)) || (c.sameWidget && c.sameWidget(s, f)));
1331
+ return {x:x, y:y, inView:inView && x>=0 && y>=0 && x<innerWidth && y<innerHeight, hit:hit};
1332
+ };
1333
+ // Don't move the page when the target is already on screen and hittable: needless scrolling
1334
+ // closes popups/date pickers and jolts the page. Otherwise bring it to the centre and re-check.
1335
+ var p=at(); if(p.error) return p;
1336
+ if(!(p.inView && p.hit) && !${noScroll}){ s.scrollIntoView({block:'center',inline:'center',behavior:'instant'}); p=at(); if(p.error) return p; }
1337
+ if(!p.inView && !${noHit}) return {error:'element off-screen after scroll'};
1338
+ if(${noHit}) return {x:p.x, y:p.y};
1339
+ if(!p.hit) return {error:'element is covered by another element (dismiss the overlay/dialog first)'};
1340
+ return {x:p.x, y:p.y};
1170
1341
  })()`);
1342
+ // A ref the page-side cache no longer recognises (its map was rebuilt, this tab's world was
1343
+ // recreated, or the element is mid-re-render from a JUST-PRIOR action — e.g. switching a
1344
+ // code-language combobox remounts the code block, including its own "Expand code" button, a beat
1345
+ // after the click that triggered it) is not necessarily gone: a fresh scan reruns the SAME
1346
+ // "gone element -> matching new element" rebind snapshot() already does for re-renders. Up to two
1347
+ // free retries with a short growing delay, transparent to the caller — costs nothing on success,
1348
+ // and never hides a real "no longer on page" failure.
1349
+ if(result && result.error === 'unknown ref (observe again)' && _retries < 2){
1350
+ await sleep(_retries === 0 ? 120 : 300);
1351
+ try { await snapshot(tabId, 1); } catch {}
1352
+ return resolveHit(tabId, ref, opts, _retries + 1);
1353
+ }
1354
+ return result;
1171
1355
  }
1172
1356
 
1173
1357
  // Set the value of a date/time/month/week/color/range input the way a user's picker would: via the
@@ -1236,7 +1420,7 @@ async function centerOfText(tabId, text) {
1236
1420
  if(!pool.length) return null;
1237
1421
  pool.sort(function(a,b){return a.area-b.area;});
1238
1422
  var chosen=pool[0].el;
1239
- chosen.scrollIntoView({block:'center',inline:'center'});
1423
+ chosen.scrollIntoView({block:'center',inline:'center',behavior:'instant'});
1240
1424
  var rr=chosen.getBoundingClientRect();
1241
1425
  var cx=Math.round(rr.left+rr.width/2), cy=Math.round(rr.top+rr.height/2);
1242
1426
  if(cx<0||cy<0||cx>=innerWidth||cy>=innerHeight) return null;
@@ -1247,12 +1431,18 @@ async function centerOfText(tabId, text) {
1247
1431
 
1248
1432
  async function clickAt(tabId, x, y, opts) {
1249
1433
  const button = (opts && opts.button) || 'left', count = Math.max(1, Math.min(3, Number(opts && opts.count) || 1));
1250
- await sendCdp(tabId, 'Input.dispatchMouseEvent', { type: 'mouseMoved', x, y });
1251
- // A double/triple click is successive press/release pairs with a rising clickCount.
1434
+ // `buttons` must say which button is held (CDP defaults it to 0; pointer-event widgets ignore a
1435
+ // pointerdown with no button). A double/triple click is press/release pairs with rising clickCount.
1436
+ // Pipelined, like Playwright: move, press and release reach the renderer together. Awaiting each
1437
+ // one leaves a gap in which a mousedown-triggered re-render (a blur committing a date, a ripple)
1438
+ // swaps the element, and Chromium then sends the click to a common ancestor — or nowhere.
1439
+ const held = { left: 1, right: 2, middle: 4 }[button];
1440
+ const sends = [sendCdp(tabId, 'Input.dispatchMouseEvent', { type: 'mouseMoved', x, y })];
1252
1441
  for (let n = 1; n <= count; n++) {
1253
- await sendCdp(tabId, 'Input.dispatchMouseEvent', { type: 'mousePressed', x, y, button, clickCount: n });
1254
- await sendCdp(tabId, 'Input.dispatchMouseEvent', { type: 'mouseReleased', x, y, button, clickCount: n });
1442
+ sends.push(sendCdp(tabId, 'Input.dispatchMouseEvent', { type: 'mousePressed', x, y, button, buttons: held, clickCount: n }));
1443
+ sends.push(sendCdp(tabId, 'Input.dispatchMouseEvent', { type: 'mouseReleased', x, y, button, buttons: 0, clickCount: n }));
1255
1444
  }
1445
+ await Promise.all(sends);
1256
1446
  }
1257
1447
 
1258
1448
  /* ------------------------------ Waiting (settle) ---------------------------- *
@@ -1263,10 +1453,11 @@ async function clickAt(tabId, x, y, opts) {
1263
1453
  const watches = new Map(); // tabId -> { mainFrame, inflight: Map(reqId -> startedAt), navStart, navDone }
1264
1454
  function tabWatch(tabId) {
1265
1455
  let w = watches.get(tabId);
1266
- if (!w) { w = { mainFrame: null, inflight: new Map(), navStart: 0, navDone: 0, committed: true, navReq: null }; watches.set(tabId, w); }
1456
+ if (!w) { w = { mainFrame: null, inflight: new Map(), frameLoads: new Map(), navStart: 0, navDone: 0, committed: true, navReq: null }; watches.set(tabId, w); }
1267
1457
  return w;
1268
1458
  }
1269
- const TRACKED = new Set(['Fetch', 'XHR', 'Document']);
1459
+ // Script too: SPA route changes lazy-load code chunks and render only after they run.
1460
+ const TRACKED = new Set(['Fetch', 'XHR', 'Document', 'Script']);
1270
1461
  chrome.debugger.onEvent.addListener((source, method, params) => {
1271
1462
  if (source.tabId == null) return;
1272
1463
  const w = watches.get(source.tabId);
@@ -1278,13 +1469,18 @@ chrome.debugger.onEvent.addListener((source, method, params) => {
1278
1469
  if (method === 'Network.loadingFailed' && params.requestId === w.navReq && !w.committed) w.navDone = Date.now(); // blocked/aborted navigation
1279
1470
  return;
1280
1471
  }
1472
+ // Same for frames: an out-of-process iframe starts loading in the parent, stops in its own session.
1473
+ if (method === 'Page.frameStoppedLoading' || method === 'Page.frameDetached') { w.frameLoads.delete(params.frameId); if (source.sessionId) return; }
1281
1474
  if (source.sessionId) return; // everything else: the top-level target only
1282
1475
  const now = Date.now();
1283
1476
  const begin = (loaderId) => { w.navStart = now; w.navDone = 0; w.committed = false; w.navReq = loaderId || null; w.netIdle = 0; };
1284
1477
  switch (method) {
1285
1478
  case 'Network.requestWillBeSent':
1286
1479
  // Documents: only the main frame's (subframe documents finish on other sessions / may never).
1287
- if (TRACKED.has(params.type) && (params.type !== 'Document' || params.frameId === w.mainFrame)) w.inflight.set(params.requestId, now);
1480
+ if (TRACKED.has(params.type) && (params.type !== 'Document' || params.frameId === w.mainFrame)) {
1481
+ w.inflight.set(params.requestId, now);
1482
+ if (params.type === 'Script') w.lastScript = now; // a code chunk: the page is mid-transition
1483
+ }
1288
1484
  if (params.type === 'Document' && params.frameId === w.mainFrame && params.requestId === params.loaderId && w.navReq !== params.requestId) {
1289
1485
  if (w.navDone >= w.navStart) begin(params.requestId); else w.navReq = params.requestId;
1290
1486
  }
@@ -1306,6 +1502,12 @@ chrome.debugger.onEvent.addListener((source, method, params) => {
1306
1502
  break;
1307
1503
  case 'Page.frameStoppedLoading':
1308
1504
  if (params.frameId === w.mainFrame && w.committed) w.navDone = now;
1505
+ else w.frameLoads.delete(params.frameId);
1506
+ break;
1507
+ case 'Page.frameStartedLoading':
1508
+ // An iframe (menu, widget, embed) that starts loading because of an action: its content is
1509
+ // part of the result, so waits follow it (capped like requests).
1510
+ if (params.frameId !== w.mainFrame) w.frameLoads.set(params.frameId, now);
1309
1511
  break;
1310
1512
  case 'Page.lifecycleEvent':
1311
1513
  // Tied to the NEW document's loader, so the old page's late events can't end a wait.
@@ -1314,7 +1516,11 @@ chrome.debugger.onEvent.addListener((source, method, params) => {
1314
1516
  if (params.name === 'networkAlmostIdle' || params.name === 'networkIdle') w.netIdle = now;
1315
1517
  }
1316
1518
  break;
1317
- case 'Page.downloadWillBegin': case 'Page.navigatedWithinDocument':
1519
+ case 'Page.navigatedWithinDocument':
1520
+ if (params.frameId === w.mainFrame) w.routeAt = now; // SPA route change (pushState)
1521
+ w.navDone = now;
1522
+ break;
1523
+ case 'Page.downloadWillBegin':
1318
1524
  w.navDone = now;
1319
1525
  break;
1320
1526
  }
@@ -1324,7 +1530,13 @@ chrome.debugger.onEvent.addListener((source, method, params) => {
1324
1530
  const quietExpr = (since) => `(function(){ ${MO_INSTALL}
1325
1531
  var now=performance.now(), s=${Number(since) || 0}-Date.now()+now, b={};
1326
1532
  M.times.forEach(function(t){ if(t<s && t>=s-600) b[Math.floor((s-t)/100)]=1; });
1327
- return [now-M.last, Object.keys(b).length>=4];
1533
+ // Finite CSS transitions/animations still running (a menu fading in, a panel sliding open):
1534
+ // until they finish, their contents may still be invisible. Infinite ones (spinners) don't count.
1535
+ // Those the ACTION started (startTime after it) are counted separately: they matter even on a page
1536
+ // that animates constantly in the background (a panel's staggered entrance on a carousel page).
1537
+ var anim=0, animNew=0;
1538
+ try{ document.getAnimations().forEach(function(a){ if(a.playState==='running' && a.effect){ var ct=a.effect.getComputedTiming(); if(isFinite(ct.endTime) && ct.endTime<=2000){ anim++; if(a.startTime!=null && a.startTime>=s-30) animNew++; } } }); }catch(_){}
1539
+ return [anim ? 0 : now-M.last, Object.keys(b).length>=4, animNew];
1328
1540
  })()`;
1329
1541
 
1330
1542
  // Network tracking only while an action is being watched (see attach()).
@@ -1392,14 +1604,27 @@ async function settle(tabId, capMs, since, opts) {
1392
1604
  if (!w.inflight.has(id)) { rel.delete(id); windowEnd = Math.max(windowEnd, now + 150); continue; } // finished: allow a follow-up
1393
1605
  if (now - w.inflight.get(id) < 8000) busy = true;
1394
1606
  }
1607
+ for (const [fid, t] of w.frameLoads) {
1608
+ if (now - t > 8000) { w.frameLoads.delete(fid); continue; }
1609
+ if (t >= start - 50 && now - t < 2500) busy = true; // an embed/menu frame; ads shouldn't hold us long
1610
+ }
1395
1611
  if (busy) { idleSince = 0; await sleep(30); continue; }
1396
1612
  if (now < grace) { await sleep(30); continue; } // debounce window: a request may still be coming
1397
1613
  if (!idleSince) idleSince = now;
1398
- let quiet = 1e9, ambient = false;
1399
- try { [quiet, ambient] = await evaluate(tabId, quietExpr(start)); } catch { await sleep(30); continue; } // document swapping
1614
+ // A route change or a freshly loaded code chunk means a new view is being built: it often goes
1615
+ // quiet for a beat (JS executing, timers) before rendering, so ask for a longer quiet period.
1616
+ const transition = (w.routeAt || 0) >= start - 50 || (w.lastScript || 0) >= start - 50;
1617
+ const needQuiet = transition ? 250 : 60, quietCap = transition ? 1500 : 600;
1618
+ let quiet = 1e9, ambient = false, animNew = 0;
1619
+ const tq = Date.now();
1620
+ try { [quiet, ambient, animNew] = await evaluate(tabId, quietExpr(start)); } catch { await sleep(30); continue; } // document swapping
1621
+ // If even this tiny probe waited >50ms to run, the page's main thread was busy (JS/render):
1622
+ // not quiet, whatever the observers have reported so far.
1623
+ if (Date.now() - tq > 50) quiet = 0;
1400
1624
  // DOM still changing: wait for 60ms of quiet, capped at 600ms after the network went idle, or
1401
1625
  // 120ms on a page that was already constantly mutating (clocks, tickers, carousels) before us.
1402
- if (quiet < 60 && now - idleSince < (ambient ? 120 : 600)) { await sleep(Math.max(10, 60 - quiet)); continue; }
1626
+ if (animNew && now - idleSince < 1500) { await sleep(40); continue; } // the action's own animations
1627
+ if (quiet < needQuiet && now - idleSince < (ambient ? 120 : quietCap)) { await sleep(Math.max(10, Math.min(60, needQuiet - quiet))); continue; }
1403
1628
  break;
1404
1629
  }
1405
1630
  }
@@ -1476,6 +1701,27 @@ async function pressKey(tabId, combo) {
1476
1701
  return null;
1477
1702
  }
1478
1703
 
1704
+ /* ------------------------- Tabs opened by an action -------------------------- *
1705
+ * target=_blank links and window.open() open a NEW tab: the agent would otherwise keep looking at
1706
+ * the old one and see "page did NOT change". A tab opened by the tab we're acting on is adopted
1707
+ * into the session (and its group) and becomes the one we drive. */
1708
+ const openedBy = new Map(); // opener tabId -> newest tab it opened during an action
1709
+ chrome.tabs.onCreated.addListener((t) => { if (t.openerTabId != null && acting.has(t.openerTabId)) openedBy.set(t.openerTabId, t.id); });
1710
+
1711
+ async function followNewTab(session, tabId) {
1712
+ const nt = openedBy.get(tabId);
1713
+ openedBy.delete(tabId);
1714
+ if (nt == null) return null;
1715
+ const s = sessionState(session);
1716
+ s.activeTabId = nt; s.createdTabs.add(nt); tabOwner.set(nt, session); persistState();
1717
+ await ensureGroup(s, nt);
1718
+ // Let it get past about:blank and load, then read it like any navigation.
1719
+ for (let i = 0; i < 50; i++) { try { const t = await chrome.tabs.get(nt); if (t.url && !/^about:blank/.test(t.url) && t.status === 'complete') break; } catch { return null; } await sleep(100); }
1720
+ await attach(nt);
1721
+ await settle(nt, 3000);
1722
+ return nt;
1723
+ }
1724
+
1479
1725
  /* ---------------------------------- WebMCP --------------------------------- *
1480
1726
  * Pages that implement WebMCP (navigator.modelContext.registerTool / <form toolname>) describe
1481
1727
  * their own actions with JSON schemas. Calling one is a single deterministic step instead of a
@@ -1536,6 +1782,58 @@ async function invokeTool(tabId, name, input) {
1536
1782
  return `tool ${name}: ${res.status}${res.errorText ? ` (${res.errorText})` : ''}${out ? `\n output (untrusted page data): ${out.slice(0, 2000).replace(/\n/g, '\n ')}` : ''}`;
1537
1783
  }
1538
1784
 
1785
+ // Wait until the target's box stops moving (an animation, a sliding panel) before clicking it —
1786
+ // Playwright's "stable" check. Capped: a forever-spinning element is clicked anyway.
1787
+ async function waitStable(target, ref) {
1788
+ const R = JSON.stringify(String(ref));
1789
+ try {
1790
+ await evaluate(target, `new Promise(function(res){
1791
+ var c=window.__pawbrowse, e=c&&c.get&&c.get(${R}), s=e&&(c.surface?c.surface(e):e); if(!s) return res(0);
1792
+ var key=function(){ var r=s.getBoundingClientRect(); return [r.x,r.y,r.width,r.height].map(Math.round).join(','); };
1793
+ // Moving = its box changed, or a finite animation is still running on it or an ancestor (an
1794
+ // orbit/slide can pause at its turning points, which looks "stable" for a frame or two).
1795
+ var animating=function(){ try{ for(var a=s,g=0;a&&g<8;a=a.parentElement,g++){ var an=a.getAnimations?a.getAnimations():[]; for(var i=0;i<an.length;i++){ var ct=an[i].effect&&an[i].effect.getComputedTiming(); if(an[i].playState==='running' && ct && isFinite(ct.endTime)) return true; } } }catch(_){} return false; };
1796
+ // Fast path: nothing animating and the box unchanged over one frame -> go. Once it has been seen
1797
+ // moving, require two quiet frames in a row.
1798
+ // Nothing animating on it or its ancestors: click now (no frame wait). JS-driven motion is
1799
+ // still caught by the click-time hit check.
1800
+ if(!animating()) return res(1);
1801
+ var last=key(), same=0, moved=true, t0=performance.now();
1802
+ (function tick(){ setTimeout(function(){ var k=key(), an=animating(); if(k===last && !an){ if(++same>=(moved?2:1)) return res(1); } else { same=0; last=k; moved=true; } if(performance.now()-t0>6000) return res(0); tick(); }, 16); })();
1803
+ })`);
1804
+ } catch {}
1805
+ }
1806
+
1807
+ // Click-time hit check (Playwright's hit-target interceptor): the FIRST trusted pointer/mouse event
1808
+ // of the click must land on the intended element (or what counts as it). If it would land on
1809
+ // something else — an overlay that appeared, a re-render — the whole gesture is stopped before the
1810
+ // wrong element sees it, and the op reports what intercepted it.
1811
+ async function armClick(target, ref) {
1812
+ const R = JSON.stringify(String(ref));
1813
+ return evaluate(target, `(function(){
1814
+ var c=window.__pawbrowse, e=c&&c.get&&c.get(${R}), s=e&&(c.surface?c.surface(e):e); if(!s||!c.accepts) return false;
1815
+ if(c.__arm) c.__arm.off();
1816
+ var st={ok:null, by:null}, types=['pointerdown','mousedown','pointerup','mouseup','click','auxclick','dblclick','contextmenu'];
1817
+ var hosted=function(f){ for(var r=s.getRootNode(); r && r.host; r=r.host.getRootNode()){ if(r.host===f) return true; } return false; };
1818
+ // The page REPLACED the target during the gesture (re-render on hover/mousedown): the new element
1819
+ // with the same identity (role + label) is the same control to a user.
1820
+ var want=c.guard?c.guard(e):'', replaced=function(f){ if(s.isConnected&&e.isConnected) return false; for(var a=f,g=0;a&&g<6;a=a.parentElement,g++){ if(want && c.guard(a)===want) return true; } return false; };
1821
+ var h=function(ev){
1822
+ if(!ev.isTrusted) return;
1823
+ if(st.ok===null){ var f=(ev.composedPath&&ev.composedPath()[0])||ev.target; if(f && f.nodeType!==1) f=f.parentElement;
1824
+ st.ok = c.accepts(s,f) || hosted(f) || (e!==s && c.accepts(e,f)) || replaced(f);
1825
+ if(!st.ok) st.by = f ? (f.tagName.toLowerCase()+(f.id?'#'+f.id:'')+' "'+String(f.innerText||f.getAttribute('aria-label')||'').trim().replace(/\\s+/g,' ').slice(0,40)+'"') : 'nothing'; }
1826
+ if(st.ok===false){ ev.preventDefault(); ev.stopImmediatePropagation(); }
1827
+ };
1828
+ types.forEach(function(t){ window.addEventListener(t, h, true); });
1829
+ c.__arm={st:st, off:function(){ types.forEach(function(t){ window.removeEventListener(t, h, true); }); c.__arm=null; }};
1830
+ return true;
1831
+ })()`).catch(() => false);
1832
+ }
1833
+ async function disarmClick(target) {
1834
+ return evaluate(target, `(function(){ var c=window.__pawbrowse, a=c&&c.__arm; if(!a) return null; var st=a.st; a.off(); return st; })()`).catch(() => null);
1835
+ }
1836
+
1539
1837
  const dragIntercepts = new Map(); // tabId -> drag data captured by Input.dragIntercepted
1540
1838
  chrome.debugger.onEvent.addListener((source, method, params) => {
1541
1839
  if (method === 'Input.dragIntercepted' && source.tabId != null) dragIntercepts.set(source.tabId, params.data);
@@ -1600,12 +1898,16 @@ async function runOp(tabId, op) {
1600
1898
  return `dialog: ${d.type} "${String(d.message || '').slice(0, 120)}" → ${op.accept ? 'accepted' : 'dismissed'}`;
1601
1899
  }
1602
1900
  case 'click': {
1901
+ await waitStable(T, REF);
1603
1902
  const r = await resolveHit(T, REF);
1604
1903
  if (r.error) return `${op.ref}: ${r.error}`;
1605
1904
  const p = await top(r.x, r.y);
1606
1905
  if (p.covered) return `${op.ref}: element is covered by another element of the page around its frame (dismiss the overlay first)`;
1607
1906
  const button = ['right', 'middle'].includes(op.button) ? op.button : 'left';
1907
+ const armed = await armClick(T, REF);
1608
1908
  await clickAt(tabId, p.x, p.y, { button, count: op.count });
1909
+ const verdict = armed ? await disarmClick(T) : null;
1910
+ if (verdict && verdict.ok === false) return `${op.ref}: click intercepted by ${verdict.by} at the moment of the click — NOT delivered (dismiss what covers it, then retry)`;
1609
1911
  return `${op.count > 1 ? `${op.count}x ` : ''}${button !== 'left' ? `${button}-` : ''}click ${op.ref}`;
1610
1912
  }
1611
1913
  case 'hover': {
@@ -1643,7 +1945,7 @@ async function runOp(tabId, op) {
1643
1945
  return `click_text "${op.text}"`;
1644
1946
  }
1645
1947
  case 'type': {
1646
- const r = await resolveHit(T, REF, { fill: true });
1948
+ const r = await resolveHit(T, REF, { fill: true, text: op.text ?? '' });
1647
1949
  if (r.error) return `${op.ref}: ${r.error}`;
1648
1950
  if (r.set) {
1649
1951
  const sv = await setValue(T, REF, op.text);
@@ -1697,6 +1999,19 @@ async function runOp(tabId, op) {
1697
1999
  const u = await uploadFiles(T, REF, paths);
1698
2000
  return u.error ? `${op.ref}: ${u.error}` : `upload ${op.ref} (${paths.length} file${paths.length > 1 ? 's' : ''})`;
1699
2001
  }
2002
+ case 'back': case 'forward': {
2003
+ const { currentIndex, entries } = await sendCdp(tabId, 'Page.getNavigationHistory');
2004
+ const to = entries[currentIndex + (op.op === 'back' ? -1 : 1)];
2005
+ if (!to) return `${op.op}: no ${op.op === 'back' ? 'previous' : 'next'} page in this tab's history`;
2006
+ const w = tabWatch(tabId); w.navStart = Date.now(); w.navDone = 0; w.committed = false; w.navReq = null;
2007
+ await sendCdp(tabId, 'Page.navigateToHistoryEntry', { entryId: to.id });
2008
+ return `${op.op} → ${String(to.url).slice(0, 100)}`;
2009
+ }
2010
+ case 'reload': {
2011
+ const w = tabWatch(tabId); w.navStart = Date.now(); w.navDone = 0; w.committed = false; w.navReq = null;
2012
+ await sendCdp(tabId, 'Page.reload', {});
2013
+ return 'reload';
2014
+ }
1700
2015
  case 'click_xy': {
1701
2016
  // Coordinates from the last browser_screenshot image (converted to CSS px).
1702
2017
  const k = shotScale.get(tabId) || 1;
@@ -1857,7 +2172,9 @@ async function handleCommand(cmd, args, token, session) {
1857
2172
  const tabId = await resolveTabId(session, args, 'inspect');
1858
2173
  await attach(tabId);
1859
2174
  assertNoOpenDialog(tabId);
1860
- return observe(tabId, args);
2175
+ const t = await observe(tabId, args);
2176
+ const dl = takeDialogLog(tabId);
2177
+ return dl ? `${dl}\n${t}` : t;
1861
2178
  }
1862
2179
  case 'read': {
1863
2180
  const tabId = await resolveTabId(session, args, 'inspect');
@@ -1907,6 +2224,11 @@ async function handleCommand(cmd, args, token, session) {
1907
2224
  // the caller think they failed and retry them.
1908
2225
  let table;
1909
2226
  try { table = await observe(tabId); } catch { table = null; }
2227
+ const nt = await followNewTab(session, tabId).catch(() => null);
2228
+ if (nt != null) {
2229
+ const t2 = await observe(nt).catch(() => '(new tab not readable yet: observe next)');
2230
+ return `ran ${ops.length} op(s) [page changed]:\n${logLines.join('\n')}\n${takeDialogLog(tabId)} → the page opened a NEW TAB (tab ${nt}); now driving it (the previous tab ${tabId} is left open)\n\n${t2}`;
2231
+ }
1910
2232
  const changed = before == null || after == null || before !== after || table == null || seen == null || tableBody(table) !== seen || dialogLog.has(tabId);
1911
2233
  const note = changed ? 'page changed' : 'page did NOT change (if you expected an effect, the action may not have worked — try a different target)';
1912
2234
  if (table == null) {
@@ -1915,6 +2237,25 @@ async function handleCommand(cmd, args, token, session) {
1915
2237
  return `ran ${ops.length} op(s) [${note}]:\n${logLines.join('\n')}\n${takeDialogLog(tabId)}\n${(changed && deltaTable(seenFull, table)) || table}`;
1916
2238
  });
1917
2239
  }
2240
+ case 'peek': {
2241
+ // Dev-only frame capture for recordings: screenshot ANY tab by id without adopting it into a
2242
+ // session, grouping it, or enabling domains on it (so another tool driving that tab — e.g. for
2243
+ // a side-by-side benchmark — is not disturbed). Attaches the debugger only if needed.
2244
+ const tabId = Number(args.tabId);
2245
+ let t; try { t = await chrome.tabs.get(tabId); } catch { throw new Error(`tab ${args.tabId} not found`); }
2246
+ if (restrictedPage(t.url)) throw new Error('that tab is a browser page that cannot be captured');
2247
+ if (!attachedTabs.has(tabId)) {
2248
+ await new Promise((res, rej) => chrome.debugger.attach({ tabId }, '1.3', () => { const e = chrome.runtime.lastError; if (e && !/already attached/i.test(e.message)) rej(new Error(e.message)); else res(); }));
2249
+ peekOnly.add(tabId);
2250
+ }
2251
+ const q = Math.max(20, Math.min(90, Number(args.quality) || 60));
2252
+ const shot = await sendCdp(tabId, 'Page.captureScreenshot', { format: 'jpeg', quality: q });
2253
+ return { data: shot.data, t: Date.now(), url: t.url };
2254
+ }
2255
+ case 'peek_end': {
2256
+ for (const id of [...peekOnly]) { peekOnly.delete(id); if (!attachedTabs.has(id)) await new Promise((r) => chrome.debugger.detach({ tabId: id }, () => { void chrome.runtime.lastError; r(); })); }
2257
+ return { ok: true };
2258
+ }
1918
2259
  case 'screenshot': {
1919
2260
  const tabId = await resolveTabId(session, args, 'inspect');
1920
2261
  await attach(tabId);
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "manifest_version": 3,
3
3
  "name": "PawBrowse",
4
- "version": "0.6.1",
4
+ "version": "0.6.3",
5
5
  "description": "Let your AI agent (Claude Code) drive your real Chrome. Fast element-table control. Open source, no keys.",
6
6
  "minimum_chrome_version": "125",
7
7
  "permissions": [
package/mcp/server.mjs CHANGED
@@ -19,6 +19,7 @@ import path from 'node:path';
19
19
  import crypto from 'node:crypto';
20
20
  import { spawn } from 'node:child_process';
21
21
  import { fileURLToPath } from 'node:url';
22
+ import fs from 'node:fs';
22
23
 
23
24
  const posInt = (v, d) => { const n = Number(v); return Number.isFinite(n) && n > 0 ? Math.floor(n) : d; };
24
25
  const PORT = posInt(process.env.PAWBROWSE_PORT, 10577);
@@ -32,6 +33,26 @@ function brokerSock(port) {
32
33
  const SOCK = brokerSock(PORT);
33
34
  const BROKER_PATH = fileURLToPath(new URL('./broker.mjs', import.meta.url));
34
35
  // A stable-ish, unique session id per server process (also names this session's tab group).
36
+ // Uploads: only files under these roots (default: the client's working directory, temp, Downloads,
37
+ // Desktop; override with PAWBROWSE_UPLOAD_ROOTS, path-list separated). Hidden files/folders
38
+ // (~/.ssh, .env, .aws …) are refused even inside a root — a page must never be able to talk the
39
+ // agent into uploading secrets.
40
+ const UPLOAD_ROOTS = (process.env.PAWBROWSE_UPLOAD_ROOTS
41
+ ? process.env.PAWBROWSE_UPLOAD_ROOTS.split(path.delimiter)
42
+ : [process.cwd(), os.tmpdir(), path.join(os.homedir(), 'Downloads'), path.join(os.homedir(), 'Desktop')])
43
+ .filter(Boolean).map((r) => { try { return fs.realpathSync(r); } catch { return null; } }).filter(Boolean);
44
+
45
+ function checkUploadPath(p) {
46
+ if (typeof p !== 'string' || !p.trim()) throw new Error('upload: empty path');
47
+ let real;
48
+ try { real = fs.realpathSync(path.resolve(p)); } catch { throw new Error(`upload: no such file: ${p}`); }
49
+ if (!fs.statSync(real).isFile()) throw new Error(`upload: not a file: ${p}`);
50
+ const root = UPLOAD_ROOTS.find((r) => real === r || real.startsWith(r + path.sep));
51
+ if (!root) throw new Error(`upload: ${p} is outside the allowed folders (${UPLOAD_ROOTS.join(', ')}). Set PAWBROWSE_UPLOAD_ROOTS to allow another folder.`);
52
+ if (path.relative(root, real).split(path.sep).some((seg) => seg.startsWith('.'))) throw new Error(`upload: refusing hidden file or folder: ${p}`);
53
+ return real;
54
+ }
55
+
35
56
  const SESSION = process.env.PAWBROWSE_SESSION || `s${process.pid}-${crypto.randomBytes(3).toString('hex')}`;
36
57
 
37
58
  const log = (...a) => process.stderr.write(`[pawbrowse] ${a.join(' ')}\n`);
@@ -172,7 +193,7 @@ const TOOLS = [
172
193
  },
173
194
  {
174
195
  name: 'browser_act',
175
- description: 'Run a list of operations on the target tab in order, then return the fresh element table — or, when the page is the same and mostly unchanged, only its new/changed rows plus the refs that are gone (refs you already hold stay valid; unchanged rows are omitted, and browser_observe returns the full table). The result says whether the page changed — if it did NOT change when you expected an effect, the action likely missed; pick a different target rather than repeating. ops: [{op:"click",ref:"e12"} (add count:2 for double-click, button:"right" for a context menu) | {op:"hover",ref:"e3"} (open hover menus/tooltips) | {op:"drag",ref:"e4",to:"e9"|to_text:"Done column"|dx:120,dy:0} (drag-and-drop, sliders, sortable lists) | {op:"click_text",text:"Built with Claude"} (click the most specific visible element matching text, for custom widgets/menus not in the table) | {op:"type",ref:"e7",text:"..."} | {op:"select",ref:"e8",value:"..."} | {op:"key",key:"Enter"} (any key or chord: "Tab", "Shift+Tab", "Escape", "PageDown", "Mod+a" = Cmd/Ctrl+A, "Control+Enter", a single character) | {op:"upload",ref:"e5",paths:["/abs/file.pdf"]} | {op:"scroll",dy:600} (add ref:"e30" to scroll the box/panel containing that control instead of the page) | {op:"tool",name:"add_to_cart",input:{...}} (call a tool the page itself offers via WebMCP — listed under "page tools" in the table; prefer it over clicking when one fits) | {op:"click_xy",x:340,y:120} (click at a point of the last browser_screenshot image — for canvas apps and things the table lacks) | {op:"wait",ms:500} | {op:"dialog",accept:true,text?:"..."} (answer an alert/confirm/prompt already open)]. JS dialogs raised by an op are answered automatically — alerts accepted, confirm/prompt DISMISSED — and reported; add dialog:"accept" (and dialog_text:"..." for a prompt) to an op to accept instead, only when the user intends it (e.g. a confirmed delete). Tips: a typed search query still needs its matching autocomplete suggestion clicked; set each requested filter explicitly (a matching-looking result alone does not prove a filter was applied); do not re-toggle a checkbox/switch/radio already in the wanted state, and do not re-type into a fill field that already shows the wanted value (the ▸ current value tells you); submit a populated search before opening a result; use wait only when the needed control is absent/disabled or results are still loading — if Submit/Search is ready, click it instead, and a recent wait is not evidence of loading.',
196
+ description: 'Run a list of operations on the target tab in order, then return the fresh element table — or, when the page is the same and mostly unchanged, only its new/changed rows plus the refs that are gone (refs you already hold stay valid; unchanged rows are omitted, and browser_observe returns the full table). The result says whether the page changed — if it did NOT change when you expected an effect, the action likely missed; pick a different target rather than repeating. ops: [{op:"click",ref:"e12"} (add count:2 for double-click, button:"right" for a context menu) | {op:"hover",ref:"e3"} (open hover menus/tooltips) | {op:"drag",ref:"e4",to:"e9"|to_text:"Done column"|dx:120,dy:0} (drag-and-drop, sliders, sortable lists) | {op:"click_text",text:"Built with Claude"} (click the most specific visible element matching text, for custom widgets/menus not in the table) | {op:"type",ref:"e7",text:"..."} | {op:"select",ref:"e8",value:"..."} | {op:"key",key:"Enter"} (any key or chord: "Tab", "Shift+Tab", "Escape", "PageDown", "Mod+a" = Cmd/Ctrl+A, "Control+Enter", a single character) | {op:"upload",ref:"e5",paths:["/abs/file.pdf"]} (only files under the working directory, temp, Downloads or Desktop unless PAWBROWSE_UPLOAD_ROOTS says otherwise; hidden files are always refused) | {op:"scroll",dy:600} (add ref:"e30" to scroll the box/panel containing that control instead of the page) | {op:"tool",name:"add_to_cart",input:{...}} (call a tool the page itself offers via WebMCP — listed under "page tools" in the table; prefer it over clicking when one fits) | {op:"click_xy",x:340,y:120} (click at a point of the last browser_screenshot image — for canvas apps and things the table lacks) | {op:"back"} | {op:"forward"} | {op:"reload"} | {op:"wait",ms:500} | {op:"dialog",accept:true,text?:"..."} (answer an alert/confirm/prompt already open)]. A link or script that opens a NEW TAB is followed: the result says so and shows the new tab, which becomes the one you drive. Values a field would reject (bad email/number/url, pattern mismatch) are refused before typing. JS dialogs raised by an op are answered automatically — alerts accepted, confirm/prompt DISMISSED — and reported; add dialog:"accept" (and dialog_text:"..." for a prompt) to an op to accept instead, only when the user intends it (e.g. a confirmed delete). Tips: a typed search query still needs its matching autocomplete suggestion clicked; set each requested filter explicitly (a matching-looking result alone does not prove a filter was applied); do not re-toggle a checkbox/switch/radio already in the wanted state, and do not re-type into a fill field that already shows the wanted value (the ▸ current value tells you); submit a populated search before opening a result; use wait only when the needed control is absent/disabled or results are still loading — if Submit/Search is ready, click it instead, and a recent wait is not evidence of loading.',
176
197
  inputSchema: { type: 'object', properties: { ops: { type: 'array', items: { type: 'object' } }, tabId: { type: 'number' } }, required: ['ops'] },
177
198
  annotations: { title: 'Act on page (click/type/select/scroll)', readOnlyHint: false, destructiveHint: true, openWorldHint: true },
178
199
  },
@@ -190,6 +211,16 @@ const TOOLS = [
190
211
  },
191
212
  ];
192
213
 
214
+ // Dev-only tools (recording benchmarks): hidden unless PAWBROWSE_DEV_TOOLS=1.
215
+ if (process.env.PAWBROWSE_DEV_TOOLS === '1') {
216
+ TOOLS.push({
217
+ name: 'browser_peek',
218
+ description: 'DEV: screenshot any tab by id (JPEG base64 + capture time) without adopting or changing it. For recording side-by-side benchmarks.',
219
+ inputSchema: { type: 'object', properties: { tabId: { type: 'number' }, quality: { type: 'number' }, end: { type: 'boolean' } } },
220
+ annotations: { title: 'Peek at a tab (dev)', readOnlyHint: true, openWorldHint: true },
221
+ });
222
+ }
223
+
193
224
  function textResult(obj) {
194
225
  const text = typeof obj === 'string' ? obj : JSON.stringify(obj, null, 2);
195
226
  return { content: [{ type: 'text', text }] };
@@ -208,8 +239,18 @@ async function callTool(name, args) {
208
239
  case 'browser_navigate':return textResult(await callExtension('navigate', args));
209
240
  case 'browser_observe': return textResult(await callExtension('observe', args));
210
241
  case 'browser_read': return textResult(await callExtension('read', args));
211
- case 'browser_act': return textResult(await callExtension('act', args));
242
+ case 'browser_act': {
243
+ // Enforce the upload policy here: the extension can't see the filesystem, the server can.
244
+ for (const op of (args && Array.isArray(args.ops) ? args.ops : [])) {
245
+ if (op && op.op === 'upload') op.paths = [].concat(op.paths ?? op.path ?? []).map(checkUploadPath);
246
+ }
247
+ return textResult(await callExtension('act', args));
248
+ }
212
249
  case 'browser_assert': return textResult(await callExtension('assert', args));
250
+ case 'browser_peek': {
251
+ if (process.env.PAWBROWSE_DEV_TOOLS !== '1') throw new Error('unknown tool: browser_peek');
252
+ return textResult(await callExtension(args.end ? 'peek_end' : 'peek', args));
253
+ }
213
254
  case 'browser_screenshot': {
214
255
  const r = await callExtension('screenshot', args);
215
256
  return { content: [{ type: 'image', data: r.data, mimeType: r.mimeType }, { type: 'text', text: r.note }] };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pawbrowse",
3
- "version": "0.6.1",
3
+ "version": "0.6.3",
4
4
  "description": "Let Claude Code drive your real, logged-in Chrome. A zero-dependency MCP server + a Chrome MV3 extension that uses element-table perception over CDP. The calling agent is the policy: no second model, no API keys, page snapshots never leave for a third party.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -9,7 +9,7 @@
9
9
  "scripts": {
10
10
  "start": "node mcp/server.mjs",
11
11
  "test": "node --test --test-timeout=15000 --test-force-exit test/*.test.mjs",
12
- "test:e2e": "node --test --test-timeout=60000 --test-concurrency=1 test/e2e/*.e2e.mjs"
12
+ "test:e2e": "node --test --test-timeout=240000 --test-concurrency=1 test/e2e/*.e2e.mjs"
13
13
  },
14
14
  "repository": {
15
15
  "type": "git",