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 +17 -20
- package/extension/background.js +123 -4
- package/extension/manifest.json +1 -1
- package/mcp/server.mjs +14 -0
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -136,30 +136,27 @@ Tips:
|
|
|
136
136
|
## Benchmark
|
|
137
137
|
|
|
138
138
|
<p align="center">
|
|
139
|
-
<img src="assets/
|
|
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"><
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
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
|
|
147
|
+
| | PawBrowse | Claude in Chrome |
|
|
153
148
|
| --- | --- | --- |
|
|
154
|
-
|
|
|
155
|
-
|
|
|
156
|
-
|
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
>
|
|
160
|
-
>
|
|
161
|
-
>
|
|
162
|
-
>
|
|
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
|
|
package/extension/background.js
CHANGED
|
@@ -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();
|
|
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:
|
|
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
|
-
|
|
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);
|
package/extension/manifest.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"manifest_version": 3,
|
|
3
3
|
"name": "PawBrowse",
|
|
4
|
-
"version": "0.6.
|
|
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.
|
|
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": {
|