pawbrowse 0.6.2 → 0.6.4

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
@@ -136,30 +136,27 @@ Tips:
136
136
  ## Benchmark
137
137
 
138
138
  <p align="center">
139
- <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>
140
140
  </p>
141
141
 
142
- <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>
143
-
144
- Because PawBrowse keeps **stable element refs** and its `navigate`/`act` already return the fresh
145
- table, the agent clicks a known target in **one** round trip. Screenshot/accessibility-tree drivers
146
- do **perceive-then-act** — a read (or screenshot) *then* a click — paying an extra agent round trip
147
- and a larger payload every action.
148
-
149
- Measured task: click 5 different section links on the same Wikipedia page, averaged, same machine,
150
- 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>
151
146
 
152
- | | PawBrowse | Claude-in-Chrome |
147
+ | | PawBrowse | Claude in Chrome |
153
148
  | --- | --- | --- |
154
- | Calls per click | **1** (`act` by stable ref) | 2 (`read_page` → click) |
155
- | Avg wall-clock per click | **~7.6 s** | ~17.0 s |
156
- | Perception payload | compact, viewport-only | full a11y tree w/ URLs (up to 50 KB) |
157
-
158
- > **Honest caveat:** with Claude as the shared brain, absolute wall-clock is dominated by agent
159
- > latency and is noisy — treat the **~2.2× ratio** as the signal, not the exact seconds. The win is
160
- > *structural* (fewer round trips + smaller payloads), which also means fewer tokens per step. It's
161
- > **not** the sub-second speed of a small, dedicated click-picking model — PawBrowse trades that
162
- > 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.
163
160
 
164
161
  ## How it compares
