pawbrowse 0.5.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,833 @@
1
+ // PawBrowse background service worker.
2
+ // Connects to the local pawbrowse broker over WebSocket and drives the user's real tabs via
3
+ // chrome.debugger (CDP) — no remote debug port, no relaunch needed.
4
+ //
5
+ // MULTI-SESSION: the broker multiplexes many editor/Claude sessions over this one connection.
6
+ // Every command carries a `session` id; each session gets its OWN tab group (🐾 PawBrowse,
7
+ // with its own color) and drives only its own tab, so sessions run concurrently without
8
+ // fighting over one tab. Commands are serialized PER SESSION (not globally), so different
9
+ // sessions' tabs are driven in parallel.
10
+ //
11
+ // The element-table perception and action-execution techniques (accessible-name
12
+ // resolution, checkVisibility filtering, viewport-center hit-testing, stable node
13
+ // identity, robust fill) are adapted from browser-use/jev-ultrafast (MIT License).
14
+
15
+ const DEFAULT_PORT = 10577;
16
+ const IS_MAC = (navigator.userAgent || '').indexOf('Macintosh') >= 0;
17
+ let ws = null;
18
+ let reconnectTimer = null;
19
+
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
22
+ const sessions = new Map(); // sessionId -> { activeTabId, createdTabs:Set, groupId, num, color }
23
+ const tabOwner = new Map(); // tabId -> sessionId (so sessions don't steal each other's tabs)
24
+ const chains = new Map(); // sessionId -> Promise (serialize commands within a session)
25
+ let sessionCounter = 0;
26
+ let colorCursor = 0;
27
+ // PawBrowse's own group identity — deliberately NOT Claude-in-Chrome's blue "Claude" group.
28
+ // Distinct emoji (🐾) + a rotating non-blue palette so concurrent sessions are visually distinct.
29
+ const GROUP_COLORS = ['orange', 'cyan', 'purple', 'pink', 'green', 'yellow', 'red', 'grey'];
30
+
31
+ const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
32
+
33
+ /* --------- persistence across service-worker restarts (avoid orphan tabs/groups) --------- *
34
+ * MV3 kills the service worker under memory pressure, wiping the maps above. Without this, a
35
+ * restart would abandon each session's tab + "🐾 PawBrowse" group (and endSession would no-op),
36
+ * leaking one tab/group per session. We mirror the minimal session→tab/group map to
37
+ * chrome.storage.session (cleared when the browser closes) and rehydrate on startup. All of it is
38
+ * best-effort: if storage is unavailable, behaviour degrades to in-memory only. */
39
+ let persistTimer = null;
40
+ function persistState() {
41
+ if (persistTimer) return;
42
+ persistTimer = setTimeout(async () => {
43
+ persistTimer = null;
44
+ try {
45
+ const ser = {};
46
+ for (const [k, v] of sessions) ser[k] = { activeTabId: v.activeTabId, createdTabs: [...v.createdTabs], groupId: v.groupId, num: v.num, color: v.color };
47
+ await chrome.storage.session.set({ pawbrowse_state: { sessions: ser, tabOwner: [...tabOwner], sessionCounter, colorCursor } });
48
+ } catch {}
49
+ }, 250);
50
+ persistTimer.unref?.();
51
+ }
52
+ const rehydrated = (async () => {
53
+ try {
54
+ const { pawbrowse_state: st } = await chrome.storage.session.get('pawbrowse_state');
55
+ if (!st) return;
56
+ for (const [k, v] of Object.entries(st.sessions || {})) {
57
+ if (!sessions.has(k)) sessions.set(k, { activeTabId: v.activeTabId, createdTabs: new Set(v.createdTabs || []), groupId: v.groupId, num: v.num, color: v.color });
58
+ }
59
+ for (const [t, sess] of (st.tabOwner || [])) if (!tabOwner.has(t)) tabOwner.set(t, sess);
60
+ if (typeof st.sessionCounter === 'number') sessionCounter = Math.max(sessionCounter, st.sessionCounter);
61
+ if (typeof st.colorCursor === 'number') colorCursor = Math.max(colorCursor, st.colorCursor);
62
+ } catch {}
63
+ })();
64
+
65
+ function sessionState(session) {
66
+ const key = session || '_default';
67
+ let s = sessions.get(key);
68
+ if (!s) {
69
+ s = { activeTabId: null, createdTabs: new Set(), groupId: null, num: ++sessionCounter, color: GROUP_COLORS[colorCursor++ % GROUP_COLORS.length] };
70
+ sessions.set(key, s);
71
+ persistState();
72
+ }
73
+ return s;
74
+ }
75
+
76
+ // Put a tab into this session's tab group, creating the group (with PawBrowse's own name +
77
+ // color + 🐾) on first use. Best-effort: grouping can fail across windows — never fatal.
78
+ async function ensureGroup(s, tabId) {
79
+ try {
80
+ if (s.groupId != null) {
81
+ try { await chrome.tabs.group({ groupId: s.groupId, tabIds: [tabId] }); return; }
82
+ catch { s.groupId = null; } // stale group (e.g. all its tabs closed) — recreate below
83
+ }
84
+ const groupId = await chrome.tabs.group({ tabIds: [tabId] });
85
+ s.groupId = groupId; persistState();
86
+ const title = s.num > 1 ? `🐾 PawBrowse ${s.num}` : '🐾 PawBrowse';
87
+ await chrome.tabGroups.update(groupId, { title, color: s.color });
88
+ } catch {}
89
+ }
90
+
91
+ async function getPort() {
92
+ try { const { port } = await chrome.storage.local.get('port'); return port || DEFAULT_PORT; }
93
+ catch { return DEFAULT_PORT; }
94
+ }
95
+
96
+ /* ------------------------- WebSocket to the bridge ------------------------- */
97
+
98
+ async function connect() {
99
+ if (ws && (ws.readyState === WebSocket.OPEN || ws.readyState === WebSocket.CONNECTING)) return;
100
+ const port = await getPort();
101
+ try {
102
+ ws = new WebSocket(`ws://127.0.0.1:${port}`);
103
+ } catch { scheduleReconnect(); return; }
104
+
105
+ ws.onopen = () => {
106
+ ws.send(JSON.stringify({ type: 'hello', ext: chrome.runtime.id }));
107
+ setBadge('on');
108
+ };
109
+ ws.onmessage = (ev) => {
110
+ let msg; try { msg = JSON.parse(ev.data); } catch { return; }
111
+ if (!msg.cmd) return;
112
+ const sock = ws;
113
+ const session = msg.session || '_default';
114
+ // Serialize commands PER SESSION (overlapping calls in one session queue instead of racing
115
+ // that session's tab); different sessions run concurrently on their own tabs. Each command is
116
+ // bounded by a 25s timeout AND a cancellation token so a stalled multi-step command (act/
117
+ // navigate) stops issuing further CDP ops instead of leaking work into the next one.
118
+ const token = { cancelled: false };
119
+ const prev = chains.get(session) || Promise.resolve();
120
+ const run = prev.then(async () => {
121
+ let timer;
122
+ try {
123
+ const result = await Promise.race([
124
+ handleCommand(msg.cmd, msg.args || {}, token, session),
125
+ new Promise((_, rej) => { timer = setTimeout(() => { token.cancelled = true; rej(new Error('command timed out in extension after 25s')); }, 25000); }),
126
+ ]);
127
+ clearTimeout(timer);
128
+ sock.send(JSON.stringify({ id: msg.id, ok: true, result }));
129
+ } catch (e) {
130
+ clearTimeout(timer);
131
+ try { sock.send(JSON.stringify({ id: msg.id, ok: false, error: String(e && e.message || e) })); } catch {}
132
+ }
133
+ });
134
+ chains.set(session, run.catch(() => {}));
135
+ };
136
+ ws.onclose = () => { setBadge('off'); scheduleReconnect(); };
137
+ ws.onerror = () => { try { ws.close(); } catch {} };
138
+ }
139
+
140
+ function scheduleReconnect() {
141
+ if (reconnectTimer) return;
142
+ reconnectTimer = setTimeout(() => { reconnectTimer = null; connect(); }, 1500);
143
+ }
144
+
145
+ function setBadge(state) {
146
+ try {
147
+ chrome.action.setBadgeText({ text: state === 'on' ? '●' : '' });
148
+ chrome.action.setBadgeBackgroundColor({ color: state === 'on' ? '#16a34a' : '#999999' });
149
+ } catch {}
150
+ }
151
+
152
+ /* ------------------------------- CDP helpers ------------------------------ */
153
+
154
+ function sendCdp(tabId, method, params = {}) {
155
+ return new Promise((resolve, reject) => {
156
+ chrome.debugger.sendCommand({ tabId }, method, params, (res) => {
157
+ const err = chrome.runtime.lastError;
158
+ if (err) reject(new Error(err.message)); else resolve(res);
159
+ });
160
+ });
161
+ }
162
+
163
+ async function evaluate(tabId, expression) {
164
+ const r = await sendCdp(tabId, 'Runtime.evaluate', { expression, returnByValue: true, awaitPromise: true });
165
+ if (r && r.exceptionDetails) {
166
+ throw new Error(r.exceptionDetails.exception?.description || r.exceptionDetails.text || 'evaluation error');
167
+ }
168
+ return r.result.value;
169
+ }
170
+
171
+ // Attach to a SPECIFIC tab and keep it attached (many tabs can be attached at once, one per
172
+ // concurrent session). We never detach another tab here — that would break a sibling session.
173
+ async function attach(tabId) {
174
+ if (attachedTabs.has(tabId)) return;
175
+ // "Already attached" can mean OUR own attachment survived a service-worker restart (fine) OR a
176
+ // FOREIGN debugger owns the tab — DevTools or another extension (not fine: we can't drive it).
177
+ const already = await new Promise((resolve, reject) => {
178
+ chrome.debugger.attach({ tabId }, '1.3', () => {
179
+ const err = chrome.runtime.lastError;
180
+ if (err) {
181
+ if (/already attached/i.test(err.message)) { resolve(true); return; }
182
+ reject(new Error(err.message)); return;
183
+ }
184
+ resolve(false);
185
+ });
186
+ });
187
+ attachedTabs.add(tabId);
188
+ if (already) {
189
+ // Probe: if we truly hold the session this succeeds; if a foreign debugger owns it, it throws.
190
+ try { await sendCdp(tabId, 'Runtime.evaluate', { expression: '1', returnByValue: true }); }
191
+ catch { attachedTabs.delete(tabId); throw new Error('another debugger is attached to this tab (close DevTools or another extension) so PawBrowse cannot drive it'); }
192
+ }
193
+ await sendCdp(tabId, 'Runtime.enable', {}).catch(() => {});
194
+ await sendCdp(tabId, 'Page.enable', {}).catch(() => {});
195
+ await sendCdp(tabId, 'DOM.enable', {}).catch(() => {});
196
+ // Make the tab behave as focused even when it's a background tab, so focus/blur, rendering,
197
+ // and focus-dependent menus/dropdowns work while driving (the same approach Playwright uses for
198
+ // backgrounded pages). A hidden tab still throttles requestAnimationFrame, so our waits use
199
+ // setTimeout/setInterval, not rAF.
200
+ await sendCdp(tabId, 'Emulation.setFocusEmulationEnabled', { enabled: true }).catch(() => {});
201
+ }
202
+
203
+ function detach(tabId) {
204
+ return new Promise((resolve) => chrome.debugger.detach({ tabId }, () => { void chrome.runtime.lastError; attachedTabs.delete(tabId); resolve(); }));
205
+ }
206
+
207
+ chrome.debugger.onDetach.addListener((source) => { if (source.tabId != null) attachedTabs.delete(source.tabId); });
208
+
209
+ // If a driven tab is closed (by the user or by us), forget it everywhere so a session doesn't
210
+ // keep pointing at a dead tab.
211
+ chrome.tabs.onRemoved.addListener((tabId) => {
212
+ attachedTabs.delete(tabId);
213
+ const owner = tabOwner.get(tabId);
214
+ tabOwner.delete(tabId);
215
+ if (owner != null) {
216
+ const s = sessions.get(owner);
217
+ if (s) { s.createdTabs.delete(tabId); if (s.activeTabId === tabId) s.activeTabId = null; }
218
+ }
219
+ persistState();
220
+ });
221
+
222
+ async function activeTab() {
223
+ const [t] = await chrome.tabs.query({ active: true, lastFocusedWindow: true });
224
+ return t || null;
225
+ }
226
+
227
+ // Browser-internal pages and the Web Store forbid CDP/debugger driving. Applied to BOTH the
228
+ // active tab and an explicitly-passed tabId so neither path can attach to a restricted page.
229
+ function restrictedPage(url) {
230
+ url = url || '';
231
+ return /^(chrome|edge|about|devtools|chrome-extension|view-source):/i.test(url)
232
+ || /^https?:\/\/chromewebstore\.google\.com/i.test(url)
233
+ || /^https?:\/\/chrome\.google\.com\/webstore/i.test(url);
234
+ }
235
+
236
+ // Resolve which tab THIS session should drive, keeping sessions isolated:
237
+ // - explicit tabId -> use it (validated), adopt into the session's group.
238
+ // - session already has a live tab -> reuse it.
239
+ // - mode 'inspect' (observe/read/act/assert), first tab -> adopt the current active page if it's
240
+ // free (not owned by another session); this preserves "read what I have open" for one session.
241
+ // - otherwise (incl. mode 'navigate' first tab) -> create a NEW tab in the session's group, so we
242
+ // never clobber the user's current page and concurrent sessions never share a tab.
243
+ async function resolveTabId(session, args, mode) {
244
+ const s = sessionState(session);
245
+ if (args.tabId != null) {
246
+ let t;
247
+ try { t = await chrome.tabs.get(args.tabId); }
248
+ catch { throw new Error(`tab ${args.tabId} not found (list tabs with browser_tabs)`); }
249
+ if (restrictedPage(t.url)) throw new Error(`that tab (${t.url}) is a browser page that cannot be driven; use a normal web page`);
250
+ s.activeTabId = t.id; tabOwner.set(t.id, session); persistState();
251
+ await ensureGroup(s, t.id);
252
+ return t.id;
253
+ }
254
+ if (s.activeTabId != null) {
255
+ try { const t = await chrome.tabs.get(s.activeTabId); if (t) return s.activeTabId; }
256
+ catch { s.activeTabId = null; }
257
+ }
258
+ if (mode === 'inspect') {
259
+ const t = await activeTab();
260
+ // Read-and-claim must stay synchronous (no await between the owner read and the set below), so
261
+ // two concurrent sessions can't both adopt the same tab: whichever runs first claims it, and the
262
+ // other sees the claim and falls through to create its own tab.
263
+ const owner = t ? tabOwner.get(t.id) : undefined;
264
+ if (t && !restrictedPage(t.url) && (owner == null || owner === session)) {
265
+ s.activeTabId = t.id; tabOwner.set(t.id, session); persistState();
266
+ await ensureGroup(s, t.id);
267
+ return t.id;
268
+ }
269
+ }
270
+ const nt = await chrome.tabs.create({ url: 'about:blank', active: false });
271
+ s.activeTabId = nt.id; s.createdTabs.add(nt.id); tabOwner.set(nt.id, session); persistState();
272
+ await ensureGroup(s, nt.id);
273
+ return nt.id;
274
+ }
275
+
276
+ // End a session (its controller disconnected): close only the tabs WE created for it, ungroup any
277
+ // tab we merely adopted (the user's own), and drop the group. Never closes the user's tabs.
278
+ async function endSession(session) {
279
+ const s = sessions.get(session || '_default');
280
+ if (!s) return { ended: true };
281
+ for (const tid of s.createdTabs) {
282
+ try { if (attachedTabs.has(tid)) await detach(tid); } catch {}
283
+ try { await chrome.tabs.remove(tid); } catch {}
284
+ attachedTabs.delete(tid); tabOwner.delete(tid);
285
+ }
286
+ if (s.activeTabId != null && !s.createdTabs.has(s.activeTabId)) {
287
+ const tid = s.activeTabId;
288
+ try { if (attachedTabs.has(tid)) await detach(tid); } catch {}
289
+ try { await chrome.tabs.ungroup([tid]); } catch {}
290
+ tabOwner.delete(tid);
291
+ }
292
+ sessions.delete(session || '_default');
293
+ chains.delete(session || '_default');
294
+ persistState();
295
+ return { ended: true };
296
+ }
297
+
298
+ /* ------------------------------ Perception -------------------------------- *
299
+ * Accessible-name resolution, native checkVisibility, viewport-center filtering,
300
+ * stable WeakMap identity, select-options-as-actions, and in-viewport page text.
301
+ * (Techniques credited in the file header.) Each snapshot re-numbers
302
+ * displayed ids (e1..) but backs them with stable node ids (cache.byId) so an
303
+ * action re-resolves the exact element it was chosen from.
304
+ * -------------------------------------------------------------------------- */
305
+
306
+ const SNAPSHOT = `(function(){
307
+ try{
308
+ if(!document.body) return null;
309
+ var cache = window.__pawbrowse || (window.__pawbrowse = {ids:new WeakMap(), nodes:new Map(), next:1, byId:{}});
310
+ function identity(e){ if(!cache.ids.has(e)) cache.ids.set(e, cache.next++); var id=cache.ids.get(e); cache.nodes.set(id,e); return id; }
311
+ cache.nodes.forEach(function(e,id){ if(!e.isConnected) cache.nodes.delete(id); });
312
+ function safe(e){ return ['password','file','hidden'].indexOf(e.type)<0; }
313
+ function visible(e){ return !e.closest('[aria-hidden="true"],[inert]') && e.checkVisibility({checkOpacity:true,checkVisibilityCSS:true}); }
314
+ function name(e,seen){
315
+ seen=seen||new Set();
316
+ if(!e||seen.has(e)) return '';
317
+ seen.add(e);
318
+ var rt=(e.getRootNode&&e.getRootNode())||document; var gid=function(id){try{return rt.getElementById?rt.getElementById(id):document.getElementById(id);}catch(_){return null;}};
319
+ var ref=(e.getAttribute('aria-labelledby')||'').split(/\\s+/).map(function(id){return name(gid(id),seen);}).filter(Boolean).join(' ');
320
+ if(ref) return ref;
321
+ if(e.getAttribute('aria-label')) return e.getAttribute('aria-label');
322
+ var labs=[].slice.call(e.labels||[]).map(function(l){return name(l,seen);}).filter(Boolean).join(' ');
323
+ if(labs) return labs;
324
+ if(['button','submit','reset'].indexOf(e.type)>=0 && e.value) return e.value;
325
+ if(e.getAttribute('alt')) return e.getAttribute('alt');
326
+ var txt = e.tagName==='INPUT' ? '' : [].map.call(e.childNodes,function(n){ return n.nodeType===3 ? n.textContent : (n.nodeType===1 && n.getAttribute('aria-hidden')!=='true' ? name(n,seen) : ''); }).join(' ').trim();
327
+ if(txt) return txt;
328
+ return e.getAttribute('title')||e.getAttribute('placeholder')||'';
329
+ }
330
+ var roles=['button','link','checkbox','radio','switch','tab','menuitem','menuitemradio','menuitemcheckbox','option','gridcell','combobox','textbox','searchbox','spinbutton','slider','treeitem'];
331
+ // Semantic controls + custom clickables: any contenteditable, an inline onclick, or a
332
+ // keyboard-focusable [tabindex] (framework buttons — React-Native-Web Pressables, design-system
333
+ // divs — often expose only these). cursor:pointer clickables are added separately in collect().
334
+ var selector='a[href],button,input,textarea,select,summary,[contenteditable]:not([contenteditable="false"]),[onclick],[tabindex]:not([tabindex="-1"]),'+roles.map(function(r){return '[role="'+r+'"]';}).join(',');
335
+ function role(e){
336
+ var explicit=e.getAttribute('role');
337
+ if(roles.indexOf(explicit)>=0) return explicit;
338
+ if(e.tagName==='BUTTON'||e.tagName==='SUMMARY') return 'button';
339
+ if(e.tagName==='A') return 'link';
340
+ if(e.tagName==='SELECT') return 'combobox';
341
+ if(e.tagName==='TEXTAREA'||e.isContentEditable) return 'textbox';
342
+ if(e.tagName==='INPUT'){
343
+ if(['checkbox','radio'].indexOf(e.type)>=0) return e.type;
344
+ if(['button','submit','reset','image'].indexOf(e.type)>=0) return 'button';
345
+ if(e.type==='search') return 'searchbox';
346
+ if(e.type==='number') return 'spinbutton';
347
+ if(['text','email','url','tel'].indexOf(e.type)>=0) return 'textbox';
348
+ }
349
+ return null;
350
+ }
351
+ // Semantic guard: a stable fingerprint of an element's MEANING (role/name/value/state).
352
+ // Compared at action time so a silently-relabeled or changed target is rejected.
353
+ // Identity-focused: role + accessible name. Catches a target silently becoming a different
354
+ // control (relabel), while tolerating benign value/checked/expanded churn and same-element
355
+ // multi-op batches.
356
+ cache.guard=function(el){ if(!el) return ''; try{ return [role(el),(name(el)||'').replace(/\\s+/g,' ').trim()].join(String.fromCharCode(1)); }catch(_){ return ''; } };
357
+ // Collect actionable elements across the top document, OPEN shadow roots, and SAME-ORIGIN
358
+ // iframes. dx/dy translate each element's frame-local rect into top-level viewport coordinates
359
+ // (shadow roots share the frame's coords so dx/dy carry through unchanged; iframes add offset).
360
+ function collect(){
361
+ var out=[], seen=new Set(), scanned=0;
362
+ function add(el, dx, dy, clk){ if(seen.has(el)) return; seen.add(el); out.push({el:el, dx:dx, dy:dy, clk:clk}); }
363
+ function walk(root, dx, dy, depth){
364
+ if(depth>12) return;
365
+ var els; try{ els=root.querySelectorAll(selector); }catch(_){ els=[]; }
366
+ for(var a=0;a<els.length;a++) add(els[a], dx, dy, false);
367
+ var all; try{ all=root.querySelectorAll('*'); }catch(_){ all=[]; }
368
+ for(var b=0;b<all.length;b++){
369
+ var n=all[b];
370
+ // Custom/framework clickables (e.g. React-Native-Web Pressable/Touchable, many design
371
+ // systems) render as role-less <div>s but get cursor:pointer. Include the ROOT of each
372
+ // pointer region (its parent is NOT pointer) so we capture the pressable itself, not its
373
+ // inherited-cursor text children. Bounded scan so huge DOMs stay fast.
374
+ if(!seen.has(n) && scanned<8000){
375
+ scanned++;
376
+ try{
377
+ var view=(n.ownerDocument&&n.ownerDocument.defaultView)||window;
378
+ if(view.getComputedStyle(n).cursor==='pointer'){
379
+ var pe=n.parentElement;
380
+ if(!pe || view.getComputedStyle(pe).cursor!=='pointer') add(n, dx, dy, true);
381
+ }
382
+ }catch(_){}
383
+ }
384
+ if(n.shadowRoot) walk(n.shadowRoot, dx, dy, depth+1);
385
+ if(n.tagName==='IFRAME'){ try{
386
+ var idoc=n.contentDocument;
387
+ if(idoc && idoc.body){
388
+ var ir=n.getBoundingClientRect(), cs=(n.ownerDocument.defaultView||window).getComputedStyle(n);
389
+ walk(idoc,
390
+ dx+ir.left+(parseFloat(cs.borderLeftWidth)||0)+(parseFloat(cs.paddingLeft)||0),
391
+ dy+ir.top+(parseFloat(cs.borderTopWidth)||0)+(parseFloat(cs.paddingTop)||0), depth+1);
392
+ }
393
+ }catch(_){} }
394
+ }
395
+ }
396
+ walk(document, 0, 0, 0);
397
+ return out;
398
+ }
399
+ var actions=[], nodes=collect();
400
+ for(var i=0;i<nodes.length;i++){
401
+ var e=nodes[i].el, ox=nodes[i].dx, oy=nodes[i].dy, clk=nodes[i].clk;
402
+ try{
403
+ if(!safe(e)||!visible(e)||e.matches(':disabled')||e.closest('[aria-disabled="true"]')) continue;
404
+ var r=e.getBoundingClientRect(), x=r.x+r.width/2+ox, y=r.y+r.height/2+oy, rname=role(e);
405
+ if(!rname){
406
+ // Role-less custom clickable (cursor:pointer, inline onclick, or focusable [tabindex]).
407
+ // Only accept it if it has a real label and isn't just a wrapper around an actual control,
408
+ // so we don't flood the table with layout containers.
409
+ var ti=e.getAttribute('tabindex');
410
+ if(clk || e.hasAttribute('onclick') || (ti!==null && ti!=='-1')){
411
+ if(!(name(e)||'').trim() || e.querySelector(selector)) continue;
412
+ rname='button';
413
+ }
414
+ }
415
+ if(!rname||r.width<=0||r.height<=0||x<0||y<0||x>=innerWidth||y>=innerHeight) continue;
416
+ if(rname==='gridcell' && e.querySelector('button,[role="button"]')) continue;
417
+ var base={node:identity(e), role:rname, label:(name(e)||rname).replace(/\\s+/g,' ').trim().slice(0,120), x:Math.round(x), y:Math.round(y)};
418
+ var achecked=e.getAttribute('aria-checked');
419
+ if(['checkbox','radio'].indexOf(e.type)>=0) base.checked=!!e.checked;
420
+ else if(achecked!=null) base.checked=(achecked==='true');
421
+ var aexp=e.getAttribute('aria-expanded'); if(aexp!=null) base.expanded=(aexp==='true');
422
+ var asel=e.getAttribute('aria-selected'); if(asel!=null) base.selected=(asel==='true');
423
+ if(e.tagName==='SELECT'){
424
+ base.kind='select';
425
+ base.value=[].map.call(e.selectedOptions,function(o){return o.label;}).join(', ');
426
+ base.options=[].filter.call(e.options,function(o){return !o.disabled && !(o.closest&&o.closest('optgroup[disabled]'));}).map(function(o){return o.label;}).slice(0,40);
427
+ actions.push(base);
428
+ } else {
429
+ var editable=!e.readOnly && e.getAttribute('aria-readonly')!=='true' && (['textbox','searchbox','spinbutton'].indexOf(rname)>=0 || (rname==='combobox' && ['INPUT','TEXTAREA'].indexOf(e.tagName)>=0));
430
+ var value = (['checkbox','radio'].indexOf(e.type)>=0) ? '' : (('value' in e) ? String(e.value) : ((e.isContentEditable||rname==='combobox') ? e.innerText.trim() : ''));
431
+ if(value) base.value=value.slice(0,80);
432
+ base.kind=editable?'fill':'click';
433
+ actions.push(base);
434
+ // For an editable combobox, also offer a plain click to open its popup (not just type).
435
+ if(editable && rname==='combobox'){ actions.push({node:base.node, role:rname, label:'Open '+base.label, x:base.x, y:base.y, kind:'click', expanded:base.expanded}); }
436
+ }
437
+ }catch(_){ continue; }
438
+ }
439
+ // Page text is a best-effort extra — a failure here must NOT discard the element table
440
+ // we already computed above.
441
+ var text='';
442
+ try{
443
+ var words=[], walker=document.createTreeWalker(document.body,NodeFilter.SHOW_TEXT), range=document.createRange(), node, length=0;
444
+ while((node=walker.nextNode()) && length<4000){
445
+ var v=node.textContent.trim(), p=node.parentElement;
446
+ if(!v||!p||p.closest('script,style,noscript,template')||!visible(p)) continue;
447
+ range.selectNodeContents(node); var tr=range.getBoundingClientRect();
448
+ if(tr.width>0&&tr.height>0&&tr.bottom>0&&tr.top<innerHeight&&tr.right>0&&tr.left<innerWidth){ words.push(v); length+=v.length; }
449
+ }
450
+ text=words.join('\\n').slice(0,4000);
451
+ }catch(_){ text=''; }
452
+ var omitted=Math.max(0, actions.length-250); actions.splice(250);
453
+ // Displayed id derives from the STABLE node id (not position), so a reused number can never
454
+ // remap to a different element across observations; duplicates (e.g. combobox Open) get a suffix.
455
+ // Guarded so a getter/DOM quirk while building ids can't blank the whole table.
456
+ try{
457
+ cache.byId={}; cache.guards={}; var used={};
458
+ for(var j=0;j<actions.length;j++){ var bid='e'+actions[j].node, id=bid, kk=2; while(used[id]){ id=bid+'_'+kk; kk++; } used[id]=1; actions[j].id=id; cache.byId[id]=actions[j].node; cache.guards[id]=cache.guard(cache.nodes.get(actions[j].node)); }
459
+ }catch(_){}
460
+ return {url:location.href, title:document.title, scrollY:Math.round(scrollY), scrollH:Math.round(document.documentElement.scrollHeight), text:text, omitted:omitted, actions:actions};
461
+ }catch(_){ return {url:location.href, title:(document&&document.title)||'', scrollY:0, scrollH:0, text:'', omitted:0, actions:[]}; }
462
+ })()`;
463
+
464
+ function formatTable(snap) {
465
+ if (!snap) return '(page not ready)';
466
+ const lines = [];
467
+ lines.push(`${snap.title || '(untitled)'} — ${snap.url}`);
468
+ lines.push(`scroll ${snap.scrollY}/${snap.scrollH} · ${snap.actions.length} controls${snap.omitted ? ` (+${snap.omitted} more; scroll to reveal)` : ''}`);
469
+ for (const a of snap.actions) {
470
+ let flag = ' ';
471
+ if (typeof a.expanded === 'boolean') flag = a.expanded ? '▾' : '▸'; // open / closed
472
+ else if (typeof a.checked === 'boolean') flag = a.checked ? '✓' : '·';
473
+ else if (a.selected === true) flag = '◉';
474
+ let line = `${a.id.padEnd(4)} ${a.kind.padEnd(6)}${flag} "${a.label}"`;
475
+ if (a.value) line += ` ▸ "${a.value}"`;
476
+ if (a.kind === 'select' && a.options && a.options.length) line += ` opts{${a.options.join(' | ')}}`;
477
+ lines.push(line);
478
+ }
479
+ return lines.join('\n');
480
+ }
481
+
482
+ // A page signature to detect whether an action actually changed the page. Includes per-input
483
+ // value/checked/selectedIndex so fills, toggles, and selects register as changes (password
484
+ // values excluded).
485
+ 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])`;
486
+
487
+ // Retry through transient "document is navigating" states so a snapshot taken
488
+ // during a transition settles instead of failing.
489
+ async function snapshot(tabId) {
490
+ let last;
491
+ for (let i = 0; i < 8; i++) {
492
+ try {
493
+ const snap = await evaluate(tabId, SNAPSHOT);
494
+ if (snap) return snap;
495
+ } catch (e) { last = e; }
496
+ await sleep(120);
497
+ }
498
+ if (last) throw last;
499
+ return null;
500
+ }
501
+
502
+ async function observe(tabId) {
503
+ return formatTable(await snapshot(tabId));
504
+ }
505
+
506
+ /* -------------------------------- Actions --------------------------------- */
507
+
508
+ // Re-resolve a ref to its live element, re-check it, and hit-test the center
509
+ // (elementFromPoint containment) so we never click a stale/covered/wrong target.
510
+ async function resolveHit(tabId, ref, opts) {
511
+ const forFill = opts && opts.fill ? 'true' : 'false';
512
+ const R = JSON.stringify(String(ref));
513
+ return evaluate(tabId, `(function(){
514
+ var c=window.__pawbrowse; if(!c||!c.byId) return {error:'no snapshot yet; observe first'};
515
+ var node=c.byId[${R}];
516
+ if(node==null) return {error:'unknown ref (observe again)'};
517
+ var e=c.nodes.get(node);
518
+ if(!e||!e.isConnected) return {error:'element no longer on page (observe again)'};
519
+ if(c.guard && c.guards && c.guards[${R}]!=null && c.guard(e)!==c.guards[${R}]) return {error:'element changed since observe (observe again)'};
520
+ if(e.matches(':disabled')||e.closest('[aria-disabled="true"],[inert]')) return {error:'element is disabled'};
521
+ if(${forFill} && (e.readOnly||e.getAttribute('aria-readonly')==='true')) return {error:'field is read-only'};
522
+ if(${forFill} && !('value' in e) && !e.isContentEditable) return {error:'not an editable field (observe again)'};
523
+ if(!e.checkVisibility({checkOpacity:true,checkVisibilityCSS:true})) return {error:'element not visible'};
524
+ e.scrollIntoView({block:'center',inline:'center'});
525
+ var r=e.getBoundingClientRect(); if(!r.width||!r.height) return {error:'element has no size'};
526
+ // Frame-local center (in the element's own frame viewport)...
527
+ var lx=r.x+r.width/2, ly=r.y+r.height/2;
528
+ // ...plus the offset chain of any ancestor iframes, giving the TOP-LEVEL click point for CDP.
529
+ var dx=0, dy=0, w=(e.ownerDocument&&e.ownerDocument.defaultView), g=0;
530
+ while(w && w.frameElement && g++<12){
531
+ var fe=w.frameElement, fr=fe.getBoundingClientRect(), fcs=fe.ownerDocument.defaultView.getComputedStyle(fe);
532
+ dx+=fr.left+(parseFloat(fcs.borderLeftWidth)||0)+(parseFloat(fcs.paddingLeft)||0);
533
+ dy+=fr.top+(parseFloat(fcs.borderTopWidth)||0)+(parseFloat(fcs.paddingTop)||0);
534
+ w=fe.ownerDocument.defaultView;
535
+ }
536
+ var x=Math.round(lx+dx), y=Math.round(ly+dy);
537
+ if(x<0||y<0||x>=innerWidth||y>=innerHeight) return {error:'element off-screen after scroll'};
538
+ // Hit-test in the element's OWN root (document / shadow root / iframe doc) using frame-local
539
+ // coords, so shadow-DOM and iframe elements aren't falsely reported as covered.
540
+ var root=e.getRootNode(); var efp=(root&&root.elementFromPoint)?root.elementFromPoint(lx,ly):document.elementFromPoint(lx,ly);
541
+ if(!e.contains(efp)) return {error:'element is covered by another element'};
542
+ return {x:x, y:y};
543
+ })()`);
544
+ }
545
+
546
+ // Click the most specific visible element matching text, for custom widgets/menus
547
+ // (dropdowns, flair pickers) whose options aren't standard controls in the table.
548
+ async function centerOfText(tabId, text) {
549
+ return evaluate(tabId, `(function(){
550
+ var target=${JSON.stringify(String(text))}.trim().toLowerCase();
551
+ if(!target) return null;
552
+ var nodes=document.querySelectorAll('a,button,li,span,div,p,label,td,th,[role=button],[role=option],[role=menuitem],[role=tab],[role=radio]');
553
+ var exact=[], partial=[];
554
+ for(var i=0;i<nodes.length;i++){
555
+ var el=nodes[i];
556
+ if((el.textContent||'').toLowerCase().indexOf(target)<0) continue; // cheap pre-filter, no reflow
557
+ var r=el.getBoundingClientRect();
558
+ if(r.width<=0||r.height<=0) continue;
559
+ if(!el.checkVisibility||!el.checkVisibility({checkOpacity:true,checkVisibilityCSS:true})) continue;
560
+ var txt=(el.innerText||el.textContent||'').trim();
561
+ if(!txt) continue;
562
+ var low=txt.toLowerCase(), area=r.width*r.height;
563
+ if(low===target) exact.push({el:el,area:area});
564
+ else if(low.indexOf(target)>=0) partial.push({el:el,area:area});
565
+ }
566
+ var pool=exact.length?exact:partial;
567
+ if(!pool.length) return null;
568
+ pool.sort(function(a,b){return a.area-b.area;});
569
+ var chosen=pool[0].el;
570
+ chosen.scrollIntoView({block:'center',inline:'center'});
571
+ var rr=chosen.getBoundingClientRect();
572
+ var cx=Math.round(rr.left+rr.width/2), cy=Math.round(rr.top+rr.height/2);
573
+ if(cx<0||cy<0||cx>=innerWidth||cy>=innerHeight) return null;
574
+ if(!chosen.contains(document.elementFromPoint(cx,cy))) return null;
575
+ return {x:cx, y:cy};
576
+ })()`);
577
+ }
578
+
579
+ async function clickAt(tabId, x, y) {
580
+ await sendCdp(tabId, 'Input.dispatchMouseEvent', { type: 'mouseMoved', x, y });
581
+ await sendCdp(tabId, 'Input.dispatchMouseEvent', { type: 'mousePressed', x, y, button: 'left', clickCount: 1 });
582
+ await sendCdp(tabId, 'Input.dispatchMouseEvent', { type: 'mouseReleased', x, y, button: 'left', clickCount: 1 });
583
+ }
584
+
585
+ // Wait for the page to settle: two animation frames, or up to ms, whichever first.
586
+ async function settle(tabId, ms) {
587
+ try {
588
+ await evaluate(tabId, `new Promise(function(res){var f=0;function step(){if(++f>=2)return res(1);requestAnimationFrame(step);}requestAnimationFrame(step);setTimeout(function(){res(1);}, ${Number(ms) || 300});})`);
589
+ } catch { await sleep(Number(ms) || 300); }
590
+ }
591
+
592
+ // After typing into a combobox, wait for its autocomplete options to actually render
593
+ // (up to ms) before the next observation, instead of paying a fixed delay.
594
+ async function waitForOptions(tabId, ref, ms) {
595
+ const R = JSON.stringify(String(ref));
596
+ const cap = Number(ms) || 250;
597
+ try {
598
+ // Poll with setInterval + a hard setTimeout cap (NOT requestAnimationFrame): rAF is paused in
599
+ // background tabs, which is the normal case when driving, so an rAF-only wait would hang.
600
+ await evaluate(tabId, `new Promise(function(res){
601
+ var done=false; function fin(){ if(done) return; done=true; try{clearInterval(iv);}catch(_){} res(1); }
602
+ setTimeout(fin, ${cap});
603
+ var c=window.__pawbrowse; var node=(c&&c.byId)?c.byId[${R}]:null; var e=node!=null?c.nodes.get(node):null;
604
+ if(!e || (e.getAttribute('role')||'').toLowerCase()!=='combobox'){ return fin(); }
605
+ var ids=(e.getAttribute('aria-controls')||e.getAttribute('aria-owns')||'').split(/\\s+/).filter(Boolean);
606
+ var iv=setInterval(function(){
607
+ try{
608
+ var roots=ids.length?ids.map(function(id){return document.getElementById(id);}).filter(Boolean):[document];
609
+ var opts=roots.reduce(function(a,r){return a.concat([].slice.call(r.querySelectorAll('[role=option]')));},[]);
610
+ var vis=opts.some(function(o){var b=o.getBoundingClientRect();return b.width&&b.height&&o.checkVisibility&&o.checkVisibility({checkOpacity:true,checkVisibilityCSS:true});});
611
+ if(vis) fin();
612
+ }catch(_){ fin(); }
613
+ }, 40);
614
+ })`);
615
+ } catch { await sleep(cap); }
616
+ }
617
+
618
+ const KEYMAP = {
619
+ Enter: { key: 'Enter', code: 'Enter', windowsVirtualKeyCode: 13, text: '\r' },
620
+ Tab: { key: 'Tab', code: 'Tab', windowsVirtualKeyCode: 9 },
621
+ Escape: { key: 'Escape', code: 'Escape', windowsVirtualKeyCode: 27 },
622
+ Backspace: { key: 'Backspace', code: 'Backspace', windowsVirtualKeyCode: 8 },
623
+ ArrowDown: { key: 'ArrowDown', code: 'ArrowDown', windowsVirtualKeyCode: 40 },
624
+ ArrowUp: { key: 'ArrowUp', code: 'ArrowUp', windowsVirtualKeyCode: 38 },
625
+ ArrowLeft: { key: 'ArrowLeft', code: 'ArrowLeft', windowsVirtualKeyCode: 37 },
626
+ ArrowRight: { key: 'ArrowRight', code: 'ArrowRight', windowsVirtualKeyCode: 39 },
627
+ };
628
+
629
+ async function runOp(tabId, op) {
630
+ switch (op.op) {
631
+ case 'click': {
632
+ const r = await resolveHit(tabId, op.ref);
633
+ if (r.error) return `${op.ref}: ${r.error}`;
634
+ await clickAt(tabId, r.x, r.y);
635
+ return `click ${op.ref}`;
636
+ }
637
+ case 'click_text': {
638
+ const c = await centerOfText(tabId, op.text);
639
+ if (!c) return `click_text "${op.text}": not found`;
640
+ await clickAt(tabId, c.x, c.y);
641
+ return `click_text "${op.text}"`;
642
+ }
643
+ case 'type': {
644
+ const r = await resolveHit(tabId, op.ref, { fill: true });
645
+ if (r.error) return `${op.ref}: ${r.error}`;
646
+ await clickAt(tabId, r.x, r.y); // focus the field with a trusted click
647
+ // Select-all then insert — robust for React/controlled inputs.
648
+ await sendCdp(tabId, 'Input.dispatchKeyEvent', { type: 'keyDown', key: 'a', code: 'KeyA', modifiers: IS_MAC ? 4 : 2, commands: ['selectAll'] });
649
+ await sendCdp(tabId, 'Input.dispatchKeyEvent', { type: 'keyUp', key: 'a', code: 'KeyA', modifiers: IS_MAC ? 4 : 2 });
650
+ const txt = String(op.text ?? '');
651
+ if (txt === '') {
652
+ // insertText('') is a no-op in many inputs; Backspace deletes the selected contents.
653
+ await sendCdp(tabId, 'Input.dispatchKeyEvent', { type: 'keyDown', ...KEYMAP.Backspace });
654
+ await sendCdp(tabId, 'Input.dispatchKeyEvent', { type: 'keyUp', ...KEYMAP.Backspace });
655
+ } else {
656
+ await sendCdp(tabId, 'Input.insertText', { text: txt });
657
+ }
658
+ await waitForOptions(tabId, op.ref, 250); // let autocomplete suggestions render
659
+ return `type ${op.ref}`;
660
+ }
661
+ case 'select': {
662
+ const R = JSON.stringify(String(op.ref));
663
+ const V = JSON.stringify(String(op.value ?? ''));
664
+ try {
665
+ const res = await evaluate(tabId, `(function(){
666
+ var c=window.__pawbrowse; var node=(c&&c.byId)?c.byId[${R}]:null;
667
+ var e=node!=null?c.nodes.get(node):null;
668
+ if(!e||!e.isConnected) return 'unknown ref (observe again)';
669
+ if(e.tagName!=='SELECT') return 'not a dropdown';
670
+ if(e.matches(':disabled')||e.closest('[aria-disabled="true"],[inert]')) return 'dropdown is disabled';
671
+ if(!e.checkVisibility({checkOpacity:true,checkVisibilityCSS:true})) return 'dropdown not visible';
672
+ var val=${V}, m=false;
673
+ for(var i=0;i<e.options.length;i++){var o=e.options[i]; if(!o.disabled && !(o.closest&&o.closest('optgroup[disabled]')) && (o.value===val||o.label===val||o.text===val)){e.selectedIndex=i;m=true;break;}}
674
+ if(!m) return 'option not found';
675
+ e.dispatchEvent(new Event('input',{bubbles:true})); e.dispatchEvent(new Event('change',{bubbles:true}));
676
+ return 'ok';
677
+ })()`);
678
+ return res === 'ok' ? `select ${op.ref}` : `${op.ref}: ${res}`;
679
+ } catch {
680
+ // The change handler may have navigated and destroyed the context — do not blindly retry.
681
+ return `select ${op.ref}: may have applied and navigated the page; observe again before retrying`;
682
+ }
683
+ }
684
+ case 'key': {
685
+ const k = KEYMAP[op.key];
686
+ if (!k) return `key "${op.key}" not supported`;
687
+ await sendCdp(tabId, 'Input.dispatchKeyEvent', { type: 'keyDown', ...k });
688
+ await sendCdp(tabId, 'Input.dispatchKeyEvent', { type: 'keyUp', ...k });
689
+ return `key ${op.key}`;
690
+ }
691
+ case 'scroll': {
692
+ const dy = Number(op.dy ?? 600);
693
+ // Real wheel event so overflow containers, virtualized lists, and infinite scroll fire.
694
+ let cx = 400, cy = 400;
695
+ try { const c = await evaluate(tabId, '[Math.round(innerWidth/2),Math.round(innerHeight/2)]'); if (Array.isArray(c)) { cx = c[0]; cy = c[1]; } } catch {}
696
+ await sendCdp(tabId, 'Input.dispatchMouseEvent', { type: 'mouseWheel', x: cx, y: cy, deltaX: 0, deltaY: dy });
697
+ return `scroll ${dy}`;
698
+ }
699
+ case 'wait': {
700
+ await sleep(Math.min(Number(op.ms ?? 300), 10000));
701
+ return `wait ${op.ms ?? 300}`;
702
+ }
703
+ default:
704
+ return `unknown op "${op.op}"`;
705
+ }
706
+ }
707
+
708
+ /* ------------------------------ Command router ---------------------------- */
709
+
710
+ async function handleCommand(cmd, args, token, session) {
711
+ await rehydrated; // ensure persisted session→tab/group state is loaded before we resolve tabs
712
+ const aborted = () => token && token.cancelled;
713
+ switch (cmd) {
714
+ case '__session_end':
715
+ return endSession(session);
716
+ case 'doctor': {
717
+ const t = await activeTab();
718
+ const s = sessions.get(session || '_default');
719
+ return {
720
+ ext_version: chrome.runtime.getManifest().version,
721
+ extension_id: chrome.runtime.id,
722
+ session,
723
+ session_tab_id: s ? s.activeTabId : null,
724
+ session_group_id: s ? s.groupId : null,
725
+ attached_tab_ids: [...attachedTabs],
726
+ active_sessions: sessions.size,
727
+ active_tab: t ? { id: t.id, url: t.url, title: t.title } : null,
728
+ };
729
+ }
730
+ case 'tabs': {
731
+ const tabs = await chrome.tabs.query({});
732
+ return tabs.map((t) => ({ id: t.id, title: t.title, url: t.url, active: t.active, windowId: t.windowId }));
733
+ }
734
+ case 'navigate': {
735
+ const tabId = await resolveTabId(session, args, 'navigate');
736
+ let url = String(args.url || '').trim();
737
+ if (!url) throw new Error('navigate needs a url');
738
+ if (!/^[a-z][a-z0-9+.-]*:/i.test(url)) url = 'https://' + url; // bare domain -> https
739
+ if (!/^https?:\/\//i.test(url)) throw new Error(`navigate only supports http(s) URLs (refusing "${url.split(':')[0]}:")`);
740
+ await attach(tabId);
741
+ await sendCdp(tabId, 'Page.navigate', { url });
742
+ await sleep(350); // let the new document commit before polling, so we don't read the old page
743
+ for (let i = 0; i < 75; i++) {
744
+ if (aborted()) break;
745
+ const rs = await evaluate(tabId, 'document.readyState').catch(() => null);
746
+ if (rs === 'complete') break;
747
+ await sleep(200);
748
+ }
749
+ await settle(tabId, 400);
750
+ return observe(tabId);
751
+ }
752
+ case 'observe': {
753
+ const tabId = await resolveTabId(session, args, 'inspect');
754
+ await attach(tabId);
755
+ return observe(tabId);
756
+ }
757
+ case 'read': {
758
+ const tabId = await resolveTabId(session, args, 'inspect');
759
+ await attach(tabId);
760
+ const max = Math.min(Number(args.max_chars) || 12000, 50000);
761
+ const text = await evaluate(tabId, `(function(){var el=document.querySelector('main')||document.body;var t=(el.innerText||'').replace(/\\n{3,}/g,'\\n\\n');return t.slice(0, ${max});})()`);
762
+ return `${await evaluate(tabId, 'document.title')} — ${await evaluate(tabId, 'location.href')}\n\n${text}`;
763
+ }
764
+ case 'act': {
765
+ const ops = args.ops || [];
766
+ if (ops.length > 50) throw new Error('too many ops in one call (max 50); split into smaller batches');
767
+ const tabId = await resolveTabId(session, args, 'inspect');
768
+ await attach(tabId);
769
+ const before = await evaluate(tabId, SIG).catch(() => null);
770
+ const logLines = [];
771
+ for (const op of ops) {
772
+ if (aborted()) { logLines.push(' (aborted: command timed out; remaining ops not run)'); break; }
773
+ try { logLines.push(' ' + await runOp(tabId, op)); }
774
+ catch (e) { logLines.push(` ${op.op} ${op.ref || ''}: ERROR ${e.message}`); }
775
+ await settle(tabId, 250);
776
+ }
777
+ await settle(tabId, 450);
778
+ const after = await evaluate(tabId, SIG).catch(() => null);
779
+ const changed = before == null || after == null || before !== after;
780
+ const note = changed ? 'page changed' : 'page did NOT change (if you expected an effect, the action may not have worked — try a different target)';
781
+ // The ops already executed; a failed post-action read (page navigating) must NOT make
782
+ // the caller think they failed and retry them.
783
+ let table;
784
+ try { table = await observe(tabId); }
785
+ catch {
786
+ return `ran ${ops.length} op(s) [${note}]:\n${logLines.join('\n')}\n\n(ops executed; the page is navigating and could not be read yet — call browser_observe next. Do NOT re-run these ops.)`;
787
+ }
788
+ return `ran ${ops.length} op(s) [${note}]:\n${logLines.join('\n')}\n\n${table}`;
789
+ }
790
+ case 'assert': {
791
+ const tabId = await resolveTabId(session, args, 'inspect');
792
+ await attach(tabId);
793
+ if (args.contains != null) {
794
+ const ok = await evaluate(tabId, `!!(document.body && document.body.innerText && document.body.innerText.indexOf(${JSON.stringify(args.contains)})>=0)`);
795
+ return { pass: !!ok, kind: 'contains', value: args.contains };
796
+ }
797
+ if (args.url_includes != null) {
798
+ const u = await evaluate(tabId, 'location.href');
799
+ return { pass: String(u).indexOf(args.url_includes) >= 0, kind: 'url_includes', url: u };
800
+ }
801
+ if (args.ref_visible != null) {
802
+ const r = await resolveHit(tabId, args.ref_visible);
803
+ return { pass: !r.error, kind: 'ref_visible', ref: args.ref_visible, note: r.error };
804
+ }
805
+ return { pass: false, error: 'provide one of: contains, url_includes, ref_visible' };
806
+ }
807
+ default:
808
+ throw new Error(`unknown command: ${cmd}`);
809
+ }
810
+ }
811
+
812
+ /* --------------------------------- Wiring --------------------------------- */
813
+
814
+ chrome.runtime.onStartup.addListener(connect);
815
+ chrome.runtime.onInstalled.addListener(connect);
816
+ chrome.alarms.create('pawbrowse-keepalive', { periodInMinutes: 0.5 });
817
+ chrome.alarms.onAlarm.addListener((a) => { if (a.name === 'pawbrowse-keepalive') connect(); });
818
+ // Let the options page read live connection status without opening a competing socket
819
+ // (which the bridge's single-connection guard would reject).
820
+ chrome.runtime.onMessage.addListener((msg, _sender, sendResponse) => {
821
+ if (msg && msg.type === 'status') { sendResponse({ connected: !!(ws && ws.readyState === WebSocket.OPEN) }); return true; }
822
+ if (msg && msg.type === 'reconnect') {
823
+ // The options page changed the port: drop the current socket and reconnect on the new one.
824
+ try { if (reconnectTimer) { clearTimeout(reconnectTimer); reconnectTimer = null; } } catch {}
825
+ try { if (ws) ws.close(); } catch {}
826
+ ws = null;
827
+ connect();
828
+ sendResponse({ ok: true });
829
+ return true;
830
+ }
831
+ return true;
832
+ });
833
+ connect();