pawbrowse 0.6.0 → 0.6.2

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,
@@ -288,7 +288,9 @@ chrome.debugger.onEvent.addListener((source, method, params) => {
288
288
  if (method === 'Page.javascriptDialogClosed') { openDialogs.delete(tabId); return; }
289
289
  if (method !== 'Page.javascriptDialogOpening') return;
290
290
  const pol = acting.get(tabId);
291
- const where = source.sessionId ? { tabId, sessionId: source.sessionId } : tabId; // dialogs can come from frames
291
+ // Dialogs from any frame (incl. out-of-process iframes) are raised on — and answered via — the
292
+ // root session (Chromium routes them through the main frame's Page domain).
293
+ const where = tabId;
292
294
  if (!pol) { openDialogs.set(tabId, { type: params.type, message: params.message, where }); return; }
293
295
  const accept = pol.accept != null ? pol.accept : params.type === 'alert';
294
296
  const promptText = pol.text != null ? String(pol.text) : (params.defaultPrompt || '');
@@ -302,7 +304,16 @@ chrome.debugger.onEvent.addListener((source, method, params) => {
302
304
  async function whileActing(tabId, fn, policy) {
303
305
  const mine = { ...(policy || {}) };
304
306
  acting.set(tabId, mine);
305
- try { return await fn(); } finally { if (acting.get(tabId) === mine) acting.delete(tabId); }
307
+ try { return await fn(); } finally {
308
+ // A dialog that pops up just AFTER the action (a follow-up alert once a confirm is answered) is
309
+ // still its consequence: keep answering with the default policy for a short while and report it
310
+ // with the next result, instead of leaving the page frozen for the next call.
311
+ if (acting.get(tabId) === mine) {
312
+ const linger = { linger: true };
313
+ acting.set(tabId, linger);
314
+ setTimeout(() => { if (acting.get(tabId) === linger) acting.delete(tabId); }, 1500);
315
+ }
316
+ }
306
317
  }
307
318
 
308
319
  function takeDialogLog(tabId) {
@@ -322,6 +333,7 @@ function assertNoOpenDialog(tabId) {
322
333
  chrome.tabs.onRemoved.addListener((tabId) => {
323
334
  attachedTabs.delete(tabId);
324
335
  forgetTab(tabId, true);
336
+ openedBy.delete(tabId);
325
337
  const owner = tabOwner.get(tabId);
326
338
  tabOwner.delete(tabId);
327
339
  if (owner != null) {
@@ -422,7 +434,11 @@ async function endSession(session) {
422
434
  const MO_INSTALL = `var M=window.__pawmo; if(!M){ M=window.__pawmo={last:performance.now(),times:[],roots:new WeakSet()};
423
435
  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
436
  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); }`;
437
+ M.watch(document);
438
+ // A busy main thread (parsing/running JS, rendering) is not "quiet" even when the DOM is still:
439
+ // SPA routes often mutate nothing for a few hundred ms and then render everything at once.
440
+ 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(_){}
441
+ }`;
426
442
  const SNAPSHOT = `(function(seed, opts){
427
443
  opts=opts||{};
428
444
  try{
@@ -470,7 +486,11 @@ const SNAPSHOT = `(function(seed, opts){
470
486
  if(['button','submit','reset'].indexOf(e.type)>=0 && e.value) return e.value;
471
487
  if(e.getAttribute('alt')) return e.getAttribute('alt');
472
488
  // Visible descendants only: display:none / hidden children (tooltips, menus) must not leak in.
473
- var txt = (tag==='INPUT'||tag==='SELECT'||tag==='TEXTAREA') ? '' : [].map.call(e.childNodes,function(n){ return n.nodeType===3 ? n.textContent : (n.nodeType===1 && visible(n) ? name(n,seen) : ''); }).join(' ').trim();
489
+ // A rich-text editor's text is its VALUE, never its name (else typing into it renames it and
490
+ // its ref is refused on the next action).
491
+ // A <slot> shows the nodes ASSIGNED to it (a web component's light-DOM label), not its children.
492
+ var kids = tag==='SLOT' ? ((e.assignedNodes && e.assignedNodes({flatten:true}).length) ? e.assignedNodes({flatten:true}) : e.childNodes) : e.childNodes;
493
+ 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();
474
494
  if(txt) return txt;
475
495
  return e.getAttribute('title')||e.getAttribute('placeholder')||e.getAttribute('aria-placeholder')||'';
476
496
  }
@@ -504,7 +524,7 @@ const SNAPSHOT = `(function(seed, opts){
504
524
  // Semantic controls + custom clickables: any contenteditable, an inline onclick, or a
505
525
  // keyboard-focusable [tabindex] (framework buttons — React-Native-Web Pressables, design-system
506
526
  // divs — often expose only these). cursor:pointer clickables are added separately in collect().
507
- 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(',');
527
+ 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(',');
508
528
  // Inputs whose value is SET (not typed): typing into these is unreliable, so type() routes them
509
529
  // through a value setter. The hint tells the agent the expected format.
510
530
  var SETTABLE={date:'YYYY-MM-DD',time:'HH:MM','datetime-local':'YYYY-MM-DDTHH:MM',month:'YYYY-MM',week:'YYYY-Www',color:'#rrggbb',range:''};
@@ -527,13 +547,47 @@ const SNAPSHOT = `(function(seed, opts){
527
547
  }
528
548
  // Hit-test a frame-local point in the element's own root, descending into nested open shadow
529
549
  // roots, so a covered control (overlay, modal backdrop, pointer-events:none) is flagged.
550
+ // Is f (what is actually under the pointer) just the target's own visible content? Common
551
+ // pattern (Google results, cards): the accessible element sits UNDER a sibling that renders the
552
+ // row. Clicking there is what a person does. Not so for an empty overlay, a modal/fixed layer, a
553
+ // different control, or an ancestor of the target.
554
+ cache.sameWidget=function(t, f){
555
+ try{
556
+ if(!f || f===t || f.contains(t)) return false;
557
+ var p=t.parentElement, d=0; while(p && d<2 && !p.contains(f)){ p=p.parentElement; d++; }
558
+ if(!p || !p.contains(f) || p.tagName==='BODY' || p.tagName==='HTML') return false;
559
+ for(var a=f; a && a!==p; a=a.parentElement){
560
+ var cs=a.ownerDocument.defaultView.getComputedStyle(a);
561
+ if(cs.position==='fixed' || cs.position==='sticky') return false;
562
+ if(a.matches('a[href],button,input,select,textarea,[role=button],[role=link],[role=checkbox],[role=menuitem],[role=option],[role=tab]')) return false;
563
+ }
564
+ // A layer that CONTAINS other controls (a promo with its own button) is a different widget.
565
+ if(f.querySelector('a[href],button,input,select,textarea,[role=button],[role=link],[role=checkbox],[role=menuitem],[role=option],[role=tab]')) return false;
566
+ return !!((f.innerText||'').trim() || f.querySelector('img,svg') || /^(IMG|SVG)$/i.test(f.tagName));
567
+ }catch(_){ return false; }
568
+ };
569
+ // Where to point at an element: the centre of its box — except for an inline element that WRAPS
570
+ // (a link across two lines), whose box centre falls between the lines, on the surrounding text.
571
+ // Then use the centre of its first visible line box.
572
+ cache.pt=function(el){
573
+ var r=el.getBoundingClientRect(), rs=el.getClientRects(), v=el.ownerDocument.defaultView;
574
+ 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}; } }
575
+ return {x:r.x+r.width/2, y:r.y+r.height/2, r:r};
576
+ };
577
+ // Pointing at a web component's SLOTTED text (its light-DOM label) makes the shadow root's
578
+ // elementFromPoint answer with the host: that's the control's own content, not a cover.
579
+ function slotted(t, f){ var rt=t.getRootNode(); return !!(rt && rt.host && f===rt.host && t.querySelector && t.querySelector('slot')); }
580
+ cache.slotted=slotted;
581
+ // Does a pointer event landing on f count as hitting t? (t itself or inside it, its label's control,
582
+ // its slotted content, or its own row content.) Used before AND during the click.
583
+ 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)); };
530
584
  cache.hits=function(t, lx, ly){
531
585
  try{
532
- if(lx==null){ var hr=t.getBoundingClientRect(); lx=hr.x+hr.width/2; ly=hr.y+hr.height/2; }
586
+ if(lx==null){ var hp=cache.pt(t); lx=hp.x; ly=hp.y; }
533
587
  var root=t.getRootNode(); if(!root.elementFromPoint) root=t.ownerDocument;
534
588
  var f=root.elementFromPoint(lx,ly), g=0;
535
589
  while(f && sroot(f) && g++<16){ var inner=sroot(f).elementFromPoint(lx,ly); if(!inner||inner===f) break; f=inner; }
536
- return !!f && (t===f || t.contains(f) || (t.control && t.control===f));
590
+ return !!f && (t===f || t.contains(f) || (t.control && t.control===f) || slotted(t, f) || cache.sameWidget(t, f));
537
591
  }catch(_){ return true; }
538
592
  };
539
593
  var hits=cache.hits;
@@ -582,6 +636,8 @@ const SNAPSHOT = `(function(seed, opts){
582
636
  // systems) render as role-less <div>s but get cursor:pointer. Include the ROOT of each
583
637
  // pointer region (its parent is NOT pointer) so we capture the pressable itself, not its
584
638
  // inherited-cursor text children. Bounded scan so huge DOMs stay fast.
639
+ // <a> without href but with inline JS handlers (onmouseenter/onmousedown/...): JS-driven links.
640
+ 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; } } }
585
641
  if(!seen.has(n) && scanned<8000){
586
642
  scanned++;
587
643
  try{
@@ -612,6 +668,23 @@ const SNAPSHOT = `(function(seed, opts){
612
668
  walk(document, 0, 0, 0);
613
669
  return out;
614
670
  }
671
+ // Not hittable, but only because a sticky/fixed bar (not a modal) or something OUTSIDE its own
672
+ // scroll box sits on top of it — e.g. a sidebar list scrolled under the sidebar's filter header.
673
+ // Acting scrolls it into the open, so it's "scrolled out", not "covered".
674
+ function reachable(t, lx, ly){
675
+ try{
676
+ var root=t.getRootNode(); if(!root.elementFromPoint) root=t.ownerDocument;
677
+ var f=root.elementFromPoint(lx,ly); if(!f || f.contains(t)) return false;
678
+ var view=t.ownerDocument.defaultView;
679
+ for(var a=f; a && a.nodeType===1; a=a.parentElement){
680
+ if(a.matches('dialog,[role=dialog],[role=alertdialog],[aria-modal=true]')) return false;
681
+ var cs=view.getComputedStyle(a);
682
+ if(cs.position==='fixed' || cs.position==='sticky'){ var q=a.getBoundingClientRect(); return q.width*q.height < 0.4*view.innerWidth*view.innerHeight; }
683
+ }
684
+ var sc=t.parentElement; while(sc && !(sc.scrollHeight>sc.clientHeight+2 && /(auto|scroll)/.test(view.getComputedStyle(sc).overflowY))) sc=sc.parentElement;
685
+ return !!sc && sc!==t.ownerDocument.body && sc!==t.ownerDocument.documentElement && !sc.contains(f);
686
+ }catch(_){ return false; }
687
+ }
615
688
  var scrollerCache=new Map(); // element -> does it clip its overflow? (one style read per ancestor per snapshot)
616
689
  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; }
617
690
  function clipped(t){
@@ -641,14 +714,14 @@ const SNAPSHOT = `(function(seed, opts){
641
714
  // Only accept it if it has a real label and isn't just a wrapper around an actual control
642
715
  // (or a <label> standing in for one), so we don't flood the table with layout containers.
643
716
  var ti=e.getAttribute('tabindex');
644
- if(clk || e.hasAttribute('onclick') || (ti!==null && ti!=='-1') || e.getAttribute('draggable')==='true'){
717
+ 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'){
645
718
  if(e.tagName==='LABEL' && e.control) continue;
646
719
  if(!(name(e)||'').trim() || e.querySelector(selector)) continue;
647
720
  rname='button';
648
721
  }
649
722
  }
650
723
  if(!rname) continue;
651
- var r=surf.getBoundingClientRect(), lx=r.x+r.width/2, ly=r.y+r.height/2, x=lx+ox, y=ly+oy;
724
+ var sp=cache.pt(surf), r=sp.r, lx=sp.x, ly=sp.y, x=lx+ox, y=ly+oy;
652
725
  if(x<0||x>=VW) continue;
653
726
  if(rname==='gridcell' && e.querySelector('button,[role="button"]')) continue;
654
727
  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)};
@@ -673,6 +746,8 @@ const SNAPSHOT = `(function(seed, opts){
673
746
  base.off=y<0?'up':'down'; base.dist=y<0?-y:y-VH;
674
747
  } else if((function(){ var fv=surf.ownerDocument.defaultView; return fv!==window && (lx<0||ly<0||lx>=fv.innerWidth||ly>=fv.innerHeight); })()){
675
748
  base.off='scroll'; // scrolled out of its (same-origin) iframe's viewport
749
+ } else if(!hits(surf, lx, ly) && reachable(surf, lx, ly)){
750
+ base.off='scroll'; base.dist=1; // hidden under a sticky bar / a panel header: right here, scrolling brings it out
676
751
  } else if(!hits(surf, lx, ly)){
677
752
  // Not hittable: either scrolled out of an overflow container (reachable — acting scrolls it
678
753
  // in) or genuinely covered by an overlay/modal (needs dismissing first).
@@ -712,6 +787,7 @@ const SNAPSHOT = `(function(seed, opts){
712
787
  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; }); }
713
788
  var actions=inView.concat(offView);
714
789
  // The nearest ancestor text that isn't just the label itself: which row/item a control is in.
790
+ cache.ctxOf=function(el, lab){ return ctxOf(el, lab); };
715
791
  function ctxOf(el, lab){
716
792
  for(var p=el&&el.parentElement, g=0; p && g<6 && p.tagName!=='BODY'; p=p.parentElement, g++){
717
793
  var tx=clean(p.innerText,400); if(!tx || tx===lab) continue;
@@ -859,7 +935,7 @@ function formatTable(snap) {
859
935
  // A page signature to detect whether an action actually changed the page. Includes per-input
860
936
  // value/checked/selectedIndex so fills, toggles, and selects register as changes (password
861
937
  // values excluded).
862
- const SIG = `JSON.stringify([location.href, document.title, [].map.call(document.querySelectorAll('input,textarea,select'),function(e){return e.type==='password'?'':(String(e.value)+'~'+(e.checked?1:0)+'~'+(e.selectedIndex==null?'':e.selectedIndex));}).join('|'), document.querySelectorAll('a,button,input,select,textarea,summary,[role]').length])`;
938
+ const SIG = `JSON.stringify([location.href, document.title, document.body ? document.body.textContent.length : 0, [].map.call(document.querySelectorAll('input,textarea,select'),function(e){return e.type==='password'?'':(String(e.value)+'~'+(e.checked?1:0)+'~'+(e.selectedIndex==null?'':e.selectedIndex));}).join('|'), document.querySelectorAll('a,button,input,select,textarea,summary,[role]').length])`;
863
939
 
864
940
  // Retry through transient "document is navigating" states so a snapshot taken
865
941
  // during a transition settles instead of failing.
@@ -1126,6 +1202,7 @@ async function observe(tabId, opts) {
1126
1202
  async function resolveHit(tabId, ref, opts) {
1127
1203
  const forFill = opts && opts.fill ? 'true' : 'false';
1128
1204
  const noScroll = opts && opts.noScroll ? 'true' : 'false', noHit = opts && opts.noHit ? 'true' : 'false';
1205
+ const TXT = opts && opts.text != null ? JSON.stringify(String(opts.text)) : 'null';
1129
1206
  const R = JSON.stringify(String(ref));
1130
1207
  return evaluate(tabId, `(function(){
1131
1208
  var c=window.__pawbrowse; if(!c||!c.byId) return {error:'no snapshot yet; observe first'};
@@ -1133,38 +1210,55 @@ async function resolveHit(tabId, ref, opts) {
1133
1210
  var e=c.get?c.get(${R}):null;
1134
1211
  if(!e||!e.isConnected) return {error:'element no longer on page (observe again)'};
1135
1212
  if(c.guard && c.guards && c.guards[${R}]!=null && c.guard(e)!==c.guards[${R}]) return {error:'element changed since observe (observe again)'};
1213
+ // Same node, same label — but a different ROW? Virtualized lists recycle row elements for other
1214
+ // items: "Delete" may now belong to someone else. The row context it was listed with must hold.
1215
+ 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'};
1136
1216
  if(e.matches(':disabled')||e.closest('[aria-disabled="true"],[inert]')) return {error:'element is disabled'};
1137
1217
  if(${forFill} && (e.readOnly||e.getAttribute('aria-readonly')==='true')) return {error:'field is read-only'};
1138
1218
  if(${forFill} && !('value' in e) && !e.isContentEditable) return {error:'not an editable field (observe again)'};
1219
+ // Typed text that the field would reject (email/number/url/pattern/maxlength) is refused up front:
1220
+ // nothing is typed, instead of a value silently dropped or half-entered.
1221
+ if(${forFill} && ${TXT}!==null && ${TXT}!=='' && e.tagName==='INPUT'){
1222
+ var probe=e.cloneNode(); probe.value=${TXT};
1223
+ 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)'};
1224
+ 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)'};
1225
+ }
1139
1226
  // Value-set inputs (date/time/range/color...) take the setter path, not click+type.
1140
1227
  if(${forFill} && e.tagName==='INPUT' && ['date','time','datetime-local','month','week','color','range'].indexOf(e.type)>=0) return {set:true};
1141
1228
  // Click the control's visible SURFACE: a styled checkbox's <label>, or the element itself.
1142
1229
  var s=c.surface?c.surface(e):e;
1143
1230
  if(!s) return {error:'element not visible'};
1144
- if(!${noScroll}) s.scrollIntoView({block:'center',inline:'center'});
1145
- var r=s.getBoundingClientRect(); if(!r.width||!r.height) return {error:'element has no size'};
1146
- // Frame-local center (in the element's own frame viewport)...
1147
- var lx=r.x+r.width/2, ly=r.y+r.height/2;
1148
- // ...plus the offset chain of any ancestor iframes, giving the TOP-LEVEL click point for CDP.
1149
- var dx=0, dy=0, w=(s.ownerDocument&&s.ownerDocument.defaultView), g=0;
1150
- while(w && w.frameElement && g++<12){
1151
- var fe=w.frameElement, fr=fe.getBoundingClientRect(), fcs=fe.ownerDocument.defaultView.getComputedStyle(fe);
1152
- dx+=fr.left+fe.clientLeft+(parseFloat(fcs.paddingLeft)||0);
1153
- dy+=fr.top+fe.clientTop+(parseFloat(fcs.paddingTop)||0);
1154
- w=fe.ownerDocument.defaultView;
1155
- }
1156
- var x=Math.round(lx+dx), y=Math.round(ly+dy);
1157
- if(x<0||y<0||x>=innerWidth||y>=innerHeight) return {error:'element off-screen after scroll'};
1158
1231
  // Hit-test in the surface's OWN root (document / shadow root / iframe doc) with frame-local
1159
- // coords, descending through nested open shadow roots, so shadow-DOM and iframe elements
1160
- // aren't falsely reported as covered.
1161
- if(${noHit}) return {x:x, y:y};
1162
- var root=s.getRootNode(); if(!root||!root.elementFromPoint) root=s.ownerDocument;
1163
- var f=root.elementFromPoint(lx,ly), k=0;
1232
+ // coords, descending through nested open/closed shadow roots.
1164
1233
  var sroot=function(n){ return n.shadowRoot || (c.closed && c.closed.get(n)) || null; };
1165
- while(f && sroot(f) && k++<16){ var inner=sroot(f).elementFromPoint(lx,ly); if(!inner||inner===f) break; f=inner; }
1166
- 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)'};
1167
- return {x:x, y:y};
1234
+ var at=function(){
1235
+ var r=s.getBoundingClientRect(); if(!r.width||!r.height) return {error:'element has no size'};
1236
+ var fw=s.ownerDocument.defaultView;
1237
+ 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;
1238
+ // ...plus the offset chain of any ancestor iframes, giving the TOP-LEVEL click point for CDP.
1239
+ var dx=0, dy=0, w=fw, g=0;
1240
+ while(w && w.frameElement && g++<12){
1241
+ var fe=w.frameElement, fr=fe.getBoundingClientRect(), fcs=fe.ownerDocument.defaultView.getComputedStyle(fe);
1242
+ dx+=fr.left+fe.clientLeft+(parseFloat(fcs.paddingLeft)||0);
1243
+ dy+=fr.top+fe.clientTop+(parseFloat(fcs.paddingTop)||0);
1244
+ w=fe.ownerDocument.defaultView;
1245
+ }
1246
+ var inView=ly>=0 && lx>=0 && ly<fw.innerHeight && lx<fw.innerWidth && (r.height>fw.innerHeight || (r.top>=0 && r.bottom<=fw.innerHeight));
1247
+ var x=Math.round(lx+dx), y=Math.round(ly+dy);
1248
+ var root=s.getRootNode(); if(!root||!root.elementFromPoint) root=s.ownerDocument;
1249
+ var f=root.elementFromPoint(lx,ly), k=0;
1250
+ while(f && sroot(f) && k++<16){ var inner=sroot(f).elementFromPoint(lx,ly); if(!inner||inner===f) break; f=inner; }
1251
+ 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)));
1252
+ return {x:x, y:y, inView:inView && x>=0 && y>=0 && x<innerWidth && y<innerHeight, hit:hit};
1253
+ };
1254
+ // Don't move the page when the target is already on screen and hittable: needless scrolling
1255
+ // closes popups/date pickers and jolts the page. Otherwise bring it to the centre and re-check.
1256
+ var p=at(); if(p.error) return p;
1257
+ if(!(p.inView && p.hit) && !${noScroll}){ s.scrollIntoView({block:'center',inline:'center',behavior:'instant'}); p=at(); if(p.error) return p; }
1258
+ if(!p.inView && !${noHit}) return {error:'element off-screen after scroll'};
1259
+ if(${noHit}) return {x:p.x, y:p.y};
1260
+ if(!p.hit) return {error:'element is covered by another element (dismiss the overlay/dialog first)'};
1261
+ return {x:p.x, y:p.y};
1168
1262
  })()`);
1169
1263
  }
1170
1264
 
@@ -1234,7 +1328,7 @@ async function centerOfText(tabId, text) {
1234
1328
  if(!pool.length) return null;
1235
1329
  pool.sort(function(a,b){return a.area-b.area;});
1236
1330
  var chosen=pool[0].el;
1237
- chosen.scrollIntoView({block:'center',inline:'center'});
1331
+ chosen.scrollIntoView({block:'center',inline:'center',behavior:'instant'});
1238
1332
  var rr=chosen.getBoundingClientRect();
1239
1333
  var cx=Math.round(rr.left+rr.width/2), cy=Math.round(rr.top+rr.height/2);
1240
1334
  if(cx<0||cy<0||cx>=innerWidth||cy>=innerHeight) return null;
@@ -1245,12 +1339,18 @@ async function centerOfText(tabId, text) {
1245
1339
 
1246
1340
  async function clickAt(tabId, x, y, opts) {
1247
1341
  const button = (opts && opts.button) || 'left', count = Math.max(1, Math.min(3, Number(opts && opts.count) || 1));
1248
- await sendCdp(tabId, 'Input.dispatchMouseEvent', { type: 'mouseMoved', x, y });
1249
- // A double/triple click is successive press/release pairs with a rising clickCount.
1342
+ // `buttons` must say which button is held (CDP defaults it to 0; pointer-event widgets ignore a
1343
+ // pointerdown with no button). A double/triple click is press/release pairs with rising clickCount.
1344
+ // Pipelined, like Playwright: move, press and release reach the renderer together. Awaiting each
1345
+ // one leaves a gap in which a mousedown-triggered re-render (a blur committing a date, a ripple)
1346
+ // swaps the element, and Chromium then sends the click to a common ancestor — or nowhere.
1347
+ const held = { left: 1, right: 2, middle: 4 }[button];
1348
+ const sends = [sendCdp(tabId, 'Input.dispatchMouseEvent', { type: 'mouseMoved', x, y })];
1250
1349
  for (let n = 1; n <= count; n++) {
1251
- await sendCdp(tabId, 'Input.dispatchMouseEvent', { type: 'mousePressed', x, y, button, clickCount: n });
1252
- await sendCdp(tabId, 'Input.dispatchMouseEvent', { type: 'mouseReleased', x, y, button, clickCount: n });
1350
+ sends.push(sendCdp(tabId, 'Input.dispatchMouseEvent', { type: 'mousePressed', x, y, button, buttons: held, clickCount: n }));
1351
+ sends.push(sendCdp(tabId, 'Input.dispatchMouseEvent', { type: 'mouseReleased', x, y, button, buttons: 0, clickCount: n }));
1253
1352
  }
1353
+ await Promise.all(sends);
1254
1354
  }
1255
1355
 
1256
1356
  /* ------------------------------ Waiting (settle) ---------------------------- *
@@ -1261,10 +1361,11 @@ async function clickAt(tabId, x, y, opts) {
1261
1361
  const watches = new Map(); // tabId -> { mainFrame, inflight: Map(reqId -> startedAt), navStart, navDone }
1262
1362
  function tabWatch(tabId) {
1263
1363
  let w = watches.get(tabId);
1264
- if (!w) { w = { mainFrame: null, inflight: new Map(), navStart: 0, navDone: 0, committed: true, navReq: null }; watches.set(tabId, w); }
1364
+ if (!w) { w = { mainFrame: null, inflight: new Map(), frameLoads: new Map(), navStart: 0, navDone: 0, committed: true, navReq: null }; watches.set(tabId, w); }
1265
1365
  return w;
1266
1366
  }
1267
- const TRACKED = new Set(['Fetch', 'XHR', 'Document']);
1367
+ // Script too: SPA route changes lazy-load code chunks and render only after they run.
1368
+ const TRACKED = new Set(['Fetch', 'XHR', 'Document', 'Script']);
1268
1369
  chrome.debugger.onEvent.addListener((source, method, params) => {
1269
1370
  if (source.tabId == null) return;
1270
1371
  const w = watches.get(source.tabId);
@@ -1276,13 +1377,18 @@ chrome.debugger.onEvent.addListener((source, method, params) => {
1276
1377
  if (method === 'Network.loadingFailed' && params.requestId === w.navReq && !w.committed) w.navDone = Date.now(); // blocked/aborted navigation
1277
1378
  return;
1278
1379
  }
1380
+ // Same for frames: an out-of-process iframe starts loading in the parent, stops in its own session.
1381
+ if (method === 'Page.frameStoppedLoading' || method === 'Page.frameDetached') { w.frameLoads.delete(params.frameId); if (source.sessionId) return; }
1279
1382
  if (source.sessionId) return; // everything else: the top-level target only
1280
1383
  const now = Date.now();
1281
1384
  const begin = (loaderId) => { w.navStart = now; w.navDone = 0; w.committed = false; w.navReq = loaderId || null; w.netIdle = 0; };
1282
1385
  switch (method) {
1283
1386
  case 'Network.requestWillBeSent':
1284
1387
  // Documents: only the main frame's (subframe documents finish on other sessions / may never).
1285
- if (TRACKED.has(params.type) && (params.type !== 'Document' || params.frameId === w.mainFrame)) w.inflight.set(params.requestId, now);
1388
+ if (TRACKED.has(params.type) && (params.type !== 'Document' || params.frameId === w.mainFrame)) {
1389
+ w.inflight.set(params.requestId, now);
1390
+ if (params.type === 'Script') w.lastScript = now; // a code chunk: the page is mid-transition
1391
+ }
1286
1392
  if (params.type === 'Document' && params.frameId === w.mainFrame && params.requestId === params.loaderId && w.navReq !== params.requestId) {
1287
1393
  if (w.navDone >= w.navStart) begin(params.requestId); else w.navReq = params.requestId;
1288
1394
  }
@@ -1304,6 +1410,12 @@ chrome.debugger.onEvent.addListener((source, method, params) => {
1304
1410
  break;
1305
1411
  case 'Page.frameStoppedLoading':
1306
1412
  if (params.frameId === w.mainFrame && w.committed) w.navDone = now;
1413
+ else w.frameLoads.delete(params.frameId);
1414
+ break;
1415
+ case 'Page.frameStartedLoading':
1416
+ // An iframe (menu, widget, embed) that starts loading because of an action: its content is
1417
+ // part of the result, so waits follow it (capped like requests).
1418
+ if (params.frameId !== w.mainFrame) w.frameLoads.set(params.frameId, now);
1307
1419
  break;
1308
1420
  case 'Page.lifecycleEvent':
1309
1421
  // Tied to the NEW document's loader, so the old page's late events can't end a wait.
@@ -1312,7 +1424,11 @@ chrome.debugger.onEvent.addListener((source, method, params) => {
1312
1424
  if (params.name === 'networkAlmostIdle' || params.name === 'networkIdle') w.netIdle = now;
1313
1425
  }
1314
1426
  break;
1315
- case 'Page.downloadWillBegin': case 'Page.navigatedWithinDocument':
1427
+ case 'Page.navigatedWithinDocument':
1428
+ if (params.frameId === w.mainFrame) w.routeAt = now; // SPA route change (pushState)
1429
+ w.navDone = now;
1430
+ break;
1431
+ case 'Page.downloadWillBegin':
1316
1432
  w.navDone = now;
1317
1433
  break;
1318
1434
  }
@@ -1322,7 +1438,13 @@ chrome.debugger.onEvent.addListener((source, method, params) => {
1322
1438
  const quietExpr = (since) => `(function(){ ${MO_INSTALL}
1323
1439
  var now=performance.now(), s=${Number(since) || 0}-Date.now()+now, b={};
1324
1440
  M.times.forEach(function(t){ if(t<s && t>=s-600) b[Math.floor((s-t)/100)]=1; });
1325
- return [now-M.last, Object.keys(b).length>=4];
1441
+ // Finite CSS transitions/animations still running (a menu fading in, a panel sliding open):
1442
+ // until they finish, their contents may still be invisible. Infinite ones (spinners) don't count.
1443
+ // Those the ACTION started (startTime after it) are counted separately: they matter even on a page
1444
+ // that animates constantly in the background (a panel's staggered entrance on a carousel page).
1445
+ var anim=0, animNew=0;
1446
+ 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(_){}
1447
+ return [anim ? 0 : now-M.last, Object.keys(b).length>=4, animNew];
1326
1448
  })()`;
1327
1449
 
1328
1450
  // Network tracking only while an action is being watched (see attach()).
@@ -1390,14 +1512,27 @@ async function settle(tabId, capMs, since, opts) {
1390
1512
  if (!w.inflight.has(id)) { rel.delete(id); windowEnd = Math.max(windowEnd, now + 150); continue; } // finished: allow a follow-up
1391
1513
  if (now - w.inflight.get(id) < 8000) busy = true;
1392
1514
  }
1515
+ for (const [fid, t] of w.frameLoads) {
1516
+ if (now - t > 8000) { w.frameLoads.delete(fid); continue; }
1517
+ if (t >= start - 50 && now - t < 2500) busy = true; // an embed/menu frame; ads shouldn't hold us long
1518
+ }
1393
1519
  if (busy) { idleSince = 0; await sleep(30); continue; }
1394
1520
  if (now < grace) { await sleep(30); continue; } // debounce window: a request may still be coming
1395
1521
  if (!idleSince) idleSince = now;
1396
- let quiet = 1e9, ambient = false;
1397
- try { [quiet, ambient] = await evaluate(tabId, quietExpr(start)); } catch { await sleep(30); continue; } // document swapping
1522
+ // A route change or a freshly loaded code chunk means a new view is being built: it often goes
1523
+ // quiet for a beat (JS executing, timers) before rendering, so ask for a longer quiet period.
1524
+ const transition = (w.routeAt || 0) >= start - 50 || (w.lastScript || 0) >= start - 50;
1525
+ const needQuiet = transition ? 250 : 60, quietCap = transition ? 1500 : 600;
1526
+ let quiet = 1e9, ambient = false, animNew = 0;
1527
+ const tq = Date.now();
1528
+ try { [quiet, ambient, animNew] = await evaluate(tabId, quietExpr(start)); } catch { await sleep(30); continue; } // document swapping
1529
+ // If even this tiny probe waited >50ms to run, the page's main thread was busy (JS/render):
1530
+ // not quiet, whatever the observers have reported so far.
1531
+ if (Date.now() - tq > 50) quiet = 0;
1398
1532
  // DOM still changing: wait for 60ms of quiet, capped at 600ms after the network went idle, or
1399
1533
  // 120ms on a page that was already constantly mutating (clocks, tickers, carousels) before us.
1400
- if (quiet < 60 && now - idleSince < (ambient ? 120 : 600)) { await sleep(Math.max(10, 60 - quiet)); continue; }
1534
+ if (animNew && now - idleSince < 1500) { await sleep(40); continue; } // the action's own animations
1535
+ if (quiet < needQuiet && now - idleSince < (ambient ? 120 : quietCap)) { await sleep(Math.max(10, Math.min(60, needQuiet - quiet))); continue; }
1401
1536
  break;
1402
1537
  }
1403
1538
  }
@@ -1474,6 +1609,27 @@ async function pressKey(tabId, combo) {
1474
1609
  return null;
1475
1610
  }
1476
1611
 
1612
+ /* ------------------------- Tabs opened by an action -------------------------- *
1613
+ * target=_blank links and window.open() open a NEW tab: the agent would otherwise keep looking at
1614
+ * the old one and see "page did NOT change". A tab opened by the tab we're acting on is adopted
1615
+ * into the session (and its group) and becomes the one we drive. */
1616
+ const openedBy = new Map(); // opener tabId -> newest tab it opened during an action
1617
+ chrome.tabs.onCreated.addListener((t) => { if (t.openerTabId != null && acting.has(t.openerTabId)) openedBy.set(t.openerTabId, t.id); });
1618
+
1619
+ async function followNewTab(session, tabId) {
1620
+ const nt = openedBy.get(tabId);
1621
+ openedBy.delete(tabId);
1622
+ if (nt == null) return null;
1623
+ const s = sessionState(session);
1624
+ s.activeTabId = nt; s.createdTabs.add(nt); tabOwner.set(nt, session); persistState();
1625
+ await ensureGroup(s, nt);
1626
+ // Let it get past about:blank and load, then read it like any navigation.
1627
+ 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); }
1628
+ await attach(nt);
1629
+ await settle(nt, 3000);
1630
+ return nt;
1631
+ }
1632
+
1477
1633
  /* ---------------------------------- WebMCP --------------------------------- *
1478
1634
  * Pages that implement WebMCP (navigator.modelContext.registerTool / <form toolname>) describe
1479
1635
  * their own actions with JSON schemas. Calling one is a single deterministic step instead of a
@@ -1534,6 +1690,58 @@ async function invokeTool(tabId, name, input) {
1534
1690
  return `tool ${name}: ${res.status}${res.errorText ? ` (${res.errorText})` : ''}${out ? `\n output (untrusted page data): ${out.slice(0, 2000).replace(/\n/g, '\n ')}` : ''}`;
1535
1691
  }
1536
1692
 
1693
+ // Wait until the target's box stops moving (an animation, a sliding panel) before clicking it —
1694
+ // Playwright's "stable" check. Capped: a forever-spinning element is clicked anyway.
1695
+ async function waitStable(target, ref) {
1696
+ const R = JSON.stringify(String(ref));
1697
+ try {
1698
+ await evaluate(target, `new Promise(function(res){
1699
+ var c=window.__pawbrowse, e=c&&c.get&&c.get(${R}), s=e&&(c.surface?c.surface(e):e); if(!s) return res(0);
1700
+ var key=function(){ var r=s.getBoundingClientRect(); return [r.x,r.y,r.width,r.height].map(Math.round).join(','); };
1701
+ // Moving = its box changed, or a finite animation is still running on it or an ancestor (an
1702
+ // orbit/slide can pause at its turning points, which looks "stable" for a frame or two).
1703
+ 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; };
1704
+ // Fast path: nothing animating and the box unchanged over one frame -> go. Once it has been seen
1705
+ // moving, require two quiet frames in a row.
1706
+ // Nothing animating on it or its ancestors: click now (no frame wait). JS-driven motion is
1707
+ // still caught by the click-time hit check.
1708
+ if(!animating()) return res(1);
1709
+ var last=key(), same=0, moved=true, t0=performance.now();
1710
+ (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); })();
1711
+ })`);
1712
+ } catch {}
1713
+ }
1714
+
1715
+ // Click-time hit check (Playwright's hit-target interceptor): the FIRST trusted pointer/mouse event
1716
+ // of the click must land on the intended element (or what counts as it). If it would land on
1717
+ // something else — an overlay that appeared, a re-render — the whole gesture is stopped before the
1718
+ // wrong element sees it, and the op reports what intercepted it.
1719
+ async function armClick(target, ref) {
1720
+ const R = JSON.stringify(String(ref));
1721
+ return evaluate(target, `(function(){
1722
+ 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;
1723
+ if(c.__arm) c.__arm.off();
1724
+ var st={ok:null, by:null}, types=['pointerdown','mousedown','pointerup','mouseup','click','auxclick','dblclick','contextmenu'];
1725
+ var hosted=function(f){ for(var r=s.getRootNode(); r && r.host; r=r.host.getRootNode()){ if(r.host===f) return true; } return false; };
1726
+ // The page REPLACED the target during the gesture (re-render on hover/mousedown): the new element
1727
+ // with the same identity (role + label) is the same control to a user.
1728
+ 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; };
1729
+ var h=function(ev){
1730
+ if(!ev.isTrusted) return;
1731
+ if(st.ok===null){ var f=(ev.composedPath&&ev.composedPath()[0])||ev.target; if(f && f.nodeType!==1) f=f.parentElement;
1732
+ st.ok = c.accepts(s,f) || hosted(f) || (e!==s && c.accepts(e,f)) || replaced(f);
1733
+ 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'; }
1734
+ if(st.ok===false){ ev.preventDefault(); ev.stopImmediatePropagation(); }
1735
+ };
1736
+ types.forEach(function(t){ window.addEventListener(t, h, true); });
1737
+ c.__arm={st:st, off:function(){ types.forEach(function(t){ window.removeEventListener(t, h, true); }); c.__arm=null; }};
1738
+ return true;
1739
+ })()`).catch(() => false);
1740
+ }
1741
+ async function disarmClick(target) {
1742
+ 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);
1743
+ }
1744
+
1537
1745
  const dragIntercepts = new Map(); // tabId -> drag data captured by Input.dragIntercepted
1538
1746
  chrome.debugger.onEvent.addListener((source, method, params) => {
1539
1747
  if (method === 'Input.dragIntercepted' && source.tabId != null) dragIntercepts.set(source.tabId, params.data);
@@ -1598,12 +1806,16 @@ async function runOp(tabId, op) {
1598
1806
  return `dialog: ${d.type} "${String(d.message || '').slice(0, 120)}" → ${op.accept ? 'accepted' : 'dismissed'}`;
1599
1807
  }
1600
1808
  case 'click': {
1809
+ await waitStable(T, REF);
1601
1810
  const r = await resolveHit(T, REF);
1602
1811
  if (r.error) return `${op.ref}: ${r.error}`;
1603
1812
  const p = await top(r.x, r.y);
1604
1813
  if (p.covered) return `${op.ref}: element is covered by another element of the page around its frame (dismiss the overlay first)`;
1605
1814
  const button = ['right', 'middle'].includes(op.button) ? op.button : 'left';
1815
+ const armed = await armClick(T, REF);
1606
1816
  await clickAt(tabId, p.x, p.y, { button, count: op.count });
1817
+ const verdict = armed ? await disarmClick(T) : null;
1818
+ 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)`;
1607
1819
  return `${op.count > 1 ? `${op.count}x ` : ''}${button !== 'left' ? `${button}-` : ''}click ${op.ref}`;
1608
1820
  }
1609
1821
  case 'hover': {
@@ -1641,7 +1853,7 @@ async function runOp(tabId, op) {
1641
1853
  return `click_text "${op.text}"`;
1642
1854
  }
1643
1855
  case 'type': {
1644
- const r = await resolveHit(T, REF, { fill: true });
1856
+ const r = await resolveHit(T, REF, { fill: true, text: op.text ?? '' });
1645
1857
  if (r.error) return `${op.ref}: ${r.error}`;
1646
1858
  if (r.set) {
1647
1859
  const sv = await setValue(T, REF, op.text);
@@ -1695,6 +1907,19 @@ async function runOp(tabId, op) {
1695
1907
  const u = await uploadFiles(T, REF, paths);
1696
1908
  return u.error ? `${op.ref}: ${u.error}` : `upload ${op.ref} (${paths.length} file${paths.length > 1 ? 's' : ''})`;
1697
1909
  }
1910
+ case 'back': case 'forward': {
1911
+ const { currentIndex, entries } = await sendCdp(tabId, 'Page.getNavigationHistory');
1912
+ const to = entries[currentIndex + (op.op === 'back' ? -1 : 1)];
1913
+ if (!to) return `${op.op}: no ${op.op === 'back' ? 'previous' : 'next'} page in this tab's history`;
1914
+ const w = tabWatch(tabId); w.navStart = Date.now(); w.navDone = 0; w.committed = false; w.navReq = null;
1915
+ await sendCdp(tabId, 'Page.navigateToHistoryEntry', { entryId: to.id });
1916
+ return `${op.op} → ${String(to.url).slice(0, 100)}`;
1917
+ }
1918
+ case 'reload': {
1919
+ const w = tabWatch(tabId); w.navStart = Date.now(); w.navDone = 0; w.committed = false; w.navReq = null;
1920
+ await sendCdp(tabId, 'Page.reload', {});
1921
+ return 'reload';
1922
+ }
1698
1923
  case 'click_xy': {
1699
1924
  // Coordinates from the last browser_screenshot image (converted to CSS px).
1700
1925
  const k = shotScale.get(tabId) || 1;
@@ -1855,7 +2080,9 @@ async function handleCommand(cmd, args, token, session) {
1855
2080
  const tabId = await resolveTabId(session, args, 'inspect');
1856
2081
  await attach(tabId);
1857
2082
  assertNoOpenDialog(tabId);
1858
- return observe(tabId, args);
2083
+ const t = await observe(tabId, args);
2084
+ const dl = takeDialogLog(tabId);
2085
+ return dl ? `${dl}\n${t}` : t;
1859
2086
  }
1860
2087
  case 'read': {
1861
2088
  const tabId = await resolveTabId(session, args, 'inspect');
@@ -1905,7 +2132,12 @@ async function handleCommand(cmd, args, token, session) {
1905
2132
  // the caller think they failed and retry them.
1906
2133
  let table;
1907
2134
  try { table = await observe(tabId); } catch { table = null; }
1908
- const changed = before == null || after == null || before !== after || table == null || seen == null || tableBody(table) !== seen;
2135
+ const nt = await followNewTab(session, tabId).catch(() => null);
2136
+ if (nt != null) {
2137
+ const t2 = await observe(nt).catch(() => '(new tab not readable yet: observe next)');
2138
+ 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}`;
2139
+ }
2140
+ const changed = before == null || after == null || before !== after || table == null || seen == null || tableBody(table) !== seen || dialogLog.has(tabId);
1909
2141
  const note = changed ? 'page changed' : 'page did NOT change (if you expected an effect, the action may not have worked — try a different target)';
1910
2142
  if (table == null) {
1911
2143
  return `ran ${ops.length} op(s) [${note}]:\n${logLines.join('\n')}\n${takeDialogLog(tabId)}\n(ops executed; the page is navigating and could not be read yet — call browser_observe next. Do NOT re-run these ops.)`;
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "manifest_version": 3,
3
3
  "name": "PawBrowse",
4
- "version": "0.6.0",
4
+ "version": "0.6.2",
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
  },
@@ -208,7 +229,13 @@ async function callTool(name, args) {
208
229
  case 'browser_navigate':return textResult(await callExtension('navigate', args));
209
230
  case 'browser_observe': return textResult(await callExtension('observe', args));
210
231
  case 'browser_read': return textResult(await callExtension('read', args));
211
- case 'browser_act': return textResult(await callExtension('act', args));
232
+ case 'browser_act': {
233
+ // Enforce the upload policy here: the extension can't see the filesystem, the server can.
234
+ for (const op of (args && Array.isArray(args.ops) ? args.ops : [])) {
235
+ if (op && op.op === 'upload') op.paths = [].concat(op.paths ?? op.path ?? []).map(checkUploadPath);
236
+ }
237
+ return textResult(await callExtension('act', args));
238
+ }
212
239
  case 'browser_assert': return textResult(await callExtension('assert', args));
213
240
  case 'browser_screenshot': {
214
241
  const r = await callExtension('screenshot', args);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pawbrowse",
3
- "version": "0.6.0",
3
+ "version": "0.6.2",
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",