app
Pup - the AI's own browser
Public Made by Adomby adom
pup gives your AI a real browser on your desktop that it drives completely, in the background, signed in as you: windows that never jump in front of your work, one shared profile that learns your logins, and every window labeled on the taskbar with its AI thread's name.
← Commit history
Every file the Rust build embeds from outside rust/ (pup-bridge#118): src/extension-nbe/**, src/extension-identity/identity-worker.js and the rest; ship.sh now derives this list from the include sites
39 files changed
+3146−4
src/extension-identity/identity-worker.js+51src/extension-identity/manifest.json+10−3src/extension-nbe/.nbe-source+1src/extension-nbe/icons/blank16.pngsrc/extension-nbe/icons/blank48.pngsrc/extension-nbe/icons/icon128.pngsrc/extension-nbe/icons/icon16.pngsrc/extension-nbe/icons/icon48.pngsrc/extension-nbe/manifest.json+46src/extension-nbe/src/cdp.js+1195src/extension-nbe/src/native.js+1109src/extension-nbe/src/offscreen-record.html+3src/extension-nbe/src/offscreen-record.js+51src/extension-nbe/src/popup.html+27src/extension-nbe/src/popup.js+51src/extension-nbe/src/record.js+108src/extension-nbe/src/sw.js+434src/extension-nbe/toolbar.json+1src/icons/settings-ex/anncapbar.pngsrc/icons/settings-ex/fgcaption.pngsrc/icons/settings-ex/jlannotate.pngsrc/icons/settings-ex/jlcloseall.pngsrc/icons/settings-ex/jldashboard.pngsrc/icons/settings-ex/jlviewtasks.pngsrc/icons/settings-ex/jumplist.pngsrc/icons/settings-ex/oboff.pngsrc/icons/settings-ex/obon.pngsrc/icons/settings-ex/obsize100.pngsrc/icons/settings-ex/obsize50.pngsrc/icons/settings-ex/obsize66.pngsrc/icons/settings-ex/obsize75.pngsrc/icons/settings-ex/ownerchip.pngsrc/icons/settings-ex/tbactflash.pngsrc/icons/settings-ex/tbactoff.pngsrc/icons/settings-ex/tbactprogress.pngsrc/icons/settings-ex/toolbarext.pngtools/list-embedded.py+23tools/ship.sh+4−1tools/sync-nbe.sh+32src/extension-identity/identity-worker.jsadded+51@@ -0,0 +1,51 @@+// Adom Identity: the toolbar button IS the user. Chrome lets an extension set its action icon at any time+// (chrome.action.setIcon with imageData), so the button shows the adom-you avatar, drawn onto the same teal+// tile as its siblings, and refreshes on a timer. Before this the button was a static glyph and the avatar+// appeared only inside the popup (John, 2026-09-13).+const CANDIDATE_PORTS = [8851, 64230];+async function findPort() {+ let stored = null; try { stored = (await chrome.storage.local.get('pupPort')).pupPort; } catch (e) {}+ for (const port of [...new Set([stored, ...CANDIDATE_PORTS].filter(Boolean))]) {+ try {+ const r = await fetch(`http://127.0.0.1:${port}/health`, { signal: AbortSignal.timeout(1200) });+ const j = await r.json();+ if (j && j.bridge === 'puppeteer') { try { chrome.storage.local.set({ pupPort: port }); } catch (e) {} return port; }+ } catch (e) {}+ }+ return null;+}+function tile(ctx, size) {+ const r = Math.round(size * 22 / 128);+ ctx.fillStyle = '#00b8b1';+ ctx.beginPath(); ctx.roundRect(0, 0, size, size, r); ctx.fill();+}+async function paintAvatar() {+ const port = await findPort(); if (!port) return false;+ let bmp;+ try {+ const r = await fetch(`http://127.0.0.1:${port}/identity/avatar`, { cache: 'no-store', signal: AbortSignal.timeout(6000) });+ if (!r.ok) return false;+ bmp = await createImageBitmap(await r.blob());+ } catch (e) { return false; }+ const imageData = {};+ for (const size of [16, 32, 48, 128]) {+ const cv = new OffscreenCanvas(size, size); const cx = cv.getContext('2d');+ tile(cx, size);+ // the avatar, circle-cropped, inset so a ring of tile shows: the family look, with the person in it+ const inset = Math.max(1, Math.round(size * 10 / 128)); const d = size - inset * 2;+ cx.save(); cx.beginPath(); cx.arc(size / 2, size / 2, d / 2, 0, Math.PI * 2); cx.clip();+ const sc = Math.max(d / bmp.width, d / bmp.height);+ cx.drawImage(bmp, (size - bmp.width * sc) / 2, (size - bmp.height * sc) / 2, bmp.width * sc, bmp.height * sc);+ cx.restore();+ imageData[size] = cx.getImageData(0, 0, size, size);+ }+ try { await chrome.action.setIcon({ imageData }); await chrome.action.setTitle({ title: 'Surfing as adom-you (your avatar: this window is signed in as you)' }); } catch (e) { return false; }+ return true;+}+let _painted = false;+async function tick() { if (!_painted) _painted = await paintAvatar(); else await paintAvatar(); }+chrome.alarms.create('identity-avatar', { periodInMinutes: 10 });+chrome.alarms.onAlarm.addListener((a) => { if (a.name === 'identity-avatar') tick(); });+// until the first success (pup may still be starting), retry every 30 s+const retry = setInterval(async () => { if (_painted) { clearInterval(retry); return; } await tick(); }, 30000);+tick();
src/extension-identity/manifest.json+10−3@@ -1,7 +1,7 @@ { "manifest_version": 3, "name": "Adom Identity",- "version": "0.1.1",+ "version": "0.2.0", "description": "Who this browser is surfing as: the shared adom-you profile. Click for the identity card.", "action": { "default_popup": "identity.html",@@ -15,5 +15,12 @@ "host_permissions": [ "http://127.0.0.1/*" ],- "key": "MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEA7BL3G9VxBGTYp95hL1go5Atzg2otDxuAZvWBFO6smZpRYLOUB7xkvW6CKkloCkkUmmf036zEbCstdApxTDa9jVyhXq37131R78h4Fh3j1qT95X1wphTGIn4GMxjN8A+KsC+vDfdL9pyLyJ8y4zTU/g0KsSP17hoFIBABrafNXhTSru/NXV27cX2VDiRoPJ2Z6Rwux/gMrxZv8VQClvD9Xt0RkIFiKQh4HwpGSGJcopgGA4jU/eUgXDynTMVo01P/I1oCaYI0TJbCA9M/0O/1YvKp9O1ZIvIdwjd/x6CzC37EPh13Vhh5BloKnX0610c862VWoAf7kXrbXlYey+ml5QIDAQAB"-}+ "key": "MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEA7BL3G9VxBGTYp95hL1go5Atzg2otDxuAZvWBFO6smZpRYLOUB7xkvW6CKkloCkkUmmf036zEbCstdApxTDa9jVyhXq37131R78h4Fh3j1qT95X1wphTGIn4GMxjN8A+KsC+vDfdL9pyLyJ8y4zTU/g0KsSP17hoFIBABrafNXhTSru/NXV27cX2VDiRoPJ2Z6Rwux/gMrxZv8VQClvD9Xt0RkIFiKQh4HwpGSGJcopgGA4jU/eUgXDynTMVo01P/I1oCaYI0TJbCA9M/0O/1YvKp9O1ZIvIdwjd/x6CzC37EPh13Vhh5BloKnX0610c862VWoAf7kXrbXlYey+ml5QIDAQAB",+ "background": {+ "service_worker": "identity-worker.js"+ },+ "permissions": [+ "alarms",+ "storage"+ ]+}
src/extension-nbe/.nbe-sourceadded+1@@ -0,0 +1 @@+adom-browser-extension/extension v0.2.14
src/extension-nbe/icons/blank16.pngadded⋯ 1 unchanged line ⋯
src/extension-nbe/icons/blank48.pngadded⋯ 1 unchanged line ⋯
src/extension-nbe/icons/icon128.pngadded⋯ 1 unchanged line ⋯
src/extension-nbe/icons/icon16.pngadded⋯ 1 unchanged line ⋯
src/extension-nbe/icons/icon48.pngadded⋯ 1 unchanged line ⋯
src/extension-nbe/manifest.jsonadded+46@@ -0,0 +1,46 @@+{+ "manifest_version": 3,+ "name": "Adom Native Browser Extension",+ "short_name": "Adom",+ "version": "0.2.14",+ "description": "Layer 2 of Adom Native Browser (nb): lets your Adom AI drive your real, signed-in Chrome or Edge, using your own logins.",+ "minimum_chrome_version": "116",+ "background": {+ "service_worker": "src/sw.js",+ "type": "module"+ },+ "action": {+ "default_popup": "src/popup.html",+ "default_title": "Adom",+ "default_icon": {+ "16": "icons/icon16.png",+ "48": "icons/icon48.png",+ "128": "icons/icon128.png"+ }+ },+ "permissions": [+ "nativeMessaging",+ "debugger",+ "tabs",+ "scripting",+ "downloads",+ "cookies",+ "webNavigation",+ "storage",+ "alarms",+ "tabCapture",+ "desktopCapture",+ "offscreen",+ "identity",+ "identity.email"+ ],+ "host_permissions": [+ "<all_urls>"+ ],+ "key": "MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAr75abHbp7QsA45nIRV1y4GoxUlT+5nba4PNPL15awcl5kz3EC1bvApBUknwH1yduMc96vpXgbWJ3luXMM+XOwcEvgs/6EYrfBvm9HsVFc7ll8UWeeeJCEfalB11cZ9L7BPBolYGgH9yD6iDqm7y8m8z9W12Vwd/V5nohO/X/vLUfe3wbjXS9Vi9MV3dF9ORgk7ZF9yTZyQD7MbwEBNLc0J+K+QU4IJPpTfyOmgdnzmszJRunGtHobN5ThfyR+TZ962oW0SLNuPHC71vyywAUD92gncgwDTtA5mblWn5BBEZf+GQednqaNk7Rf7y4w5jsWo4QIgPBa1yLwILsdXTwUQIDAQAB",+ "icons": {+ "16": "icons/icon16.png",+ "48": "icons/icon48.png",+ "128": "icons/icon128.png"+ }+}
src/extension-nbe/src/cdp.jsadded+1195@@ -0,0 +1,1195 @@+// adom-native-browser :: CDP helpers (chrome.debugger)+// --------------------------------------------------------+// Used ONLY for verbs that need DevTools Protocol parity with pup: full-page screenshots and+// arbitrary expression eval. Navigation/tabs/windows go through native chrome.* APIs (native.js) so+// they never raise the "started debugging this browser" banner.+//+// Banner policy: attach lazily on first CDP use of a tab, auto-detach after IDLE_MS of no CDP use, so+// the banner only shows while actively driving. On Adom-managed machines the browser is launched with+// --silent-debugger-extension-api, which removes it entirely.+//+// DEBOUNCE (John, 2026-07-14): the banner slides IN on attach and OUT on detach, and each transition+// REFLOWS the page (content shifts up/down), which moves where a click lands and ruins the user's own+// clicks in a window they're looking at. A short idle window made it flicker in/out between every verb+// burst (think/page-load pauses > 8s detached it, then the next verb re-attached). So IDLE_MS is long+// enough to span normal per-verb gaps: the banner stays STEADY through a drive session and only slides+// out once the AI has genuinely stopped for a while (not left up forever - screen real estate matters).+// We also NEVER attach to a tab the caller didn't explicitly target (see resolveTabId requireTarget), so+// the banner can only ever appear on a window the AI is actually driving, not the user's active tab.++const PROTO = '1.3';+const IDLE_MS = 45000; // was 8000 — long enough to not flicker between verbs, short enough to not linger+const attached = new Map(); // tabId -> idleTimer++function debuggee(tabId) { return { tabId }; }++function send(tabId, method, params) {+ return new Promise((resolve, reject) => {+ chrome.debugger.sendCommand(debuggee(tabId), method, params || {}, (res) => {+ const err = chrome.runtime.lastError;+ if (err) reject(new Error(err.message)); else resolve(res);+ });+ });+}++function bumpIdle(tabId) {+ const t = attached.get(tabId);+ if (t) clearTimeout(t);+ attached.set(tabId, setTimeout(() => detach(tabId).catch(() => {}), IDLE_MS));+}++async function attach(tabId) {+ if (attached.has(tabId)) { bumpIdle(tabId); return; }+ await new Promise((resolve, reject) => {+ chrome.debugger.attach(debuggee(tabId), PROTO, () => {+ const err = chrome.runtime.lastError;+ if (err && !/already attached/i.test(err.message)) reject(new Error(err.message));+ else resolve();+ });+ });+ attached.set(tabId, null);+ await send(tabId, 'Page.enable').catch(() => {});+ await send(tabId, 'Runtime.enable').catch(() => {});+ await send(tabId, 'Log.enable').catch(() => {});+ // CRITICAL for BACKGROUND clicking: a window fully occluded by another (e.g. the user's maximized+ // window over our background one) is marked hidden by Chrome's native-occlusion tracking, so+ // Input.dispatchMouseEvent stops hit-testing and clicks silently land NOWHERE (eval/screenshot still+ // work - they don't need a hit-testable frame). Emulating focus makes the page behave as focused+visible+ // so isTrusted CDP input lands, WITHOUT ever raising the window (stays background, no focus theft).+ await send(tabId, 'Emulation.setFocusEmulationEnabled', { enabled: true }).catch(() => {});+ bumpIdle(tabId);+}++// pup (2.0.578) drives its extension-lane windows through THIS driver instead of a second one: one raw+// DevTools call on nbe's own attach + idle-detach lifecycle. Additive; nothing else here changes.+export async function raw(tabId, method, params) {+ await attach(tabId);+ const r = await send(tabId, method, params || {});+ bumpIdle(tabId);+ return r;+}++export async function detach(tabId) {+ const t = attached.get(tabId);+ if (t) clearTimeout(t);+ if (!attached.has(tabId)) return;+ attached.delete(tabId);+ await new Promise((resolve) => chrome.debugger.detach(debuggee(tabId), () => { void chrome.runtime.lastError; resolve(); }));+}++// ---- BACKGROUND CAPTURE: force a frame, never a foreground -----------------+// (John, 2026-08-22) The excuse an AI reaches for when a background capture comes back blank is "I had+// to bring the window forward to get a good screenshot." That is the single rudest thing nb can cause,+// and it is ALSO unnecessary - it is a compositor problem, not a focus problem. Chrome stops painting a+// window it believes is occluded (CalculateNativeWinOcclusion) and FREEZES a backgrounded page's+// rendering, so Page.captureScreenshot can hand back a stale or uniform frame. The answer is to make the+// renderer produce a frame, which we can do without touching the user's focus:+// 1. Page.setWebLifecycleState{active} - the documented CDP un-freeze for a backgrounded page+// 2. a double-rAF round-trip in the page - forces the renderer to commit one fresh frame+// 3. re-capture, and only judge the result after that+// Best-effort at every step; a window that never needed it pays two animation frames. If we ever CANNOT+// get a live frame we still refuse to foreground - we say so, and hand the AI the WGC fallback, which+// captures an occluded window at the OS level.+async function forceFrame(tabId) {+ const used = [];+ try { await send(tabId, 'Page.setWebLifecycleState', { state: 'active' }); used.push('lifecycle:active'); } catch {}+ try {+ await send(tabId, 'Runtime.evaluate', {+ expression: 'new Promise(r=>requestAnimationFrame(()=>requestAnimationFrame(()=>r(1))))',+ awaitPromise: true, returnByValue: true,+ });+ used.push('raf');+ } catch {}+ return used;+}+// A capture that is one flat colour is the occlusion signature (Chrome handed us an unpainted surface).+// Sampled at <=64x64 so the check costs microseconds on a full-page shot.+async function frameIsBlank(b64) {+ try {+ const bmp = await bitmapFromB64(b64);+ const w = Math.max(1, Math.min(bmp.width, 64)), h = Math.max(1, Math.min(bmp.height, 64));+ const canvas = new OffscreenCanvas(w, h);+ const g = canvas.getContext('2d', { willReadFrequently: true });+ g.drawImage(bmp, 0, 0, w, h);+ bmp.close && bmp.close();+ const d = g.getImageData(0, 0, w, h).data;+ let min = 255, max = 0;+ for (let i = 0; i < d.length; i += 4) {+ const v = (d[i] + d[i + 1] + d[i + 2]) / 3;+ if (v < min) min = v;+ if (v > max) max = v;+ }+ return (max - min) < 3;+ } catch { return false; }+}++// ---- nbrowser_screenshot / nbrowser_screenshot_full_res --------------------+export async function screenshot(tabId, { fullPage = false, maxWidth = 1568, resize = true } = {}) {+ await attach(tabId);+ const forced = await forceFrame(tabId);+ const params = { format: 'png', captureBeyondViewport: !!fullPage };+ if (fullPage) {+ const m = await send(tabId, 'Page.getLayoutMetrics');+ const css = m.cssContentSize || m.contentSize;+ params.clip = { x: 0, y: 0, width: Math.ceil(css.width), height: Math.ceil(css.height), scale: 1 };+ }+ let res = await send(tabId, 'Page.captureScreenshot', params);+ // Blank frame = the compositor never painted (occluded window). Re-force and re-capture ONCE. We do+ // NOT foreground to fix this, ever - that is the user's focus, not ours to take.+ let recovered = false, stillBlank = false;+ if (await frameIsBlank(res.data)) {+ forced.push(...(await forceFrame(tabId)), 'recapture');+ const res2 = await send(tabId, 'Page.captureScreenshot', params);+ if (await frameIsBlank(res2.data)) stillBlank = true;+ else { res = res2; recovered = true; }+ }+ bumpIdle(tabId);+ const out = resize ? await resizePng(res.data, maxWidth) : await pngDims(res.data);+ const iw = out.width, ih = out.height, ow = out.origWidth ?? out.width, oh = out.origHeight ?? out.height;+ // Viewport coordinate-map, sampled AT capture time, so screenshot-pixel -> click-coordinate mapping is+ // exact and never needs a separate probe (and can't drift from a later scroll). To turn a pixel (sx,sy)+ // in the RETURNED image into a viewport click coordinate (CSS px, what nbrowser_click/input_dispatch+ // take): clickX = sx * cssPerImageX, clickY = sy * cssPerImageY. scrollX/scrollY are included for+ // page-absolute math; devicePixelRatio + captured/image dims are given so any custom mapping is possible.+ let viewport = null;+ try {+ const m = await evaluate(tabId, '({iw:innerWidth,ih:innerHeight,dpr:devicePixelRatio,sx:Math.round(scrollX),sy:Math.round(scrollY),dw:Math.round(document.documentElement.scrollWidth),dh:Math.round(document.documentElement.scrollHeight)})');+ if (m && typeof m === 'object') {+ const spanW = fullPage ? m.dw : m.iw, spanH = fullPage ? m.dh : m.ih; // CSS px the image spans+ viewport = {+ innerWidth: m.iw, innerHeight: m.ih, devicePixelRatio: m.dpr,+ scrollX: m.sx, scrollY: m.sy,+ capturedWidth: ow, capturedHeight: oh, // raw CDP capture px (~ span*dpr)+ imageWidth: iw, imageHeight: ih, // the returned (possibly downscaled) image px+ resizeRatio: ow ? +(iw / ow).toFixed(6) : 1,+ fullPage: !!fullPage, // true = image spans the whole page; coords are page-absolute+ cssPerImageX: iw ? +(spanW / iw).toFixed(6) : 1, // <- multiply a screenshot X by this to get a click X (CSS px)+ cssPerImageY: ih ? +(spanH / ih).toFixed(6) : 1,+ };+ }+ } catch (e) {}+ const shot = { ok: true, base64: out.base64, width: iw, height: ih, origWidth: ow, origHeight: oh,+ resized: !!out.resized, sizeKB: Math.round((out.base64.length * 3 / 4) / 1024), viewport,+ // Tell the AI what we did to get a live frame WITHOUT focus, so "I had to foreground it to see the+ // page" is never a conclusion it can honestly reach.+ capture: { forcedRepaint: forced, blankRecovered: recovered, blank: stillBlank } };+ if (stillBlank) {+ shot._hint = 'The page composited a BLANK frame even after a forced repaint (lifecycle:active + double rAF). ' ++ 'Do NOT foreground the window to fix this - that steals the user\'s focus and is not the fix anyway. ' ++ 'Use ab desktop_screenshot_window {hwnd} instead: WGC captures an OCCLUDED window at the OS level, ' ++ 'chrome included. If you own this Chrome, relaunching it with --disable-features=CalculateNativeWinOcclusion ' ++ '--disable-backgrounding-occluded-windows --disable-renderer-backgrounding stops it happening at all.';+ } else if (recovered) {+ shot._hint = 'This window was NOT painting (occluded/backgrounded); nbe forced a repaint and captured a live ' ++ 'frame with the window still in the background. You never need to foreground a window to screenshot it.';+ }+ // A native JS dialog does NOT appear in a CDP capture (it's browser chrome, not page content), so tell+ // the AI explicitly - otherwise it screenshots, sees a normal page, and never realizes a modal is up.+ const dlg = _openDialogs.get(Number(tabId));+ if (dlg) shot._modal = `⚠️ a native ${dlg.type} dialog is open ("${(dlg.message || '').slice(0, 120)}") - it is NOT in this screenshot (dialogs are browser chrome) and it is BLOCKING the page. Dismiss it with nbrowser_handle_dialog {sessionId, accept:true|false} before doing anything else.`;+ return shot;+}++// ---- nbrowser_eval / nbrowser_evaluate ------------------------------------+export async function evaluate(tabId, expr) {+ await attach(tabId);+ // SPA route changes (e.g. Gusto's wizard steps) tear down the execution+ // context; for a short window Runtime.evaluate can throw "Cannot find+ // context with specified id" or come back with neither result nor+ // exceptionDetails. Retry a few times so callers don't see a phantom+ // empty/undefined right after an in-app navigation.+ let res;+ for (let attempt = 0; attempt < 4; attempt++) {+ try {+ res = await send(tabId, 'Runtime.evaluate', {+ expression: `(${expr})`,+ returnByValue: true,+ awaitPromise: true,+ userGesture: true,+ });+ } catch (err) {+ const msg = String((err && err.message) || err);+ if (/context|detached|target closed|destroyed/i.test(msg) && attempt < 3) {+ await attach(tabId); // re-resolve the context+ await new Promise((r) => setTimeout(r, 250 * (attempt + 1)));+ continue;+ }+ throw err;+ }+ // A torn-down context yields an empty envelope (no result, no exception).+ if (!res || (!res.result && !res.exceptionDetails)) {+ if (attempt < 3) {+ await new Promise((r) => setTimeout(r, 250 * (attempt + 1)));+ continue;+ }+ }+ break;+ }+ bumpIdle(tabId);+ if (res && res.exceptionDetails) {+ const e = res.exceptionDetails;+ return { __error: (e.exception && (e.exception.description || e.exception.value)) || e.text };+ }+ return res && res.result ? res.result.value : undefined;+}++// ---- input dispatch (CDP Input domain → isTrusted events) ------------------+const KEYMAP = {+ Enter: { key: 'Enter', code: 'Enter', vk: 13 },+ Tab: { key: 'Tab', code: 'Tab', vk: 9 },+ Escape: { key: 'Escape', code: 'Escape', vk: 27 },+ Backspace: { key: 'Backspace', code: 'Backspace', vk: 8 },+ Delete: { key: 'Delete', code: 'Delete', vk: 46 },+ ArrowUp: { key: 'ArrowUp', code: 'ArrowUp', vk: 38 },+ ArrowDown: { key: 'ArrowDown', code: 'ArrowDown', vk: 40 },+ ArrowLeft: { key: 'ArrowLeft', code: 'ArrowLeft', vk: 37 },+ ArrowRight: { key: 'ArrowRight', code: 'ArrowRight', vk: 39 },+ Home: { key: 'Home', code: 'Home', vk: 36 },+ End: { key: 'End', code: 'End', vk: 35 },+};++async function pressKey(tabId, name) {+ const k = KEYMAP[name] || { key: name, code: name, vk: name.length === 1 ? name.toUpperCase().charCodeAt(0) : 0 };+ const base = { key: k.key, code: k.code, windowsVirtualKeyCode: k.vk, nativeVirtualKeyCode: k.vk };+ await send(tabId, 'Input.dispatchKeyEvent', { type: 'keyDown', ...base });+ await send(tabId, 'Input.dispatchKeyEvent', { type: 'keyUp', ...base });+}++async function mouseClick(tabId, x, y, button = 'left', clickCount = 1) {+ await send(tabId, 'Input.dispatchMouseEvent', { type: 'mouseMoved', x, y });+ await send(tabId, 'Input.dispatchMouseEvent', { type: 'mousePressed', x, y, button, clickCount, buttons: button === 'right' ? 2 : 1 });+ await send(tabId, 'Input.dispatchMouseEvent', { type: 'mouseReleased', x, y, button, clickCount, buttons: 0 });+}++// Port of pup's modal-aware smart-pick: resolve a selector to the best visible element's center.+function scoreExpr(selector, firstMatch) {+ const sel = JSON.stringify(selector);+ return `(function(){+ var els=[].slice.call(document.querySelectorAll(${sel}));+ if(!els.length)return{found:false,count:0};+ function vis(el){var r=el.getBoundingClientRect(),s=getComputedStyle(el);+ return r.width>0&&r.height>0&&s.visibility!=='hidden'&&s.display!=='none'&&parseFloat(s.opacity||'1')>0.01;}+ var c=els.filter(vis); if(!c.length)c=els;+ var modal=document.querySelector('dialog[open],[aria-modal="true"],[role="dialog"]');+ if(modal){var m=c.filter(function(e){return modal.contains(e);});if(m.length)c=m;}+ var chosen=${firstMatch ? 'c[0]' : 'c.reduce(function(a,b){var ra=a.getBoundingClientRect(),rb=b.getBoundingClientRect();return (rb.width*rb.height)>(ra.width*ra.height)?b:a;})'};+ if(chosen.scrollIntoView)chosen.scrollIntoView({block:'center',inline:'center'});+ var r=chosen.getBoundingClientRect();+ return{found:true,count:c.length,x:Math.round(r.left+r.width/2),y:Math.round(r.top+r.height/2),+ rect:{x:Math.round(r.left),y:Math.round(r.top),w:Math.round(r.width),h:Math.round(r.height)},+ text:((chosen.innerText||chosen.value||'')+'').slice(0,60),modal:!!modal};+ })()`;+}+function focusExpr(selector) {+ const sel = JSON.stringify(selector);+ return `(function(){var e=document.querySelector(${sel});if(!e)return{found:false};if(e.focus)e.focus();if(e.scrollIntoView)e.scrollIntoView({block:'center'});return{found:true};})()`;+}++const sleep = (ms) => new Promise((r) => setTimeout(r, ms));++// ---- visible Adom cursor — EXACT match to Hydrogen's AI cursor (hd-control AI_CURSOR_JS) --------------+// Teal Adom arrow (#00b8b0) + dark-teal outline + teal glow, a pulsing teal ring, and a monospace+// "Adom" label so the user always knows it's an Adom-driven cursor (trust). Kept byte-faithful to Hydrogen+// so the cursor looks identical across the whole product.+function cursorScript(action, x, y, label) {+ var id = '__adom_cursor__';+ var c = document.getElementById(id);+ if (action === 'hide') { if (c) c.style.display = 'none'; return; }+ if (!c) {+ c = document.createElement('div');+ c.id = id;+ c.style.cssText = 'position:fixed;z-index:2147483647;pointer-events:none;transition:left 0.15s,top 0.15s;display:none;';+ var g = document.createElement('div'); // pulsing teal glow ring+ g.id = '__adom_cursor_ring__';+ g.style.cssText = 'position:absolute;top:-16px;left:-16px;width:40px;height:40px;border-radius:50%;background:rgba(0,184,176,0.15);border:2px solid rgba(0,184,176,0.4);animation:adomCurP 1.5s ease-in-out infinite;';+ c.appendChild(g);+ var a = document.createElement('div'); // the teal Adom arrow (Hydrogen's exact SVG)+ a.innerHTML = '<svg width="20" height="28" viewBox="0 0 20 28" xmlns="http://www.w3.org/2000/svg"><path d="M1 1L1 22L6.5 16.5L10.5 26L14 24.5L10 15L17 15Z" fill="#00b8b0" stroke="#004d49" stroke-width="1.2" stroke-linejoin="round"/></svg>';+ a.style.cssText = 'position:absolute;top:0;left:0;filter:drop-shadow(0 0 6px rgba(0,184,176,0.7));';+ c.appendChild(a);+ var l = document.createElement('div'); // the "Adom" trust label+ l.id = '__adom_cursor_label__';+ l.style.cssText = 'position:absolute;top:32px;left:8px;white-space:nowrap;font-size:11px;color:#00b8b0;background:rgba(13,17,23,0.92);padding:2px 10px;border-radius:4px;border:1px solid rgba(0,184,176,0.6);font-family:monospace;';+ c.appendChild(l);+ (document.body || document.documentElement).appendChild(c);+ if (!document.getElementById('__adom_cur_kf__')) {+ var s = document.createElement('style'); s.id = '__adom_cur_kf__';+ s.textContent = '@keyframes adomCurP{0%{transform:scale(1);opacity:0.7}50%{transform:scale(1.5);opacity:0.2}100%{transform:scale(1);opacity:0.7}}@keyframes adomCurClick{0%{transform:scale(1);opacity:0.85}45%{transform:scale(2.2);opacity:0.05}100%{transform:scale(1);opacity:0.7}}';+ document.head.appendChild(s);+ }+ }+ var lab = c.querySelector('#__adom_cursor_label__');+ if (lab) {+ lab.textContent = (label != null ? label : 'Adom');+ // flip the label to the LEFT of the cursor when near the right edge, so it never clips offscreen+ var nearRight = (x != null) && (x > (window.innerWidth - 250));+ if (nearRight) { lab.style.left = 'auto'; lab.style.right = '0px'; lab.style.textAlign = 'right'; }+ else { lab.style.left = '8px'; lab.style.right = 'auto'; lab.style.textAlign = 'left'; }+ }+ if (x != null && y != null) { c.style.left = x + 'px'; c.style.top = y + 'px'; }+ c.style.display = 'block';+ if (action === 'click') {+ var ring = c.querySelector('#__adom_cursor_ring__');+ if (ring) { ring.style.animation = 'none'; void ring.offsetWidth; ring.style.animation = 'adomCurClick 0.45s ease-out, adomCurP 1.5s ease-in-out infinite 0.45s'; }+ }+}+export async function cursor(tabId, action, x, y, label) {+ try { await chrome.scripting.executeScript({ target: { tabId }, func: cursorScript, args: [action, x == null ? null : x, y == null ? null : y, label == null ? null : String(label)] }); } catch (e) {}+}++// Hydrogen-style intention narration for the cursor label. phase 'find' = while gliding there; 'act' = doing it.+function phaseLabel(type, tgt, phase) {+ const name = tgt ? `"${tgt}"` : 'this';+ if (type === 'hover') return phase === 'act' ? `Hovering ${name}` : `Moving to ${name}`;+ if (type === 'dblclick' || type === 'doubleclick') return phase === 'act' ? `Double-clicking ${name}` : `Finding ${name}`;+ if (type === 'rightclick' || type === 'contextmenu') return phase === 'act' ? `Right-clicking ${name}` : `Finding ${name}`;+ return phase === 'act' ? `Clicking ${name}` : `Finding ${name}`;+}++// ---- smart finder: locate visible interactive elements (for the screenshot->find->click loop) -----+// Injected via chrome.scripting (no debugger banner). Matches by visible text / aria-label.+// `rich`: when true, attach per-candidate debug data (score/scoreBreakdown, occlusion, visibility,+// inViewport, a best-effort CSS selector) WITHOUT changing the selection order — chosen stays index 0,+// so {debug:true} never regresses a click. The numeric `score` is for human/AI debugging only.+function findFn(query, limit, rich) {+ var q = (query || '').toLowerCase();+ var sel = 'a,button,[role=button],[role=link],[role=menuitem],[role=menuitemradio],[role=menuitemcheckbox],[role=tab],[role=checkbox],[role=radio],[role=switch],[role=option],[role=combobox],[role=treeitem],[role=gridcell],input,select,textarea,summary,[onclick],[tabindex],[contenteditable]';+ // Deep collector: light DOM + every OPEN shadow root (recursively). Custom-element finance/SPA+ // widgets (Schwab account picker, QBO report search) put their real options INSIDE shadow roots that+ // document.querySelectorAll can't see - so we descend into each element's .shadowRoot. (Closed shadow+ // roots are invisible to any script; only open ones are reachable, which covers the vast majority.)+ function deepQ(root, s) {+ var acc = [];+ (function walk(node) {+ try { acc.push.apply(acc, node.querySelectorAll(s)); } catch (e) {}+ var kids; try { kids = node.querySelectorAll('*'); } catch (e) { kids = []; }+ for (var i = 0; i < kids.length; i++) { var sr = kids[i].shadowRoot; if (sr) walk(sr); }+ })(root || document);+ return acc;+ }+ // Scroll the winner to centre and re-read its geometry: the coordinates we hand back are viewport+ // coordinates, so a candidate that was off-screen when matched must be brought on-screen BEFORE its+ // x/y are computed. Without this the dispatch fires at stale/offscreen coordinates and silently+ // misses (nb-bridge #20: right element resolved, changed:false, DOM untouched).+ function centre(list) {+ if (!list.length) return list;+ var top = list[0];+ if (top && top._el && top._el.scrollIntoView) {+ try { top._el.scrollIntoView({ block: 'center', inline: 'center' }); } catch (e) {}+ var r = top._el.getBoundingClientRect();+ top.rect = { x: Math.round(r.left), y: Math.round(r.top), w: Math.round(r.width), h: Math.round(r.height) };+ top.x = Math.round(r.left + r.width / 2); top.y = Math.round(r.top + r.height / 2);+ }+ for (var i = 0; i < list.length; i++) delete list[i]._el;+ return list;+ }+ var els = deepQ(document, sel);+ // RENDERED, not necessarily ON-SCREEN. Requiring the element to be inside the viewport made every+ // target the user had scrolled past invisible to click-by-text (nb-bridge #20): the interactive pass+ // found nothing and the fallback returned <body>/<html>. resolveCoords scrolls the chosen element to+ // centre before dispatching, so off-viewport-but-rendered is perfectly clickable.+ function vis(el) { var r = el.getBoundingClientRect(), s = getComputedStyle(el); return r.width > 1 && r.height > 1 && s.visibility !== 'hidden' && s.display !== 'none' && parseFloat(s.opacity || '1') > 0.01; }+ // NEVER a click target: the document/root containers match almost any text and clicking them does+ // nothing (or hits whatever is underneath). Excluding them also removes the phantom second+ // "candidate" that produced false ambiguous_target refusals.+ function clickable(el) { var t = (el.tagName || '').toUpperCase(); return t !== 'HTML' && t !== 'BODY' && t !== 'HEAD' && t !== 'SCRIPT' && t !== 'STYLE' && t !== 'NOSCRIPT'; }+ function txt(el) { return ((el.innerText || el.value || el.getAttribute('aria-label') || el.getAttribute('placeholder') || el.getAttribute('title') || '') + '').replace(/\s+/g, ' ').trim(); }+ function cssPath(el) { try { if (!el || el.nodeType !== 1) return ''; if (el.id) return '#' + el.id; var p = el.tagName.toLowerCase(); var cn = el.className; cn = (cn && cn.baseVal !== undefined ? cn.baseVal : cn) || ''; var cls = ('' + cn).trim().split(/\s+/).filter(Boolean).slice(0, 2); if (cls.length) p += '.' + cls.join('.'); var par = el.parentElement; if (par) { var same = [].slice.call(par.children).filter(function (x) { return x.tagName === el.tagName; }); if (same.length > 1) p += ':nth-of-type(' + (same.indexOf(el) + 1) + ')'; } return p; } catch (e) { return ''; } }+ function richOf(el, t, al) {+ var r = el.getBoundingClientRect(); var tl = (t || '').toLowerCase(); var tag = el.tagName.toLowerCase();+ var textMatch = !q ? 0 : tl === q ? 100 : al === q ? 95 : tl.indexOf(q) === 0 ? 70 : tl.indexOf(q) >= 0 ? 50 : al.indexOf(q) >= 0 ? 40 : 0;+ var clickableTag = (/^(a|button|input|select|textarea|summary)$/.test(tag) || el.getAttribute('role')) ? 20 : 0;+ var visible = vis(el); var visibility = visible ? 10 : 0;+ var inViewport = r.top >= 0 && r.left >= 0 && r.bottom <= (innerHeight || 0) && r.right <= (innerWidth || 0);+ var cx = Math.round(r.left + r.width / 2), cy = Math.round(r.top + r.height / 2);+ var occluded = false, occludedBy = '';+ try { var top = document.elementFromPoint(cx, cy); if (top && top !== el && !el.contains(top) && !top.contains(el)) { occluded = true; occludedBy = cssPath(top); } } catch (e) {}+ var occlusionPenalty = occluded ? -30 : 0;+ return { score: textMatch + clickableTag + visibility + occlusionPenalty, scoreBreakdown: { textMatch: textMatch, clickableTag: clickableTag, visibility: visibility, occlusionPenalty: occlusionPenalty, area: Math.round(r.width * r.height) }, visible: visible, occluded: occluded, occludedBy: occludedBy, inViewport: inViewport, selector: cssPath(el) };+ }+ function mk(el, t, r) { var o = { _el: el, text: (t || '').slice(0, 80), tag: el.tagName.toLowerCase(), role: el.getAttribute('role') || '', type: el.type || '', x: Math.round(r.left + r.width / 2), y: Math.round(r.top + r.height / 2), rect: { x: Math.round(r.left), y: Math.round(r.top), w: Math.round(r.width), h: Math.round(r.height), centerX: Math.round(r.left + r.width / 2), centerY: Math.round(r.top + r.height / 2) } }; if (rich) { var rf = richOf(el, t, (el.getAttribute('aria-label') || '').toLowerCase()); for (var k in rf) o[k] = rf[k]; } return o; }+ var out = [];+ for (var i = 0; i < els.length; i++) {+ var el = els[i]; if (!vis(el) || !clickable(el)) continue; var t = txt(el); var al = (el.getAttribute('aria-label') || '').toLowerCase();+ if (q && t.toLowerCase().indexOf(q) < 0 && al.indexOf(q) < 0) continue;+ out.push(mk(el, t, el.getBoundingClientRect()));+ }+ // Fallback: hover/move targets are often NON-interactive (info icons, cards, tooltip+ // triggers, plain spans). If nothing interactive matched the text, scan all elements and+ // take the smallest (leaf-most) visible element whose text contains the query — that's the+ // specific thing the user named, not a giant wrapping container.+ if (q && out.length === 0) {+ var all = deepQ(document, '*');+ var fb = [];+ for (var j = 0; j < all.length; j++) {+ var e2 = all[j]; if (!vis(e2) || !clickable(e2)) continue;+ var t2 = ((e2.innerText || e2.textContent || '') + '').replace(/\s+/g, ' ').trim();+ if (!t2 || t2.toLowerCase().indexOf(q) < 0) continue;+ var r2 = e2.getBoundingClientRect();+ var o2 = mk(e2, t2, r2); o2.area = r2.width * r2.height; fb.push(o2);+ }+ // exact text match wins, else smallest area (most specific leaf) — return directly,+ // skipping the interactive-pass sort below (which prefers LARGEST, wrong for leaves).+ fb.sort(function (a, b) { var ae = a.text.toLowerCase() === q ? 0 : 1, be = b.text.toLowerCase() === q ? 0 : 1; if (ae !== be) return ae - be; return a.area - b.area; });+ return centre(fb.map(function (o) { delete o.area; return o; }).slice(0, limit || 40));+ }+ out.sort(function (a, b) { var ae = a.text.toLowerCase() === q ? 0 : 1, be = b.text.toLowerCase() === q ? 0 : 1; if (ae !== be) return ae - be; return (b.rect.w * b.rect.h) - (a.rect.w * a.rect.h); });+ return centre(out.slice(0, limit || 40));+}+export async function find(tabId, query, limit, rich) {+ try {+ const r = await chrome.scripting.executeScript({ target: { tabId }, func: findFn, args: [query || '', limit || 40, !!rich] });+ const els = (r && r[0] && r[0].result) || [];+ return { ok: true, query: query || '', count: els.length, elements: els };+ } catch (e) { return { ok: false, error: String((e && e.message) || e) }; }+}++// All visible matches of a CSS selector, with debug data — for {debug}/{resolveOnly} on selector targets.+function selectorCandidatesFn(selector) {+ // Light-DOM first (fast, honors combinators). If it finds nothing, deep-walk every open shadow root and+ // match each element with .matches(selector) - so a stable attribute (e.g. [data-testid=x]) or class/tag+ // selector pierces custom-element shadow DOM. (Cross-shadow combinators like ".a > .b" only match within+ // one root; simple attribute/class/tag/role selectors are what this recovers, which is the common case.)+ var els = [].slice.call(document.querySelectorAll(selector));+ if (!els.length) {+ var deep = [];+ (function walk(node) {+ var kids; try { kids = node.querySelectorAll('*'); } catch (e) { kids = []; }+ for (var i = 0; i < kids.length; i++) {+ var el = kids[i];+ try { if (el.matches && el.matches(selector)) deep.push(el); } catch (e) {}+ if (el.shadowRoot) walk(el.shadowRoot);+ }+ })(document);+ els = deep;+ }+ function vis(el) { var r = el.getBoundingClientRect(), s = getComputedStyle(el); return r.width > 0 && r.height > 0 && s.visibility !== 'hidden' && s.display !== 'none' && parseFloat(s.opacity || '1') > 0.01; }+ function cssPath(el) { try { if (el.id) return '#' + el.id; var p = el.tagName.toLowerCase(); var par = el.parentElement; if (par) { var same = [].slice.call(par.children).filter(function (x) { return x.tagName === el.tagName; }); if (same.length > 1) p += ':nth-of-type(' + (same.indexOf(el) + 1) + ')'; } return p; } catch (e) { return ''; } }+ var c = els.filter(vis); if (!c.length) c = els;+ var modal = document.querySelector('dialog[open],[aria-modal="true"],[role="dialog"]');+ if (modal) { var m = c.filter(function (e) { return modal.contains(e); }); if (m.length) c = m; }+ if (!c.length) return [];+ var chosenEl = c.reduce(function (a, b) { var ra = a.getBoundingClientRect(), rb = b.getBoundingClientRect(); return (rb.width * rb.height) > (ra.width * ra.height) ? b : a; });+ return c.slice(0, 40).map(function (el) {+ var r = el.getBoundingClientRect(); var cx = Math.round(r.left + r.width / 2), cy = Math.round(r.top + r.height / 2);+ var occ = false, occBy = ''; try { var top = document.elementFromPoint(cx, cy); if (top && top !== el && !el.contains(top) && !top.contains(el)) { occ = true; occBy = cssPath(top); } } catch (e) {}+ return { text: ((el.innerText || el.value || '') + '').replace(/\s+/g, ' ').trim().slice(0, 80), tag: el.tagName.toLowerCase(), role: el.getAttribute('role') || '', selector: cssPath(el), x: cx, y: cy, rect: { x: Math.round(r.left), y: Math.round(r.top), w: Math.round(r.width), h: Math.round(r.height), centerX: cx, centerY: cy }, score: el === chosenEl ? 100 : 50, scoreBreakdown: { rule: 'largest-visible-match', area: Math.round(r.width * r.height) }, visible: vis(el), occluded: occ, occludedBy: occBy, inViewport: r.top >= 0 && r.left >= 0 && r.bottom <= innerHeight && r.right <= innerWidth, chosen: el === chosenEl };+ });+}++// ---- nbrowser_select: open a custom dropdown and pick an option by its VISIBLE TEXT ----------------+// Solves: coordinate clicks on virtualized/custom option lists land on the WRONG row (mis-selecting a+// bank account by one row). This locates the trigger (by accessible `label` or the `near` text beside+// it), opens it with an isTrusted CDP click, WAITS for the options to render, clicks the EXACT text+// match (shadow-piercing), and returns the newly-displayed value so the caller can verify.+function selectTriggerFn(label, near) {+ function deepQ(root, s) { var a = []; (function w(n) { try { a.push.apply(a, n.querySelectorAll(s)); } catch (e) {} var k; try { k = n.querySelectorAll('*'); } catch (e) { k = []; } for (var i = 0; i < k.length; i++) { var sr = k[i].shadowRoot; if (sr) w(sr); } })(root || document); return a; }+ function vis(el) { var r = el.getBoundingClientRect(), s = getComputedStyle(el); return r.width > 1 && r.height > 1 && s.visibility !== 'hidden' && s.display !== 'none' && parseFloat(s.opacity || '1') > 0.01 && r.top < innerHeight && r.bottom > 0; }+ function txt(el) { return (((el.getAttribute && (el.getAttribute('aria-label') || el.getAttribute('title') || el.getAttribute('placeholder'))) || el.innerText || el.textContent || '') + '').replace(/\s+/g, ' ').trim(); }+ var cand = null;+ if (label) {+ var lab = (label + '').toLowerCase();+ var trigs = deepQ(document, 'select,[role=combobox],[role=listbox],[aria-haspopup],button,[role=button],input').filter(vis);+ trigs.sort(function (a, b) { function sc(e) { var t = txt(e).toLowerCase(); return t === lab ? 3 : t.indexOf(lab) === 0 ? 2 : t.indexOf(lab) >= 0 ? 1 : 0; } return sc(b) - sc(a); });+ if (trigs.length && txt(trigs[0]).toLowerCase().indexOf(lab) >= 0) cand = trigs[0];+ }+ if (!cand && near) {+ var nl = (near + '').toLowerCase();+ var texts = deepQ(document, 'label,span,div,legend,th,td,p').filter(function (e) { return vis(e) && txt(e).toLowerCase().indexOf(nl) >= 0; });+ texts.sort(function (a, b) { return txt(a).length - txt(b).length; });+ if (texts.length) {+ var L = texts[0], forId = L.getAttribute && L.getAttribute('for');+ if (forId) { var t = document.getElementById(forId); if (t && vis(t)) cand = t; }+ if (!cand) {+ var lr = L.getBoundingClientRect(), lx = lr.left + lr.width / 2, ly = lr.top + lr.height / 2;+ var ctrls = deepQ(document, 'select,[role=combobox],[aria-haspopup],button,[role=button],input,[role=listbox]').filter(vis);+ ctrls.sort(function (a, b) { var ra = a.getBoundingClientRect(), rb = b.getBoundingClientRect(); return Math.hypot(ra.left + ra.width / 2 - lx, ra.top + ra.height / 2 - ly) - Math.hypot(rb.left + rb.width / 2 - lx, rb.top + rb.height / 2 - ly); });+ if (ctrls.length) cand = ctrls[0];+ }+ }+ }+ if (!cand) return { found: false };+ var r = cand.getBoundingClientRect();+ // `display` = the trigger's now-VISIBLE text (what a human sees selected), preferred for the readback.+ // `current` = its accessible name (aria-label/title/placeholder, used for finding) - kept as fallback.+ var disp = ((cand.innerText || cand.textContent || '') + '').replace(/\s+/g, ' ').trim();+ return { found: true, x: Math.round(r.left + r.width / 2), y: Math.round(r.top + r.height / 2), tag: cand.tagName.toLowerCase(), isNativeSelect: cand.tagName.toLowerCase() === 'select', current: txt(cand).slice(0, 120), display: disp.slice(0, 120) };+}+function nativeSetFn(x, y, optionText) {+ var el = document.elementFromPoint(x, y); while (el && el.tagName !== 'SELECT') el = el.parentElement;+ if (!el) return { ok: false, error: 'native select not found at point' };+ var ot = (optionText + '').toLowerCase(), opts = [].slice.call(el.options), m = null;+ for (var i = 0; i < opts.length; i++) { if ((opts[i].text || '').replace(/\s+/g, ' ').trim().toLowerCase() === ot) { m = opts[i]; break; } }+ if (!m) for (var j = 0; j < opts.length; j++) { if ((opts[j].text || '').toLowerCase().indexOf(ot) >= 0) { m = opts[j]; break; } }+ if (!m) return { ok: false, error: 'option not found in select: ' + optionText };+ el.value = m.value; el.dispatchEvent(new Event('input', { bubbles: true })); el.dispatchEvent(new Event('change', { bubbles: true }));+ return { ok: true, selected: (m.text || '').replace(/\s+/g, ' ').trim() };+}+function findOptionFn(optionText) {+ function deepQ(root, s) { var a = []; (function w(n) { try { a.push.apply(a, n.querySelectorAll(s)); } catch (e) {} var k; try { k = n.querySelectorAll('*'); } catch (e) { k = []; } for (var i = 0; i < k.length; i++) { var sr = k[i].shadowRoot; if (sr) w(sr); } })(root || document); return a; }+ function txt(e) { return ((e.innerText || e.textContent || '') + '').replace(/\s+/g, ' ').trim(); }+ var ot = (optionText + '').toLowerCase();+ var opts = deepQ(document, '[role=option],[role=menuitem],[role=menuitemradio],[role=treeitem],[role=row],li,option,a,button').filter(function (e) { var r = e.getBoundingClientRect(), s = getComputedStyle(e); return r.width > 1 && r.height > 1 && s.visibility !== 'hidden' && s.display !== 'none' && parseFloat(s.opacity || '1') > 0.01; });+ var exact = opts.filter(function (e) { return txt(e).toLowerCase() === ot; });+ var cont = exact.length ? exact : opts.filter(function (e) { return txt(e).toLowerCase().indexOf(ot) >= 0; });+ if (!cont.length) return { found: false, optionCount: opts.length };+ cont.sort(function (a, b) { var ra = a.getBoundingClientRect(), rb = b.getBoundingClientRect(); return (ra.width * ra.height) - (rb.width * rb.height); }); // smallest = leaf-most option row+ var el = cont[0], r = el.getBoundingClientRect();+ return { found: true, x: Math.round(r.left + r.width / 2), y: Math.round(r.top + r.height / 2), text: txt(el).slice(0, 120), exact: exact.length > 0, optionCount: opts.length };+}+export async function selectOption(tabId, args) {+ await attach(tabId);+ const timeoutMs = Math.min(Math.max(Number(args.timeoutMs) || 4000, 500), 20000);+ const inj = (func, a) => chrome.scripting.executeScript({ target: { tabId }, func, args: a }).then((r) => r && r[0] && r[0].result);+ const t = await inj(selectTriggerFn, [args.label || '', args.near || '']);+ if (!t || !t.found) return { ok: false, error: 'dropdown trigger not found' + (args.label ? ` for label "${args.label}"` : '') + (args.near ? ` near "${args.near}"` : ''), _hint: 'pass label (the control\'s accessible name / visible text) or near (a visible text label beside it)' };+ if (t.isNativeSelect) {+ const r = await inj(nativeSetFn, [t.x, t.y, args.optionText || '']);+ return r && r.ok ? { ok: true, selected: r.selected, via: 'native-select', matchedExact: true, _hint: 'set a native <select> by option text and dispatched change.' } : { ok: false, error: (r && r.error) || 'native select failed' };+ }+ await mouseClick(tabId, t.x, t.y); // open (isTrusted)+ let opt = null; const deadline = Date.now() + timeoutMs; let lastCount = 0;+ while (Date.now() < deadline) {+ await new Promise((r) => setTimeout(r, 150));+ const o = await inj(findOptionFn, [args.optionText || '']);+ if (o) { lastCount = o.optionCount || lastCount; if (o.found) { opt = o; break; } }+ }+ if (!opt) return { ok: false, error: `option "${args.optionText}" did not appear after opening the dropdown (${lastCount} option(s) seen)`, _hint: 'the trigger opened but no option matched that EXACT visible text; check spelling/case, or raise timeoutMs for a slow/virtualized list' };+ await mouseClick(tabId, opt.x, opt.y); // pick (isTrusted)+ await new Promise((r) => setTimeout(r, 220));+ const after = await inj(selectTriggerFn, [args.label || '', args.near || '']);+ // Prefer the trigger's now-visible display text; fall back to the option we clicked.+ const selected = (after && after.found && (after.display || after.current)) || opt.text;+ return { ok: true, selected, matchedExact: opt.exact, clickedText: opt.text, via: 'custom-dropdown', _hint: `selected "${selected}". Verify it matches what you intended before continuing.` };+}++// ---- annotated overlay shot: draw every candidate's rect (numbered), chosen highlighted, click crosshair.+// Injected into the page, CDP-screenshotted, then removed — so the overlay never lingers. Rects are+// viewport-relative (getBoundingClientRect), and we capture the viewport, so fixed-positioned boxes align.+function overlayDrawFn(boxes, chosenIndex, clickX, clickY) {+ var id = '__adom_finder_overlay__'; var prev = document.getElementById(id); if (prev) prev.remove();+ var o = document.createElement('div'); o.id = id; o.style.cssText = 'position:fixed;inset:0;z-index:2147483646;pointer-events:none;margin:0;padding:0;';+ for (var i = 0; i < boxes.length; i++) {+ var c = boxes[i], r = c.rect, chosen = (i === chosenIndex);+ var col = chosen ? '#00e676' : (c.occluded ? '#ff5252' : '#ffb300');+ var b = document.createElement('div');+ b.style.cssText = 'position:fixed;left:' + r.x + 'px;top:' + r.y + 'px;width:' + r.w + 'px;height:' + r.h + 'px;border:' + (chosen ? '3px' : '2px') + ' solid ' + col + ';box-sizing:border-box;background:' + (chosen ? 'rgba(0,230,118,0.14)' : 'transparent') + ';';+ var lab = document.createElement('div'); lab.textContent = i + (chosen ? ' ★' : '') + (c.occluded ? ' ⛔' : '');+ lab.style.cssText = 'position:absolute;top:-15px;left:0;font:bold 12px/14px monospace;color:#000;background:' + col + ';padding:0 4px;border-radius:3px;white-space:nowrap;';+ b.appendChild(lab); o.appendChild(b);+ }+ if (clickX != null && clickY != null) {+ var ch = document.createElement('div'); ch.style.cssText = 'position:fixed;left:' + (clickX - 11) + 'px;top:' + (clickY - 11) + 'px;width:22px;height:22px;pointer-events:none;';+ ch.innerHTML = '<svg width="22" height="22"><line x1="11" y1="0" x2="11" y2="22" stroke="#2979ff" stroke-width="2"/><line x1="0" y1="11" x2="22" y2="11" stroke="#2979ff" stroke-width="2"/><circle cx="11" cy="11" r="7" fill="none" stroke="#2979ff" stroke-width="2"/></svg>';+ o.appendChild(ch);+ }+ (document.body || document.documentElement).appendChild(o);+}+function overlayClearFn() { var o = document.getElementById('__adom_finder_overlay__'); if (o) o.remove(); }++async function annotateShot(tabId, candidates, chosenIndex, x, y) {+ try {+ const boxes = candidates.map((c) => ({ rect: c.rect, occluded: !!c.occluded }));+ await chrome.scripting.executeScript({ target: { tabId }, func: overlayDrawFn, args: [boxes, chosenIndex == null ? -1 : chosenIndex, x == null ? null : x, y == null ? null : y] });+ const shot = await screenshot(tabId, { fullPage: false, maxWidth: 1568, resize: true });+ await chrome.scripting.executeScript({ target: { tabId }, func: overlayClearFn }).catch(() => {});+ return { base64: shot.base64, width: shot.width, height: shot.height };+ } catch (e) {+ try { await chrome.scripting.executeScript({ target: { tabId }, func: overlayClearFn }); } catch (_) {}+ return { __error: String((e && e.message) || e) };+ }+}++// Build the debug `finder` object (incl. shots + annotatedShot as base64; the bridge spills them to files).+async function buildFinder(tabId, query, candidates, chosenIndex, x, y, withShots) {+ const finder = { query, chosenIndex, candidates };+ if (withShots !== false) {+ try { const raw = await screenshot(tabId, { fullPage: false, maxWidth: 1568, resize: true }); if (raw && raw.base64) finder.shots = [{ label: 'viewport-raw', base64: raw.base64 }]; } catch (e) {}+ const ann = await annotateShot(tabId, candidates, chosenIndex, x, y);+ if (ann && ann.base64) finder.annotatedShot = { base64: ann.base64, width: ann.width, height: ann.height };+ else if (ann && ann.__error) finder.annotatedShotError = ann.__error;+ }+ return finder;+}++// Resolve target xy. When `rich`, also return the full candidate set + chosenIndex + structured query.+async function resolveCoords(tabId, args, rich) {+ if (args.selector) {+ if (rich) {+ const cands = await evaluate(tabId, `(${selectorCandidatesFn.toString()})(${JSON.stringify(args.selector)})`);+ const list = Array.isArray(cands) ? cands : [];+ const ci = Math.max(0, list.findIndex((c) => c.chosen));+ if (!list.length) return { found: false, candidates: [], chosenIndex: -1, query: { selector: args.selector, intent: args.intent } };+ const el = list[ci] || list[0];+ return { found: true, x: el.x, y: el.y, info: { selector: args.selector, matchedCount: list.length, clickedRect: el.rect, clickedText: el.text }, candidates: list, chosenIndex: ci, query: { selector: args.selector, intent: args.intent } };+ }+ const r = await evaluate(tabId, scoreExpr(args.selector, !!args.firstMatch));+ if (!r || !r.found) return { found: false };+ return { found: true, x: r.x, y: r.y, info: { selector: args.selector, matchedCount: r.count, clickedRect: r.rect, clickedText: r.text, modalDetected: r.modal } };+ }+ if (args.text) { // click/hover BY VISIBLE TEXT — the smart-click path+ const f = await find(tabId, args.text, rich ? 16 : 10, rich);+ if (!f.ok || !f.elements.length) return { found: false, candidates: [], chosenIndex: -1, query: { text: args.text, role: args.role, intent: args.intent } };+ const el = f.elements[0];+ const list = rich ? f.elements.map((c, i) => ({ index: i, ...c, chosen: i === 0 })) : null;+ return { found: true, x: el.x, y: el.y, info: { matchedText: el.text, matchedTag: el.tag, via: 'text', candidates: f.count }, candidates: list, chosenIndex: rich ? 0 : undefined, query: { text: args.text, role: args.role, intent: args.intent } };+ }+ return { found: true, x: args.x, y: args.y, info: {}, candidates: rich ? [] : undefined, chosenIndex: rich ? -1 : undefined, query: { intent: args.intent } };+}++// type ∈ move | hover | click | dblclick | rightclick | key | type+// args.cursor:true shows the gliding visual cursor (demos); off by default for fast automation.+export async function inputDispatch(tabId, args) {+ await attach(tabId);+ const _m = modalGuard(tabId); if (_m && !args.resolveOnly) return _m; // a native dialog is blocking the page+ const type = (args.type || 'click').toLowerCase();+ const showCursor = args.cursor === true;+ const resolveOnly = args.resolveOnly === true; // dry-run: resolve + return finder, dispatch NOTHING+ const wantFinder = resolveOnly || args.debug === true; // opt-in; default responses stay lean+ try {+ if (type === 'key') {+ if (resolveOnly) return { ok: true, resolveOnly: true, dispatched: false, key: args.key, _hint: 'key press has no finder target to resolve' };+ await pressKey(tabId, args.key); return { ok: true, dispatched: 'key', key: args.key };+ }+ if (type === 'type') {+ let finder = null;+ if (wantFinder) {+ if (args.selector) { const c = await resolveCoords(tabId, args, true); finder = await buildFinder(tabId, c.query || { selector: args.selector }, c.candidates || [], c.chosenIndex == null ? -1 : c.chosenIndex, c.x, c.y); }+ else finder = { query: { intent: args.intent }, chosenIndex: -1, candidates: [], _note: 'typing into the focused field — no selector to resolve' };+ }+ if (resolveOnly) return { ok: true, resolveOnly: true, dispatched: false, ...(finder ? { finder } : {}) };+ if (args.selector) {+ const f = await evaluate(tabId, focusExpr(args.selector));+ if (!f || !f.found) return { ok: false, error: `selector not found: ${args.selector}`, _hint: 'check the selector or wait for the field', ...(finder ? { finder } : {}) };+ }+ await send(tabId, 'Input.insertText', { text: String(args.text || '') });+ return { ok: true, dispatched: 'type', length: String(args.text || '').length, via: args.selector ? 'selector' : 'focused', ...(finder ? { finder } : {}) };+ }+ // pointer types: move | hover | click | dblclick | rightclick | wheel+ // nb-bridge#41 item 2: an UNKNOWN type used to fall through to a click (four "zoom" attempts became four+ // clicks on a canvas editor with a tool armed). Refuse it by name instead.+ const KNOWN = ['click', 'dblclick', 'doubleclick', 'rightclick', 'contextmenu', 'move', 'hover', 'wheel', 'mousewheel', 'scroll'];+ if (!KNOWN.includes(type)) {+ return { ok: false, errorCode: 'unknown_input_type', dispatched: false,+ error: `unknown_input_type: "${args.type}" is not an input type I dispatch - refusing rather than guessing (an unknown type used to become a CLICK).`,+ _hint: 'type must be one of click | dblclick | rightclick | move | hover | wheel | type | key. For a mouse wheel / canvas zoom use type:"wheel" with deltaX/deltaY (or nbrowser_scroll).' };+ }+ const isWheel = (type === 'wheel' || type === 'mousewheel' || type === 'scroll');+ const c = await resolveCoords(tabId, args, wantFinder);+ if (!c.found) {+ const out = { ok: false, error: `target not found: ${args.selector || args.text || '(no selector/text)'}`, _hint: 'element not visible/present; wait or refine the selector/text' };+ if (wantFinder) out.finder = { query: c.query || {}, chosenIndex: -1, candidates: c.candidates || [] };+ return out;+ }+ const { x, y, info } = c;+ // GUARD ambiguous_target — clicking BY TEXT that matches >1 visible element is refused; demand a+ // disambiguating selector (or pass acceptAmbiguous:true / index to override). Stops the AI clicking the+ // wrong one of two "Next"/"Back" buttons or three "Yes" radios.+ if (/click/.test(type) && info.via === 'text' && Number(info.candidates) > 1 && args.acceptAmbiguous !== true && args.index == null && !resolveOnly) {+ return { ok: false, errorCode: 'ambiguous_target',+ error: `ambiguous_target: text "${args.text}" matches ${info.candidates} visible elements — refusing to guess which to click.`,+ _hint: 'Pass a unique selector (nbrowser_click {selector:"…"}), or {debug:true} to see the numbered candidate overlay and pick one, or acceptAmbiguous:true to accept the top-scored match.',+ ...(wantFinder ? { finder: await buildFinder(tabId, c.query || {}, c.candidates || [], c.chosenIndex == null ? 0 : c.chosenIndex, x, y) } : {}) };+ }+ // GUARD stale_coordinates — a raw {x,y} pointer verb is only safe if the page hasn't scrolled since+ // those coords were computed. Require the scroll baseline (atScrollX/atScrollY, from a screenshot's+ // coordMap) and refuse if it drifted — a cached pixel silently becomes a DIFFERENT element after a+ // scroll/re-render (the "Next" that turned into "Back"). Re-resolve by selector/text instead.+ // nb-bridge#41 item 1: the gate applies to CLICKS only. A hover, move or wheel is read-only pointer motion:+ // a stale hover shows the wrong tooltip, it never presses the wrong control. Those accept raw {x,y}.+ if (/click|contextmenu/.test(type) && !args.selector && !args.text && (args.x != null || args.y != null) && !resolveOnly) {+ const live = await evaluate(tabId, '({sx:Math.round(scrollX),sy:Math.round(scrollY)})');+ const bx = args.atScrollX, by = args.atScrollY;+ if (bx == null && by == null) {+ return { ok: false, errorCode: 'stale_coordinates',+ error: `stale_coordinates: a raw {x,y} ${type} carries no freshness proof - the page may have scrolled/re-rendered since those coords were valid.`,+ _hint: 'Prefer nbrowser_click {selector:"…"} or {text:"…"} (re-resolved live). If you must click by pixel, pass atScrollX/atScrollY from your latest screenshot coordMap so I can verify the page has not scrolled.' };+ }+ if ((bx != null && Math.abs(Number(bx) - live.sx) > 2) || (by != null && Math.abs(Number(by) - live.sy) > 2)) {+ return { ok: false, errorCode: 'stale_coordinates',+ error: `stale_coordinates: the page scrolled since these coords were captured (baseline ${bx},${by}; now ${live.sx},${live.sy}) — {x:${args.x},y:${args.y}} now points at different content.`,+ _hint: 'Re-screenshot and re-resolve, or click by selector/text. Never reuse pixel coordinates across a scroll or re-render.' };+ }+ }+ // Build the debug finder (candidates + scores + annotated overlay shot) BEFORE acting, so a+ // resolveOnly dry-run and a debug click see the same pre-action page state.+ const finder = wantFinder ? await buildFinder(tabId, c.query || {}, c.candidates || [], c.chosenIndex == null ? 0 : c.chosenIndex, x, y) : null;+ const fin = finder ? { finder } : {};+ if (resolveOnly) return { ok: true, resolveOnly: true, dispatched: false, x, y, ...info, ...fin };+ // The cursor label narrates intention (Hydrogen-style). args.intent overrides; else auto from element text.+ const intent = args.intent || args.label;+ const tgt = (info.clickedText || info.matchedText || '').trim().slice(0, 40);+ if (showCursor) { await cursor(tabId, 'move', x, y, intent || phaseLabel(type, tgt, 'find')); await sleep(args.cursorGlideMs || 450); }+ if (isWheel) {+ // Real CDP wheel (isTrusted): what a canvas camera (Babylon ArcRotateCamera, three.js OrbitControls) and+ // a scrollable pane both listen for. deltaY < 0 zooms in / scrolls up, > 0 zooms out / scrolls down.+ const deltaX = Number(args.deltaX) || 0, deltaY = Number(args.deltaY) || 0;+ if (!deltaX && !deltaY) return { ok: false, errorCode: 'bad_args', error: 'wheel needs deltaX and/or deltaY (px; deltaY:-300 zooms in / scrolls up)' };+ const steps = Math.max(1, Math.min(20, Number(args.steps) || 1));+ const before = await evaluate(tabId, '({sx:Math.round(scrollX),sy:Math.round(scrollY)})');+ await send(tabId, 'Input.dispatchMouseEvent', { type: 'mouseMoved', x, y });+ for (let i = 0; i < steps; i++) {+ await send(tabId, 'Input.dispatchMouseEvent', { type: 'mouseWheel', x, y, deltaX: deltaX / steps, deltaY: deltaY / steps });+ if (steps > 1) await sleep(Number(args.stepDelayMs) || 40);+ }+ await sleep(Number(args.settleMs) || 150);+ const after = await evaluate(tabId, '({sx:Math.round(scrollX),sy:Math.round(scrollY)})');+ return { ok: true, dispatched: 'wheel', x, y, deltaX, deltaY, steps, scrollBefore: before, scrollAfter: after,+ pageScrolled: !!(before && after && (before.sx !== after.sx || before.sy !== after.sy)), ...info, ...fin,+ _hint: 'isTrusted wheel at that point. pageScrolled:false is normal on a canvas (the app consumed the wheel, e.g. a camera zoom); verify the effect with nbrowser_screenshot or an eval of the app state.' };+ }+ if (type === 'move' || type === 'hover') {+ if (showCursor) await cursor(tabId, 'move', x, y, intent || phaseLabel(type, tgt, 'act'));+ // Approach from just outside, then settle on center, then dwell — a single mouseMoved+ // often fails to fire mouseover/mouseenter or let a CSS-transition tooltip paint. Moving+ // INTO the element generates the enter/over events real hover needs, and the dwell lets+ // delayed/animated tooltips appear before the caller screenshots.+ await send(tabId, 'Input.dispatchMouseEvent', { type: 'mouseMoved', x: x - 24, y: y - 24 });+ await sleep(40);+ await send(tabId, 'Input.dispatchMouseEvent', { type: 'mouseMoved', x, y }); // triggers :hover / mouseover / tooltips+ await sleep(args.hoverDwellMs || 600);+ return { ok: true, dispatched: type, x, y, intentLabel: intent || phaseLabel(type, tgt, 'act'), ...info, ...fin };+ }+ if (showCursor) await cursor(tabId, 'click', x, y, intent || phaseLabel(type, tgt, 'act')); // ripple + label+ if (type === 'dblclick' || type === 'doubleclick') {+ await mouseClick(tabId, x, y, 'left', 1); await mouseClick(tabId, x, y, 'left', 2);+ return { ok: true, dispatched: 'dblclick', x, y, ...info, ...fin };+ }+ if (type === 'rightclick' || type === 'contextmenu') {+ await mouseClick(tabId, x, y, 'right', 1);+ return { ok: true, dispatched: 'rightclick', x, y, ...info, ...fin };+ }+ await mouseClick(tabId, x, y, args.button || 'left', args.clickCount || 1);+ return { ok: true, dispatched: 'click', via: args.selector ? 'selector' : 'coords', x, y, ...info, ...fin };+ } finally {+ bumpIdle(tabId);+ }+}++// ---- nbrowser_autologin: inject the bridge-decrypted saved password into the password field ----------+// The bridge decrypts the saved password on the laptop and passes it as args.__secret (laptop-local, over+// native-messaging stdio). This handler injects it via CDP isTrusted keystrokes and submits. SECURITY:+// __secret is NEVER returned, logged, or echoed — only {ok, loggedIn, needs2FA, host, url}.+function probeLoginFn() {+ function vis(el) { var r = el.getBoundingClientRect(), s = getComputedStyle(el); return r.width > 1 && r.height > 1 && s.visibility !== 'hidden' && s.display !== 'none' && parseFloat(s.opacity || '1') > 0.01; }+ function info(el) { if (!el) return null; var r = el.getBoundingClientRect(); return { x: Math.round(r.left + r.width / 2), y: Math.round(r.top + r.height / 2), name: el.name || '', id: el.id || '', hasVal: !!(el.value && el.value.length) }; }+ var pw = [].slice.call(document.querySelectorAll('input[type=password]')).filter(vis)[0];+ var user = [].slice.call(document.querySelectorAll('input[type=email],input[autocomplete=username],input[name*="user" i],input[name*="email" i],input[id*="user" i],input[id*="email" i],input[type=text]')).filter(vis)[0];+ return { pw: info(pw), user: info(user), url: location.href };+}+function assessLoginFn() {+ var url = location.href; var body = ((document.body && document.body.innerText) || '').toLowerCase();+ var hasPw = !!document.querySelector('input[type=password]');+ var twofa = /(verification code|two[- ]factor|2[- ]step|enter the code|authenticator app|one[- ]time (code|passcode)|security code|6[- ]digit|passcode we (sent|texted)|verify it's you)/.test(body);+ var failed = /(incorrect|wrong password|invalid (email|password)|try again|password isn't right)/.test(body) && hasPw;+ return { url: url, hasPw: hasPw, twofa: twofa, failed: failed };+}+async function focusType(tabId, fld, text) {+ await mouseClick(tabId, fld.x, fld.y); await sleep(70);+ if (fld.hasVal) { for (let i = 0; i < 60; i++) await pressKey(tabId, 'Backspace'); } // clear pre-fills+ await send(tabId, 'Input.insertText', { text: String(text) });+}+export async function autologin(tabId, args) {+ await attach(tabId);+ const secret = args && args.__secret; // injected by the bridge; do NOT log/return this+ const username = args && args.username;+ try {+ if (!secret) return { ok: false, error: 'no credential available' + (args && args.__secretError ? ': ' + args.__secretError : ''), _hint: 'autologin needs confirm:true + host, and a saved login for that host in this profile. Decryption happens in the in-Chrome native host; if __secretError shows, the saved password could not be decrypted/found.' };+ let probe = await evaluate(tabId, `(${probeLoginFn.toString()})()`);+ // Split form: no password field yet → fill username + advance to the password step.+ if (probe && !probe.pw) {+ if (probe.user && username) {+ await focusType(tabId, probe.user, String(username));+ await pressKey(tabId, 'Enter');+ await sleep(1800);+ probe = await evaluate(tabId, `(${probeLoginFn.toString()})()`);+ }+ if (!probe || !probe.pw) return { ok: false, loggedIn: false, step: 'username', url: (probe && probe.url) || '', _hint: 'Filled username + advanced but no password field appeared yet — wait for the password step (or click Sign in/Continue), then call nbrowser_autologin again.' };+ }+ // Username present + empty on the same page → fill it (not secret).+ if (probe.user && !probe.user.hasVal && username) await focusType(tabId, probe.user, String(username));+ // Inject the secret into the password field via real keystrokes (fires framework onChange).+ await focusType(tabId, probe.pw, secret);+ await sleep(120);+ await pressKey(tabId, 'Enter'); // submit+ await sleep(2800);+ const a = await evaluate(tabId, `(${assessLoginFn.toString()})()`);+ const url = (a && a.url) || '';+ const needs2FA = !!(a && a.twofa);+ const loggedIn = !needs2FA && a && !a.hasPw && !a.failed;+ return { ok: true, loggedIn, needs2FA, host: args.host, url,+ _hint: loggedIn ? 'Logged in (no password form remains).' : needs2FA ? '2FA gate detected — a human verification code (or a TOTP/passkey path) is required; nbrowser_screenshot to see it.' : a && a.failed ? 'Credentials rejected — saved password may be stale.' : 'Submitted but a password form is still present; the submit button may differ — nbrowser_screenshot, then nbrowser_click the explicit Sign in button.' };+ } finally { bumpIdle(tabId); }+}++// ---- nbrowser_login_form: detect a login form's state + recommend the SMARTEST path -----------------+// The #1 rule of login automation: if the browser already AUTOFILLED the form, just SUBMIT it (fully+// background, no autofill popup, no SSO). This inspects the current page and tells the AI which path to take.+function loginFormFn() {+ function vis(el) { var r = el.getBoundingClientRect(), s = getComputedStyle(el); return r.width > 1 && r.height > 1 && s.visibility !== 'hidden' && s.display !== 'none' && parseFloat(s.opacity || '1') > 0.01; }+ function txt(b) { return ((b.innerText || b.value || b.getAttribute('aria-label') || '') + '').replace(/\s+/g, ' ').trim(); }+ function sel(el) { if (!el) return null; if (el.id) return '#' + el.id; if (el.name) return (el.tagName.toLowerCase() + '[name="' + el.name + '"]'); return el.tagName.toLowerCase(); }+ var pw = [].slice.call(document.querySelectorAll('input[type=password]')).filter(vis)[0];+ var user = [].slice.call(document.querySelectorAll('input[type=email],input[autocomplete=username],input[name*="user" i],input[name*="email" i],input[id*="email" i],input[id*="user" i],input[type=text]')).filter(vis)[0];+ var btns = [].slice.call(document.querySelectorAll('button,input[type=submit],[role=button],a[href]')).filter(vis);+ var submit = btns.find(function (b) { return /^(sign in|log ?in|continue|submit|next|verify)$/i.test(txt(b)); })+ || btns.find(function (b) { return /sign in|log ?in|continue|submit/i.test(txt(b)) && !/google|microsoft|apple|sso|with/i.test(txt(b)); })+ || (pw && pw.form && pw.form.querySelector('button[type=submit],input[type=submit],button'));+ var sso = btns.filter(function (b) { return /(sign in|continue|log ?in) with (google|microsoft|apple|github|okta|saml|sso)|use (sso|single sign)/i.test(txt(b)); }).map(txt).slice(0, 5);+ var twofa = /(verification code|two[- ]factor|2[- ]step|authenticator app|enter the (\d|six)[- ]?digit|security code|one[- ]time)/i.test((document.body && document.body.innerText || '').toLowerCase());+ return {+ hasPasswordField: !!pw,+ passwordFilled: !!(pw && pw.value && pw.value.length > 0),+ usernameField: !!user,+ usernameFilled: !!(user && user.value && user.value.length > 0),+ usernameValue: user ? (user.value || '').slice(0, 60) : '', // email/username is NOT a secret — lets the AI confirm the right account+ submitText: submit ? txt(submit) : '',+ submitSelector: sel(submit),+ ssoOptions: sso,+ twoFactorPrompt: twofa,+ url: location.href,+ };+}+export async function detectLoginForm(tabId) {+ await attach(tabId);+ const f = await evaluate(tabId, `(${loginFormFn.toString()})()`);+ bumpIdle(tabId);+ if (!f || f.__error) return { ok: false, error: (f && f.__error) || 'eval failed' };+ let recommendation, action;+ if (f.twoFactorPrompt) { recommendation = '2FA gate — needs a human verification code (authenticator/SMS). Surface it to the user; cannot be automated.'; action = '2fa'; }+ else if (f.hasPasswordField && f.passwordFilled) { recommendation = `✅ FORM IS AUTOFILLED${f.usernameValue ? ' for ' + f.usernameValue : ''} — the browser pre-filled the credentials. JUST SUBMIT IT (fully background, no popup/SSO): nbrowser_click {text:"${f.submitText || 'Sign in'}"}${f.submitSelector ? ' or {selector:"' + f.submitSelector + '"}' : ''}.`; action = 'submit'; }+ else if (f.ssoOptions.length) { recommendation = `Form not autofilled, but SSO is available (${f.ssoOptions.join(', ')}). Prefer SSO — clicking it uses the existing identity-provider session (background, no password): nbrowser_click {text:"${f.ssoOptions[0]}"}.`; action = 'sso'; }+ else if (f.hasPasswordField) { recommendation = 'Password form present but NOT autofilled. Fill the email (background) if split, then summon the browser autofill popup (~1s foreground: click the password field → desktop_screenshot_window owned popup → click the saved-login row). See the driving-the-desktop skill.'; action = 'autofill-popup'; }+ else if (f.usernameField) { recommendation = 'Email/username step (no password field yet). Type the email (background) + click Continue, then re-check nbrowser_login_form on the password step.'; action = 'username-step'; }+ else { recommendation = 'No login form detected on this page.'; action = 'none'; }+ return { ok: true, action, recommendation, ...f, _hint: 'ALWAYS call this before driving a login. If passwordFilled=true, the browser already autofilled it — just submit (background); never reach for the autofill popup or SSO when the form is already filled.' };+}++// ---- native JS dialogs (alert/confirm/prompt/beforeunload) — modal detection ----------------------+// These are NOT OS child windows (no hwnd) - Chrome renders them itself and they BLOCK the page's main+// thread, so an AI that doesn't know one is up will keep firing clicks/eval into a frozen page (real+// incident: an Intuit "questionnaire saved" alert sat over the form while the AI kept trying to fill it).+// We catch Page.javascriptDialogOpening (the debugger is attached during any CDP drive), record it, and+// surface it on the session's NEXT verb (modal_open) with an easy way to dismiss it.+const _openDialogs = new Map(); // tabId -> { type, message, defaultPrompt, ts } (currently OPEN)+const _dialogPolicy = new Map(); // tabId -> 'auto' (default: never wedge) | 'manual' (caller answers)+const _recentDialogs = []; // ring for nbrowser_events - DRAINED by whoever polls events+// ⚠️ _recentDialogs is drained by ANY events consumer (another AI thread polling nbrowser_events on this+// profile empties it), so it cannot double as the "was a dialog just auto-handled here?" memory - the+// alreadyHandled recovery and the click dialogOpened check lost their evidence to a foreign drain+// (found 2026-08-22: the Fusion thread's event polling raced the verb battery). Per-tab last-dialog+// memory lives here instead, and nothing drains it.+const _lastDialog = new Map(); // tabId -> { dialogType, message, autoHandled, accepted, ts }+export function dialogFor(tabId) { return _openDialogs.get(Number(tabId)) || null; }+export function lastDialogFor(tabId) { return _lastDialog.get(Number(tabId)) || null; }+export function drainDialogs() { const d = _recentDialogs.splice(0); return d; }+// nbrowser_handle_dialog — control native JS dialogs. GROUND TRUTH (measured 2026-08-22, Chrome 139,+// john's laptop): while a chrome.debugger client is attached, CHROME ITSELF cancels page dialogs -+// Page.javascriptDialogOpening never reaches the extension (confirm() returns false instantly, our+// listener sees nothing). So the CDP arming design ("dialog stays open, answer it later") CANNOT work,+// and it silently didn't: policy:'manual' armed a listener Chrome never called. The honest, working+// design is a PAGE-LEVEL PRE-ANSWER: arming overrides window.confirm/alert/prompt in the page to record+// the call and return YOUR preset answer, and disarming restores the originals and reports what was+// seen. The CDP path below stays for the (non-attached / other-browser) case where it does fire.+export async function handleDialog(tabId, accept, promptText, policy) {+ await attach(tabId);+ const id = Number(tabId);+ if (policy === 'manual' || policy === 'auto') {+ _dialogPolicy.set(id, policy);+ if (policy === 'manual') {+ const armed = await evaluate(tabId,+ `(function(){ if (window.__nbDlg && window.__nbDlg.armed) { window.__nbDlg.accept=${accept !== false}; window.__nbDlg.text=${JSON.stringify(promptText != null ? String(promptText) : null)}; return 're-armed'; }+ var w=window; w.__nbDlg={armed:true, accept:${accept !== false}, text:${JSON.stringify(promptText != null ? String(promptText) : null)}, seen:[], orig:{c:w.confirm,a:w.alert,p:w.prompt}};+ w.confirm=function(m){ w.__nbDlg.seen.push({type:'confirm',message:String(m),answered:w.__nbDlg.accept,ts:Date.now()}); return w.__nbDlg.accept; };+ w.alert=function(m){ w.__nbDlg.seen.push({type:'alert',message:String(m),answered:true,ts:Date.now()}); };+ w.prompt=function(m,d){ var ans=w.__nbDlg.accept ? (w.__nbDlg.text!=null?w.__nbDlg.text:(d||'')) : null; w.__nbDlg.seen.push({type:'prompt',message:String(m),answered:ans,ts:Date.now()}); return ans; };+ return 'armed'; })()`);+ return { ok: true, policy: 'manual', armed: true, mode: String(armed), preAnswer: { accept: accept !== false, text: promptText != null ? String(promptText) : null }, tabId: String(id),+ _hint: `Manual mode ARMED as a pre-answer: the NEXT dialog(s) on this tab are answered ${accept !== false ? 'ACCEPT (OK/Yes)' : 'DISMISS (Cancel)'} the instant they fire${promptText != null ? `, prompts answered "${String(promptText).slice(0, 40)}"` : ''}. This is a page-level override (Chrome cancels dialogs itself while a debugger is attached, so a dialog can never be held open for a later answer). Do the action now, then policy:"auto" to disarm and get the list of dialogs seen. The override does NOT survive a page navigation - re-arm after navigating.` };+ }+ // policy:'auto' - restore the page's own dialog functions and report what the arming saw.+ const seen = await evaluate(tabId,+ `(function(){ var w=window, d=w.__nbDlg; if (!d) return []; if (d.orig){ w.confirm=d.orig.c; w.alert=d.orig.a; w.prompt=d.orig.p; } var s=d.seen||[]; delete w.__nbDlg; return s; })()`).catch(() => []);+ return { ok: true, policy: 'auto', disarmed: true, seen: Array.isArray(seen) ? seen : [], tabId: String(id),+ _hint: 'Auto mode restored (page dialog functions un-overridden). While driving, Chrome auto-cancels dialogs the moment they open (confirm/prompt return the dismiss value, alerts just close) so the page can never wedge - to ANSWER one deliberately, arm the pre-answer first: {policy:"manual", accept:true|false, text?}. `seen` lists every dialog the arming answered.' };+ }+ const dlg = _openDialogs.get(id);+ try { await send(tabId, 'Page.handleJavaScriptDialog', { accept: accept !== false, promptText: promptText != null ? String(promptText) : undefined }); }+ catch (e) {+ const auto = ((_lastDialog.get(id) || {}).autoHandled ? _lastDialog.get(id) : null)+ || [..._recentDialogs].reverse().find((x) => x.tabId === String(id) && x.autoHandled);+ if (auto) return { ok: true, handled: false, alreadyHandled: true, was: auto, tabId: String(id),+ _hint: `No dialog was open: a ${auto.dialogType} ("${String(auto.message).slice(0, 80)}") was AUTO-${auto.accepted ? 'ACCEPTED' : 'DISMISSED'} the moment it opened, so the page never blocked. To answer one yourself, arm the PRE-ANSWER first: nbrowser_handle_dialog {sessionId, policy:"manual", accept:true}, then do the action, then policy:"auto" to disarm.` };+ // Pre-answer memory: the page-level arming records every dialog it answered in window.__nbDlg.seen.+ const seen = await evaluate(tabId, '(window.__nbDlg && window.__nbDlg.seen) || []').catch(() => []);+ if (Array.isArray(seen) && seen.length) {+ const last = seen[seen.length - 1];+ return { ok: true, handled: false, alreadyHandled: true, was: last, tabId: String(id),+ _hint: `No dialog is open: your ARMED pre-answer already answered a ${last.type} ("${String(last.message).slice(0, 80)}") with ${JSON.stringify(last.answered)}. Disarm + collect the full list with {policy:"auto"}.` };+ }+ return { ok: false, error: 'no dialog to handle (it may have already closed): ' + ((e && e.message) || e),+ _hint: 'No dialog is open. NOTE: while nbe is driving, Chrome cancels page dialogs the moment they fire (confirm returns false, alerts close) - the page can never wedge, but a dialog also can never be caught open. To ANSWER the next dialog deliberately, arm a pre-answer BEFORE the action: nbrowser_handle_dialog {sessionId, policy:"manual", accept:true|false, text?}, do the action, then {policy:"auto"} to disarm and see what was answered.' };+ }+ _openDialogs.delete(Number(tabId));+ return { ok: true, handled: true, accepted: accept !== false, was: dlg || null, tabId: String(tabId), _hint: 'Dialog dismissed — the page is unblocked. Continue driving; re-screenshot to see the current state.' };+}+// Guard called at the start of page-ACTION verbs: if a native dialog is blocking this tab, refuse the+// action and tell the AI to deal with the modal first (it cannot succeed while the page is frozen).+export function modalGuard(tabId) {+ const dlg = _openDialogs.get(Number(tabId));+ if (!dlg) return null;+ return { ok: false, errorCode: 'modal_open',+ error: `modal_open: a native ${dlg.type} dialog is blocking this tab: "${(dlg.message || '').slice(0, 140)}". The page is FROZEN until it is handled - your clicks/typing/eval will not work.`,+ dialog: { type: dlg.type, message: dlg.message },+ _hint: `Handle it first: nbrowser_handle_dialog {sessionId, accept:true} to press OK/Yes/Leave, or accept:false to Cancel/Stay${dlg.type === 'prompt' ? ', with text:"…" to answer a prompt' : ''}. Then re-screenshot and continue.` };+}++// ---- nbrowser_errors: buffered console/page errors per tab ------------------+const errorBuffers = new Map(); // tabId -> [{type,text,ts}]+chrome.debugger.onEvent.addListener((source, method, params) => {+ const tabId = source.tabId;+ if (tabId == null) return;+ if (method === 'Page.javascriptDialogOpening') {+ const d = { type: params.type || 'alert', message: params.message || '', defaultPrompt: params.defaultPrompt || '', ts: Date.now() };+ _openDialogs.set(tabId, d);+ // AUTO-HANDLE (nb-bridge #21). A native dialog BLOCKS the page's main thread, so if nobody answers+ // it every later eval/click/screenshot on that tab hangs until the window is closed - a driven+ // click on any confirm() used to kill the whole drive. The docs always promised auto-dismissal+ // while driving; this is it. Policy per dialog TYPE, biased to the SAFE answer:+ // alert -> accept (only has OK) confirm -> DISMISS (do not perform the guarded action)+ // prompt -> dismiss (never invent input) beforeunload -> accept (we asked to navigate)+ // A caller that genuinely wants OK sets policy:'manual' (nbrowser_handle_dialog) and answers itself.+ const policy = _dialogPolicy.get(tabId) || 'auto';+ const accept = d.type === 'alert' || d.type === 'beforeunload';+ if (policy === 'auto') {+ send(tabId, 'Page.handleJavaScriptDialog', { accept }).then(() => {+ _openDialogs.delete(tabId);+ _lastDialog.set(tabId, { dialogType: d.type, message: d.message, autoHandled: true, accepted: accept, ts: d.ts });+ _recentDialogs.push({ event: 'dialog', tabId: String(tabId), dialogType: d.type, message: d.message, autoHandled: true, accepted: accept, ts: d.ts,+ note: `auto-${accept ? 'accepted' : 'dismissed'} so the page could not wedge; set policy:"manual" via nbrowser_handle_dialog BEFORE the action if you need to answer it yourself` });+ while (_recentDialogs.length > 20) _recentDialogs.shift();+ }).catch(() => {+ _recentDialogs.push({ event: 'dialog', tabId: String(tabId), dialogType: d.type, message: d.message, autoHandled: false, ts: d.ts });+ while (_recentDialogs.length > 20) _recentDialogs.shift();+ });+ return;+ }+ _recentDialogs.push({ event: 'dialog', tabId: String(tabId), dialogType: d.type, message: d.message, autoHandled: false, ts: d.ts });+ while (_recentDialogs.length > 20) _recentDialogs.shift();+ return;+ }+ if (method === 'Page.javascriptDialogClosed') { const o = _openDialogs.get(tabId); _openDialogs.delete(tabId); if (o) _recentDialogs.push({ event: 'dialog_closed', tabId: String(tabId), dialogType: o.type, result: params.result, ts: Date.now() }); return; }+ if (method === 'Page.fileChooserOpened') {+ // armed by nbrowser_set_file_input {armForNextChooser:true}: satisfy the page's chooser with the+ // staged files - silently, in the background, no OS dialog ever appears.+ const arm = _chooserArm.get(tabId);+ if (arm && params.backendNodeId) {+ send(tabId, 'DOM.setFileInputFiles', { files: arm.files, backendNodeId: params.backendNodeId })+ .then(() => { _chooserServed.set(tabId, { files: arm.files.map((f) => String(f).split(/[\\/]/).pop()), ts: Date.now() }); })+ .catch(() => {})+ .finally(() => { if (arm.timer) clearTimeout(arm.timer); _chooserArm.delete(tabId); send(tabId, 'Page.setInterceptFileChooserDialog', { enabled: false }).catch(() => {}); });+ }+ return;+ }+ let entry = null;+ if (method === 'Runtime.exceptionThrown') {+ const e = params.exceptionDetails || {};+ entry = { type: 'exception', text: (e.exception && (e.exception.description || e.exception.value)) || e.text, ts: Date.now() };+ } else if (method === 'Runtime.consoleAPICalled' && params.type === 'error') {+ entry = { type: 'console.error', text: (params.args || []).map((a) => a.value ?? a.description ?? '').join(' '), ts: Date.now() };+ } else if (method === 'Log.entryAdded' && params.entry && params.entry.level === 'error') {+ entry = { type: 'log', text: params.entry.text, ts: Date.now() };+ }+ if (!entry) return;+ const buf = errorBuffers.get(tabId) || [];+ buf.push(entry);+ errorBuffers.set(tabId, buf.slice(-200));+});++export async function getErrors(tabId, clear = true) {+ const buf = errorBuffers.get(tabId) || [];+ if (clear) errorBuffers.delete(tabId);+ return { ok: true, errors: buf, count: buf.length, tabId: String(tabId) };+}++// ---- PNG helpers (OffscreenCanvas, runs in the service worker) -------------+async function bitmapFromB64(b64) {+ const bytes = Uint8Array.from(atob(b64), (c) => c.charCodeAt(0));+ return createImageBitmap(new Blob([bytes], { type: 'image/png' }));+}+async function pngDims(b64) {+ const bmp = await bitmapFromB64(b64);+ const out = { base64: b64, width: bmp.width, height: bmp.height };+ bmp.close && bmp.close();+ return out;+}+async function resizePng(b64, maxWidth) {+ const bmp = await bitmapFromB64(b64);+ const { width, height } = bmp;+ if (width <= maxWidth) { bmp.close && bmp.close(); return { base64: b64, width, height, resized: false }; }+ const w = maxWidth;+ const h = Math.round(height * (maxWidth / width));+ const canvas = new OffscreenCanvas(w, h);+ canvas.getContext('2d').drawImage(bmp, 0, 0, w, h);+ bmp.close && bmp.close();+ const blob = await canvas.convertToBlob({ type: 'image/png' });+ const buf = new Uint8Array(await blob.arrayBuffer());+ let bin = '';+ for (let i = 0; i < buf.length; i++) bin += String.fromCharCode(buf[i]);+ return { base64: btoa(bin), width: w, height: h, origWidth: width, origHeight: height, resized: true };+}++// ── #5 Robust click: click, then report whether the page ACTUALLY changed ──────+// Captures a light page signature (url, readyState, visible-text length) before and after the click,+// so callers can detect "click dispatched but nothing happened" (disabled/covered element, missed+// target) and retry. Also scrolls the target into view (resolveCoords already centers it).+export async function pageSig(tabId) {+ // t = a cheap rolling hash of the visible text, so a SMALL local mutation (a button relabelling+ // itself from "Click Me" to "Clicked") is detected. Length alone missed those and reported a+ // landed click as changed:false (nb-bridge #20).+ try { const r = await evaluate(tabId, "(function(){var s=document.body?document.body.innerText:'';var h=0;for(var i=0;i<s.length;i++){h=(h*31+s.charCodeAt(i))|0;}return {u:location.href, r:document.readyState, n:s.length, t:h};})()"); return r || {}; } catch { return {}; }+}+export async function clickChanged(tabId, args) {+ const before = await pageSig(tabId);+ let r = await inputDispatch(tabId, { ...args, type: 'click' });+ if (r && r.ok === false) return r;+ await sleep(Number(args.settleMs) || 500);+ let after = await pageSig(tabId);+ const didChange = (b, a) => (b.u !== a.u) || (a.t !== undefined && b.t !== undefined && a.t !== b.t) || Math.abs((a.n || 0) - (b.n || 0)) > 3 || (b.r !== a.r && a.r !== 'complete');+ let changed = didChange(before, after), retried = false;+ // SELF-HEAL (nb-bridge #20). A click-by-text that resolves fine and then lands nowhere is the+ // single most confusing failure this bridge had: ok:true, matchedText set, changed:false, DOM+ // untouched - because the coordinates were resolved a beat before the page settled (late layout,+ // font swap, lazy image) so the point no longer hit the element. Re-resolving and dispatching once+ // more costs ~600ms only in the failure case and turns a silent miss into a hit.+ // A click that OPENED A DIALOG froze the page - the DOM cannot change, so changed:false is expected+ // and the dialog IS the effect. Retrying here queued a second click into the frozen page, which fired+ // a SECOND confirm() the moment the first auto-dismissed (found by the verb battery, 2026-08-22).+ const dlgNow = _openDialogs.get(Number(tabId));+ const _ld = _lastDialog.get(Number(tabId));+ const dlgRecent = !changed && ((_ld && (Date.now() - _ld.ts) < 3000) ? _ld+ : [..._recentDialogs].reverse().find((x) => x.tabId === String(tabId) && (Date.now() - x.ts) < 3000));+ if (!changed && (dlgNow || dlgRecent)) {+ const d = dlgNow || dlgRecent;+ return { ...r, changed: true, retried: false, dialogOpened: { type: d.type || d.dialogType, message: d.message },+ urlBefore: before.u, urlAfter: after.u,+ _hint: `The click's effect was a native ${d.type || d.dialogType} dialog ("${String(d.message || '').slice(0, 80)}")${dlgNow ? ' which is OPEN and blocking the page - handle it: nbrowser_handle_dialog {sessionId, accept:true|false}' : ' which was auto-handled (see nbrowser_events)'} - the page DOM did not change and that is expected.` };+ }+ if (!changed && args.retry !== false) {+ await sleep(350);+ const r2 = await inputDispatch(tabId, { ...args, type: 'click' });+ if (!(r2 && r2.ok === false)) {+ await sleep(Number(args.settleMs) || 500);+ const after2 = await pageSig(tabId);+ if (didChange(before, after2)) { r = r2; after = after2; changed = true; }+ retried = true;+ }+ }+ return { ...r, changed, retried, urlBefore: before.u, urlAfter: after.u,+ _hint: changed+ ? (retried ? 'Landed on the SECOND dispatch - the first missed because the page was still settling when the coordinates were resolved. If you see this often on one site, prefer a selector over click-by-text there.' : undefined)+ : 'The click DISPATCHED TWICE and the page still did not visibly change (same URL + DOM). The element is probably disabled, covered, or the handler does nothing visible. Re-resolve the selector, nbrowser_wait_for a follow-up element, or verify with nbrowser_eval.' };+}++// ── #7 set_date: fill a react-aria segmented date field (role=spinbutton MM/DD/YYYY) ──+// Focus the field, then type each digit as a real keydown (react-aria segments advance on digit+// keydown - Input.insertText does NOT trigger them). US MM/DD/YYYY order. date = "YYYY-MM-DD".+export async function setDate(tabId, args) {+ await attach(tabId);+ const selector = args.selector, date = args.date;+ if (!selector || !date) return { ok: false, error: 'set_date needs {selector, date:"YYYY-MM-DD"}' };+ const m = /^(\d{4})-(\d{2})-(\d{2})$/.exec(String(date));+ if (!m) return { ok: false, error: 'date must be "YYYY-MM-DD"' };+ const [, Y, M, D] = m;+ const c = await resolveCoords(tabId, { selector }, false);+ if (!c.found) return { ok: false, error: `date field not found: ${selector}`, _hint: 'check the selector or wait for the field' };+ await mouseClick(tabId, c.x, c.y, 'left', 1); // focus the first segment+ await sleep(90);+ const order = (String(args.order || 'MDY').toUpperCase() === 'DMY') ? [D, M, Y] : [M, D, Y];+ for (const seg of order) { for (const ch of seg) { await pressKey(tabId, ch); await sleep(35); } await sleep(40); }+ const val = await evaluate(tabId, `(function(){var e=document.querySelector(${JSON.stringify(selector)});if(!e)return null;return e.value||e.getAttribute('aria-valuetext')||(e.textContent||'').trim().slice(0,40);})()`);+ return { ok: true, dispatched: 'set_date', date, order: (String(args.order || 'MDY').toUpperCase() === 'DMY') ? 'DMY' : 'MDY', fieldValue: val, _hint: 'Typed each digit as a real keydown so react-aria segments advance. If fieldValue looks wrong, the field may use DMY order (pass order:"DMY") or a custom widget - screenshot to check.' };+}++// ── #3 TOTP: type the current 6-digit code (computed by the BRIDGE, injected as __code) into the+// focused 2FA field. The agent never sees the code; we only report {typed}. ──+export async function typeTotp(tabId, args) {+ const code = args && args.__code;+ if (!code) return { ok: false, error: 'no code (register a seed first: nbrowser_register_totp {host, seed})' };+ await attach(tabId);+ if (args.selector) { const f = await evaluate(tabId, focusExpr(args.selector)); if (!f || !f.found) return { ok: false, error: `2FA field not found: ${args.selector}` }; }+ await send(tabId, 'Input.insertText', { text: String(code) });+ if (args.submit === true) await pressKey(tabId, 'Enter');+ return { ok: true, typed: true, digits: String(code).length, submitted: args.submit === true };+}++// ---- nbrowser_set_file_input: populate <input type=file> over CDP, NO OS picker ------------------+// The OS file dialog cannot be driven with the debugger attached (and it FOREGROUNDS), so uploads go+// through DOM.setFileInputFiles - the same primitive Puppeteer's uploadFile / Playwright's+// setInputFiles use. Two modes:+// direct (default): resolve the input (selector/text -> walk Browse-button/label to its input) and+// inject the files, then dispatch input+change so React/Angular listeners fire.+// armForNextChooser: for UIs that CREATE the input inside the Browse click handler - we intercept+// the page's next file chooser (Page.setInterceptFileChooserDialog) and satisfy it with the staged+// files when Page.fileChooserOpened fires. Auto-disarms after 30s.+// LIMITATION (documented in the bridge hint): the File System Access API (showOpenFilePicker) cannot+// be satisfied over CDP at all - those sites need their drag-drop path.+function findFileInputFn(sel, txt) {+ return `(function(){+ var sel=${JSON.stringify(sel || null)}, txt=${JSON.stringify(txt || null)};+ function fromEl(el){+ if(!el) return null;+ if(el.matches && el.matches('input[type=file]')) return el;+ if(el.tagName==='LABEL' && el.htmlFor){ var t=document.getElementById(el.htmlFor); if(t && t.type==='file') return t; }+ var lbl=el.closest && el.closest('label'); if(lbl){ var i=lbl.querySelector('input[type=file]'); if(i) return i; }+ if(el.querySelector){ var i2=el.querySelector('input[type=file]'); if(i2) return i2; }+ var form=el.closest && el.closest('form'); if(form){ var i3=form.querySelector('input[type=file]'); if(i3) return i3; }+ var p=el.parentElement; for(var d=0; p && d<4; d++,p=p.parentElement){ var i4=p.querySelector('input[type=file]'); if(i4) return i4; }+ return null;+ }+ var el=null;+ if(sel) el=document.querySelector(sel);+ else if(txt){ var t=txt.trim().toLowerCase();+ var nodes=Array.prototype.slice.call(document.querySelectorAll('button,a,label,input,[role=button],div,span'));+ el=nodes.find(function(n){ var s=(n.innerText||n.value||n.getAttribute('aria-label')||'').trim().toLowerCase(); return s && s.indexOf(t)>=0 && s.length < t.length+60; })||null;+ }+ var inp=fromEl(el);+ if(!inp && !sel && !txt) inp=document.querySelector('input[type=file]');+ if(!inp){ var all=document.querySelectorAll('input[type=file]'); if(all.length===1) inp=all[0]; }+ return inp||null;+ })()`;+}+const _chooserArm = new Map(); // tabId -> { files, timer }+const _chooserServed = new Map(); // tabId -> { files, ts }+export async function setFileInput(tabId, args) {+ await attach(tabId);+ const _m = modalGuard(tabId); if (_m) return _m;+ const files = args.filePaths || [];+ if (args.chooserStatus === true) {+ const s = _chooserServed.get(Number(tabId)) || null;+ return { ok: true, armed: _chooserArm.has(Number(tabId)), served: !!s, ...(s ? { files: s.files, servedAgoMs: Date.now() - s.ts } : {}), _hint: s ? 'The intercepted file chooser was satisfied with the staged files.' : (_chooserArm.has(Number(tabId)) ? 'Still armed - click the Browse/Upload control now (nbrowser_click), then re-check.' : 'Not armed and nothing served. Arm with {armForNextChooser:true, filePaths} first.') };+ }+ if (!files.length) return { ok: false, errorCode: 'no_files', error: 'filePaths is empty', _hint: 'Pass filePaths:["C:/abs/path/on/the/laptop", ...]. Push files to the laptop first with adom-bridge send_files (or use files:[{name,contentBase64}] - the bridge stages them for you).' };+ if (args.armForNextChooser === true) {+ await send(tabId, 'Page.setInterceptFileChooserDialog', { enabled: true });+ const prev = _chooserArm.get(Number(tabId)); if (prev && prev.timer) clearTimeout(prev.timer);+ const timer = setTimeout(() => { _chooserArm.delete(Number(tabId)); send(tabId, 'Page.setInterceptFileChooserDialog', { enabled: false }).catch(() => {}); }, 30000);+ _chooserArm.set(Number(tabId), { files, timer });+ _chooserServed.delete(Number(tabId));+ return { ok: true, armed: true, files: files.map((f) => String(f).split(/[\\/]/).pop()), _hint: 'ARMED for 30s: now nbrowser_click the Browse/Upload control - the page\'s file chooser will be satisfied with these files silently (no OS dialog). Then confirm with {chooserStatus:true} or just screenshot the page.' };+ }+ // direct mode: resolve the input element as a RemoteObject+ let obj;+ try { obj = await send(tabId, 'Runtime.evaluate', { expression: findFileInputFn(args.selector, args.text), returnByValue: false }); }+ catch (e) { return { ok: false, error: 'finder evaluate failed: ' + ((e && e.message) || e) }; }+ const ro = obj && obj.result;+ if (!ro || !ro.objectId || ro.subtype === 'null') {+ const diag = await evaluate(tabId, `(function(){ return { fileInputs: document.querySelectorAll('input[type=file]').length, selMatched: ${args.selector ? `!!document.querySelector(${JSON.stringify(args.selector)})` : 'null'} }; })()`).catch(() => ({}));+ return { ok: false, errorCode: 'input_not_found', error: `no <input type=file> resolved from ${args.selector ? 'selector ' + args.selector : args.text ? 'text "' + args.text + '"' : 'the page'}`, diag, _hint: diag && diag.fileInputs === 0 ? 'This page has NO file input right now. If the input is created by the Browse button\'s click handler, use {armForNextChooser:true, filePaths} then click the button. If the site uses showOpenFilePicker (File System Access API), CDP cannot inject files at all - use the site\'s drag-drop zone manually.' : 'A file input exists but your selector/text did not lead to it - target the input directly (selector:"input[type=file]") or the visible Browse control.' };+ }+ try { await send(tabId, 'DOM.enable').catch(() => {}); await send(tabId, 'DOM.setFileInputFiles', { files, objectId: ro.objectId }); }+ catch (e) { return { ok: false, error: 'DOM.setFileInputFiles failed: ' + ((e && e.message) || e), _hint: 'Verify every path exists ON THE LAPTOP (the bridge stats them, but a file may have been moved) and that the resolved element truly is an input[type=file].' }; }+ try { await send(tabId, 'Runtime.callFunctionOn', { objectId: ro.objectId, functionDeclaration: `function(){ this.dispatchEvent(new Event('input',{bubbles:true,composed:true})); this.dispatchEvent(new Event('change',{bubbles:true,composed:true})); }` }); } catch {}+ const names = await send(tabId, 'Runtime.callFunctionOn', { objectId: ro.objectId, functionDeclaration: 'function(){ return Array.prototype.map.call(this.files||[], function(f){return f.name;}); }', returnByValue: true }).then((r) => (r && r.result && r.result.value) || []).catch(() => []);+ return { ok: true, inputFound: true, via: 'direct', files: names, dispatchedEvents: ['input', 'change'], _hint: 'Files injected + input/change dispatched. Screenshot to confirm the page shows the selected file(s); many UIs need a separate Submit/Upload click next.' };+}
src/extension-nbe/src/native.jsadded+1109@@ -0,0 +1,1109 @@+// adom-native-browser :: native-API verbs (chrome.tabs / chrome.windows / downloads / fetch)+// --------------------------------------------------------------------------------------------+// No debugger attach → no banner. A "session" maps to a real Chrome window; tabId in our API is the+// real chrome tab id (stringified). The session→window map is PERSISTED to chrome.storage.local so it+// survives service-worker restarts (dev_reload) and browser restarts — pruned against live windows.++const sessionWindows = new Map(); // sessionId label -> chrome windowId (the OPENED registry; ownership root)+const sessionMeta = new Map(); // sessionId -> { thread, purpose, profile, createdAt } (from registration)+let seq = 0;+const newSessionId = () => 's' + (++seq);++// ── SPAWN registries — windows/tabs a DRIVEN page opened indirectly (compose, target=_blank, window.open,+// OAuth popup, PDF viewer). These belong to no session by construction, so without adoption they leak: no+// sessionId ever reaches them and no close verb can find them. We ADOPT them (strictly, only when the+// opener is an agent surface — see the onCreated listeners) so they can be listed, cleaned up, and badged.+const spawnedWindows = new Map(); // windowId -> { spawnedBy, openerTabId, url, title, createdAt }+const spawnedTabs = new Map(); // tabId -> { spawnedBy, openerTabId, windowId, url, createdAt }+const _dirtySessions = new Set(); // sessions with unacknowledged spawns → next mutating verb refused++const SKEY = 'nb_sessions';+const MKEY = 'nb_session_meta';+const PKEY = 'nb_spawns';+let loaded = false;++async function persist() {+ try {+ await chrome.storage.local.set({+ [SKEY]: { seq, sessions: [...sessionWindows] },+ [MKEY]: [...sessionMeta],+ [PKEY]: { windows: [...spawnedWindows], tabs: [...spawnedTabs] },+ });+ } catch {}+}+function windowExists(winId) {+ return new Promise((r) => chrome.windows.get(winId, () => r(!chrome.runtime.lastError)));+}+async function ensureLoaded() {+ if (loaded) return;+ loaded = true;+ await reconcile();+}+// Re-read the persisted map, MERGE it into memory (never lose an in-memory session that hasn't been+// persisted yet), and reconcile against LIVE windows. A session whose window no longer exists is MOVED+// to _closedSessions - NOT silently dropped - so a later verb reports "closed" and NEVER falls back to+// the user's active tab. Safe to call anytime; nbrowser_resync_sessions calls it. This is what makes+// tracking survive SW sleep + external window closes (SetForegroundWindow / WM_CLOSE from another tool).+async function tabExists(tabId) {+ return new Promise((r) => chrome.tabs.get(Number(tabId), () => r(!chrome.runtime.lastError)));+}+async function reconcile() {+ try {+ const got = await chrome.storage.local.get([SKEY, MKEY, PKEY]);+ const obj = got[SKEY];+ if (obj) {+ seq = Math.max(seq, obj.seq || 0);+ for (const [sid, winId] of obj.sessions || []) if (!sessionWindows.has(sid)) sessionWindows.set(sid, winId);+ }+ for (const [sid, meta] of got[MKEY] || []) if (!sessionMeta.has(sid)) sessionMeta.set(sid, meta);+ const sp = got[PKEY];+ if (sp) {+ for (const [wid, v] of sp.windows || []) if (!spawnedWindows.has(wid)) spawnedWindows.set(wid, v);+ for (const [tid, v] of sp.tabs || []) if (!spawnedTabs.has(tid)) spawnedTabs.set(tid, v);+ }+ } catch {}+ for (const [sid, winId] of [...sessionWindows]) {+ if (!(await windowExists(winId))) {+ sessionWindows.delete(sid);+ if (!_closedSessions.has(sid)) _closedSessions.set(sid, { windowId: winId, byUser: null, ts: Date.now(), external: true });+ }+ }+ // Evict spawns whose window/tab is gone (same reconcile-against-live discipline as sessions).+ for (const [wid] of [...spawnedWindows]) if (!(await windowExists(wid))) spawnedWindows.delete(wid);+ for (const [tid] of [...spawnedTabs]) if (!(await tabExists(tid))) spawnedTabs.delete(tid);+ await persist();+}++function tabView(t) {+ return { tabId: String(t.id), url: t.url, title: t.title, active: !!t.active, windowId: t.windowId };+}++// ---- user-action detection: window/tab closes + hints ----------------------+// So the AI is TOLD when its windows are closed (esp. by the user) instead of only finding out when a+// later verb mysteriously fails. We distinguish user-closed from agent-closed.+const _events = []; // recent lifecycle events for nbrowser_events+const _agentClosing = new Set(); // windowIds the AGENT is closing (so we don't mislabel as user-closed)+const _closedSessions = new Map(); // sessionId -> {windowId, byUser, ts}+function _emit(e) { e.ts = Date.now(); _events.push(e); if (_events.length > 300) _events.shift(); }+// focus_violations are PERSISTED (not just kept in the in-memory _events, which an MV3 service-worker+// suspension wipes) so the telemetry survives until polled - the user can always see when an AI tried to+// steal focus. Capped ring buffer in chrome.storage.local.+const VKEY = 'nb_violations';+async function persistViolation(v) {+ try {+ const got = await chrome.storage.local.get(VKEY);+ const arr = got[VKEY] || [];+ arr.push(v); while (arr.length > 20) arr.shift();+ await chrome.storage.local.set({ [VKEY]: arr });+ } catch {}+}+export async function drainEvents() {+ const e = _events.splice(0);+ let violations = [];+ try {+ const got = await chrome.storage.local.get(VKEY);+ violations = (got[VKEY] || []).map((v) => ({ event: 'focus_violation', ...v }));+ if (violations.length) await chrome.storage.local.set({ [VKEY]: [] });+ } catch {}+ const all = e.concat(violations);+ return { ok: true, events: all, count: all.length };+}+export function recentClose(sessionId) { return _closedSessions.get(sessionId) || null; }++chrome.windows.onRemoved.addListener(async (windowId) => {+ await ensureLoaded();+ const byUser = !_agentClosing.has(windowId);+ _agentClosing.delete(windowId);+ for (const [sid, wid] of [...sessionWindows]) {+ if (wid === windowId) {+ sessionWindows.delete(sid);+ _closedSessions.set(sid, { windowId, byUser, ts: Date.now() });+ _emit({ event: 'window_closed', sessionId: sid, windowId, byUser });+ }+ }+ if (spawnedWindows.has(windowId)) { const s = spawnedWindows.get(windowId); spawnedWindows.delete(windowId); _emit({ event: 'spawned_closed', windowId, spawnedBy: s.spawnedBy, byUser }); }+ await persist();+});+chrome.tabs.onRemoved.addListener((tabId, info) => {+ if (spawnedTabs.has(tabId)) spawnedTabs.delete(tabId);+ _emit({ event: 'tab_closed', tabId: String(tabId), windowId: info.windowId, windowClosing: !!info.isWindowClosing });+});++// ── SPAWN ADOPTION — the fix for the leaked-compose-window incident. ────────────────────────────────+// A page a session drives can open a NEW window/tab we did not ask for (Gmail compose, target=_blank,+// window.open, OAuth popup). We ADOPT it into the originating session so it can be listed/cleaned/badged.+// THE ANTI-OVER-ADOPTION RULE (safety linchpin): adopt ONLY when the new tab's openerTabId resolves to a+// tab living in a window we own or already adopted (ownedOrSpawnedWindowSet). No opener, or an opener+// outside an agent surface → it is the USER's own window/tab and is NEVER adopted. Strictly transitive+// from an owned root; never a focus/z-order/timing heuristic. So cleanup/force_close can never reach the+// user's windows.+async function adoptFromOpener(newTab) {+ if (!newTab || newTab.openerTabId == null) return; // no opener → user action → do NOT adopt+ if (sessionWindows.has(newTab.windowId) || [...sessionWindows.values()].includes(newTab.windowId)) {+ // window is an agent-OPENED window (our own); a plain new tab in it is fine, not a "spawn" to police+ }+ let opener;+ try { opener = await chrome.tabs.get(newTab.openerTabId); } catch { return; }+ if (!ownedOrSpawnedWindowSet().has(opener.windowId)) return; // opener not an agent surface → user's own+ const spawnedBy = sessionForWindow(opener.windowId) || (spawnedWindows.get(opener.windowId) || {}).spawnedBy;+ if (!spawnedBy) return;+ const isNewWindow = !ownedWindowSet().has(newTab.windowId) && !spawnedWindows.has(newTab.windowId) && newTab.windowId !== opener.windowId;+ const url = newTab.pendingUrl || newTab.url || '';+ if (isNewWindow) {+ if (spawnedWindows.has(newTab.windowId)) return; // already adopted+ spawnedWindows.set(newTab.windowId, { spawnedBy, openerTabId: newTab.openerTabId, url, title: newTab.title || '', createdAt: Date.now() });+ } else {+ if (spawnedTabs.has(newTab.id) || ownedWindowSet().has(newTab.windowId)) return; // tab in our own opened window: not policed+ spawnedTabs.set(newTab.id, { spawnedBy, openerTabId: newTab.openerTabId, windowId: newTab.windowId, url, createdAt: Date.now() });+ }+ _dirtySessions.add(spawnedBy); // its next mutating verb is refused until the thread acknowledges+ await persist();+ _emit({ event: 'spawned_window', windowId: newTab.windowId, tabId: String(newTab.id), spawnedBy, openerTabId: newTab.openerTabId, url, kind: isNewWindow ? 'window' : 'tab' });+}+chrome.tabs.onCreated.addListener(async (tab) => { await ensureLoaded(); await adoptFromOpener(tab); });+chrome.windows.onCreated.addListener(async (win) => {+ await ensureLoaded();+ if (spawnedWindows.has(win.id) || [...sessionWindows.values()].includes(win.id)) return; // handled elsewhere+ // window.onCreated has no opener → look at the new window's first tab and read ITS openerTabId (short+ // retry for the race where the tab isn't attached yet). This is a de-dup backstop; tabs.onCreated is primary.+ for (let i = 0; i < 3; i++) {+ let tabs = [];+ try { tabs = await chrome.tabs.query({ windowId: win.id }); } catch {}+ if (tabs && tabs[0]) { await adoptFromOpener(tabs[0]); return; }+ await new Promise((r) => setTimeout(r, 120));+ }+});++// ⛔ FIRST-CLASS PWA GUARD — never hijack an installed app window.+// Installed PWAs / "Add to taskbar" apps (Google Chat, Google Messages, Gmail-as-app, etc.) run in+// their OWN window, which Chrome reports as type:'app' (standalone apps are 'app'; popups 'popup';+// ordinary browsing 'normal'). Driving or NAVIGATING a tab inside an 'app' window would seize the+// user's standalone application — exactly the regression we hit (the Google Chat PWA got navigated to+// Gusto). HARD RULE: refuse to target a non-'normal' window unless the caller EXPLICITLY opts in with+// allowPwa:true (i.e. the user specifically asked to drive that exact app window).+async function assertDriveableWindow(tabId, args) {+ const allow = !!(args && (args.allowPwa === true || args.allowAppWindow === true));+ if (allow) return;+ let tab, win;+ try {+ tab = await chrome.tabs.get(Number(tabId));+ win = await chrome.windows.get(tab.windowId);+ } catch { return; } // lookup failed for an unrelated reason — let the real verb surface it+ if (win && win.type && win.type !== 'normal') {+ const kind = win.type === 'app' ? 'PWA / installed-app' : `${win.type}`;+ throw new Error(+ `⛔ refusing to drive a ${kind} window (tab ${tabId}: "${(tab.title || '').slice(0, 60)}") — ` ++ `this would hijack the user's standalone app (e.g. a taskbar PWA like Google Chat/Messages). ` ++ `Open a dedicated browser window with nbrowser_open_window and target THAT. ` ++ `Pass allowPwa:true ONLY if the user explicitly asked to drive this specific app window.`);+ }+}++// ⛔ OWNERSHIP GUARD — the agent may only ACT on windows IT opened (nbrowser_open_window).+// sessionWindows doubles as the ownership registry: entries are created ONLY by openWindow,+// persisted, and pruned when the window closes. Every mutating verb refuses a target outside it,+// and refuses a BARE call (no sessionId/tabId) outright — the active-tab fallback is what let an+// agent navigate a user's working tab and wipe their other AI threads. There is deliberately NO+// agent-passable override; skills/hooks reinforce this, the code enforces it.+export class GuardError extends Error {+ constructor(code, msg, hint) { super(msg); this.code = code; this.hintText = hint; }+}++const OPEN_WINDOW_HINT =+ 'Correct flow: nbrowser_profiles (map profiles → Google identities) → nbrowser_open_window ' ++ '{profile:"chrome:<email>", url:…} (opens in the BACKGROUND and auto-pins its sessionId) → drive ' ++ 'ONLY via that sessionId. There is NO agent-side override for driving the user\'s own tabs; if the ' ++ 'user wants you inside one of their tabs, they must say so and you still work in a window you open.';++function ownedWindowSet() { return new Set(sessionWindows.values()); }+function ownedOrSpawnedWindowSet() { return new Set([...sessionWindows.values(), ...spawnedWindows.keys()]); }+// Who owns a live windowId? 'agent-opened' (a session's primary window), 'agent-spawned' (adopted), or+// 'user' (never adopted). Used by list labels + force_close attribution.+function ownerOfWindow(winId) {+ const sid = sessionForWindow(winId);+ if (sid) return { owner: 'agent-opened', sessionId: sid };+ if (spawnedWindows.has(winId)) return { owner: 'agent-spawned', sessionId: spawnedWindows.get(winId).spawnedBy };+ return { owner: 'user', sessionId: null };+}++function requireTarget(args, verb) {+ if (args && (args.tabId != null || args.sessionId != null)) return;+ throw new GuardError('no_target',+ `⛔ no_target: ${verb} with no sessionId/tabId would act on the USER'S ACTIVE TAB — refused.`,+ `The active tab is whatever the user is working in RIGHT NOW; a bare ${verb} once wiped a user's other AI threads. ${OPEN_WINDOW_HINT}`);+}++// REGISTRATION (guard #1). Every window/tab you open must be registered with WHO is driving (thread) and+// WHY (purpose) so it can be tracked, cleaned up, and shown in the Session Monitor. Hard requirement.+function requireRegistration(args, verb) {+ const missing = [];+ if (!args || args.sessionId == null || args.sessionId === '') missing.push('sessionId');+ if (!args || !args.thread) missing.push('thread');+ if (!args || !args.purpose) missing.push('purpose');+ if (!missing.length) return;+ throw new GuardError('session_unregistered',+ `⛔ session_unregistered: ${verb} requires ${missing.join(' + ')}. Every window you open is registered so it can be tracked, cleaned up, and shown in the Session Monitor (who is driving each window, and why).`,+ `Call ${verb} {sessionId:"<short task id>", thread:"<which AI thread you are>", purpose:"<human-readable, e.g. finishing John's IRS signup>", url:…}. ${OPEN_WINDOW_HINT}`);+}++// UNACKNOWLEDGED SPAWNS (guard #2). If a driven page spawned a window/tab (compose/popup), refuse the+// session's next DRIVING verb until the thread deals with it (list via nbrowser_owned, close via cleanup).+function assertSpawnsAcknowledged(sid, verb) {+ if (!sid || !_dirtySessions.has(sid)) return;+ const items = [...spawnedWindows].filter(([, v]) => v.spawnedBy === sid).map(([wid, v]) => ({ windowId: wid, url: v.url }))+ .concat([...spawnedTabs].filter(([, v]) => v.spawnedBy === sid).map(([tid, v]) => ({ tabId: tid, url: v.url })));+ throw new GuardError('unacknowledged_spawns',+ `⛔ unacknowledged_spawns: a page session '${sid}' drove spawned ${items.length} new window(s)/tab(s) you have not dealt with — ${verb} is blocked until you do. You cannot forget your own litter.`,+ `See them: nbrowser_owned {sessionId:"${sid}"}. Then KEEP them (they are now tracked) or close the litter: nbrowser_cleanup {sessionId:"${sid}"}. (e.g. clicking an email in Gmail Contacts spawns a compose window.)`);+}+// Acknowledging = the thread looked (owned) or cleaned (cleanup). Clears the block.+function acknowledgeSpawns(sid) { if (sid) _dirtySessions.delete(sid); }++// PROFILE MISMATCH (guard #6). If a verb names a profile that contradicts the profile this session was+// pinned to at open time, refuse — guards against acting on the wrong Google account (personal vs corp).+// (The bridge also routes by profile; this is the extension-side backstop using sessionMeta.profile.)+function assertProfileMatch(args, verb) {+ if (!args || args.sessionId == null || args.profile == null) return;+ const meta = sessionMeta.get(args.sessionId);+ if (!meta || !meta.profile) return; // session not pinned to a profile → nothing to contradict+ if (String(args.profile) !== String(meta.profile)) {+ throw new GuardError('profile_mismatch',+ `⛔ profile_mismatch: session '${args.sessionId}' is pinned to profile '${meta.profile}', but ${verb} was called with profile '${args.profile}'. Refused so you never act on the wrong account.`,+ `Drop the profile arg (the session already routes to '${meta.profile}'), or open a NEW registered session in '${args.profile}' if you truly meant that account.`);+ }+}++// NO KEEPALIVE (guard #7, warning). A session driving a known idle-timeout host for >10 min with no+// keepalive risks a mid-task logout (an IRS/ID.me idle-out cost a full re-login here). Non-fatal.+const IDLE_TIMEOUT_HOSTS = /(^|\.)(gusto\.com|intuit\.com|quickbooks\.|irs\.gov|id\.me|adp\.com|login\.gov)$/i;+function keepaliveWarning(sid, url) {+ try {+ if (!sid || _keepalives.has(sid)) return null;+ const meta = sessionMeta.get(sid); if (!meta) return null;+ if (Date.now() - (meta.createdAt || Date.now()) < 10 * 60 * 1000) return null;+ const host = new URL(url || '').hostname || '';+ if (!IDLE_TIMEOUT_HOSTS.test(host)) return null;+ return `⚠️ no_keepalive: session '${sid}' has driven ${host} for >10 min with no keepalive — this host idle-times-out (a logout mid-task cost a full re-login once). Set nbrowser_keepalive {sessionId:"${sid}"} now.`;+ } catch { return null; }+}++// Ownership summary for the dirty_session nag in nbrowser_status.+export function ownershipSummary() {+ const openWins = sessionWindows.size;+ const spawns = spawnedWindows.size + spawnedTabs.size;+ return {+ ownedWindows: openWins, spawnedWindows: spawns,+ sessions: [...sessionWindows.keys()],+ _nag: (openWins || spawns)+ ? `You still own ${openWins} window(s)${spawns ? ` + ${spawns} spawned` : ''}. Before ending the task, nbrowser_owned {sessionId} to review and nbrowser_close_window {sessionId, includeSpawned:true} (or nbrowser_cleanup) to close them — don't leave litter for the user.`+ : undefined,+ };+}++async function assertOwned(tabId, verb) {+ await ensureLoaded();+ let tab;+ try { tab = await chrome.tabs.get(Number(tabId)); } catch { return; } // let the verb surface the real error+ if (!ownedWindowSet().has(tab.windowId)) {+ throw new GuardError('not_owned',+ `⛔ not_owned: tab ${tabId} ("${(tab.title || '').slice(0, 60)}") is in a window the USER opened — ${verb} refused.`,+ `You may only drive windows you created this connection. ${OPEN_WINDOW_HINT}`);+ }+}++// ── POLITENESS / ANTI-FOREGROUND ENFORCEMENT ──────────────────────────────────+// The bridge GUARANTEES it can never shove a window in front of the user, no matter how the driving AI+// behaves. Windows open in the BACKGROUND; a raise happens ONLY when the user EXPLICITLY asked to watch+// live (userRequestedForeground:true). Any other path that leaves a driven window topmost is auto-reverted+// to the background and logged as a focus_violation. This removes the rudeness AND the temptation to fall+// back to OS-level foreground launches (the old bug: a blocked background path pushed the AI to+// Start-Process/bring_to_front, which is exactly the disruptive behavior).+const POLITE_HINT =+ 'Open in the BACKGROUND. NEVER raise or maximize a window over the user\'s other work - it is rude and ' ++ 'disruptive. NEVER fall back to OS-level window launches (Start-Process/launch) or bring_to_front/maximize ' ++ 'to open or surface windows. If a background open is blocked, return a shareable link instead of ' ++ 'foregrounding. Foregrounding is the user\'s choice alone.';++// A raise/foreground is honored ONLY when the user explicitly asked AND the AI justified it. Foregrounding+// STEALS the user's keyboard focus: users automating the browser almost always have 5 other things in the+// foreground, and a raised window captures their keystrokes (real incident: it typed a user's chat message+// into a username field and dismissed Chrome's password dropdown). So a bare userRequestedForeground:true is+// NOT enough — it must ALSO carry a foregroundReason. Missing the reason ⇒ stays in the background.+function userWantsForeground(args) { return !!(args && args.userRequestedForeground === true && args.foregroundReason && String(args.foregroundReason).trim()); }+// The AI asked to foreground but gave no reason → we keep it background and teach it how to do it properly.+function foregroundUnjustified(args) { return !!(args && args.userRequestedForeground === true && !(args.foregroundReason && String(args.foregroundReason).trim())); }+// THE TWO EXCUSES (John, 2026-08-22). Every AI that foregrounds an nb window justifies it one of two+// ways: "I need native mouse/keyboard so the keystrokes are real" or "I can't screenshot a background+// window." BOTH ARE FALSE WHEN nbe IS DRIVING, and an AI that believes them will be rude to the user on+// every single task. So the refusal does not merely explain how to ask for a foreground - it REBUTS the+// excuse and hands over the verb that makes the foreground unnecessary. If we only say "don't do that",+// the AI does it anyway, because from where it sits it has no other way to finish the job.+const FOREGROUND_ALTERNATIVES =+ 'YOU ALMOST CERTAINLY DO NOT NEED A FOREGROUND. The two reasons AIs give are both solved: ' ++ '(a) "I need NATIVE mouse/keyboard input" - you do not. nbrowser_click / nbrowser_type / ' ++ 'nbrowser_input_dispatch dispatch CDP input with isTrusted:true, which is the same trusted-event flag a ' ++ 'real human click carries; it satisfies user-activation gates, React/Vue handlers, and login forms, and it ' ++ 'lands on an UNFOCUSED window. OS-level input (ab desktop_send_keys) is the only thing that needs focus - ' ++ 'so use the nbe verbs instead of the desktop ones and the problem disappears. ' ++ '(b) "I can\'t screenshot a background window" - you can. nbrowser_screenshot forces a repaint ' ++ '(lifecycle:active + double rAF) before capturing, so an occluded window still yields a live frame; if it ' ++ 'ever comes back blank it TELLS you so and points at ab desktop_screenshot_window {hwnd}, which captures an ' ++ 'occluded window plus its chrome and child popups at the OS level. Neither path needs focus.';+const FOREGROUND_JUSTIFY_HINT =+ '⚠️ Kept this window in the BACKGROUND. Foregrounding STEALS the user\'s keyboard focus — if they are typing, ' ++ 'your window captures their keystrokes (it has typed a user\'s message into a username field and dismissed the ' ++ 'password dropdown). Background is where automation SUCCEEDS (the user keeps working; screenshots, eval, clicks, ' ++ 'and typing all work on a background window). ' + FOREGROUND_ALTERNATIVES + ' ' ++ 'If after all that a foreground is genuinely required (a browser/OS feature that truly demands focus, or the ' ++ 'user asked to WATCH), you must: (1) pass foregroundReason:"<why>", and (2) FIRST warn the ' ++ 'user with a Bridge notify (adom-bridge notify_user {message:"Adom is about to bring a browser window forward for <reason>"}). ' ++ 'Only then set userRequestedForeground:true + foregroundReason. The user SEES an on-screen caption naming your ' ++ 'thread and your reason whenever a raise happens, so the reason must be one you are willing to show them.';+// Note on the response when a raise IS granted — remind the AI it should have notified the user.+const FOREGROUND_GRANTED_HINT = 'Raised to the FOREGROUND (you justified it). This interrupts the user — make sure you sent a Bridge notify first, do the ONE thing you needed the foreground for, then return to the background so you stop capturing their focus.';+// Attach the teaching warning (unjustified → stayed background) or log the justified raise. Call on every+// foreground-capable verb's result.+function foregroundNote(args, res, sid) {+ // CANONICAL RESPONSE CONTRACT (Bridge SDK, adom/nb-bridge#28): what a caller BRANCHES on is a stable+ // non-underscore key. windowEtiquette says where the window went; errorCode says a bare foreground+ // was refused. keptBackground is kept for one migration window and is deprecated on sight.+ if (foregroundUnjustified(args)) {+ res._warning = (res._warning ? res._warning + ' ' : '') + FOREGROUND_JUSTIFY_HINT;+ res.keptBackground = true;+ res.windowEtiquette = 'background';+ res.errorCode = 'foreground_reason_required';+ } else if (userWantsForeground(args)) {+ _emit({ event: 'foreground_raised', sessionId: sid || null, reason: args.foregroundReason });+ res._foreground = FOREGROUND_GRANTED_HINT;+ res.windowEtiquette = 'foreground';+ } else {+ res.windowEtiquette = 'background';+ }+ return res;+}++function sessionForWindow(winId) { for (const [sid, wid] of sessionWindows) if (wid === winId) return sid; return null; }++// The chrome window currently focused (i.e. Chrome is in the OS foreground), or null when the user is in+// another app. Used to know which window to hand focus BACK to if a driven window steals it.+async function currentForeground() {+ try { const w = await chrome.windows.getLastFocused(); return w && w.focused ? w.id : null; } catch { return null; }+}++// Backstop invoked after any open/navigate/reload/switch/state change: if the driven window ended up+// topmost (focused) and the user did NOT ask to watch live, send it back behind the user's window and log+// a focus_violation. Chrome only exposes `focused` (not true OS z-order), so focused===true is our best+// proxy for "landed on top". We cannot unfocus a window directly (Chrome forbids windows.update{focused:+// false}), so we re-raise whatever window the user had, which drops ours behind it. Never throws.+async function enforceBackground(winId, args, verb, prevFocusedId) {+ if (userWantsForeground(args)) return null; // the user asked to see it - allowed+ if (winId == null) return null;+ // If the window was ALREADY the foreground before this verb ran, the verb did NOT steal focus - it was+ // already there (e.g. the user raised it to watch, or it's the only window). Leave it alone; otherwise+ // we'd wrongly minimize/demote a window every time we, say, open a background tab into it.+ if (prevFocusedId != null && prevFocusedId === winId) return null;+ let w;+ try { w = await chrome.windows.get(winId); } catch { return null; }+ if (!w || !w.focused) return null; // not topmost → nothing to correct+ let how = null;+ // Preferred: hand focus back to whatever window the user had before, which drops ours behind it.+ if (prevFocusedId != null && prevFocusedId !== winId) {+ try { await chrome.windows.update(prevFocusedId, { focused: true }); how = 'restored-previous'; } catch {}+ }+ // Fallback (the user was in a NON-Chrome app, so there is no Chrome window to hand focus back to):+ // we cannot unfocus a window directly (Chrome forbids windows.update{focused:false}), so MINIMIZE ours+ // to physically remove it from the user's foreground. This is the case that matches the user's actual+ // complaint (a window shoved in front while they multitask in another app) - it must never be left up.+ if (!how) {+ try { await chrome.windows.update(winId, { state: 'minimized' }); how = 'minimized'; } catch {}+ }+ await persistViolation({ verb, sessionId: sessionForWindow(winId), windowId: winId, autoReverted: true, how, ts: Date.now() });+ return {+ autoReverted: true, how,+ _warning: `⚠️ focus_violation: ${verb} left a driven window in the FOREGROUND over the user's work; it was auto-sent to the BACKGROUND (${how === 'restored-previous' ? "focus returned to the user's previous window" : how === 'minimized' ? 'minimized, because the user was in another app' : 'could not be corrected'}). ${POLITE_HINT}`,+ };+}++// Fold an enforceBackground() warning into a verb's result object (no-op if there was no violation).+function withWarn(res, warn) { if (warn && warn._warning) { res._warning = warn._warning; res.focusAutoReverted = true; } return res; }++// Resolve which tab a verb targets. Keyed off the REAL session→window map, never an "active tab"+// heuristic when a sessionId/tabId is given:+// explicit tabId → that tab (ownership re-checked for mutating verbs)+// explicit sessionId → the active tab of THAT SESSION'S OWN window; if the session isn't tracked+// (window closed/re-created externally, or the map was lost while the SW slept)+// it THROWS a clear session_untracked/closed error - it NEVER acts on whatever+// tab the user happens to be in. (That fallback is the bug this fixes.)+// bare (neither) → mutating verbs are refused; only a read-only bare call may use the active tab.+async function resolveTabId(args, opts = {}) {+ await ensureLoaded();+ const verb = opts.verb || 'this verb';++ // Driving verbs are blocked while the session has unacknowledged spawns (close/cleanup/owned are the+ // ways to acknowledge, so they are exempt). Also enforce profile-pin match (wrong-account guard).+ if (opts.requireOwned && args && args.sessionId != null) {+ assertProfileMatch(args, verb);+ if (!/close|cleanup|owned/.test(verb)) assertSpawnsAcknowledged(args.sessionId, verb);+ }++ if (args && args.tabId != null) {+ const tabId = Number(args.tabId);+ await assertDriveableWindow(tabId, args);+ if (opts.requireOwned) await assertOwned(tabId, verb);+ return tabId;+ }++ if (args && args.sessionId != null) {+ const tabId = await tabForSession(args.sessionId, verb); // reconciles; throws if untracked - NEVER active-tab+ await assertDriveableWindow(tabId, args);+ return tabId; // owned by construction: the session→window map is written ONLY by openWindow+ }++ // Bare call - no sessionId, no tabId.+ // requireOwned (mutating) OR requireTarget (CDP read-only that ATTACHES the debugger) → refuse: never+ // touch the user's tab. For CDP verbs this also stops the "debugging this browser" banner from appearing+ // on the user's OWN active tab (it can only ever show on a window the AI explicitly targeted).+ if (opts.requireOwned || opts.requireTarget) requireTarget(args, verb);+ const [tab] = await chrome.tabs.query({ active: true, lastFocusedWindow: true }); // read-only bare only+ if (!tab) throw new Error('no target tab found');+ await assertDriveableWindow(tab.id, args);+ return tab.id;+}++// Resolve the tab for an EXPLICIT session strictly from the session→window map. Reconciles first (so a+// map lost from memory but present in storage is recovered), and if the session's window is truly gone+// it records the close and throws a clear error - it NEVER falls back to the user's active tab.+async function tabForSession(sid, verb) {+ if (!sessionWindows.has(sid)) await reconcile();+ if (sessionWindows.has(sid)) {+ const windowId = sessionWindows.get(sid);+ if (await windowExists(windowId)) {+ let [tab] = await chrome.tabs.query({ active: true, windowId });+ if (!tab) [tab] = await chrome.tabs.query({ windowId }); // OUR window, any tab+ if (tab) return tab.id;+ }+ sessionWindows.delete(sid);+ if (!_closedSessions.has(sid)) _closedSessions.set(sid, { windowId, byUser: null, ts: Date.now(), external: true });+ await persist();+ }+ const c = _closedSessions.get(sid);+ throw new GuardError('session_untracked',+ c+ ? `session '${sid}' is closed (${c.byUser === true ? 'by the user' : c.byUser === false ? 'by the agent' : 'its window was closed externally'}) — open a fresh window with nbrowser_open_window. NOT retrying into your active tab.`+ : `session '${sid}' isn't tracked (its window was likely closed/re-created outside the extension). Run nbrowser_resync_sessions to rebuild tracking, then nbrowser_open_window for a fresh window. NOT falling back to your active tab.`,+ OPEN_WINDOW_HINT);+}++// Reconcile-aware window resolve for window-level verbs (open_tab/close_window/switch_window/…).+async function resolveWindowId(args) {+ if (!(args && args.sessionId != null)) return null;+ if (!sessionWindows.has(args.sessionId)) await reconcile();+ return sessionWindows.has(args.sessionId) ? sessionWindows.get(args.sessionId) : null;+}++function waitForLoad(tabId, timeoutMs = 30000) {+ return new Promise((resolve) => {+ let done = false;+ const finish = () => { if (done) return; done = true; chrome.tabs.onUpdated.removeListener(onUpd); resolve(); };+ const onUpd = (id, info) => { if (id === tabId && info.status === 'complete') finish(); };+ chrome.tabs.onUpdated.addListener(onUpd);+ chrome.tabs.get(tabId, (t) => { if (!chrome.runtime.lastError && t && t.status === 'complete') finish(); });+ setTimeout(finish, timeoutMs);+ });+}++// ---- windows / sessions ----------------------------------------------------+export async function openWindow(args = {}) {+ await ensureLoaded();+ requireRegistration(args, 'nbrowser_open_window'); // guard #1: sessionId + thread + purpose+ // Open in the BACKGROUND by default — never steal the user's focus. The window is fully drivable ++ // capturable while occluded. It is raised ONLY when the user EXPLICITLY asked to watch live+ // (userRequestedForeground:true); a bare focused:true is IGNORED. (This is the whole point of+ // native-browser driving: the user keeps working in their foreground window while we operate another.)+ const fg = userWantsForeground(args);+ const prev = fg ? null : await currentForeground();+ const win = await chrome.windows.create({ url: args.url, focused: fg });+ const sid = args.sessionId;+ sessionWindows.set(sid, win.id);+ sessionMeta.set(sid, { thread: args.thread, purpose: args.purpose, profile: args.profile || null, createdAt: Date.now() });+ await persist();+ const warn = await enforceBackground(win.id, args, 'nbrowser_open_window', prev); // backstop: never leaves it topmost+ const tab = (win.tabs && win.tabs[0]) || {};+ // Return the window's BOUNDS so the bridge can correlate its OS hwnd by rect (profile-independent) for+ // the taskbar badge — the old "new Chrome window" diff can't tell profiles apart and badged the wrong one.+ let bounds; try { const w2 = await chrome.windows.get(win.id); bounds = { left: w2.left, top: w2.top, width: w2.width, height: w2.height }; } catch { bounds = { left: win.left, top: win.top, width: win.width, height: win.height }; }+ const res = {+ ok: true, sessionId: sid, tabId: String(tab.id), windowId: win.id, url: tab.url || args.url, title: tab.title, bounds,+ _hint: 'New window opened in the BACKGROUND — the user\'s focus is deliberately NOT stolen. ' + POLITE_HINT ++ ' Screenshot it where it sits with nbrowser_screenshot (CDP) or ab desktop_screenshot_window (WGC); both capture a background/occluded window, so NEVER foreground it to grab a shot. To raise it you must justify it (userRequestedForeground:true + foregroundReason) AND warn the user first. Remember to nbrowser_close_window {sessionId:"' + sid + '"} when done so you don\'t leave abandoned windows.',+ };+ return foregroundNote(args, withWarn(res, warn), sid);+}++export async function closeWindow(args = {}) {+ await ensureLoaded();+ const winId = await resolveWindowId(args);+ if (winId == null) {+ const c = _closedSessions.get(args.sessionId);+ throw new Error(c ? `session '${args.sessionId}' was already closed ${c.byUser ? 'by the USER' : 'by the agent'}` : `unknown sessionId '${args.sessionId}'`);+ }+ // includeSpawned:true → also close everything this session SPAWNED (leaked composes/popups), so the+ // one call leaves nothing behind. Never touches the user's own windows.+ const alsoClosedSpawned = [];+ if (args.includeSpawned) {+ for (const [wid, v] of [...spawnedWindows]) if (v.spawnedBy === args.sessionId) { _agentClosing.add(wid); try { await chrome.windows.remove(wid); alsoClosedSpawned.push(wid); } catch {} spawnedWindows.delete(wid); }+ for (const [tid, v] of [...spawnedTabs]) if (v.spawnedBy === args.sessionId) { try { await chrome.tabs.remove(tid); } catch {} spawnedTabs.delete(tid); }+ }+ acknowledgeSpawns(args.sessionId);+ sessionMeta.delete(args.sessionId);+ _agentClosing.add(winId); // mark so onRemoved labels this agent-closed (byUser:false), not user-closed+ await chrome.windows.remove(winId);+ await persist();+ // NOTE: session deletion + the window_closed event are handled in the onRemoved listener (single+ // source of truth), so agent- and user-closes are recorded identically (just different byUser).+ return { ok: true, closed: true, sessionId: args.sessionId, alsoClosedSpawned,+ _hint: alsoClosedSpawned.length ? `Also closed ${alsoClosedSpawned.length} spawned window(s) this session opened. Nothing left behind.` : (spawnCountFor(args.sessionId) ? `Note: this session still has ${spawnCountFor(args.sessionId)} spawned window(s) open — pass includeSpawned:true or call nbrowser_cleanup to close them too.` : undefined) };+}+function spawnCountFor(sid) { return [...spawnedWindows.values()].filter((v) => v.spawnedBy === sid).length + [...spawnedTabs.values()].filter((v) => v.spawnedBy === sid).length; }++export async function switchWindow(args = {}) {+ await ensureLoaded();+ const winId = await resolveWindowId(args);+ if (winId == null) throw new Error('unknown sessionId');+ // BACKGROUND by default — switching which window the agent targets must NEVER raise it into the user's+ // face. Every verb already targets a window by its sessionId, so this is a no-op for driving; raise it+ // ONLY when the USER explicitly asked to watch live (userRequestedForeground:true).+ const fg = userWantsForeground(args);+ const prev = fg ? null : await currentForeground();+ if (fg) await chrome.windows.update(winId, { focused: true });+ const warn = await enforceBackground(winId, args, 'nbrowser_switch_window', prev);+ const res = { ok: true, focused: fg, _hint: fg ? FOREGROUND_GRANTED_HINT : 'Targeted in the BACKGROUND, not raised — drive it by passing this sessionId. ' + POLITE_HINT };+ return foregroundNote(args, withWarn(res, warn), args.sessionId);+}++// Window state — fullscreen / maximized / normal / minimized, via chrome.windows.update.+// This is the CORRECT way to toggle fullscreen: a synthetic F11 key can't (the browser+// intercepts browser-chrome shortcuts before the page ever sees the CDP key event).+export async function windowState(args = {}) {+ await ensureLoaded();+ const winId = await resolveWindowId(args);+ if (winId == null) throw new Error('unknown sessionId');+ const fg = userWantsForeground(args);+ const prev = fg ? null : await currentForeground();+ // Move/resize path — any of left/top/width/height present. chrome.windows requires the+ // window be in 'normal' state before bounds take, so force it first.+ const hasBounds = ['left', 'top', 'width', 'height'].some((k) => args[k] != null);+ if (hasBounds) {+ await chrome.windows.update(winId, { state: 'normal' });+ const b = {};+ for (const k of ['left', 'top', 'width', 'height']) if (args[k] != null) b[k] = Math.round(args[k]);+ const w = await chrome.windows.update(winId, b);+ const warn = await enforceBackground(winId, args, 'nbrowser_window_state', prev);+ return foregroundNote(args, withWarn({ ok: true, windowId: winId, bounds: { left: w.left, top: w.top, width: w.width, height: w.height }, _hint: 'Moved/resized via chrome.windows — no bar. Use list_windows to read work-area bounds for side-by-side tiling.' }, warn), args.sessionId);+ }+ let state = String(args.state || 'fullscreen').toLowerCase();+ if (state === 'restore' || state === 'windowed') state = 'normal';+ if (!['normal', 'minimized', 'maximized', 'fullscreen'].includes(state)) {+ return { ok: false, error: `bad state '${state}'`, _hint: 'state ∈ fullscreen | maximized | normal | minimized — or pass left/top/width/height to move/resize' };+ }+ await chrome.windows.update(winId, { state });+ // maximize/fullscreen are foregrounding actions (they make the window large + can pull it forward).+ // They are USER-only: the window still stays in the BACKGROUND (auto-reverted) unless the user asked to+ // watch live (userRequestedForeground:true). Resizing for a clean recording therefore still works, but+ // the AI can never use maximize/fullscreen to shove the window in front of the user.+ const warn = await enforceBackground(winId, args, 'nbrowser_window_state', prev);+ const raiseState = state === 'maximized' || state === 'fullscreen';+ return foregroundNote(args, withWarn({ ok: true, windowId: winId, state, _hint: 'Native window-state change — no debugger, no bar. fullscreen hides tabs+URL bar+taskbar; normal restores.' + (raiseState ? ' This enlarges the window but keeps it in the BACKGROUND (auto-reverted) unless you justify a foreground (userRequestedForeground:true + foregroundReason). ' + POLITE_HINT : '') }, warn), args.sessionId);+}++export async function listWindows(args = {}) {+ await ensureLoaded();+ const wins = await chrome.windows.getAll({ populate: true });+ const tracked = new Map([...sessionWindows].map(([k, v]) => [v, k]));+ let sessions = wins.map((w) => {+ const o = ownerOfWindow(w.id);+ return {+ id: tracked.get(w.id) || String(w.id),+ windowId: w.id,+ tracked: tracked.has(w.id),+ owner: o.owner, // agent-opened | agent-spawned | user+ spawnedBy: o.owner === 'agent-spawned' ? o.sessionId : undefined,+ purpose: o.owner === 'agent-opened' ? (sessionMeta.get(o.sessionId) || {}).purpose : undefined,+ tabCount: (w.tabs || []).length,+ active: !!w.focused,+ state: w.state,+ bounds: { left: w.left, top: w.top, width: w.width, height: w.height },+ url: (w.tabs || []).find((t) => t.active)?.url,+ title: (w.tabs || []).find((t) => t.active)?.title, // for DPI-independent hwnd correlation (OS title match)+ };+ });+ if (args.mine) sessions = sessions.filter((s) => s.owner !== 'user'); // only windows an agent opened/spawned+ return { ok: true, sessions, count: sessions.length,+ _hint: sessions.some((s) => s.owner === 'agent-spawned') ? 'Windows marked owner:"agent-spawned" were opened INDIRECTLY by a page you drove — close leftovers with nbrowser_cleanup {sessionId}.' : undefined };+}++// reconcile the session map against live windows (drop closed ones)+export async function rescan() {+ await reconcile();+ return listWindows();+}++// nbrowser_resync_sessions — LIGHTWEIGHT rebuild of session tracking WITHOUT a full dev_reload. Re-reads+// the persisted map, merges it into memory, and re-validates every session against live windows (gone+// windows → marked closed). Use it when session verbs start failing with session_untracked after the+// user interacted with Chrome or another tool moved/closed windows - it un-sticks tracking in one call.+export async function resyncSessions() {+ loaded = false; // force a fresh read from storage+ await ensureLoaded();+ const tracked = [...sessionWindows].map(([sessionId, windowId]) => ({ sessionId, windowId }));+ const closed = [...(_closedSessions.entries())].map(([sessionId, c]) => ({ sessionId, byUser: c.byUser, external: !!c.external }));+ return {+ ok: true, tracked, trackedCount: tracked.length, closed,+ _hint: tracked.length+ ? `Session tracking rebuilt: ${tracked.length} live agent window(s) are drivable by their sessionId. Closed/untracked sessions are listed under "closed" - open fresh windows for those with nbrowser_open_window.`+ : 'No live agent windows are tracked. Open one with nbrowser_open_window (it auto-pins its sessionId).',+ };+}++// nbrowser_owned — everything a session (or all sessions) owns: opened windows + adopted spawns. Calling+// this ACKNOWLEDGES the session's spawns (clears the unacknowledged_spawns block). Use it before ending a+// task to see what to clean up.+export async function owned(args = {}) {+ await ensureLoaded();+ await reconcile();+ const sid = args.sessionId;+ const age = (t) => Date.now() - (t || Date.now());+ const enrich = async (winId) => { try { const w = await chrome.windows.get(winId, { populate: true }); const t = (w.tabs || []).find((x) => x.active) || (w.tabs || [])[0] || {}; return { url: t.url, title: t.title, bounds: { left: w.left, top: w.top, width: w.width, height: w.height } }; } catch { return {}; } };+ const opened = [];+ for (const [s, winId] of sessionWindows) {+ if (sid && s !== sid) continue;+ const e = await enrich(winId);+ opened.push({ sessionId: s, windowId: winId, url: e.url, title: e.title, purpose: (sessionMeta.get(s) || {}).purpose, ageMs: age((sessionMeta.get(s) || {}).createdAt) });+ }+ const spawned = [];+ for (const [winId, v] of spawnedWindows) { if (sid && v.spawnedBy !== sid) continue; const e = await enrich(winId); spawned.push({ kind: 'window', windowId: winId, spawnedBy: v.spawnedBy, opener: v.openerTabId, url: e.url || v.url, title: e.title, bounds: e.bounds, ageMs: age(v.createdAt) }); }+ for (const [tabId, v] of spawnedTabs) { if (sid && v.spawnedBy !== sid) continue; spawned.push({ kind: 'tab', tabId: String(tabId), windowId: v.windowId, spawnedBy: v.spawnedBy, opener: v.openerTabId, url: v.url, ageMs: age(v.createdAt) }); }+ if (sid) acknowledgeSpawns(sid);+ return { ok: true, opened, spawned, count: { opened: opened.length, spawned: spawned.length },+ _hint: spawned.length+ ? `This session owns ${spawned.length} SPAWNED window(s)/tab(s) (a page you drove opened them - e.g. a Gmail compose). Keep the ones you still need; close the litter with nbrowser_cleanup {sessionId:"${sid || '<id>'}"}. They are now acknowledged, so driving verbs are unblocked.`+ : 'No spawned windows. At task end, close your opened windows with nbrowser_close_window {sessionId} so you never leave litter for the user.' };+}++// nbrowser_cleanup — close ONLY what this session SPAWNED (never the user's windows, never the primary+// opened window). This is the incident fix: it reaches the leaked compose windows. Acknowledges spawns.+export async function cleanup(args = {}) {+ await ensureLoaded();+ const sid = args.sessionId;+ if (!sid) throw new Error('nbrowser_cleanup needs a sessionId (the session whose spawned windows to close)');+ const closed = [];+ for (const [winId, v] of [...spawnedWindows]) { if (v.spawnedBy !== sid) continue; _agentClosing.add(winId); try { await chrome.windows.remove(winId); closed.push({ windowId: winId, url: v.url }); } catch {} spawnedWindows.delete(winId); }+ for (const [tabId, v] of [...spawnedTabs]) { if (v.spawnedBy !== sid) continue; try { await chrome.tabs.remove(tabId); closed.push({ tabId: String(tabId), url: v.url }); } catch {} spawnedTabs.delete(tabId); }+ acknowledgeSpawns(sid);+ await persist();+ return { ok: true, closed, count: closed.length, keptOpenedWindow: sessionWindows.get(sid) || null,+ _hint: `Closed ${closed.length} spawned window(s)/tab(s) for session '${sid}'. Your primary window (if any) is KEPT and the user's own windows were never touched. At task end also nbrowser_close_window {sessionId:"${sid}"} (or use includeSpawned:true) to close your primary window.` };+}++// nbrowser_force_close — LAST-RESORT override for a TRUE ORPHAN (a window nbe cannot attribute to any live+// session) or the CALLING session's own window. Two-step attestation: call 1 returns a challenge ticket ++// exactly what will be destroyed; call 2 with the ticket + attestation destroys it. It can NEVER close a+// window owned by a DIFFERENT live thread — cross-thread protection is absolute and not overridable.+const _overrideTickets = new Map(); // ticket -> { windowId, expires }+let _ticketSeq = 0;+export async function forceClose(args = {}) {+ await ensureLoaded();+ await reconcile();+ const winId = Number(args.windowId);+ if (!winId || !(await windowExists(winId))) throw new GuardError('bad_window', `⛔ force_close: windowId ${args.windowId} is not a live window.`, 'List candidates with nbrowser_list_windows {mine:false} — a true orphan shows owner:"user" with no live session driving it.');+ const o = ownerOfWindow(winId);+ // HARD INVARIANT: never pierce another live thread's ownership.+ if (o.owner !== 'user' && o.sessionId && o.sessionId !== args.sessionId) {+ throw new GuardError('owned_by_other', `⛔ force_close REFUSED: window ${winId} is owned by another live session ('${o.sessionId}'). Cross-thread ownership is ABSOLUTE and can NEVER be force-closed.`, 'Only that thread may close its own window. If it is truly abandoned, its own thread cleans up, or the user closes it manually.');+ }+ let title = '', url = '';+ try { const w = await chrome.windows.get(winId, { populate: true }); const t = (w.tabs || []).find((x) => x.active) || (w.tabs || [])[0] || {}; title = t.title || ''; url = t.url || ''; } catch {}+ const willDestroy = { windowId: winId, title, url, owner: o.owner === 'user' ? 'none (orphan)' : o.owner, spawnedBy: o.sessionId || null, ageMs: spawnedWindows.get(winId) ? Date.now() - spawnedWindows.get(winId).createdAt : null };+ // STEP 1 — no ticket: mint a challenge and REFUSE (a single call NEVER force-closes; that is the point).+ if (!args.overrideTicket) {+ const ticket = 'ovr-' + (++_ticketSeq) + '-' + winId;+ _overrideTickets.set(ticket, { windowId: winId, expires: Date.now() + 60000 });+ return { ok: false, needsAttestation: true, overrideTicket: ticket, willDestroy,+ _hint: `⚠️ FORCE-CLOSE is a destructive last resort. This will PERMANENTLY close window ${winId} ("${(title || url || '').slice(0, 60)}") and lose any unsaved content in it. To proceed you MUST call again with EXACTLY: nbrowser_force_close {windowId:${winId}, overrideTicket:"${ticket}", attestOrphan:true, reason:"<why this window must be force-closed>"}. The ticket expires in 60s and closes only this one window. force_close can NEVER touch another thread's window.` };+ }+ // STEP 2 — validate ticket + attestation, re-check attribution (TOCTOU), then close.+ const t = _overrideTickets.get(args.overrideTicket);+ if (!t || t.windowId !== winId) throw new GuardError('bad_ticket', `⛔ force_close: overrideTicket does not match window ${winId}. Request a fresh ticket by calling force_close with just {windowId} first.`, 'One ticket = one window, one use.');+ if (Date.now() > t.expires) { _overrideTickets.delete(args.overrideTicket); throw new GuardError('ticket_expired', '⛔ force_close: the override ticket expired (60s). Request a fresh one.', 'Call force_close with just {windowId} to get a new ticket, then confirm promptly.'); }+ if (args.attestOrphan !== true || !args.reason) throw new GuardError('attestation_required', '⛔ force_close: you must attest {attestOrphan:true, reason:"…"} to proceed. This deliberate friction ensures force-close is never reflexive.', 'Include attestOrphan:true and a human-readable reason for the override.');+ const o2 = ownerOfWindow(winId); // re-check: another thread may have adopted it since step 1+ if (o2.owner !== 'user' && o2.sessionId && o2.sessionId !== args.sessionId) throw new GuardError('owned_by_other', `⛔ force_close REFUSED at commit: window ${winId} is now owned by another live session. Cross-thread ownership is absolute.`, 'Aborted to protect another thread.');+ _overrideTickets.delete(args.overrideTicket);+ _agentClosing.add(winId);+ try { await chrome.windows.remove(winId); } catch (e) { throw new Error('force_close failed: ' + ((e && e.message) || e)); }+ spawnedWindows.delete(winId);+ await persist();+ _emit({ event: 'force_closed', windowId: winId, reason: args.reason, by: args.sessionId || null });+ return { ok: true, closed: true, windowId: winId, reason: args.reason, _hint: 'Orphan force-closed. This should be RARE - prefer nbrowser_cleanup for tracked spawns. If you hit orphans often, spawn-adoption is missing an opener path; report it.' };+}++// ---- tabs ------------------------------------------------------------------+export async function openTab(args = {}) {+ await ensureLoaded();+ const windowId = await resolveWindowId(args);+ if (windowId == null) {+ throw new GuardError('no_target',+ "\u26d4 no_target: nbrowser_open_tab without the sessionId of a window YOU opened would drop a tab into the USER'S OWN window \u2014 refused.",+ 'Pass the sessionId from nbrowser_open_window, or open a new window instead. ' + OPEN_WINDOW_HINT);+ }+ if (!sessionMeta.has(args.sessionId)) requireRegistration(args, 'nbrowser_open_tab'); // register a legacy/unregistered session (new sessions always carry meta)+ // BACKGROUND by default — a new tab must not steal the user's focus or yank their active tab.+ // chrome.tabs.create defaults to active:true; force active:false unless the user EXPLICITLY asked to+ // watch live (userRequestedForeground:true). A bare focused:true/active:true is IGNORED.+ const fg = userWantsForeground(args);+ const prev = fg ? null : await currentForeground();+ const base = { url: args.url, active: fg };+ const tab = await chrome.tabs.create(windowId != null ? { windowId, ...base } : base);+ const warn = await enforceBackground(windowId, args, 'nbrowser_open_tab', prev); // backstop: opening a tab must not raise the window+ // Echo args.url (chrome.tabs.create returns the tab BEFORE it navigates, so tab.url/title are empty at+ // this instant - returning them raw made callers think the open "returned nothing"). The tab IS open+ // (tabId proves it); it may still be loading, so drive it by tabId / wait with nbrowser_wait_for.+ const res = { ok: true, sessionId: args.sessionId, tabId: String(tab.id), url: tab.url || args.url, title: tab.title || undefined, _hint: 'Tab opened in the BACKGROUND (active:false) at ' + (tab.url || args.url) + ' — it may still be loading (drive it by this tabId; nbrowser_wait_for to await a selector/URL). ' + POLITE_HINT };+ return foregroundNote(args, withWarn(res, warn), args.sessionId);+}++export async function closeTab(args = {}) {+ const tabId = await resolveTabId(args, { requireOwned: true, verb: 'nbrowser_close_tab' });+ await chrome.tabs.remove(tabId);+ return { ok: true, tabId: String(tabId) };+}++export async function switchTab(args = {}) {+ const tabId = await resolveTabId(args, { requireOwned: true, verb: 'nbrowser_switch_tab' });+ await chrome.tabs.update(tabId, { active: true });+ return { ok: true, tabId: String(tabId) };+}++export async function listTabs(args = {}) {+ await ensureLoaded();+ const windowId = await resolveWindowId(args);+ const tabs = await chrome.tabs.query(windowId != null ? { windowId } : {});+ const active = tabs.find((t) => t.active);+ const owned = ownedWindowSet();+ let out = tabs.map((t) => {+ const st = spawnedTabs.get(t.id);+ const owner = st ? 'agent-spawned' : (owned.has(t.windowId) ? 'agent-opened' : (spawnedWindows.has(t.windowId) ? 'agent-spawned' : 'user'));+ return { ...tabView(t), owner, spawnedBy: st ? st.spawnedBy : (spawnedWindows.get(t.windowId) || {}).spawnedBy };+ });+ if (args.mine) out = out.filter((t) => t.owner !== 'user');+ return { ok: true, count: out.length, activeTabId: active ? String(active.id) : null, tabs: out };+}++// ---- navigation ------------------------------------------------------------+export async function navigate(args = {}) {+ const tabId = await resolveTabId(args, { requireOwned: true, verb: 'nbrowser_navigate' });+ const prev = userWantsForeground(args) ? null : await currentForeground();+ await chrome.tabs.update(tabId, { url: args.url });+ await waitForLoad(tabId);+ const t = await chrome.tabs.get(tabId);+ const warn = await enforceBackground(t.windowId, args, 'nbrowser_navigate', prev); // navigating must never raise the window+ const res = withWarn({ ok: true, url: t.url, tabId: String(tabId), sessionId: args.sessionId }, warn);+ const kw = keepaliveWarning(args.sessionId, t.url); if (kw) res._keepaliveWarning = kw;+ return res;+}++export async function reload(args = {}) {+ const tabId = await resolveTabId(args, { requireOwned: true, verb: 'nbrowser_reload' });+ const prev = userWantsForeground(args) ? null : await currentForeground();+ await chrome.tabs.reload(tabId);+ await waitForLoad(tabId);+ const t = await chrome.tabs.get(tabId);+ const warn = await enforceBackground(t.windowId, args, 'nbrowser_reload', prev);+ return withWarn({ ok: true, tabId: String(tabId), sessionId: args.sessionId }, warn);+}++// Route back/forward through the PAGE's own history, not chrome.tabs.goBack/goForward. The tabs API+// throws "Cannot find a next/previous page in history" on tabs whose navigations were driven by us+// (chrome.tabs.update + a lazily-attached debugger), even when window.history genuinely has entries.+// history.back()/forward() in the page context is reliable and stays banner-free (no debugger attach).+export async function back(args = {}) {+ const tabId = await resolveTabId(args, { requireOwned: true, verb: 'nbrowser_back' });+ const [{ result } = {}] = await chrome.scripting.executeScript({+ target: { tabId },+ func: () => { if (history.length <= 1) return 'no-history'; history.back(); return 'ok'; },+ });+ if (result === 'no-history') throw new Error('Cannot go back: no previous page in this tab history.');+ await waitForLoad(tabId);+ return { ok: true, tabId: String(tabId), sessionId: args.sessionId };+}++export async function forward(args = {}) {+ const tabId = await resolveTabId(args, { requireOwned: true, verb: 'nbrowser_forward' });+ await chrome.scripting.executeScript({ target: { tabId }, func: () => { history.forward(); } });+ await waitForLoad(tabId);+ return { ok: true, tabId: String(tabId), sessionId: args.sessionId };+}++export async function wait(args = {}) {+ const ms = Math.min(Number(args.ms) || 3000, 120000);+ await new Promise((r) => setTimeout(r, ms));+ return { ok: true, waited: ms };+}++// nbrowser_wait_for - poll the page until a CSS selector exists / the URL contains a substring / the+// visible text contains a substring, or timeoutMs elapses. Deterministic post-click/nav wait instead+// of fixed sleeps + coordinate races. Pass one or more of {selector, urlIncludes, textIncludes}.+export async function waitFor(args = {}) {+ const tabId = await resolveTabId(args, { requireOwned: true, verb: 'nbrowser_wait_for' });+ const { selector, urlIncludes, textIncludes } = args;+ if (!selector && !urlIncludes && !textIncludes) throw new Error('wait_for needs one of: selector, urlIncludes, textIncludes');+ const timeoutMs = Math.min(Number(args.timeoutMs) || 15000, 120000);+ const t0 = Date.now();+ while (Date.now() - t0 < timeoutMs) {+ let res;+ try {+ const [{ result } = {}] = await chrome.scripting.executeScript({+ target: { tabId },+ args: [selector || null, urlIncludes || null, textIncludes || null],+ func: (sel, u, t) => {+ try {+ if (sel && document.querySelector(sel)) return { matched: true, by: 'selector', url: location.href };+ if (u && location.href.includes(u)) return { matched: true, by: 'url', url: location.href };+ if (t && ((document.body && document.body.innerText) || '').includes(t)) return { matched: true, by: 'text', url: location.href };+ return { matched: false, url: location.href };+ } catch (e) { return { matched: false, error: String(e) }; }+ },+ });+ res = result;+ } catch (e) { res = null; } // page mid-navigation (no doc yet) - keep polling+ if (res && res.matched) return { ok: true, matched: true, by: res.by, url: res.url, waitedMs: Date.now() - t0, tabId: String(tabId), sessionId: args.sessionId };+ await new Promise((r) => setTimeout(r, 250));+ }+ return { ok: false, matched: false, timedOut: true, waitedMs: Date.now() - t0, tabId: String(tabId), sessionId: args.sessionId };+}++// nbrowser_keepalive - replay a benign no-op interaction (mousemove + focus, NO navigation/side-effects)+// on an interval to stave off vendor idle sign-outs (Gusto/Intuit ~10-15 min). Per-session; auto-clears+// when the tab/window closes. Pass {stop:true} to cancel. Re-calling replaces the interval.+const _keepalives = new Map(); // sessionId -> interval handle+export async function keepalive(args = {}) {+ const sid = args.sessionId;+ if (!sid) throw new Error('keepalive needs a sessionId');+ const existing = _keepalives.get(sid);+ if (existing) { clearInterval(existing); _keepalives.delete(sid); }+ if (args.stop) return { ok: true, keepalive: 'off', stopped: true, sessionId: sid };+ const intervalSec = Math.max(30, Math.min(Number(args.intervalSec) || 240, 900)); // 30s..15min, default 4min+ const stopSelf = () => { const h = _keepalives.get(sid); if (h) { clearInterval(h); _keepalives.delete(sid); } };+ const tick = async () => {+ try {+ const tabId = await resolveTabId({ sessionId: sid });+ await chrome.scripting.executeScript({ target: { tabId }, func: () => {+ try { document.dispatchEvent(new MouseEvent('mousemove', { bubbles: true, clientX: 2, clientY: 2 })); } catch {}+ try { window.dispatchEvent(new Event('focus')); } catch {}+ } });+ } catch (e) { stopSelf(); } // tab/window gone -> stop+ };+ const h = setInterval(tick, intervalSec * 1000);+ _keepalives.set(sid, h);+ tick();+ return { ok: true, keepalive: 'on', intervalSec, sessionId: sid };+}++// Detect whether the user appears SIGNED IN to a site, by inspecting its cookies. Chrome does+// NOT expose saved passwords to extensions (security), so we can't read a stored credential —+// but auth/session cookies tell us if a live session exists, which is what you need before+// driving a logout (and to know a re-login is required afterward). Re-login is then driven by+// focusing the login form and letting Chrome AUTOFILL the saved credential (the secret never+// reaches Adom), or by snapshotting these cookies before logout and restoring them.+export async function loginState(args = {}) {+ if (!args.url) throw new Error('url required (e.g. https://www.mouser.com)');+ let host;+ try { host = new URL(args.url).hostname; } catch { throw new Error('bad url'); }+ const base = host.replace(/^www\./, '');+ const cookies = await chrome.cookies.getAll({ domain: base });+ const authRe = /sess|auth|token|\bsid\b|login|account|uid|csrf|jwt|__Secure|__Host/i;+ const auth = cookies.filter((c) => authRe.test(c.name) || (c.secure && c.httpOnly));+ const strong = auth.some((c) => /sess|auth|token|login|jwt/i.test(c.name));+ const loggedIn = auth.length > 0;+ return {+ ok: true,+ host: base,+ loggedIn,+ confidence: loggedIn ? (strong ? 'high' : 'medium') : 'low',+ cookieCount: cookies.length,+ authCookieNames: auth.map((c) => c.name).slice(0, 12),+ _hint: loggedIn+ ? 'Auth/session cookies present → user appears SIGNED IN. Chrome does NOT expose saved passwords to extensions, so to re-login after a logout: drive the login form and let Chrome AUTOFILL the saved credential (you never read the secret), then submit. Or snapshot these cookies BEFORE logout and restore them (only works if logout did not invalidate them server-side).'+ : 'No obvious auth cookies → likely SIGNED OUT. If a credential is saved, Chrome autofill on the login form can sign in without exposing the password to Adom.',+ };+}++// ---- nbrowser_clear_cookies: BACKGROUND logout — drop a site's cookies (incl. httpOnly) -------------+// chrome.cookies can remove httpOnly/secure session cookies that page JS can't touch — a clean, background+// way to sign a site out. Scoped to ONE registrable domain so it never nukes unrelated sessions (e.g.+// clearing gusto.com leaves your google.com session intact, so Google SSO still works on the re-login).+export async function clearCookies(args = {}) {+ const raw = (args.host || args.domain || args.url || '').replace(/^https?:\/\//, '').split('/')[0];+ if (!raw) throw new Error('host required (e.g. host:"gusto.com")');+ const base = raw.split('.').slice(-2).join('.'); // registrable domain: app.gusto.com -> gusto.com+ const cookies = await chrome.cookies.getAll({ domain: base }); // matches base + all its subdomains+ let removed = 0; const hosts = new Set();+ for (const c of cookies) {+ const d = c.domain.replace(/^\./, '');+ const url = (c.secure ? 'https://' : 'http://') + d + (c.path || '/');+ try { await chrome.cookies.remove({ url, name: c.name, storeId: c.storeId }); removed++; hosts.add(d); } catch (e) {}+ }+ return { ok: true, domain: base, removed, hosts: [...hosts].slice(0, 8),+ _hint: `Cleared ${removed} cookie(s) for ${base} (background logout). Navigate to the site → it should show the login. Other sites' sessions (e.g. Google for SSO) are untouched.` };+}++// ---- M4: authed fetch (uses the user's real cookies) + downloads -----------+export async function fetchUrl(args = {}) {+ if (!args.url) throw new Error('url required');+ const res = await fetch(args.url, {+ method: args.method || 'GET',+ credentials: 'include', // <-- the native advantage: the user's real session cookies+ headers: args.headers || {},+ body: args.body,+ });+ const ct = res.headers.get('content-type') || '';+ const isText = /text|json|xml|javascript|html|svg|csv|urlencoded/.test(ct) || !ct;+ let body, encoding;+ if (isText) { body = await res.text(); encoding = 'utf8'; }+ else {+ const buf = new Uint8Array(await res.arrayBuffer());+ let bin = ''; for (let i = 0; i < buf.length; i++) bin += String.fromCharCode(buf[i]);+ body = btoa(bin); encoding = 'base64';+ }+ let downloadId;+ if (args.download || args.desktopSaveTo || args.filename) {+ downloadId = await new Promise((r) => chrome.downloads.download({ url: args.url, filename: args.filename }, (id) => { void chrome.runtime.lastError; r(id); }));+ }+ return { ok: true, url: res.url, status: res.status, contentType: ct, encoding, length: body.length, body, downloadId };+}++export async function download(args = {}) {+ if (!args.url) throw new Error('url required');+ const id = await new Promise((resolve, reject) => {+ chrome.downloads.download({ url: args.url, filename: args.filename, saveAs: !!args.saveAs }, (downloadId) => {+ const e = chrome.runtime.lastError; if (e) reject(new Error(e.message)); else resolve(downloadId);+ });+ });+ return { ok: true, downloadId: id, url: args.url, filename: args.filename };+}++// ---- demo aids: on-page captions (subtitle | alert) + bring-to-front -------+// Injected into the page (self-contained; chrome.scripting serializes via toString()).+// style 'subtitle' = clean bottom narration (Hydrogen feel);+// style 'alert' = red PULSING attention banner (ab feel). pulse via Web Animations API+// so page CSP can't block it.+function injectCaption(o) {+ var id = '__adom_caption__';+ var old = document.getElementById(id);+ if (old) { if (old.__anim) try { old.__anim.cancel(); } catch (e) {} old.remove(); }+ if (!o.text) return; // empty text = dismiss+ var alert = o.style === 'alert';+ var pos = o.position || (alert ? 'top' : 'bottom');+ var d = document.createElement('div');+ d.id = id; d.textContent = o.text;+ var s = d.style;+ s.position = 'fixed'; s.left = '50%'; s.zIndex = '2147483647'; s.pointerEvents = 'none';+ s.textAlign = 'center'; s.maxWidth = '92vw'; s.boxSizing = 'border-box'; s.opacity = '0';+ s.fontFamily = 'system-ui,Segoe UI,Arial,sans-serif'; s.transition = 'opacity .3s ease';+ var ty = '-50%';+ if (pos === 'center') { s.top = '50%'; ty = '-50%,-50%'; s.transform = 'translate(-50%,-50%)'; }+ else if (pos === 'top') { s.top = '22px'; s.transform = 'translateX(-50%)'; }+ else { s.bottom = '8%'; s.transform = 'translateX(-50%)'; }+ if (alert) {+ s.color = '#fff'; s.background = 'rgba(176,0,0,0.93)'; s.border = '4px solid #ff4d4d';+ s.font = '800 ' + (o.fontSize || 38) + 'px/1.2 system-ui,Arial,sans-serif';+ s.padding = '20px 34px'; s.borderRadius = '14px'; s.letterSpacing = '0.5px';+ s.boxShadow = '0 8px 44px rgba(255,0,0,0.55)';+ } else {+ s.color = '#fff'; s.background = 'rgba(0,0,0,0.80)';+ s.font = '600 ' + (o.fontSize || 30) + 'px/1.35 system-ui,Arial,sans-serif';+ s.padding = '14px 28px'; s.borderRadius = '12px'; s.boxShadow = '0 4px 24px rgba(0,0,0,0.55)';+ }+ (document.documentElement || document.body).appendChild(d);+ requestAnimationFrame(function () { d.style.opacity = '1'; });+ if (o.pulse !== false && (alert || o.pulse)) {+ try { d.__anim = d.animate([{ opacity: 1 }, { opacity: 0.45 }, { opacity: 1 }], { duration: 850, iterations: Infinity }); } catch (e) {}+ }+ if (o.durationMs) setTimeout(function () {+ var e = document.getElementById(id);+ if (e) { if (e.__anim) try { e.__anim.cancel(); } catch (x) {} e.style.opacity = '0'; setTimeout(function () { try { e.remove(); } catch (x) {} }, 350); }+ }, o.durationMs);+}++export async function caption(args = {}) {+ const tabId = await resolveTabId(args, { requireOwned: true, verb: 'nbrowser_caption' });+ await chrome.scripting.executeScript({+ target: { tabId },+ func: injectCaption,+ args: [{+ text: args.action === 'hide' ? '' : String(args.text || ''),+ style: args.style === 'alert' ? 'alert' : 'subtitle',+ position: args.position,+ fontSize: args.fontSize ? Number(args.fontSize) : undefined,+ pulse: args.pulse,+ durationMs: args.durationMs === undefined ? 5000 : Number(args.durationMs),+ }],+ });+ return { ok: true, tabId: String(tabId), text: args.text, style: args.style || 'subtitle' };+}++// Select the tab within its window + (politely) flash its taskbar button. It does NOT raise the window+// above the user's foreground app unless the user EXPLICITLY asked to watch live+// (userRequestedForeground:true). Without that flag we only drawAttention (a taskbar flash) + set the+// active tab — never a focus/raise — so the user's foreground work is never yanked.+export async function focus(args = {}) {+ const tabId = await resolveTabId(args, { requireOwned: true, verb: 'nbrowser_focus' });+ const tab = await chrome.tabs.get(tabId);+ const fg = userWantsForeground(args);+ await chrome.tabs.update(tabId, { active: true }); // select the tab within its window (no raise)+ if (fg) await chrome.windows.update(tab.windowId, { focused: true }); // user asked → raise+ else { try { await chrome.windows.update(tab.windowId, { drawAttention: true }); } catch {} } // polite taskbar flash only+ return foregroundNote(args, {+ ok: true, tabId: String(tabId), windowId: tab.windowId, title: tab.title, raised: fg,+ _hint: fg+ ? FOREGROUND_GRANTED_HINT+ : `Did NOT raise the window — only selected the tab + flashed its taskbar button, so the user's focus is never stolen. ${POLITE_HINT} Capture it in place with nbrowser_screenshot (works on a background/occluded window).`,+ }, args.sessionId);+}++// shared resolver for CDP verbs (screenshot/eval/input target the same tab)+export { resolveTabId };++// ── #6 Downloads: buffer completed downloads + optional dest subfolder ─────────+// Listeners registered at module load so downloads are caught even before download_wait is called.+const _downloads = []; // {id, filename, finishedAt, bytes}+let _dlSubfolder = null; // pending saveToSubfolder (relative to the Downloads dir)+try {+ chrome.downloads.onChanged.addListener((delta) => {+ if (delta && delta.state && delta.state.current === 'complete') {+ try { chrome.downloads.search({ id: delta.id }, (items) => { const it = items && items[0]; if (it) _downloads.push({ id: it.id, filename: it.filename, finishedAt: Date.now(), bytes: it.fileSize }); }); } catch {}+ }+ });+ if (chrome.downloads.onDeterminingFilename) {+ chrome.downloads.onDeterminingFilename.addListener((item, suggest) => {+ if (_dlSubfolder) { const base = (item.filename || 'download').split(/[\\/]/).pop(); suggest({ filename: String(_dlSubfolder).replace(/[\\/]+$/, '') + '/' + base, conflictAction: 'uniquify' }); }+ else suggest();+ });+ }+} catch {}++// nbrowser_download_wait - return filenames that COMPLETED within the last sinceMs (or wait up to+// timeoutMs for one to land). Set saveToSubfolder to route subsequent downloads into a subfolder of+// the Downloads dir. Beats polling the Downloads folder by timestamp.+export async function downloadWait(args = {}) {+ if (args.saveToSubfolder !== undefined) _dlSubfolder = args.saveToSubfolder || null;+ const sinceMs = Math.min(Number(args.sinceMs) || 60000, 3600000);+ const timeoutMs = Math.min(Number(args.timeoutMs) || 60000, 300000);+ const cutoff = Date.now() - sinceMs;+ const collect = () => _downloads.filter((x) => x.finishedAt >= cutoff);+ const t0 = Date.now();+ let hits = collect();+ while (!hits.length && Date.now() - t0 < timeoutMs) { await new Promise((r) => setTimeout(r, 500)); hits = collect(); }+ return { ok: true, files: hits.map((x) => x.filename), count: hits.length, timedOut: !hits.length, saveToSubfolder: _dlSubfolder || undefined,+ _hint: hits.length ? undefined : 'No download completed in the window. Increase timeoutMs, or the click that triggers the export may not have fired - verify it, then call download_wait again.' };+}
src/extension-nbe/src/offscreen-record.htmladded+3@@ -0,0 +1,3 @@+<!doctype html>+<html><head><meta charset="utf-8"></head>+<body><script src="offscreen-record.js"></script></body></html>
src/extension-nbe/src/offscreen-record.jsadded+51@@ -0,0 +1,51 @@+// offscreen-record.js — runs MediaRecorder (not available in the MV3 service worker).+// The SW hands us a tabCapture streamId; we record to WebM and return it as base64 on stop.++let recorder = null;+let chunks = [];+let mime = 'video/webm';++chrome.runtime.onMessage.addListener((msg, _sender, sendResponse) => {+ if (!msg || msg.target !== 'offscreen-record') return;+ if (msg.action === 'start') { start(msg).then(sendResponse).catch((e) => sendResponse({ ok: false, error: String((e && e.message) || e) })); return true; }+ if (msg.action === 'stop') { stop().then(sendResponse).catch((e) => sendResponse({ ok: false, error: String((e && e.message) || e) })); return true; }+});++async function start(msg) {+ const src = msg.source === 'desktop' ? 'desktop' : 'tab'; // desktopCapture vs legacy tabCapture+ const fps = Number(msg.fps) || 30;+ const stream = await navigator.mediaDevices.getUserMedia({+ audio: msg.audio ? { mandatory: { chromeMediaSource: src, chromeMediaSourceId: msg.streamId } } : false,+ video: { mandatory: { chromeMediaSource: src, chromeMediaSourceId: msg.streamId, maxFrameRate: fps } },+ });+ chunks = [];+ // vp9 for quality/fps; fall back if unsupported+ mime = MediaRecorder.isTypeSupported('video/webm;codecs=vp9') ? 'video/webm;codecs=vp9' : 'video/webm';+ recorder = new MediaRecorder(stream, { mimeType: mime, videoBitsPerSecond: msg.bitrate || 6000000 });+ recorder.ondataavailable = (e) => { if (e.data && e.data.size) chunks.push(e.data); };+ // stop cleanly if the user ends the share from Chrome's "Stop sharing" bar+ stream.getVideoTracks()[0].addEventListener('ended', () => { try { if (recorder && recorder.state !== 'inactive') recorder.stop(); } catch (e) {} });+ recorder.start(1000); // 1s timeslices+ return { ok: true, mimeType: mime, fps };+}++function stop() {+ return new Promise((resolve) => {+ if (!recorder) return resolve({ ok: false, error: 'not recording' });+ recorder.onstop = async () => {+ try {+ const blob = new Blob(chunks, { type: mime });+ const buf = new Uint8Array(await blob.arrayBuffer());+ let bin = '';+ const CH = 0x8000;+ for (let i = 0; i < buf.length; i += CH) bin += String.fromCharCode.apply(null, buf.subarray(i, i + CH));+ const out = { ok: true, base64: btoa(bin), mimeType: mime, sizeKB: Math.round(buf.length / 1024) };+ recorder.stream.getTracks().forEach((t) => t.stop());+ recorder = null;+ chunks = [];+ resolve(out);+ } catch (e) { resolve({ ok: false, error: String((e && e.message) || e) }); }+ };+ recorder.stop();+ });+}
src/extension-nbe/src/popup.htmladded+27@@ -0,0 +1,27 @@+<!doctype html>+<html>+<head>+ <meta charset="utf-8" />+ <style>+ body { font-family: system-ui, sans-serif; width: 250px; margin: 0; padding: 14px; }+ h1 { font-size: 14px; margin: 0 0 10px; }+ .row { display: flex; align-items: center; gap: 8px; font-size: 13px; }+ .dot { width: 10px; height: 10px; border-radius: 50%; background: #c0392b; }+ .dot.on { background: #27ae60; }+ .muted { color: #888; font-size: 11px; margin-top: 8px; }+ button { margin-top: 14px; width: 100%; padding: 10px; font-size: 14px; font-weight: 600;+ border: none; border-radius: 8px; background: #1769ff; color: #fff; cursor: pointer; }+ button.rec { background: #c0392b; }+ button:disabled { opacity: .6; cursor: default; }+ #recmsg { font-size: 11px; color: #888; margin-top: 6px; min-height: 14px; }+ </style>+</head>+<body>+ <h1>Adom for Chrome & Edge</h1>+ <div class="row"><span id="dot" class="dot"></span><span id="status">checking…</span></div>+ <div class="muted" id="detail"></div>+ <button id="rec">⏺ Record this tab</button>+ <div id="recmsg"></div>+ <script src="popup.js"></script>+</body>+</html>
src/extension-nbe/src/popup.jsadded+51@@ -0,0 +1,51 @@+// Popup: connection status + the friendly "Record this tab" button (no share-picker).+async function renderStatus() {+ const { connected, since } = await chrome.storage.session.get(['connected', 'since']);+ const dot = document.getElementById('dot');+ const status = document.getElementById('status');+ const detail = document.getElementById('detail');+ if (connected) {+ dot.classList.add('on');+ status.textContent = 'Connected to Bridge';+ detail.textContent = since ? `since ${new Date(since).toLocaleTimeString()}` : '';+ } else {+ dot.classList.remove('on');+ status.textContent = 'Not connected';+ detail.textContent = 'Is Bridge running with the native-browser bridge installed?';+ }+}+renderStatus();+chrome.storage.session.onChanged.addListener(renderStatus);++const recBtn = document.getElementById('rec');+const recMsg = document.getElementById('recmsg');++async function refreshRec() {+ const st = await chrome.runtime.sendMessage({ type: 'popup-record-state' }).catch(() => null);+ const recording = st && st.state === 'recording';+ recBtn.dataset.recording = recording ? '1' : '';+ recBtn.textContent = recording ? '⏹ Stop & save' : '⏺ Record this tab';+ recBtn.classList.toggle('rec', recording);+ if (recording && st.elapsedMs) recMsg.textContent = `recording… ${Math.round(st.elapsedMs / 1000)}s`;+}++recBtn.onclick = async () => {+ recBtn.disabled = true;+ try {+ if (recBtn.dataset.recording) {+ recMsg.textContent = 'saving…';+ const r = await chrome.runtime.sendMessage({ type: 'popup-record-stop' });+ recMsg.textContent = r && r.ok ? (r.downloaded ? `saved (${r.sizeKB} KB) to Downloads` : 'stopped') : `stop failed: ${(r && r.error) || ''}`;+ } else {+ const [tab] = await chrome.tabs.query({ active: true, currentWindow: true });+ recMsg.textContent = 'starting…';+ const r = await chrome.runtime.sendMessage({ type: 'popup-record-start', tabId: tab.id });+ recMsg.textContent = r && r.ok ? 'recording this tab — no picker!' : `failed: ${(r && r.error) || ''} ${(r && r._hint) || ''}`;+ }+ } catch (e) { recMsg.textContent = String((e && e.message) || e); }+ recBtn.disabled = false;+ refreshRec();+};++refreshRec();+setInterval(refreshRec, 1000);
src/extension-nbe/src/record.jsadded+108@@ -0,0 +1,108 @@+// record.js — SW-side control for browser recording (nbrowser_record_*).+//+// 🎥 DOCTRINE — why it's built this way (full detail: the native-browser-recording skill + README "Recording"):+// GOAL: demos must look AMAZING → full-res, HIGH FRAMERATE (20–60fps). The enemy is PAINT-THROTTLING, not the+// codec: Chrome correctly stops painting occluded/background/static windows (battery) → capture freezes/drops fps.+// (CDP screencast is NOT garbage — it's pup's pup_record_start engine, 20–30fps when the page paints.)+// The NATIVE-browser methods here are 'wgc' + 'getdisplaymedia' (real high-fps engines). Background capture of+// the user's REAL Chrome (the only browser with their logins) needs it to PAINT while occluded — which means+// launching it with the occlusion/throttle-disable flags (--disable-features=CalculateNativeWinOcclusion+// --disable-backgrounding-occluded-windows --disable-renderer-backgrounding --disable-background-timer-throttling).+// Default Chrome lacks them (correctly — battery). So:+// • No login needed → record via PUP/Hydrogen (see the demo-recording skill); don't touch the user's Chrome.+// • Login needed → native Chrome: ASK+WARN, FULLY kill the whole chrome.exe process tree, relaunch with the+// user's profile + the flags, record full-fps in the BACKGROUND, then OFFER TO REVERT later (battery saver).+// ⛔ NEVER foreground the user's real Chrome to fix framerate (use the flags); NEVER leave the flags on forever.+//+// TWO methods (caller picks via args.method; README has the full pros/cons):+// • 'getdisplaymedia' (desktopCapture) — CROSS-PLATFORM (Mac/Ubuntu/Windows). High-fps (the WebRTC+// pipeline Google Meet uses). Chrome REQUIRES a one-time "choose what to share" picker per+// recording (no silent variant in any browser). Records the chosen surface in the background.+// • 'wgc' — WINDOWS-only, handled BRIDGE-SIDE via Hydrogen's Windows-Graphics-Capture recorder: no picker,+// fully background, no wake-lock, 60fps mp4. The bridge intercepts method:'wgc' before the+// extension; reaching here means Hydrogen/bridge WGC isn't wired on this machine.+//+// record_start is NON-BLOCKING: it shows the picker and returns immediately with a recordId in state+// 'awaiting_pick'. The picker can take as long as the user needs (the old blocking version timed out+// the relay request). Poll nbrowser_record_status until state=='recording', then drive, then stop.+// The MediaRecorder lives in the offscreen document, so it keeps recording even if the SW idles.++const OFFSCREEN_URL = 'src/offscreen-record.html';+const active = new Map(); // recordId -> { state, method, startedAt?, mimeType?, error? }+let seq = 0;++async function ensureOffscreen() {+ const has = chrome.offscreen.hasDocument ? await chrome.offscreen.hasDocument() : false;+ if (!has) await chrome.offscreen.createDocument({ url: OFFSCREEN_URL, reasons: ['USER_MEDIA'], justification: 'Record the browser to WebM for demos' });+}++export async function recordStart(tabId, args = {}) {+ const method = (args.method || 'getdisplaymedia').toLowerCase();+ if (method === 'wgc') {+ return { ok: false, error: 'method:wgc is handled by the bridge (Hydrogen WGC recorder), not the extension',+ _hint: 'the bridge intercepts method:"wgc"; reaching the extension means Hydrogen/bridge WGC is not wired here — use method:"getdisplaymedia"' };+ }+ // 'tabcapture' — record the CURRENT tab with NO picker. Needs activeTab, which is granted when the+ // user clicks the Adom toolbar icon (that gesture). This powers the friendly "Record this tab" button.+ if (method === 'tabcapture' || method === 'thistab') {+ let streamId;+ try {+ streamId = await new Promise((res, rej) => chrome.tabCapture.getMediaStreamId({ targetTabId: Number(tabId) }, (id) =>+ (chrome.runtime.lastError || !id) ? rej(new Error((chrome.runtime.lastError && chrome.runtime.lastError.message) || 'tabCapture failed')) : res(id)));+ } catch (e) {+ return { ok: false, error: String((e && e.message) || e), _hint: 'tabCapture needs activeTab — start it from the Adom toolbar button ("Record this tab"); that click grants it.' };+ }+ const recordId = 'r' + (++seq);+ await ensureOffscreen();+ const res = await chrome.runtime.sendMessage({ target: 'offscreen-record', action: 'start', streamId, source: 'tab', fps: Number(args.fps) || 30, audio: args.audio === true });+ if (res && res.ok) { active.set(recordId, { state: 'recording', method: 'tabcapture', startedAt: Date.now(), mimeType: res.mimeType }); return { ok: true, recordId, method: 'tabcapture', state: 'recording' }; }+ return { ok: false, error: (res && res.error) || 'recorder failed to start' };+ }++ const recordId = 'r' + (++seq);+ active.set(recordId, { state: 'awaiting_pick', method: 'getdisplaymedia' });+ const tab = tabId != null ? await chrome.tabs.get(Number(tabId)).catch(() => null) : null;++ // Picker callback — runs whenever the user picks (or cancels). Recording starts HERE, async.+ const onPick = async (streamId) => {+ if (chrome.runtime.lastError || !streamId) { active.set(recordId, { state: 'cancelled', method }); return; }+ try {+ await ensureOffscreen();+ const res = await chrome.runtime.sendMessage({ target: 'offscreen-record', action: 'start', streamId, source: 'desktop', fps: Number(args.fps) || 30, audio: args.audio === true });+ if (res && res.ok) active.set(recordId, { state: 'recording', method: 'getdisplaymedia', startedAt: Date.now(), mimeType: res.mimeType });+ else active.set(recordId, { state: 'error', method, error: (res && res.error) || 'recorder start failed' });+ } catch (e) { active.set(recordId, { state: 'error', method, error: String((e && e.message) || e) }); }+ };++ try {+ if (tab) chrome.desktopCapture.chooseDesktopMedia(['window', 'screen', 'tab'], tab, onPick);+ else chrome.desktopCapture.chooseDesktopMedia(['window', 'screen'], onPick);+ } catch (e) {+ active.set(recordId, { state: 'error', method, error: String((e && e.message) || e) });+ return { ok: false, error: String((e && e.message) || e) };+ }+ return { ok: true, recordId, method: 'getdisplaymedia', state: 'awaiting_pick',+ _hint: 'a share picker is up — once the user picks, state becomes "recording". Poll nbrowser_record_status {recordId}; then drive; then nbrowser_record_stop.' };+}++export async function recordStop(args = {}) {+ const recordId = args.recordId || [...active.keys()].reverse().find((id) => active.get(id).state === 'recording');+ const meta = recordId ? active.get(recordId) : null;+ if (!meta) return { ok: false, error: 'no recording found' };+ if (meta.state !== 'recording') return { ok: false, error: `recording is in state '${meta.state}', not recording`, state: meta.state };+ const res = await chrome.runtime.sendMessage({ target: 'offscreen-record', action: 'stop' });+ active.delete(recordId);+ if (![...active.values()].some((m) => m.state === 'recording') && chrome.offscreen.closeDocument) chrome.offscreen.closeDocument().catch(() => {});+ if (!res || !res.ok) return { ok: false, error: (res && res.error) || 'recorder failed to stop' };+ return { ok: true, recordId, method: meta.method, durationMs: Date.now() - meta.startedAt, mimeType: res.mimeType, sizeKB: res.sizeKB, base64: res.base64 };+}++export function recordStatus(args = {}) {+ const recordId = args.recordId || [...active.keys()].pop();+ const m = recordId ? active.get(recordId) : null;+ if (!m) return { ok: true, recordId: recordId || null, state: 'none' };+ return { ok: true, recordId, state: m.state, method: m.method, error: m.error, elapsedMs: m.startedAt ? Date.now() - m.startedAt : 0 };+}+export function recordList() {+ return { ok: true, count: active.size, recordings: [...active].map(([id, m]) => ({ recordId: id, state: m.state, method: m.method, elapsedMs: m.startedAt ? Date.now() - m.startedAt : 0 })) };+}
src/extension-nbe/src/sw.jsadded+434@@ -0,0 +1,434 @@+// adom-native-browser :: MV3 service worker+// ---------------------------------------------+// Holds the native-messaging port to the ab bridge (via the native host), routes nbrowser_* commands+// to native.js (chrome.tabs/windows — no banner) or cdp.js (chrome.debugger — screenshot/eval), and+// answers the host's keepalive ping (which keeps this service worker resident).++import * as native from './native.js';+import * as cdp from './cdp.js';+import * as record from './record.js';++const HOST = 'inc.adom.native_browser';+const VERSION = chrome.runtime.getManifest().version;++// TOOLBAR ICON (John, 2026-09-12). Three modes, set by the bridge (nbrowser_settings {set:{toolbarIcon}})+// and persisted here so a service-worker restart keeps it:+// auto (default): Chrome's own default for an unpacked extension, the icon exists but is UNPINNED (under the+// puzzle menu) - the "small surface" John wants when nb loads the nbe into a user's browser.+// on: the icon is enabled, carries an "nb" badge and a clear title, so a host that PINS it (pup+// pins the extensions it loads into its own profile) shows a live Adom presence in the toolbar.+// off: transparent icon and disabled action, as invisible as an extension can make itself.+// An extension cannot pin itself: pinning is the profile's extensions.pinned_extensions preference, which the+// launcher that owns the profile (pup) sets. This switch decides what the pinned slot SHOWS.+const ICON_DEFAULT = { 16: 'icons/icon16.png', 48: 'icons/icon48.png', 128: 'icons/icon128.png' };+const ICON_BLANK = { 16: 'icons/blank16.png', 48: 'icons/blank48.png' };+let _toolbarIcon = 'auto', _toolbarEffective = 'auto', _packagedDefault = null;+// PACKAGED DEFAULT: a host that copies this extension into its own profile (pup) can drop a toolbar.json+// next to the manifest, {"toolbarIcon":"on"}, and 'auto' then resolves to that. So pup's window shows the+// icon while John's own browser, loaded from the plain folder, keeps Chrome's unpinned default; no+// desktop-wide setting has to change for one host. The user's explicit on/off (nbrowser_settings) still wins.+let _packagedDiag = null;+async function packagedDefault() {+ if (_packagedDefault !== null) return _packagedDefault;+ try {+ const url = chrome.runtime.getURL('toolbar.json');+ const r = await fetch(url, { cache: 'no-store' }); // a deleted toolbar.json must not keep answering from the HTTP cache+ const text = r.ok ? await r.text() : '';+ let j = null; try { j = text ? JSON.parse(text) : null; } catch (e) { _packagedDiag = { url, status: r.status, parse: String(e), head: text.slice(0, 40) }; }+ if (!_packagedDiag) _packagedDiag = { url, status: r.status, head: text.slice(0, 40) };+ _packagedDefault = (j && ['on', 'off'].includes(j.toolbarIcon)) ? j.toolbarIcon : '';+ } catch (e) { _packagedDiag = { error: String(e && e.message || e) }; _packagedDefault = ''; }+ return _packagedDefault;+}+async function applyToolbarIcon(mode) {+ mode = ['auto', 'on', 'off'].includes(mode) ? mode : 'auto';+ _toolbarIcon = mode;+ try { await chrome.storage.local.set({ nb_toolbar_icon: mode }); } catch {}+ const effective = mode === 'auto' ? ((await packagedDefault()) || 'auto') : mode;+ _toolbarEffective = effective;+ mode = effective;+ try {+ if (mode === 'off') {+ await chrome.action.setIcon({ path: ICON_BLANK });+ await chrome.action.setBadgeText({ text: '' });+ await chrome.action.setTitle({ title: 'Adom Native Browser Extension (hidden)' });+ await chrome.action.disable();+ } else {+ await chrome.action.enable();+ await chrome.action.setIcon({ path: ICON_DEFAULT });+ if (mode === 'on') {+ await chrome.action.setBadgeBackgroundColor({ color: '#14b8a6' });+ await chrome.action.setBadgeTextColor({ color: '#04201d' }).catch(() => {});+ await chrome.action.setBadgeText({ text: 'nb' });+ await chrome.action.setTitle({ title: 'Adom Native Browser Extension (nbe): your Adom AI can drive this browser' });+ } else {+ await chrome.action.setBadgeText({ text: '' });+ await chrome.action.setTitle({ title: 'Adom' });+ }+ }+ } catch (e) { /* action API missing (no action in manifest) or a race on startup: harmless */ }+ return { ok: true, toolbarIcon: _toolbarIcon, effective: _toolbarEffective };+}+(async () => { try { const { nb_toolbar_icon = 'auto' } = await chrome.storage.local.get('nb_toolbar_icon'); await applyToolbarIcon(nb_toolbar_icon); } catch {} })();+++let port = null;+let connecting = false;+let gotMessage = false;+let _safetySent = false;++// Bounded reconnect: exponential backoff + a give-up state. NEVER a fixed-rate spin — a fast reconnect+// loop here re-spawns native-host processes, which is what exhausted the machine's sockets on+// 2026-06-19. After SW_GIVEUP misses we stop the fast loop; the 0.5-min keepalive alarm then re-arms+// exactly ONE attempt per tick (a slow heartbeat), so we still recover without hammering connectNative.+const SW_BASE = 1500, SW_MAX = 60000, SW_GIVEUP = 40;+let swBackoff = SW_BASE;+let swAttempts = 0;+let givenUp = false;+let reconnectTimer = null;++function setStatus(connected) {+ chrome.storage.session.set({ connected, since: Date.now() }).catch(() => {});+}++function scheduleConnect() {+ if (givenUp || port || connecting || reconnectTimer) return; // never stack timers / parallel attempts+ if (swAttempts >= SW_GIVEUP) { givenUp = true; setStatus(false); return; }+ const delay = Math.min(swBackoff, SW_MAX) + Math.floor(Math.random() * 400);+ swBackoff = Math.min(swBackoff * 2, SW_MAX);+ reconnectTimer = setTimeout(() => { reconnectTimer = null; connect(); }, delay);+}++// Idempotent: exactly ONE native port / one in-flight attempt at a time. Multiple callers (onDisconnect+// + keepalive alarm + onStartup) must NOT open parallel ports — that spawns multiple host processes and+// the bridge flaps between them ("newest host wins"). The guards below make concurrent calls no-ops.+function connect() {+ if (port || connecting || givenUp) return;+ connecting = true;+ swAttempts++;+ let p;+ try {+ p = chrome.runtime.connectNative(HOST);+ } catch (e) {+ connecting = false;+ setStatus(false);+ scheduleConnect();+ return;+ }+ port = p;+ connecting = false;+ gotMessage = false;+ setStatus(true);+ port.onMessage.addListener(onMessage);+ port.onDisconnect.addListener(() => {+ if (port === p) port = null;+ helloSent = false;+ setStatus(false);+ scheduleConnect(); // backoff, NOT a fixed 1.5s retry+ });+ sendHello(); // identify THIS profile so the bridge can route per-profile (multi-profile support)+}++// Tell the host+bridge which browser PROFILE this is, so one bridge can hold a connection per profile+// ([email protected], [email protected], …) and the AI can target/switch between them. Email comes from+// chrome.identity; if the profile isn't signed into Google, fall back to a stable random id.+let helloSent = false;+let _emailResolved = false, _emailTries = 0;+async function stableId() {+ try {+ const got = await chrome.storage.local.get('_nbProfileId');+ let pid = got && got._nbProfileId;+ if (!pid) { pid = 'p_' + Math.random().toString(36).slice(2, 10); await chrome.storage.local.set({ _nbProfileId: pid }); }+ return pid;+ } catch { return 'p_unknown'; }+}+async function resolveEmail() {+ try {+ const info = await Promise.race([+ new Promise((res) => { try { chrome.identity.getProfileUserInfo({ accountStatus: 'ANY' }, res); } catch { res(null); } }),+ new Promise((res) => setTimeout(() => res(null), 1500)),+ ]);+ return (info && info.email) || '';+ } catch { return ''; }+}+function postHello(profile) { try { if (port) { port.postMessage({ type: 'hello', profile, version: VERSION, browser: detectBrowser() }); helloSent = true; } } catch {} }+async function sendHello() {+ // Stable id FIRST — always available, so the hello can ALWAYS be sent even if identity misbehaves.+ const profile = { email: await resolveEmail(), id: await stableId(), name: '' };+ if (profile.email) _emailResolved = true;+ postHello(profile);+ // If the email didn't resolve (identity slow, or briefly signed-out), retry and RE-announce once it+ // lands - so the bridge re-keys "pending/id" -> the real "chrome:[email protected]" WITHOUT the caller+ // doing anything. This is what removes the "pending-N / empty email until driven" gap.+ if (!_emailResolved) scheduleEmailRetry();+}+function scheduleEmailRetry() {+ if (_emailResolved || _emailTries >= 6) return;+ _emailTries++;+ setTimeout(async () => {+ if (_emailResolved) return;+ const email = await resolveEmail();+ if (email) { _emailResolved = true; postHello({ email, id: await stableId(), name: '' }); }+ else scheduleEmailRetry();+ }, Math.min(1500 * _emailTries, 10000));+}++function reply(id, ok, payload, error, hint) {+ if (!port) return;+ port.postMessage(ok ? { id, ok: true, payload } : { id, ok: false, error, _hint: hint });+}++async function onMessage(msg) {+ if (!msg) return;+ if (!gotMessage) { gotMessage = true; swAttempts = 0; swBackoff = SW_BASE; if (!helloSent) sendHello(); } // proven-good connection → ensure the profile handshake went out+ if (msg.ping) { try { port && port.postMessage({ pong: true }); } catch {} return; }+ const { id, verb, args } = msg;+ if (id == null || !verb) return;+ try {+ let payload = await dispatch(verb, args || {});+ // First-contact safety banner: teach the cardinal rule once per SW lifetime, in-band.+ if (!_safetySent && payload && typeof payload === 'object' && !Array.isArray(payload)) {+ _safetySent = true;+ payload._safety = '\u26d4 Cardinal rules: (1) REGISTER every window: nbrowser_open_window needs sessionId + thread + purpose ' ++ '(who is driving + why) or it is refused (session_unregistered). (2) Only drive windows YOU open; bare mutating verbs ' ++ 'and targets in user-opened windows are refused (no_target / not_owned). (3) A page you drive can SPAWN windows ' ++ '(a Gmail-Contacts email click opens a compose window) \u2014 those are auto-adopted; your next verb is refused ' ++ '(unacknowledged_spawns) until you nbrowser_owned then nbrowser_cleanup. At task end, close everything you own. ' ++ '(4) Work in the BACKGROUND. NEVER raise/maximize over the user\u2019s work or fall back to OS-level launches \u2014 it is auto-reverted + ' ++ 'logged as a focus_violation. Foregrounding is the user\u2019s choice alone (userRequestedForeground:true). ' ++ 'Start with nbrowser_profiles to pick the right profile.';+ }+ reply(id, true, payload);+ } catch (e) {+ // GuardError (ownership/no-target refusals) carries a teaching hint — deliver it verbatim so+ // the calling AI is corrected at the moment of need, not pointed at a skill it may never read.+ reply(id, false, undefined, String((e && e.message) || e), (e && e.hintText) || 'verb threw in extension');+ }+}++async function dispatch(verb, args) {+ switch (verb) {+ // --- meta -------------------------------------------------------------+ case 'nbrowser_status': {+ const own = native.ownershipSummary(); // dirty_session nag: surfaced while owned/spawned windows remain+ return { ok: true, ext: 'nbe', version: VERSION, engine: 'native', browser: detectBrowser(), toolbarIcon: _toolbarIcon, toolbarIconEffective: _toolbarEffective, toolbarPackaged: _packagedDefault, _toolbarDiag: _packagedDiag, ownedWindows: own.ownedWindows, spawnedWindows: own.spawnedWindows, sessions: own.sessions, _hint: own._nag };+ }+ case 'nbrowser_whoami': {+ // Force a fresh Google-email resolve + RE-announce, so the bridge re-keys this connection to+ // "chrome:[email protected]" eagerly (fixes "pending-N / empty email until driven"). Returns the email.+ _emailResolved = false; _emailTries = 0;+ const email = await resolveEmail();+ if (email) _emailResolved = true;+ const id = await stableId();+ postHello({ email, id, name: '' });+ return { ok: true, email, id, browser: detectBrowser(), signedIn: !!email,+ _hint: email ? 'Resolved this profile\'s Google email and re-announced it to the bridge.' : 'This browser profile is NOT signed into Google, so it has no email - target it by its id-key or by browser:.' };+ }+ case 'nbrowser_ping':+ return { ok: true, pong: true };+ case 'nbrowser_apply_settings': {+ // Pushed by the bridge on every hello and whenever nbrowser_settings changes.+ const r = {};+ if (args && args.toolbarIcon !== undefined) Object.assign(r, await applyToolbarIcon(String(args.toolbarIcon)));+ return { ok: true, applied: r, toolbarIcon: _toolbarIcon, toolbarIconEffective: _toolbarEffective };+ }+ case 'nbrowser_dev_reload': {+ // HARD-GATED. chrome.runtime.reload() restarts the SW, which re-launches the native host. This is+ // a bridge-DISPATCHABLE verb, so a looping/buggy bridge could call it repeatedly → hosts+ // accumulate → port exhaustion (the 2026-06-19 incident). Throttle it, persisted in storage so an+ // SW restart cannot reset the clock and bypass it. 90s is plenty: a reload-per-90s cannot exhaust+ // ports (that needed many/second), and the per-profile host singleton + bridge circuit-breaker+ // (20 connects/10s → trip) are the real backstops. 10min was too coarse for iteration.+ const MIN_INTERVAL = 90 * 1000;+ const now = Date.now();+ const { _lastReload = 0 } = await chrome.storage.local.get('_lastReload');+ if (now - _lastReload < MIN_INTERVAL) {+ return { ok: false, error: 'dev_reload throttled',+ _hint: `last reload ${Math.round((now - _lastReload) / 1000)}s ago; min interval ${MIN_INTERVAL / 1000}s. If you truly need it now, reload from chrome://extensions.` };+ }+ // 2-pass reload: chrome.runtime.reload() does a SOFT reload first (serves cached extension+ // resources, so a single call kept the OLD code on disk-change), and only a 2nd reload re-reads+ // files. Arm a one-shot flag so the SW completes the HARD pass itself on restart — a single+ // dev_reload then reliably picks up on-disk changes. Completion logic is in the init at EOF.+ await chrome.storage.local.set({ _lastReload: now, _devReload2: now });+ setTimeout(() => chrome.runtime.reload(), 150); // reply first, then reload+ return { ok: true, reloading: true,+ _hint: 'Reloading in 2 self-completing passes (soft now → hard on restart) to load your on-disk changes; reconnects in ~6-10s. Poll nbrowser_status until version reflects the edit.' };+ }++ // --- windows / sessions ----------------------------------------------+ case 'nbrowser_open_window': return native.openWindow(args);+ case 'nbrowser_close_window': return native.closeWindow(args);+ case 'nbrowser_switch_window': return native.switchWindow(args);+ case 'nbrowser_list_windows': return native.listWindows(args);+ case 'nbrowser_owned': return native.owned(args); // opened + spawned windows this session owns+ case 'nbrowser_cleanup': return native.cleanup(args); // close ONLY this session's spawned windows+ case 'nbrowser_force_close': return native.forceClose(args); // last-resort orphan override (two-step attestation)+ case 'nbrowser_window_state': return native.windowState(args);+ case 'nbrowser_fullscreen': return native.windowState({ ...args, state: args.state || 'fullscreen' });+ case 'nbrowser_login_state': return native.loginState(args);+ case 'nbrowser_clear_cookies': return native.clearCookies(args); // BACKGROUND logout — drops a site's cookies (incl. httpOnly)+ case 'nbrowser_rescan': return native.rescan();+ case 'nbrowser_resync_sessions': return native.resyncSessions(); // lightweight session-tracking rebuild (no dev_reload)++ // --- demo aids -------------------------------------------------------+ case 'nbrowser_caption': return native.caption(args);+ case 'nbrowser_focus': return native.focus(args);++ // --- tabs -------------------------------------------------------------+ case 'nbrowser_open_tab': return native.openTab(args);+ case 'nbrowser_close_tab': return native.closeTab(args);+ case 'nbrowser_switch_tab': return native.switchTab(args);+ case 'nbrowser_list_tabs': return native.listTabs(args);++ // --- navigation -------------------------------------------------------+ case 'nbrowser_navigate': return native.navigate(args);+ case 'nbrowser_reload': return native.reload(args);+ case 'nbrowser_back': return native.back(args);+ case 'nbrowser_forward': return native.forward(args);+ case 'nbrowser_wait': return native.wait(args);+ case 'nbrowser_wait_for': return native.waitFor(args);+ case 'nbrowser_keepalive': return native.keepalive(args);++ // --- input (CDP, isTrusted) ------------------------------------------+ case 'nbrowser_input_dispatch':+ return cdp.inputDispatch(await native.resolveTabId(args, { requireOwned: true, verb: 'nbrowser_input_dispatch' }), args);+ case 'nbrowser_click':+ return cdp.clickChanged(await native.resolveTabId(args, { requireOwned: true, verb: 'nbrowser_click' }), args); // #5 robust: reports changed:bool+ case 'nbrowser_select': // open a custom/native dropdown, pick option by VISIBLE TEXT, return selected value+ return cdp.selectOption(await native.resolveTabId(args, { requireOwned: true, verb: 'nbrowser_select' }), args);+ case 'nbrowser_set_date': // #7 react-aria segmented date field+ return cdp.setDate(await native.resolveTabId(args, { requireOwned: true, verb: 'nbrowser_set_date' }), args);+ case 'nbrowser_set_file_input': // CDP file upload - no OS picker (DOM.setFileInputFiles); armForNextChooser for transient inputs+ return cdp.setFileInput(await native.resolveTabId(args, { requireOwned: true, verb: 'nbrowser_set_file_input' }), args);+ case 'nbrowser_totp': // #3 type the bridge-computed 6-digit code (args.__code) into the focused 2FA field+ return cdp.typeTotp(await native.resolveTabId(args, { requireOwned: true, verb: 'nbrowser_totp' }), args);+ case 'nbrowser_download_wait': // #6 wait for a download to complete; optional saveToSubfolder+ return native.downloadWait(args);+ case 'nbrowser_type':+ return cdp.inputDispatch(await native.resolveTabId(args, { requireOwned: true, verb: 'nbrowser_type' }), { ...args, type: 'type' });+ case 'nbrowser_press_key':+ return cdp.inputDispatch(await native.resolveTabId(args, { requireOwned: true, verb: 'nbrowser_press_key' }), { ...args, type: 'key' });+ case 'nbrowser_scroll':+ return cdp.inputDispatch(await native.resolveTabId(args, { requireOwned: true, verb: 'nbrowser_scroll' }), { ...args, type: 'wheel' });+ case 'nbrowser_hover':+ return cdp.inputDispatch(await native.resolveTabId(args, { requireOwned: true, verb: 'nbrowser_hover' }), { ...args, type: 'hover' });+ case 'nbrowser_double_click':+ return cdp.inputDispatch(await native.resolveTabId(args, { requireOwned: true, verb: 'nbrowser_double_click' }), { ...args, type: 'dblclick' });+ case 'nbrowser_right_click':+ return cdp.inputDispatch(await native.resolveTabId(args, { requireOwned: true, verb: 'nbrowser_right_click' }), { ...args, type: 'rightclick' });+ case 'nbrowser_login_form':+ // Detect a login form's state (autofilled? SSO? 2FA?) and recommend the smartest path. ALWAYS call+ // before driving a login: if the form is already autofilled, just submit (background) — no popup/SSO.+ return cdp.detectLoginForm(await native.resolveTabId(args, { requireOwned: true, verb: 'nbrowser_login_form' }));+ case 'nbrowser_autologin':+ // Bridge injected args.__secret (the laptop-decrypted saved password). The extension injects it via+ // CDP and returns ONLY {ok,loggedIn,needs2FA} — never the secret. See cdp.autologin.+ return cdp.autologin(await native.resolveTabId(args, { requireOwned: true, verb: 'nbrowser_autologin' }), args);+ case 'nbrowser_cursor': {+ const tabId = await native.resolveTabId(args, { requireOwned: true, verb: 'nbrowser_cursor' });+ await cdp.cursor(tabId, args.action || 'move', args.x, args.y, args.label || args.intent);+ return { ok: true, tabId: String(tabId), action: args.action || 'move', label: args.label || args.intent || 'Adom' };+ }++ // --- errors / lifecycle events ---------------------------------------+ case 'nbrowser_errors':+ return cdp.getErrors(await native.resolveTabId(args, { requireTarget: true, verb: 'nbrowser_errors' }), args.clear !== false);+ case 'nbrowser_events': {+ // window/tab closes (incl. byUser) + spawned_window/focus_violation since last drain — so the AI+ // learns the user closed things, a page spawned a window, or a native JS dialog fired.+ const e = await native.drainEvents();+ const dlgs = cdp.drainDialogs();+ if (dlgs.length) { e.events = (e.events || []).concat(dlgs); e.count = (e.events || []).length; }+ return e;+ }++ // --- data (M4: authed fetch via the user's cookies, downloads) --------+ case 'nbrowser_fetch_url': return native.fetchUrl(args);+ case 'nbrowser_download': return native.download(args);++ // --- recording (tab → WebM via offscreen MediaRecorder) ---------------+ case 'nbrowser_record_start': return record.recordStart(await native.resolveTabId(args, { requireOwned: true, verb: 'nbrowser_record_start' }), args);+ case 'nbrowser_record_stop': return record.recordStop(args);+ case 'nbrowser_record_status': return record.recordStatus(args);+ case 'nbrowser_record_list': return record.recordList();++ // --- CDP: screenshot / eval ------------------------------------------+ case 'nbrowser_screenshot':+ return cdp.screenshot(await native.resolveTabId(args, { requireTarget: true, verb: 'nbrowser_screenshot' }), { fullPage: !!args.fullPage, maxWidth: args.maxWidth || 1568, resize: true });+ case 'nbrowser_screenshot_full_res':+ return cdp.screenshot(await native.resolveTabId(args, { requireTarget: true, verb: 'nbrowser_screenshot_full_res' }), { fullPage: !!args.fullPage, resize: false });+ case 'nbrowser_eval':+ case 'nbrowser_evaluate': {+ const tabId = await native.resolveTabId(args, { requireOwned: true, verb: 'nbrowser_eval' });+ const m = cdp.modalGuard(tabId); if (m) return m; // a native JS dialog is blocking the page+ const result = await cdp.evaluate(tabId, String(args.expr ?? args.expression ?? ''));+ return { ok: true, result, tabId: String(tabId), sessionId: args.sessionId };+ }+ case 'nbrowser_handle_dialog': // press OK/Cancel (or answer a prompt) on a native JS dialog blocking the page+ return cdp.handleDialog(await native.resolveTabId(args, { requireOwned: true, verb: 'nbrowser_handle_dialog' }), args.accept, args.text ?? args.promptText, args.policy);++ default:+ return { ok: false, error: `verb not implemented yet: ${verb}`,+ _hint: 'Milestone 2 ships windows/tabs/navigate/reload/screenshot/eval; input_dispatch/errors/fetch_url/record land in M3–M4.' };+ }+}++function detectBrowser() {+ try { return navigator.userAgent.includes('Edg') ? 'edge' : 'chrome'; } catch { return 'unknown'; }+}++chrome.runtime.onStartup.addListener(connect);+chrome.runtime.onInstalled.addListener(connect);++// Popup-driven "Record this tab" (tabCapture, no picker). The user's icon-click grants activeTab.+chrome.runtime.onMessage.addListener((msg, _sender, sendResponse) => {+ if (!msg || !msg.type) return;+ if (msg.type === 'popup-record-start') {+ record.recordStart(msg.tabId, { method: 'tabcapture', fps: 30 }).then(sendResponse);+ return true;+ }+ if (msg.type === 'popup-record-stop') {+ (async () => {+ const r = await record.recordStop({});+ if (r.ok && r.base64) {+ try {+ await chrome.downloads.download({ url: `data:${r.mimeType};base64,${r.base64}`, filename: `adom-tab-recording-${Date.now()}.webm` });+ r.downloaded = true;+ } catch (e) { r.downloadError = String((e && e.message) || e); }+ delete r.base64; // don't ship the big payload back to the popup+ }+ sendResponse(r);+ })();+ return true;+ }+ if (msg.type === 'popup-record-state') { sendResponse(record.recordStatus({})); return true; }+});++// Keepalive + self-heal. The host's ping keeps the SW warm WHILE connected, but if the SW is ever+// terminated the host dies too and nothing restarts it — so an alarm (which wakes a terminated MV3+// service worker) re-opens the native port. This is what makes the bridge reliably reachable.+chrome.alarms.create('nb-keepalive', { periodInMinutes: 0.5 });+chrome.alarms.onAlarm.addListener((a) => {+ if (a.name !== 'nb-keepalive') return;+ // If the fast reconnect loop gave up, this tick re-arms exactly ONE attempt (a 0.5-min heartbeat) so+ // we still recover when the bridge returns — bounded, never a spin.+ if (givenUp) { givenUp = false; swAttempts = 0; swBackoff = SW_BASE; }+ if (!port) { connect(); return; }+ try { port.postMessage({ keepalive: true }); } catch (e) { port = null; setStatus(false); scheduleConnect(); }+});++// dev_reload 2-pass completion (see nbrowser_dev_reload). chrome.runtime.reload() runs a SOFT reload+// first; arming _devReload2 makes the SW do ONE more HARD reload here on restart so a single dev_reload+// reliably re-reads disk. Guards: cleared BEFORE reloading (no loop), and a >15s-old flag is treated as+// stale and dropped (a normal SW wake never triggers a stray reload). Otherwise connect as usual.+(async () => {+ try {+ const { _devReload2 = 0 } = await chrome.storage.local.get('_devReload2');+ if (_devReload2) {+ await chrome.storage.local.remove('_devReload2');+ if (Date.now() - _devReload2 < 15000) { chrome.runtime.reload(); return; } // hard pass, then stop+ }+ } catch { /* fall through to a normal connect */ }+ connect();+})();
src/extension-nbe/toolbar.jsonadded+1@@ -0,0 +1 @@+{"toolbarIcon":"on"}
src/icons/settings-ex/anncapbar.pngadded⋯ 1 unchanged line ⋯
src/icons/settings-ex/fgcaption.pngadded⋯ 1 unchanged line ⋯
src/icons/settings-ex/jlannotate.pngadded⋯ 1 unchanged line ⋯
src/icons/settings-ex/jlcloseall.png⋯ 1 unchanged line ⋯
src/icons/settings-ex/jldashboard.pngadded⋯ 1 unchanged line ⋯
src/icons/settings-ex/jlviewtasks.png⋯ 1 unchanged line ⋯
src/icons/settings-ex/jumplist.png⋯ 1 unchanged line ⋯
src/icons/settings-ex/oboff.pngadded⋯ 1 unchanged line ⋯
src/icons/settings-ex/obon.pngadded⋯ 1 unchanged line ⋯
src/icons/settings-ex/obsize100.pngadded⋯ 1 unchanged line ⋯
src/icons/settings-ex/obsize50.pngadded⋯ 1 unchanged line ⋯
src/icons/settings-ex/obsize66.pngadded⋯ 1 unchanged line ⋯
src/icons/settings-ex/obsize75.pngadded⋯ 1 unchanged line ⋯
src/icons/settings-ex/ownerchip.pngadded⋯ 1 unchanged line ⋯
src/icons/settings-ex/tbactflash.pngadded⋯ 1 unchanged line ⋯
src/icons/settings-ex/tbactoff.pngadded⋯ 1 unchanged line ⋯
src/icons/settings-ex/tbactprogress.pngadded⋯ 1 unchanged line ⋯
src/icons/settings-ex/toolbarext.pngadded⋯ 1 unchanged line ⋯
tools/list-embedded.pyadded+23@@ -0,0 +1,23 @@+#!/usr/bin/env python3+"""Every file the Rust build pulls in with include_bytes!/include_str!, as repo-relative paths.++tools/ship.sh pushes this list with the rust/ tree so a checkout of the page builds (pup-bridge#118,+Kyle: ext_assets.rs embeds src/extension-nbe/** and src/extension-identity/identity-worker.js, which no+hand-kept push list covered). Derived from the sources themselves, so a new embed can never be missed.+Exits non-zero if an embedded file is missing on disk."""+import os, re, sys+root = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))+pat = re.compile(r'include_(?:bytes|str)!\(\s*"([^"]+)"\s*\)')+found, missing = set(), []+for base, dirs, files in os.walk(os.path.join(root, 'rust')):+ dirs[:] = [d for d in dirs if d != 'target']+ for f in files:+ if not f.endswith('.rs'): continue+ src = os.path.join(base, f)+ for m in pat.finditer(open(src, encoding='utf-8', errors='replace').read()):+ p = os.path.normpath(os.path.join(base, m.group(1)))+ rel = os.path.relpath(p, root).replace(os.sep, '/')+ (found.add(rel) if os.path.isfile(p) else missing.append(f"{rel} (from {os.path.relpath(src, root)})"))+if missing:+ print("embedded but missing on disk:\n " + "\n ".join(missing), file=sys.stderr); sys.exit(1)+print("\n".join(sorted(found)))
tools/ship.sh+4−1@@ -193,11 +193,14 @@ echo "== 5. push the source text files to the product page (source is open; publ # 2.0.595 (pup-bridge#117, Kyle): the Rust tree the exe is built from ships with it, every release, so a # port (host-mac) is written against code that compiles. Everything under rust/ except target/, plus the # two build tools. 62 files, ~1.5 MB. Cargo.lock included so a checkout builds the same dependency set.+# 2.0.598 (pup-bridge#118, Kyle): plus every file the Rust build EMBEDS from outside rust/ (the extensions,+# icons, dashboard pages). Derived from the include_bytes!/include_str! sites, so a new embed ships by itself.+EMBED_FILES=$(python3 tools/list-embedded.py | grep -v '^rust/' | tr '\n' ' ') || { echo " an embedded file is missing on disk (tools/list-embedded.py)"; exit 1; } RUST_FILES=$(find rust -path rust/target -prune -o -type f \( -name '*.rs' -o -name 'Cargo.toml' -o -name 'Cargo.lock' -o -name '*.ttf' -o -name '*.png' -o -name '*.svg' -o -name '*.json' -o -name '*.js' -o -name '*.html' -o -name '*.md' \) -print | tr '\n' ' ') adom-wiki repo push "$PAGE" \ --files src/bridge.json src/BRIDGE_VERSION src/*.js src/extension/*.js src/extension/*.json SKILL.md package.json \ skills/*/SKILL.md dev-skills/*/SKILL.md publish-skills/*/SKILL.md \- $RUST_FILES tools/rust-build.sh tools/check-borders.sh tools/gen-assets.py \+ $RUST_FILES $EMBED_FILES tools/rust-build.sh tools/check-borders.sh tools/gen-assets.py tools/list-embedded.py tools/sync-nbe.sh \ -m "v${VER} (insiders): ${LOG}" >/dev/null 2>&1 || { [ "${FORCE_PUSH:-0}" = "1" ] || { echo " PUSH FAILED (set FORCE_PUSH=1 to retry)"; exit 1; } }
tools/sync-nbe.shadded+32@@ -0,0 +1,32 @@+#!/usr/bin/env bash+# Re-copy nb's extension into pup's staged copy. nb's repo is the source of truth: pup never edits+# nbe's code, it only adds toolbar.json (the packaged default nb built for pup: sw.js "a host that+# copies this extension into its own profile (pup) can drop a toolbar.json next to the manifest").+set -euo pipefail+SRC="${1:-/home/adom/project/adom-browser-extension/extension}"+DST="$(cd "$(dirname "$0")/.." && pwd)/src/extension-nbe"+[ -f "$SRC/manifest.json" ] || { echo "no nbe at $SRC"; exit 1; }+rm -rf "$DST"; mkdir -p "$DST"; cp -r "$SRC/." "$DST/"+rm -f "$DST/icons/README.md"+printf '{"toolbarIcon":"on"}\n' > "$DST/toolbar.json"+python3 - "$DST/icons" <<'PYICON'+import sys+from PIL import Image, ImageDraw+d=sys.argv[1]; TEAL=(0,184,177,255); INK=(0,61,64,255); S=512+im=Image.new('RGBA',(S,S),(0,0,0,0)); dr=ImageDraw.Draw(im)+dr.rounded_rectangle([0,0,S-1,S-1], radius=int(S*22/128), fill=TEAL)+m=int(S*26/128); top=int(S*30/128); bot=S-m+dr.rounded_rectangle([m,top,S-m,bot], radius=int(S*10/128), fill=INK)+bar=int(S*22/128)+dr.rounded_rectangle([m+int(S*9/128), top+bar, S-m-int(S*9/128), bot-int(S*9/128)], radius=int(S*5/128), fill=TEAL)+r=int(S*5/128); cy=top+bar//2+for cx in [m+int(S*13/128), m+int(S*27/128)]: dr.ellipse([cx-r,cy-r,cx+r,cy+r], fill=TEAL)+for sz in (16,48,128): im.resize((sz,sz), Image.LANCZOS).save('%s/icon%d.png'%(d,sz))+PYICON+V=$(python3 -c "import json;print(json.load(open('$DST/manifest.json'))['version'])")+printf 'adom-browser-extension/extension v%s\n' "$V" > "$DST/.nbe-source"+echo "synced nbe v$V into src/extension-nbe"+# 2.0.578: pup's OWN worker imports nbe's DevTools driver (one driver, not two). A module worker can only+# import files inside its own extension, so the driver is copied next to worker.js as nbe-cdp.js.+cp "$SRC/src/cdp.js" "$(dirname "$DST")/extension/nbe-cdp.js"+echo "synced nbe cdp.js -> src/extension/nbe-cdp.js"