165
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();
@@ -732,6 +733,18 @@ const SNAPSHOT = `(function(seed, opts){
732
733
  var asel=e.getAttribute('aria-selected'); if(asel!=null) base.selected=(asel==='true');
733
734
  if(e.required || e.getAttribute('aria-required')==='true') base.required=true;
734
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
+ }
735
748
  // Only surface validation errors on fields the user (or agent) has put a value in, or that the
736
749
  // page itself flags, so an untouched required form isn't a wall of warnings.
737
750
  if(e.getAttribute('aria-invalid')==='true') base.invalid='invalid';
@@ -782,10 +795,75 @@ const SNAPSHOT = `(function(seed, opts){
782
795
  var omitted=Math.max(0, inView.length-250); inView.splice(250);
783
796
  // Keep the 25 off-screen controls NEAREST the visible area (not the first 25 in DOM order, which
784
797
  // for a scrolled list are the rows furthest behind), then restore page order.
785
- 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
786
799
  var offMore=Math.max(0, offView.length-OFFCAP)+farOff;
787
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
788
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(_){}
789
867
  // The nearest ancestor text that isn't just the label itself: which row/item a control is in.
790
868
  cache.ctxOf=function(el, lab){ return ctxOf(el, lab); };
791
869
  function ctxOf(el, lab){
@@ -1199,12 +1277,13 @@ async function observe(tabId, opts) {
1199
1277
 
1200
1278
  // Re-resolve a ref to its live element, re-check it, and hit-test the center
1201
1279
  // (elementFromPoint containment) so we never click a stale/covered/wrong target.
1202
- async function resolveHit(tabId, ref, opts) {
1280
+ async function resolveHit(tabId, ref, opts, _retries) {
1281
+ _retries = _retries || 0;
1203
1282
  const forFill = opts && opts.fill ? 'true' : 'false';
1204
1283
  const noScroll = opts && opts.noScroll ? 'true' : 'false', noHit = opts && opts.noHit ? 'true' : 'false';
1205
1284
  const TXT = opts && opts.text != null ? JSON.stringify(String(opts.text)) : 'null';
1206
1285
  const R = JSON.stringify(String(ref));
1207
- return evaluate(tabId, `(function(){
1286
+ const result = await evaluate(tabId, `(function(){
1208
1287
  var c=window.__pawbrowse; if(!c||!c.byId) return {error:'no snapshot yet; observe first'};
1209
1288
  if(c.byId[${R}]==null) return {error:'unknown ref (observe again)'};
1210
1289
  var e=c.get?c.get(${R}):null;
@@ -1260,6 +1339,27 @@ async function resolveHit(tabId, ref, opts) {
1260
1339
  if(!p.hit) return {error:'element is covered by another element (dismiss the overlay/dialog first)'};
1261
1340
  return {x:p.x, y:p.y};
1262
1341
  })()`);
1342
+ // Any "(observe again)"-flavored failure can mean the page is mid-re-render — from a just-prior
1343
+ // action, or from something async and independent of us entirely (a sidebar widget that hydrates
1344
+ // on its own timer) — rather than the target being truly gone. Up to two free retries with a short
1345
+ // growing delay, transparent to the caller: costs nothing on success, and still surfaces a real
1346
+ // failure once retries run out.
1347
+ //
1348
+ // 'unknown ref' means byId[ref] itself is missing — there is no fingerprint left to match against,
1349
+ // so only a fresh full snapshot() (which runs the "gone element -> matching new element" rebind for
1350
+ // EVERY ref) has any chance of reviving it. For every other case (element/row disconnected or
1351
+ // changed), byId[ref]'s fingerprint is still intact, and c.get()'s own live-DOM fallback (above)
1352
+ // already re-scans the page fresh on every call — so we must NOT call snapshot() here: a full
1353
+ // rebuild resets byId/guards/fps for ALL refs from whatever's on the page at that instant, and if it
1354
+ // happens to land while our target is still absent (e.g. mid-hydration), it erases the very
1355
+ // fingerprint a LATER, successful retry would need — permanently orphaning a ref that would
1356
+ // otherwise have recovered on its own.
1357
+ if(result && result.error && /observe again/i.test(result.error) && _retries < 2){
1358
+ await sleep(_retries === 0 ? 120 : 300);
1359
+ if(result.error === 'unknown ref (observe again)'){ try { await snapshot(tabId, 1); } catch {} }
1360
+ return resolveHit(tabId, ref, opts, _retries + 1);
1361
+ }
1362
+ return result;
1263
1363
  }
1264
1364
 
1265
1365
  // Set the value of a date/time/month/week/color/range input the way a user's picker would: via the
@@ -2145,6 +2245,25 @@ async function handleCommand(cmd, args, token, session) {
2145
2245
  return `ran ${ops.length} op(s) [${note}]:\n${logLines.join('\n')}\n${takeDialogLog(tabId)}\n${(changed && deltaTable(seenFull, table)) || table}`;
2146
2246
  });
2147
2247
  }
2248
+ case 'peek': {
2249
+ // Dev-only frame capture for recordings: screenshot ANY tab by id without adopting it into a
2250
+ // session, grouping it, or enabling domains on it (so another tool driving that tab — e.g. for
2251
+ // a side-by-side benchmark — is not disturbed). Attaches the debugger only if needed.
2252
+ const tabId = Number(args.tabId);
2253
+ let t; try { t = await chrome.tabs.get(tabId); } catch { throw new Error(`tab ${args.tabId} not found`); }
2254
+ if (restrictedPage(t.url)) throw new Error('that tab is a browser page that cannot be captured');
2255
+ if (!attachedTabs.has(tabId)) {
2256
+ 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(); }));
2257
+ peekOnly.add(tabId);
2258
+ }
2259
+ const q = Math.max(20, Math.min(90, Number(args.quality) || 60));
2260
+ const shot = await sendCdp(tabId, 'Page.captureScreenshot', { format: 'jpeg', quality: q });
2261
+ return { data: shot.data, t: Date.now(), url: t.url };
2262
+ }
2263
+ case 'peek_end': {
2264
+ 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(); })); }
2265
+ return { ok: true };
2266
+ }
2148
2267
  case 'screenshot': {
2149
2268
  const tabId = await resolveTabId(session, args, 'inspect');
2150
2269
  await attach(tabId);
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "manifest_version": 3,
3
3
  "name": "PawBrowse",
4
- "version": "0.6.2",
4
+ "version": "0.6.4",
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
@@ -211,6 +211,16 @@ const TOOLS = [
211
211
  },
212
212
  ];
213
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
+
214
224
  function textResult(obj) {
215
225
  const text = typeof obj === 'string' ? obj : JSON.stringify(obj, null, 2);
216
226
  return { content: [{ type: 'text', text }] };
@@ -237,6 +247,10 @@ async function callTool(name, args) {
237
247
  return textResult(await callExtension('act', args));
238
248
  }
239
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
+ }
240
254
  case 'browser_screenshot': {
241
255
  const r = await callExtension('screenshot', args);
242
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.2",
3
+ "version": "0.6.4",
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": {