← Commit history
Publish: multi-profile + credential query + demo video
16 files changed
+618−67
README.md+22−2bridge/bridge.json+9−1bridge/package.json+1−1bridge/server.js+169−17demo/demo3.html+30extension/manifest.json+3−1extension/src/cdp.js+27−1extension/src/native.js+57extension/src/sw.js+35−4host/native-host.js+43−13keys/extension-id.txt+1keys/manifest-key.txt+1package.json+14page.json+33−26skills/driving-the-extension-demo/SKILL.md+126skills/driving-the-extension/SKILL.md+47−1README.md+22−2@@ -1,7 +1,5 @@ # Adom Native Browser (Chrome + Edge) -- **Drive your real, logged-in Chrome/Edge from the Adom ecosystem — the native-browser counterpart to `pup`.** Same command surface as the puppeteer bridge (`nbrowser_*` mirrors `browser_*`), but every verb runs inside the user's *actual* signed-in browser, so cookies, SSO, saved logins, shopping@@ -10,6 +8,28 @@ carts, and "I'm not a robot" human-trust signals all just work. Cloud containers, Hydrogen Desktop, and the AD CLI reach it through the exact same `relay → adom-desktop → bridge` path they already use for pup. +## Demo — credential-aware login → chip fetch (no bot wall)++<video width="100%" controls poster="/blob/app/adom-browser-extension/screenshots/hero.png"+ style="border-radius:8px;border:1px solid #333">+ <source src="/blob/app/adom-browser-extension/demo/adom-native-browser-demo.mp4" type="video/mp4">+</video>++Driving John's real Chrome, hands-free: **the Adom cursor signs in to Mouser** (Chrome autofills the+saved password — Adom never sees it), then **fetches the ST VL53L5CX 8×8 ToF sensor's CAD** from+ComponentSearchEngine — KiCad symbol + footprint, STEP 3D model, and datasheet — straight past the+login wall, with no captcha because it's a *real* session.++## What's new++- **Drive ALL your browser profiles** ([email protected], [email protected], …), not just one — `nbrowser_profiles`+ / `nbrowser_use_profile` switch instantly, with a persistent **security blocklist** to exclude any.+- **Know which vendors you can log into** — `nbrowser_credentials` is a *read-only* query over the+ browser's own saved-login store (**Chrome + Edge**); it never stores or exposes passwords.+- **Automate any vendor login** — focus the field → the browser autofills → submit. Proven on Mouser + CSE.+- **Window control** — `nbrowser_fullscreen` / `window_state` (fullscreen / maximize / move / tile),+ `login_state`, and a robust hover that reveals tooltips on non-interactive elements.+ ## Architecture (why it's shaped this way) An MV3 extension can't host a listening port, and per
bridge/bridge.json+9−1@@ -2,7 +2,7 @@ "manifest_version": 1, "name": "native-browser", "displayName": "Adom Native Browser (Chrome + Edge)",- "version": "0.0.10",+ "version": "0.0.11", "description": "Drives the user's REAL signed-in Chrome/Edge via an installed extension (native-messaging transport). Mirrors the puppeteer bridge's verb surface as nbrowser_* but runs inside the actual logged-in profile, so cookies/SSO/saved-logins/captcha human-trust all work. The bridge binds an ephemeral loopback port (PORTS.md compliant) and writes a discovery file; the native-messaging host pumps stdio <-> this bridge; the extension never touches a TCP port.", "homepage": "https://wiki.adom.inc/adom/adom-browser-extension", "author": "Adom Inc.",@@ -26,6 +26,14 @@ "nbrowser_close_window", "nbrowser_switch_window", "nbrowser_list_windows",+ "nbrowser_window_state",+ "nbrowser_fullscreen",+ "nbrowser_login_state",+ "nbrowser_credentials",+ "nbrowser_profiles",+ "nbrowser_use_profile",+ "nbrowser_profile_block",+ "nbrowser_profile_unblock", "nbrowser_rescan", "nbrowser_open_tab", "nbrowser_close_tab",
bridge/package.json+1−1@@ -1,6 +1,6 @@ { "name": "adom-native-browser-bridge",- "version": "0.0.1",+ "version": "0.0.11", "private": true, "description": "adom-desktop bridge for the Adom native-browser extension (Chrome + Edge)", "main": "server.js",
bridge/server.js+169−17@@ -51,10 +51,121 @@ setInterval(() => {}, 60000); // keep-alive: the listeners already pin the loop, // ---- pending AD commands awaiting an extension reply ----------------------- let seq = 0; const pending = new Map(); // id -> { resolve, timer }-let hostSock = null; // the single connected native-messaging host-let connectedSince = 0; // when the extension host connected (for the self-reported status) let lastVerb = null, lastActivityAt = 0; +// ---- multi-profile connections --------------------------------------------+// One bridge can hold a host connection PER BROWSER PROFILE ([email protected], [email protected], …) so the+// AI can drive any of the user's signed-in browsers. By default we'll drive all of them (the user can+// switch the active one or blocklist any they don't want touched). Each host sends a `hello {profile}`+// handshake on connect; we key the connection by profile email (or a stable fallback id).+const conns = new Map(); // profileKey -> { sock, profile, since }+let activeProfile = null; // default target for action verbs (instantly switchable, no window-closing)+let provSeq = 0; // provisional keys for hosts that haven't sent their hello yet+function profileKey(p) { return (p && (p.email || p.id)) || 'unknown'; }+function profileLabel(p) { return (p && (p.email || p.name || p.id)) || 'unknown'; }++// Blocklist: profiles the user told us to NEVER drive. Persisted so it survives bridge respawns.+const BLOCKFILE = path.join(os.homedir(), '.adom', 'bridges', BRIDGE, 'profile-blocklist.json');+let blocklist = new Set();+try { const j = JSON.parse(fs.readFileSync(BLOCKFILE, 'utf8')); if (Array.isArray(j.blocklist)) blocklist = new Set(j.blocklist.map(String)); } catch {}+function saveBlocklist() { try { fs.writeFileSync(BLOCKFILE, JSON.stringify({ blocklist: [...blocklist] }, null, 2)); } catch {} }+function isBlocked(key) { return blocklist.has(key); }++function liveConns() { return [...conns.entries()].filter(([k, c]) => c.sock && !c.sock.destroyed && !isBlocked(k)); }+function anyConnected() { return liveConns().length > 0; }+// Pick the connection an action verb should target: explicit args.profile, else the active profile,+// else the only/most-recent live one. Returns null if none drivable (or the target is blocklisted).+function targetConn(args) {+ const want = args && (args.profile || args.profileEmail);+ if (want) {+ if (isBlocked(String(want))) return { blocked: true, key: String(want) };+ for (const [k, c] of conns) if ((k === want || c.profile.email === want) && c.sock && !c.sock.destroyed) return { conn: c, key: k };+ return null;+ }+ const live = liveConns();+ if (!live.length) return null;+ const act = live.find(([k]) => k === activeProfile);+ const [k, c] = act || live[live.length - 1];+ return { conn: c, key: k };+}+// Register a freshly-connected host BEFORE its hello, under a provisional key, so even an old extension+// (no hello) is drivable — and so we can dev_reload it to pick up the hello-sending build. The hello+// then re-keys it to the real profile (registerConn).+function registerProvisional(sock) {+ const key = 'pending-' + (++provSeq);+ conns.set(key, { sock, profile: { id: key, email: '' }, since: Date.now(), provisional: true });+ if (!activeProfile) activeProfile = key;+ return key;+}+// ---- credential QUERY LAYER (reads the BROWSER's own saved-login store) -----+// We do NOT store or replace anything — Chrome/Edge remain the source of truth for passwords. This is a+// read-only layer that QUERIES the browser's native credential store so the AI can tell which vendors a+// user already has a login for (origins + usernames; NEVER passwords — those stay encrypted and Chrome/+// Edge autofill them at login time). A browser EXTENSION can't read this (sandbox); the bridge runs+// natively, so it reads the `Login Data` SQLite for BOTH Chromium browsers. Dependency-free leaf reader.+const LOCALAPP = process.env.LOCALAPPDATA || path.join(os.homedir(), 'AppData', 'Local');+const BROWSER_UD = {+ chrome: path.join(LOCALAPP, 'Google', 'Chrome', 'User Data'),+ edge: path.join(LOCALAPP, 'Microsoft', 'Edge', 'User Data'),+};+function sqliteLogins(file) {+ const buf = fs.readFileSync(file);+ let pageSize = buf.readUInt16BE(16); if (pageSize === 1) pageSize = 65536;+ const n = Math.floor(buf.length / pageSize), out = [];+ const varint = (off) => { let r = 0n, i = 0; for (; i < 9; i++) { const b = buf[off + i]; if (i === 8) { r = (r << 8n) | BigInt(b); i++; break; } r = (r << 7n) | BigInt(b & 0x7f); if (!(b & 0x80)) { i++; break; } } return [r, i]; };+ for (let p = 1; p <= n; p++) {+ const base = (p - 1) * pageSize, hdr = p === 1 ? base + 100 : base;+ if (buf[hdr] !== 0x0d) continue;+ const cells = buf.readUInt16BE(hdr + 3), ptrArr = hdr + 8;+ for (let c = 0; c < cells; c++) {+ let o = base + buf.readUInt16BE(ptrArr + c * 2);+ let [, l1] = varint(o); o += l1; let [, l2] = varint(o); o += l2;+ const recStart = o; let [hLen, hb] = varint(o); const hEnd = recStart + Number(hLen); o += hb;+ const serials = []; while (o < hEnd) { const [s, sb] = varint(o); o += sb; serials.push(Number(s)); }+ let d = hEnd; const cols = [];+ for (const s of serials) { let v = null, sz = 0; if (s === 0 || s === 8 || s === 9) sz = 0; else if (s >= 1 && s <= 4) sz = s; else if (s === 5) sz = 6; else if (s === 6 || s === 7) sz = 8; else if (s >= 12 && s % 2 === 0) sz = (s - 12) / 2; else if (s >= 13) { sz = (s - 13) / 2; v = buf.toString('utf8', d, d + sz); } cols.push(v); d += sz; }+ if (cols[0] && /^https?:\/\//.test(cols[0])) out.push({ host: cols[0].split('/')[2], username: cols[3] || '' });+ }+ }+ const seen = new Set(); return out.filter((r) => { const k = r.host + '|' + r.username; if (seen.has(k)) return false; seen.add(k); return true; });+}+function profilesOf(browser) {+ try { const ls = JSON.parse(fs.readFileSync(path.join(BROWSER_UD[browser], 'Local State'), 'utf8')); return Object.entries(ls.profile.info_cache || {}).map(([dir, info]) => ({ dir, email: info.user_name || '', name: info.name || '' })); } catch { return []; }+}+function readLoginsFor(browser, dir) {+ const src = path.join(BROWSER_UD[browser], dir, 'Login Data');+ const tmp = path.join(os.tmpdir(), `nb-logins-${browser}-${dir.replace(/\W/g, '_')}-${Date.now()}.db`);+ try { fs.copyFileSync(src, tmp); return sqliteLogins(tmp); } catch (e) { return { error: String((e && e.message) || e) }; } finally { try { fs.unlinkSync(tmp); } catch {} }+}+// Query saved logins across Chrome + Edge (or a filtered browser/profile), each tagged with its source.+function queryCredentials({ browser, profile, match } = {}) {+ const re = match ? new RegExp(String(match), 'i') : null;+ const browsers = browser ? [browser] : Object.keys(BROWSER_UD);+ const out = [];+ for (const b of browsers) {+ if (!BROWSER_UD[b] || !fs.existsSync(BROWSER_UD[b])) continue;+ let profs = profilesOf(b);+ if (profile) profs = profs.filter((p) => p.email.toLowerCase() === String(profile).toLowerCase() || p.dir === profile);+ for (const pr of profs) {+ const rows = readLoginsFor(b, pr.dir);+ if (!Array.isArray(rows)) continue;+ for (const r of rows) if (!re || re.test(r.host) || re.test(r.username)) out.push({ browser: b, profile: pr.email || pr.dir, host: r.host, username: r.username });+ }+ }+ return out;+}++function registerConn(sock, profile) {+ const key = profileKey(profile);+ // Re-key any provisional/old entry for THIS socket to the real profile.+ for (const [k, c] of [...conns]) if (c.sock === sock && k !== key) { conns.delete(k); if (activeProfile === k) activeProfile = null; }+ const prev = conns.get(key);+ if (prev && prev.sock !== sock) { try { prev.sock.destroy(); } catch {} } // newest host wins per profile+ conns.set(key, { sock, profile: profile || {}, since: Date.now() });+ if (!activeProfile || !liveConns().some(([k]) => k === activeProfile)) activeProfile = isBlocked(key) ? activeProfile : key;+ dlog(`hello: profile ${key} registered (${conns.size} total)`);+}+ // Bridge-self-reported chip status (led/summary/tooltip). AD is a pure renderer — ALL the reasoning // about what green/yellow/red means lives HERE, since only this bridge knows its own domain. AD just // shows the color + prints the tooltip on hover; it owns only the offline/gray state (a dead bridge@@ -68,19 +179,21 @@ function statusReport() { tooltip: `Circuit breaker tripped: too many extension-host connections in a short window (runaway loop suspected). Refusing new host connections for ${Math.ceil((cbTrippedUntil - now) / 1000)}s.\nSee ${DEBUGLOG}.`, }; }- if (hostSock) {- const since = connectedSince ? `${Math.round((now - connectedSince) / 1000)}s` : 'just now';+ const live = liveConns();+ if (live.length) {+ const names = live.map(([k]) => (k === activeProfile ? `▶ ${k}` : k)).join(', '); const last = lastVerb ? `\nLast verb: ${lastVerb} (${Math.round((now - lastActivityAt) / 1000)}s ago).` : '';+ const blocked = blocklist.size ? `\nBlocked (never driven): ${[...blocklist].join(', ')}.` : ''; return { led: 'green',- summary: 'Extension connected',- tooltip: `Adom browser extension connected - driving the real Chrome/Edge through this bridge.\nConnected for ${since}.${last}`,+ summary: live.length === 1 ? 'Extension connected' : `${live.length} profiles connected`,+ tooltip: `Adom browser extension driving ${live.length} profile(s): ${names}.\n▶ = active default target (switch with nbrowser_use_profile).${last}${blocked}`, }; } return { led: 'yellow', summary: 'No extension',- tooltip: 'Bridge is running, but no browser extension is connected.\nOpen a window in the Chrome/Edge profile where the Adom extension is loaded (its service worker sleeps when that profile has no windows), or verify it at chrome://extensions.',+ tooltip: 'Bridge is running, but no browser extension is connected.\nOpen a window in a Chrome/Edge profile where the Adom extension is loaded (its service worker sleeps when that profile has no windows), or verify it at chrome://extensions.', }; } @@ -236,7 +349,38 @@ function flashHint() { function dispatch(verb, args) { return new Promise((resolve) => { if (verb === 'nbrowser_help') {- return resolve({ success: true, output: JSON.stringify({ ok: true, bridge: BRIDGE, version: VERSION, extConnected: !!hostSock, flashMode, ...HELP }) });+ return resolve({ success: true, output: JSON.stringify({ ok: true, bridge: BRIDGE, version: VERSION, extConnected: anyConnected(), flashMode, ...HELP }) });+ }+ // --- multi-profile management (bridge-local) ---+ if (verb === 'nbrowser_profiles') {+ const list = [...conns.entries()].map(([k, c]) => ({ profile: k, label: profileLabel(c.profile), email: c.profile.email || '', active: k === activeProfile, blocked: isBlocked(k), live: !!(c.sock && !c.sock.destroyed) }));+ return resolve({ success: true, output: JSON.stringify({ ok: true, profiles: list, active: activeProfile, blocklist: [...blocklist], _hint: 'Each entry is one signed-in browser PROFILE the AI can drive. Default verbs target the ▶active one (or pass profile:"<email>" on any verb). Switch default with nbrowser_use_profile. Exclude one forever with nbrowser_profile_block. By default ALL non-blocked profiles are drivable.' }) });+ }+ if (verb === 'nbrowser_use_profile') {+ const want = String((args && (args.profile || args.email)) || '');+ const hit = [...conns.keys()].find((k) => k === want || (conns.get(k).profile.email === want));+ if (!hit) return resolve({ success: false, error: `no connected profile matches '${want}'`, _hint: 'Run nbrowser_profiles to list connected profiles by email.' });+ if (isBlocked(hit)) return resolve({ success: false, error: `profile '${hit}' is blocklisted`, _hint: 'nbrowser_profile_unblock first.' });+ activeProfile = hit;+ return resolve({ success: true, output: JSON.stringify({ ok: true, active: activeProfile, _hint: 'Action verbs now default to this profile. You can still override per-call with profile:"<email>".' }) });+ }+ if (verb === 'nbrowser_profile_block' || verb === 'nbrowser_profile_unblock') {+ const want = String((args && (args.profile || args.email)) || '');+ if (!want) return resolve({ success: false, error: 'profile (email) required' });+ if (verb === 'nbrowser_profile_block') { blocklist.add(want); if (activeProfile === want) activeProfile = (liveConns()[0] || [])[0] || null; }+ else blocklist.delete(want);+ saveBlocklist();+ return resolve({ success: true, output: JSON.stringify({ ok: true, blocklist: [...blocklist], active: activeProfile, _hint: verb === 'nbrowser_profile_block' ? 'Adom will NEVER drive this profile until unblocked (security whitelist). Persisted across restarts.' : 'Profile is drivable again.' }) });+ }+ // The CREDENTIAL LIBRARY — which vendors this user has a saved login for (origins+usernames; NEVER+ // passwords). Chrome's extension API can't read this; the bridge reads Chrome's Login Data DB+ // natively. Use it to know up-front which sites you can auto-login to, then drive the login.+ if (verb === 'nbrowser_credentials') {+ const match = args && (args.match || args.vendor || args.host);+ const browser = args && args.browser; // optional: "chrome" | "edge"+ const profile = args && (args.profile || args.email); // optional: a specific profile email+ const rows = queryCredentials({ browser, profile, match });+ return resolve({ success: true, output: JSON.stringify({ ok: true, count: rows.length, credentials: rows, _hint: 'Read-only query of the BROWSER\'s own saved-login store (Chrome + Edge) — we do NOT store or replace passwords; the browser stays the source of truth. Each row = {browser, profile, host, username} you CAN auto-login to (password is NOT exposed — Chrome/Edge autofill it when you focus the login field). Flow: credentials {match:"digikey"} -> if present, navigate to the sign-in page -> click the user/email field (autofills) -> click submit -> login_state. Filters: match:"<vendor>", browser:"chrome"|"edge", profile:"<email>".' }) }); } // --- taskbar flash (bridge-local; targets the cached driven-window hwnd) --- if (verb === 'nbrowser_flash_mode') {@@ -272,13 +416,18 @@ function dispatch(verb, args) { if (verb.startsWith('nbrowser_record_') && args && args.method === 'wgc') { return wgcRecord(verb, args).then(resolve); }- if (!hostSock) {+ const tgt = targetConn(args);+ if (!tgt || tgt.blocked || !tgt.conn) {+ if (tgt && tgt.blocked) {+ return resolve({ success: false, error: `profile '${tgt.key}' is blocklisted`, _hint: 'The user blocked this profile from being driven. nbrowser_profile_unblock to allow it.' });+ } return resolve({ success: false, error: 'native browser extension not connected',- _hint: 'The extension\'s browser PROFILE likely has NO OPEN WINDOWS (closing them all kills its service worker), or the browser/extension isn\'t running. Ask the user to open a window in that profile (where the extension was loaded), then retry. Run nbrowser_help for more.',+ _hint: 'No drivable profile is connected. A profile\'s extension DIES if that profile has NO OPEN WINDOWS (its service worker sleeps). Open a window in a profile where the Adom extension is loaded, then retry. nbrowser_profiles lists connected profiles; nbrowser_help for more.', }); }+ const sock = tgt.conn.sock; lastVerb = verb; lastActivityAt = Date.now(); // for the self-reported status tooltip const forward = () => { const id = ++seq;@@ -287,7 +436,7 @@ function dispatch(verb, args) { resolve({ success: false, error: `verb ${verb} timed out`, _hint: 'extension did not reply in time' }); }, timeoutFor(verb)); pending.set(id, { resolve, timer, verb, toFile: !!(args && args.toFile) });- hostSock.write(JSON.stringify({ id, verb, args: args || {} }) + '\n');+ sock.write(JSON.stringify({ id, verb, args: args || {} }) + '\n'); }; // For open_window, snapshot the Chrome window list BEFORE forwarding so the post-open diff reliably // catches the new window (the old fire-and-forget snapshot raced the window appearing).@@ -358,7 +507,7 @@ const httpSrv = http.createServer((req, res) => { }; if (req.method === 'GET' && req.url === '/status') {- return reply(200, { ok: true, bridge: BRIDGE, version: VERSION, extConnected: !!hostSock, ...statusReport() });+ return reply(200, { ok: true, bridge: BRIDGE, version: VERSION, extConnected: anyConnected(), profiles: [...conns.keys()], active: activeProfile, ...statusReport() }); } if (req.method === 'POST' && req.url === '/command') {@@ -401,9 +550,9 @@ const hostSrv = net.createServer((sock) => { try { sock.destroy(); } catch {} return; }- if (hostSock) { try { hostSock.destroy(); } catch {} } // newest host wins- hostSock = sock;- connectedSince = Date.now();+ // Register provisionally so this host is drivable immediately; the `hello` re-keys it to the real+ // profile. Several profiles can be connected at once.+ registerProvisional(sock); let buf = ''; sock.on('data', (chunk) => { buf += chunk.toString('utf8');@@ -412,10 +561,13 @@ const hostSrv = net.createServer((sock) => { const line = buf.slice(0, nl); buf = buf.slice(nl + 1); if (!line.trim()) continue;- try { onHostFrame(JSON.parse(line)); } catch {}+ let frame;+ try { frame = JSON.parse(line); } catch { continue; }+ if (frame && frame.type === 'hello') { registerConn(sock, frame.profile); continue; }+ onHostFrame(frame); } });- const drop = () => { if (hostSock === sock) { hostSock = null; connectedSince = 0; } };+ const drop = () => { for (const [k, c] of conns) if (c.sock === sock) { conns.delete(k); if (activeProfile === k) activeProfile = (liveConns()[0] || [])[0] || null; } }; sock.on('close', drop); sock.on('error', drop); });
demo/demo3.htmladded+30@@ -0,0 +1,30 @@+<!doctype html><html><head><meta charset="utf-8"><title>Adom Native Browser — Live Demo</title>+<style>+ html,body{margin:0;height:100%}+ body{background:radial-gradient(1200px 700px at 85% -8%,rgba(0,184,177,.14),transparent 60%),linear-gradient(155deg,#0a0e14,#0d1117 55%,#071318);+ color:#e6edf3;font-family:'Segoe UI',system-ui,sans-serif;padding:46px 56px}+ h1{color:#39d3cb;font-size:38px;margin:0 0 6px;letter-spacing:-.5px}+ .sub{color:#8b949e;margin:0 0 34px;font-size:17px}+ .row{margin:20px 0}+ label{color:#aeb8c4;margin-right:14px;font-size:16px}+ #name{padding:13px 16px;width:330px;border:1px solid #2a3340;border-radius:10px;background:#11161d;color:#e6edf3;font-size:16px;outline:none}+ #name:focus{border-color:#00b8b1;box-shadow:0 0 0 3px rgba(0,184,177,.18)}+ .pay{padding:15px 30px;border:none;border-radius:11px;background:linear-gradient(180deg,#0fb3ab,#0a8f88);color:#fff;font-size:17px;font-weight:700;cursor:pointer;box-shadow:0 6px 18px rgba(0,184,177,.25)}+ .tipwrap{position:relative;display:inline-block}+ .hovbox{padding:13px 22px;border:1.5px solid #00b8b1;border-radius:10px;color:#5fe3db;font-weight:600;background:#11161d;cursor:default;display:inline-block}+ .tipwrap .tt{visibility:hidden;opacity:0;transition:.15s;position:absolute;bottom:128%;left:0;background:#1c2a3a;color:#fff;padding:10px 14px;border-radius:8px;white-space:nowrap;font-size:15px;box-shadow:0 10px 26px rgba(0,0,0,.5)}+ .tipwrap:hover .tt{visibility:visible;opacity:1}+ .ctx{padding:26px;background:#11161d;border:2px dashed #2a3340;border-radius:12px;color:#8b949e;font-size:16px}+ #status{margin-top:30px;font-size:22px;font-weight:700;color:#39d3cb}+.dblbox{padding:13px 22px;border:1.5px solid #8c6bf7;border-radius:10px;color:#b9a6ff;font-weight:600;background:#11161d;cursor:default;display:inline-block}+</style></head><body>+<h1>Adom Native Browser — Live Demo</h1>+<p class="sub">Driving John's real, signed-in Chrome. Watch the actions happen for real.</p>+<div class="row"><label>Your name:</label><input id="name" placeholder="Adom will type here"></div>+<div class="row"><button class="pay" onclick="document.getElementById('status').textContent='✓ Paid $42.00'">Pay $42.00</button></div>+<div class="row"><span class="tipwrap"><span class="hovbox">Hover me for a tooltip</span><span class="tt">🅰️ Tooltip revealed by Adom's hover!</span></span></div>+<div class="row"><span class="dblbox" ondblclick="document.getElementById('status').textContent='✓ Double-click detected'">Double-click me</span></div>++<div class="row ctx" oncontextmenu="document.getElementById('status').textContent='✓ Right-click detected'">Right-click anywhere inside this box</div>+<div id="status">status: waiting…</div>+</body></html>
extension/manifest.json+3−1@@ -30,7 +30,9 @@ "alarms", "tabCapture", "desktopCapture",- "offscreen"+ "offscreen",+ "identity",+ "identity.email" ], "host_permissions": [ "<all_urls>"
extension/src/cdp.js+27−1@@ -215,6 +215,25 @@ function findFn(query, limit) { var r = el.getBoundingClientRect(); out.push({ 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) } }); }+ // 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 = [].slice.call(document.querySelectorAll('body *'));+ var fb = [];+ for (var j = 0; j < all.length; j++) {+ var e2 = all[j]; if (!vis(e2)) continue;+ var t2 = ((e2.innerText || e2.textContent || '') + '').replace(/\s+/g, ' ').trim();+ if (!t2 || t2.toLowerCase().indexOf(q) < 0) continue;+ var r2 = e2.getBoundingClientRect();+ fb.push({ text: t2.slice(0, 80), tag: e2.tagName.toLowerCase(), role: e2.getAttribute('role') || '', type: '', area: r2.width * r2.height, x: Math.round(r2.left + r2.width / 2), y: Math.round(r2.top + r2.height / 2), rect: { x: Math.round(r2.left), y: Math.round(r2.top), w: Math.round(r2.width), h: Math.round(r2.height) } });+ }+ // 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 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 out.slice(0, limit || 40); }@@ -267,7 +286,14 @@ export async function inputDispatch(tabId, args) { if (showCursor) { await cursor(tabId, 'move', x, y, intent || phaseLabel(type, tgt, 'find')); await sleep(args.cursorGlideMs || 450); } if (type === 'move' || type === 'hover') { if (showCursor) await cursor(tabId, 'move', x, y, intent || phaseLabel(type, tgt, 'act'));- await send(tabId, 'Input.dispatchMouseEvent', { type: 'mouseMoved', x, y }); // triggers :hover / tooltips+ // 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 }; } if (showCursor) await cursor(tabId, 'click', x, y, intent || phaseLabel(type, tgt, 'act')); // ripple + label
extension/src/native.js+57@@ -130,6 +130,32 @@ export async function switchWindow(args = {}) { return { ok: true }; } +// 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 = resolveWindowId(args);+ if (winId == null) throw new Error('unknown sessionId');+ // 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);+ return { 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.' };+ }+ 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 });+ return { ok: true, windowId: winId, state, _hint: 'Native window-state change — no debugger, no bar. fullscreen hides tabs+URL bar+taskbar; normal restores.' };+}+ export async function listWindows() { await ensureLoaded(); const wins = await chrome.windows.getAll({ populate: true });@@ -140,6 +166,8 @@ export async function listWindows() { tracked: tracked.has(w.id), 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, })); return { ok: true, sessions, count: sessions.length };@@ -217,6 +245,35 @@ export async function wait(args = {}) { return { ok: true, waited: ms }; } +// 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.',+ };+}+ // ---- M4: authed fetch (uses the user's real cookies) + downloads ----------- export async function fetchUrl(args = {}) { if (!args.url) throw new Error('url required');
extension/src/sw.js+35−4@@ -60,9 +60,35 @@ function connect() { 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;+async function sendHello() {+ const profile = { email: '', id: '', name: '' };+ // Stable id FIRST — always available, so the hello can ALWAYS be sent even if identity misbehaves.+ 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 }); }+ profile.id = pid;+ } catch {}+ // Enrich with the Google email if identity cooperates — timeout-protected (can hang with no throw).+ 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), 1200)),+ ]);+ if (info && info.email) profile.email = info.email;+ } catch {}+ try { if (port) { port.postMessage({ type: 'hello', profile, version: VERSION }); helloSent = true; } } catch {} } function reply(id, ok, payload, error, hint) {@@ -72,7 +98,7 @@ function reply(id, ok, payload, error, hint) { async function onMessage(msg) { if (!msg) return;- if (!gotMessage) { gotMessage = true; swAttempts = 0; swBackoff = SW_BASE; } // proven-good connection+ 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;@@ -93,9 +119,11 @@ async function dispatch(verb, args) { 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 to at most once per 10 min,- // persisted in storage so an SW restart cannot reset the clock and bypass the throttle.- const MIN_INTERVAL = 10 * 60 * 1000;+ // 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) {@@ -112,6 +140,9 @@ async function dispatch(verb, args) { case 'nbrowser_close_window': return native.closeWindow(args); case 'nbrowser_switch_window': return native.switchWindow(args); case 'nbrowser_list_windows': return native.listWindows();+ 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_rescan': return native.rescan(); // --- demo aids -------------------------------------------------------
host/native-host.js+43−13@@ -26,16 +26,17 @@ const path = require('path'); const BRIDGE_DIR = path.join(os.homedir(), '.adom', 'bridges', 'native-browser'); const HOSTFILE = path.join(BRIDGE_DIR, 'host.json');-const PIDFILE = path.join(BRIDGE_DIR, 'host.pid'); -// ---- SINGLETON guard -------------------------------------------------------+// ---- PER-PROFILE SINGLETON guard ------------------------------------------- // Native-messaging hosts must NOT accumulate. The 2026-06-19 machine-wide port exhaustion was caused-// by N hosts each retrying connect()/sec. Exactly one host per machine: on startup, if a LIVE host PID-// is already recorded, exit immediately. Stale-PID-aware (a dead PID's file is reclaimed).-// Is `pid` a LIVE native-host? Must be alive AND actually a node process — a stale pidfile whose PID-// got reused by some unrelated app otherwise makes every new host defer forever (the 2026-06-19 false-// positive that blocked the live load). If we can't confirm it's node, take over (availability beats a-// rare duplicate, which the backoff + bridge circuit-breaker catch anyway).+// by N hosts each retrying connect()/sec. The rule is now ONE host PER BROWSER PROFILE (not one per+// machine) so several profiles ([email protected], [email protected], …) can each drive concurrently — but a+// single profile still can't spawn a pile of hosts. The profile id arrives in the extension's `hello`+// handshake (see pumpStdin); until then no lock is held, and a fallback timer takes a 'default' lock if+// no hello ever comes (old extension), preserving the original single-host loop-safety.+// Stale-PID-aware (a dead PID's file is reclaimed). Must be alive AND actually a node process — a stale+// pidfile whose PID got reused by an unrelated app otherwise makes every new host defer forever.+let PIDFILE = null; // set once we know our profile function isLiveHost(pid) { if (!pid || pid === process.pid) return false; try { process.kill(pid, 0); } catch (e) { if (e.code !== 'EPERM') return false; } // dead -> reclaim@@ -47,25 +48,37 @@ function isLiveHost(pid) { return /node/i.test(execSync(`ps -p ${pid} -o comm=`, { encoding: 'utf8', timeout: 4000 })); } catch { return false; } // can't verify -> assume reused -> take over }-function acquireSingleton() {+function profileSlug(key) {+ return String(key || 'default').replace(/[^a-z0-9._@-]+/gi, '_').slice(0, 64) || 'default';+}+let singletonDone = false;+function acquireSingleton(profileKey) {+ if (singletonDone) return; singletonDone = true;+ PIDFILE = path.join(BRIDGE_DIR, `host.${profileSlug(profileKey)}.pid`); try { fs.mkdirSync(BRIDGE_DIR, { recursive: true }); } catch {} try { const existing = parseInt(String(fs.readFileSync(PIDFILE, 'utf8')).trim(), 10); if (isLiveHost(existing)) {- process.stderr.write(`[native-host] another host (pid ${existing}) is already live — exiting (singleton)\n`);+ process.stderr.write(`[native-host] another host (pid ${existing}) already drives profile ${profileSlug(profileKey)} — exiting (singleton)\n`); process.exit(0); } } catch {} // no/garbage pidfile -> we take ownership try { fs.writeFileSync(PIDFILE, String(process.pid)); } catch {} } function releaseSingleton() {+ if (!PIDFILE) return; try { const cur = parseInt(String(fs.readFileSync(PIDFILE, 'utf8')).trim(), 10); if (cur === process.pid) fs.unlinkSync(PIDFILE); // only delete OUR pidfile, never a sibling's } catch {} } process.on('exit', releaseSingleton);-acquireSingleton();+// Fallback: if the extension never sends a `hello` (old build mid-upgrade), take a UNIQUE per-process+// lock — NOT a shared one. A shared 'default' lock would let one no-hello profile BLOCK every other+// profile's host (they'd fight over it and churn). Unique-per-pid means no cross-profile conflict; the+// bridge's provisional registration + circuit breaker + this host's own backoff/give-up still bound any+// runaway. Once every profile is on the hello build, this path is never taken.+const helloFallback = setTimeout(() => acquireSingleton('nohello-' + process.pid), 4000); // ---- reconnect policy: exponential backoff + give-up (NO fixed-rate spin) --- const BASE_DELAY = 500; // first retry@@ -100,13 +113,30 @@ function pumpStdin() { let msg; try { msg = JSON.parse(json.toString('utf8')); } catch { continue; } if (msg && msg.pong) continue; // keepalive ack — don't forward- if (bridge && !bridge.destroyed) bridge.write(JSON.stringify(msg) + '\n');+ // First frame is the profile handshake → take this profile's singleton lock, then forward it on so+ // the bridge registers the connection under this profile.+ if (msg && msg.type === 'hello') {+ clearTimeout(helloFallback);+ acquireSingleton((msg.profile && (msg.profile.email || msg.profile.id)) || 'default');+ }+ sendToBridge(msg); } } process.stdin.on('data', (c) => { inbuf = Buffer.concat([inbuf, c]); pumpStdin(); }); process.stdin.on('end', () => process.exit(0)); process.stdin.on('error', () => process.exit(0)); +// Outbound frames (ext -> bridge). If the bridge TCP isn't up yet, QUEUE them — otherwise an early+// frame like the `hello` handshake (the extension sends it the instant it connects) gets dropped and+// the bridge never learns this profile. Flushed on bridge 'connect'.+let outQueue = [];+function sendToBridge(msg) {+ const line = JSON.stringify(msg) + '\n';+ if (bridge && !bridge.destroyed) bridge.write(line);+ else { outQueue.push(line); if (outQueue.length > 200) outQueue.shift(); } // bound it+}+function flushOut() { if (bridge && !bridge.destroyed) { const q = outQueue; outQueue = []; for (const l of q) bridge.write(l); } }+ // ---- bridge connection (newline JSON) -> extension ------------------------- function readHostPort() { try { return JSON.parse(fs.readFileSync(HOSTFILE, 'utf8')).hostPort; } catch { return null; }@@ -138,7 +168,7 @@ function connectBridge() { attempts++; const sock = net.connect(port, '127.0.0.1'); let buf = '';- sock.on('connect', () => { bridge = sock; everConnected = true; connectedAt = Date.now(); });+ sock.on('connect', () => { bridge = sock; everConnected = true; connectedAt = Date.now(); flushOut(); }); sock.on('data', (chunk) => { buf += chunk.toString('utf8'); let nl;
keys/extension-id.txtadded+1@@ -0,0 +1 @@+nncjgceobmlenfliamnibncjojpedbcc
keys/manifest-key.txtadded+1@@ -0,0 +1 @@+MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAr75abHbp7QsA45nIRV1y4GoxUlT+5nba4PNPL15awcl5kz3EC1bvApBUknwH1yduMc96vpXgbWJ3luXMM+XOwcEvgs/6EYrfBvm9HsVFc7ll8UWeeeJCEfalB11cZ9L7BPBolYGgH9yD6iDqm7y8m8z9W12Vwd/V5nohO/X/vLUfe3wbjXS9Vi9MV3dF9ORgk7ZF9yTZyQD7MbwEBNLc0J+K+QU4IJPpTfyOmgdnzmszJRunGtHobN5ThfyR+TZ962oW0SLNuPHC71vyywAUD92gncgwDTtA5mblWn5BBEZf+GQednqaNk7Rf7y4w5jsWo4QIgPBa1yLwILsdXTwUQIDAQAB
package.jsonadded+14@@ -0,0 +1,14 @@+{+ "slug": "adom-browser-extension",+ "version": "0.2.0",+ "type": "app",+ "title": "Adom Native Browser (Chrome + Edge)",+ "description": "Drive your real, logged-in Chrome/Edge from the Adom ecosystem — the nbrowser_* native-browser bridge (counterpart to pup). Multi-profile driving, a read-only credential-library query over the browser's own saved logins (Chrome + Edge), and hands-free vendor login automation, so chip CAD / datasheets download past every login wall in your real signed-in session.",+ "install": { "binary_name": "adom-browser-extension" },+ "files": [+ "README.md", "page.json",+ "screenshots", "demo",+ "extension", "bridge", "host", "install", "skills", "wiki",+ "AD-REQUESTS.md", "TASKBAR-FEATURE.md"+ ]+}
page.json+33−26@@ -1,32 +1,39 @@ {- "schema_version": 1,- "type": "app", "slug": "adom-browser-extension",+ "org": "adom", "title": "Adom Native Browser (Chrome + Edge)",- "brief": "Drive your real, logged-in Chrome/Edge — the native-browser counterpart to pup, past every login wall.",- "version": "0.0.10",- "visibility": "public",- "install": {- "binary_name": "adom-browser-extension",- "install_dir": "",- "install_hint": "",- "version_cmd": ""+ "summary": "Drive your real, logged-in Chrome/Edge from the Adom ecosystem \u2014 the native-browser counterpart to pup. Same nbrowser_* surface as the puppeteer bridge, but inside the actual signed-in profile so cookies, SSO, saved logins, and captcha human-trust all work.",+ "category": "Browser Automation",+ "tags": [+ "browser",+ "extension",+ "chrome",+ "edge",+ "nbrowser",+ "pup",+ "native-messaging",+ "adom-desktop",+ "bridge"+ ],+ "status": "live",+ "discovery": {+ "trigger_words": [+ "native browser",+ "control my real browser",+ "adom browser extension",+ "nbrowser",+ "drive chrome",+ "drive edge",+ "logged-in browser",+ "real chrome",+ "pup native",+ "browser extension",+ "captcha real browser"+ ] },- "author": {- "id": "6738b00a4dc87b8adbb0a5a3",- "name": "John Lauer",- "email": "[email protected]"+ "hero": {+ "type": "image",+ "path": "screenshots/hero.png" },- "tags": [],- "license": "proprietary",- "hero": null,- "sample_prompts": [],- "discovery_triggers": [],- "discovery_pitch": null,- "metadata": {},- "created_at": "2026-06-20T15:39:49.808Z",- "updated_at": "2026-06-20T15:40:46.781Z",- "skills": [],- "hero_type": "image",- "hero_path": "screenshots/hero.png"+ "visibility": "public" }
skills/driving-the-extension-demo/SKILL.mdadded+126@@ -0,0 +1,126 @@+---+name: driving-the-extension-demo+description: Runnable PLAYBOOK for showing off / driving the Adom native browser (the real signed-in Chrome/Edge via nbrowser_*). Match this when the user asks to "demo the native browser", "show me what the browser extension can do", "drive my real Chrome", "open my browser and …", maximize / fullscreen / un-fullscreen / move / resize / tile the browser window, put two browser windows side-by-side, open tabs, show the taskbar status (Adom overlay badge / progress bar / flash alert), click-by-text with the Adom cursor, type / hover-for-a-tooltip / right-click, check "am I logged in to <site>" / drive a logout-and-login, or fetch a chip's CAD (chip-fetcher) through their logged-in browser. Includes the exact commands, the demo page, the foreground/screenshot gotchas, and shotlog.+---++Parent skill: **driving-the-extension**++# Native-browser DEMO playbook (`nbrowser_*`)++This is the copy-paste recipe set for **driving and demoing** the user's real Chrome/Edge. Each+recipe is a sequence of `adom-desktop nbrowser_*` / `desktop_*` calls proven live. For the full+capability reference (CDP-vs-native, the debug bar, xy-finding, verbs), read the parent skill+**driving-the-extension** first.++`SES` = a session id (a real Chrome window). `HWND` = the OS window handle (from+`desktop_find_window` / captured at `open_window`). Examples assume one Adom Desktop; add+`--target <host>` if more than one is connected.++## ⭐ The golden rules for a watchable demo (learned the hard way)+1. **Foreground the window before EVERY step**, or the user can't see it and your screenshots are+ wrong: `desktop_bring_to_front {"hwnd":<HWND>}` — **omit `state`** so it just raises and+ **preserves** the current maximized/fullscreen state. (`state:"restore"` would un-maximize it.)+2. **Most users mean MAXIMIZED, not immersive fullscreen.** Run the majority of a demo maximized+ (`window_state {state:"maximized"}`). Show true fullscreen only as a quick "it can also do this".+3. **Screenshot method matters:**+ - **maximized / normal window →** `desktop_screenshot_window {hwnd}` (PrintWindow).+ - **immersive fullscreen →** `desktop_screenshot_window` returns **BLANK WHITE** (GPU-composited+ fullscreen Chrome can't be PrintWindow'd) → use `desktop_screenshot_screen` (full screen).+ - **taskbar / two-windows side-by-side →** `desktop_screenshot_screen`. It **occasionally returns+ a white frame** (compositor race) → retry, and if it keeps failing, capture each window with+ `desktop_screenshot_window` and **montage** them (see the side-by-side recipe).+4. **Caption every step** so the viewer knows what's happening:+ `desktop_caption {"text":"…","position":"bottom","id":"nbd","durationMs":120000}` (bottom — top+ covers the URL bar + debug banner). Clear with `{"text":"","id":"nbd","clear":true}`.+5. **shotlog every frame AND surface the viewer** (don't just paste a URL): `shotlog open --channel+ <ch>` then `adom-cli hydrogen workspace active-tab --name "Shots: <ch>"`, then per frame+ `shotlog paste --channel <ch> --desc "<desc>" <file>`. Hand the user the phone URL+ `https://<slug>.adom.cloud/proxy/8820/log/<ch>/`.+6. **Pace it:** caption → ~3s (read) → foreground → action → ~2s (settle) → screenshot → ~1s. The+ #1 demo failure is running so fast the caption and the on-screen action are a step out of sync.+7. **Clean up after yourself:** `nbrowser_flash_stop`, `nbrowser_taskbar {progress:{state:"none"},+ overlay:{badge:"none"}}`, close extra windows, clear the caption.++## Recipe — guided interaction demo (the dark demo page)+A self-contained dark-mode page (good for video — not too bright) with a Pay button, a name field,+a hover-tooltip, and a right-click box. Write it once to the user's box, then drive it.+```bash+# (page source is in this repo: demo/demo3.html — write it to C:\Users\john\.adom\demo3.html)+nbrowser_navigate {"sessionId":"demo","url":"file:///C:/Users/<u>/.adom/demo3.html"}+nbrowser_window_state {"sessionId":"demo","state":"maximized"}+nbrowser_click {"sessionId":"demo","text":"Pay","cursor":true,"intent":"Clicking the Pay button"} # click BY TEXT + Adom cursor+nbrowser_type {"sessionId":"demo","selector":"#name","text":"John Lauer","cursor":true,"intent":"Typing your name"}+nbrowser_hover {"sessionId":"demo","text":"Hover me","cursor":true,"intent":"Hovering to reveal the tooltip"} # tooltip+nbrowser_right_click {"sessionId":"demo","selector":".ctx","cursor":true,"intent":"Right-clicking the box"}+```++## Recipe — window state: maximize / fullscreen / un-fullscreen / move / resize+Native `chrome.windows` — **no debug bar**. (A synthetic **F11 does NOT work** — the browser+intercepts browser-chrome shortcuts before the page sees the CDP key event; use this verb instead.)+```bash+nbrowser_window_state {"sessionId":"demo","state":"maximized"} # what users usually want+nbrowser_window_state {"sessionId":"demo","state":"fullscreen"} # immersive: no tabs/URL bar/taskbar+nbrowser_window_state {"sessionId":"demo","state":"normal"} # un-fullscreen / un-maximize+nbrowser_window_state {"sessionId":"demo","left":60,"top":50,"width":1050,"height":700} # move + resize+# alias: nbrowser_fullscreen {"sessionId":"demo"}+```++## Recipe — two browser windows tiled SIDE-BY-SIDE+```bash+# read the work area (chrome's own coordinate units, DPI-correct) from a maximized window:+nbrowser_window_state {"sessionId":"demo","state":"maximized"}+nbrowser_list_windows {} # -> sessions[].bounds {left,top,width,height}; halve the width+# open a 2nd window and tile (work area ~1707x1020 on a 150%-scaled 2560px screen):+nbrowser_open_window {"sessionId":"demo2","url":"https://www.ultralibrarian.com"}+nbrowser_window_state {"sessionId":"demo","left":0,"top":0,"width":853,"height":1015}+nbrowser_window_state {"sessionId":"demo2","left":853,"top":0,"width":853,"height":1015}+```+To shotlog the side-by-side, prefer a montage of two `desktop_screenshot_window {hwnd}` captures+(reliable) over `desktop_screenshot_screen` (white-frame prone). Find each hwnd with+`desktop_find_window {"className":"Chrome_WidgetWin_1"}` and pick by `rect.left`.++## Recipe — open tabs in a window+```bash+nbrowser_open_tab {"sessionId":"demo","url":"https://www.mouser.com"}+nbrowser_open_tab {"sessionId":"demo","url":"https://www.ultralibrarian.com"}+nbrowser_list_tabs {"sessionId":"demo"} # -> [{tabId,url}]+nbrowser_switch_tab {"sessionId":"demo","tabId":"<id>"} # bring a tab forward+```++## Recipe — taskbar status: Adom overlay badge, progress, flash alert+Paints on the real browser window's **taskbar button** (the hwnd is captured at control-start). The+window must be windowed/maximized (taskbar visible) — not immersive fullscreen.+```bash+nbrowser_taskbar {"sessionId":"demo","progress":{"state":"normal","value":45},"overlay":{"badge":"adom","tooltip":"Adom is driving your browser"}} # GREEN + Adom badge+nbrowser_taskbar {"sessionId":"demo","progress":{"state":"paused","value":70}} # YELLOW = human-gate (captcha/2FA/login)+nbrowser_flash {"sessionId":"demo"} # orange "come look" flash+nbrowser_flash_stop {"sessionId":"demo"}+```+Verified to render: the **teal Adom badge sits on the Chrome taskbar button's corner**, and a+green/yellow **progress bar** runs along the button's bottom. To prove it in a frame, full-screen+capture then crop+zoom the button (`desktop_screenshot_screen` → crop the bottom taskbar strip).++## Recipe — "am I logged in to <site>?" + drive logout/login+```bash+nbrowser_login_state {"url":"https://www.mouser.com"}+# -> {loggedIn:true, confidence:"high", authCookieNames:[ASP.NET_SessionId, …]}+```+- Detects a live session from the site's **auth/session cookies** (`chrome.cookies`).+- **Chrome does NOT expose saved passwords to extensions** (security) — you can't read the stored+ credential. To **re-login after a logout**: drive the login form and let **Chrome autofill** the+ saved credential, then submit (Adom never sees the secret). Or snapshot the auth cookies *before*+ logout and restore them (only works if logout didn't invalidate them server-side).++## Recipe — the killer use case: fetch a chip's CAD (chip-fetcher) in the REAL browser+EE CAD sources (manufacturer → SnapMagic/SnapEDA → Mouser → DigiKey → Arrow → UltraLibrarian →+LCSC-last) gate downloads behind **login + captcha**; `pup`'s empty profile hits the wall. The+native browser uses the engineer's **real signed-in session**, so the STEP / KiCad / Altium / Fusion+bundles just download. `chip-fetcher` is the Rust CLI that walks that ladder — point it at the+native browser instead of pup. Demo by navigating the real sites (`nbrowser_navigate+https://www.mouser.com` → `…ultralibrarian.com`) and showing `login_state` = signed in where pup+would be signed out. `chip-fetcher sources` prints the canonical ladder.++## Screenshot/shotlog helper pattern (cloud-side)+`desktop_screenshot_screen` **auto-pulls** both PNGs to the cloud (`localFullPath` in the result) —+shotlog that path directly. `desktop_screenshot_window` returns base64 in `.output.data.image`+(decode then shotlog). Resize busy frames ≤1400px for the model.
skills/driving-the-extension/SKILL.md+47−1@@ -43,7 +43,29 @@ read/navigate, prefer native verbs and the bar never shows. ## Verb surface - **meta:** `status`, `ping`, `help` (full inline reference), `dev_reload` (throttled ≤1/10min)-- **windows/sessions:** `open_window`, `close_window`, `switch_window`, `list_windows`, `rescan`+- **windows/sessions:** `open_window`, `close_window`, `switch_window`, `list_windows` (returns+ `bounds`+`state`), `window_state`/`fullscreen` (maximize·fullscreen·normal·minimize, or+ `left/top/width/height` to move+resize — native, no bar; F11-via-key does NOT work), `rescan`+- **account / credentials:** `login_state {url}` (signed-in NOW? via `chrome.cookies`) ·+ `credentials {match?, browser?, profile?}` — a **read-only QUERY of the browser's OWN saved-login+ store** (we don't store or replace anything; Chrome/Edge stay the source of truth). Returns+ `{browser, profile, host, username}` rows across **Chrome AND Edge**, all profiles — i.e. which+ vendors the user can auto-login to. Never passwords (the browser autofills those). The extension+ can't read this (sandbox); the bridge reads each browser's `Login Data` SQLite natively.++## Logging into vendors (the key to fetching electronics data)+Every EE vendor (DigiKey, Mouser, SnapMagic, UltraLibrarian, ComponentSearchEngine, TI, NXP, Arrow…)+gates CAD/datasheet downloads behind a login. You can **automate all of it** without ever seeing a+password:+1. **Know what you can log into:** `nbrowser_credentials {match:"digikey"}` → if the vendor is in the+ library, the user has a saved login and Chrome will autofill it. (No match → ask the user to save it+ once, or pick another rung of the sourcing ladder.)+2. **Drive the login:** `nbrowser_navigate` to the sign-in page → `nbrowser_click` the email/username+ field (Chrome **autofills** username+password — you never see the secret) → `nbrowser_click` "Sign+ In"/"Log In" → `nbrowser_login_state` to confirm. Proven live on Mouser (`johnAdom`) + CSE.+3. **Signed in = no bot wall:** a real logged-in session also skips the anti-bot "security slider"+ captchas that block pup's empty profile. That's the whole point of driving the real browser.+Mouser uses a **Username** field (not email) — check `input` by name, not just `type=email`. - **tabs:** `open_tab`, `close_tab`, `switch_tab`, `list_tabs` - **navigation:** `navigate`, `reload`, `back`, `forward`, `wait{ms}` - **input (isTrusted, CDP):** `click`, `double_click`, `right_click`, `hover`, `type`, `press_key`,@@ -58,6 +80,23 @@ read/navigate, prefer native verbs and the bar never shows. You don't guess pixels. The **smart finder** scores the DOM, takes the matched element's `getBoundingClientRect`, and computes its center xy — then CDP dispatches the `isTrusted` event there. Targeting is **DOM-truth**, not pixel-guessing.++### Why DPI / window-resize never breaks targeting (the question every AI asks)+This is **not** screenshot→eyeball-a-pixel→click automation, so it does **not** suffer that class of bug:+- **High-DPI / display scaling is handled by Chrome, not you.** `getBoundingClientRect` and CDP+ `Input.dispatchMouseEvent` are BOTH in **CSS pixels, viewport-relative** — the same space. The browser+ maps CSS→physical internally. A 4K@200% screen and a 1080p screen give the identical xy for the same+ button. There is **zero** DPI-translation code, hence zero DPI bugs. Never touch `devicePixelRatio`.+- **Screen resolution / window size / window position are irrelevant** — targeting is page-relative, not+ screen-relative. Drag Chrome to the other monitor mid-task; it doesn't matter.+- **No stale-coordinate / "dirty flag" problem.** Measure and act are **one atomic verb, sub-second,+ with no AI loop between them** — the finder re-measures `getBoundingClientRect` fresh on *every* click/+ type/hover. A user resizing the window 100ms before your click is fine: the next call reads the live+ rect. Nothing is cached across a gap. **So you can reassure the user that resizing/scrolling while Adom+ drives is SAFE** (a nice `nbrowser_caption`) — the opposite of the usual "don't touch anything" fear.+- **The only path that CAN go stale** is passing **raw `{x,y}`** to `input_dispatch` (a literal pixel,+ no re-resolve). Prefer `text`/`selector` — they re-resolve every call. AD is **not** involved in click+ xy at all (it only does OS-level foreground/taskbar/window-screenshots). ```bash # click by VISIBLE TEXT (finder computes xy) — returns {x,y,matchedText} adom-desktop -t AdomLapper nbrowser_click '{"sessionId":"s1","text":"Pay","cursor":true,"intent":"Clicking the Pay button"}'@@ -65,8 +104,15 @@ adom-desktop -t AdomLapper nbrowser_click '{"sessionId":"s1","selector":"#downlo adom-desktop -t AdomLapper nbrowser_type '{"sessionId":"s1","selector":"#search","text":"STM32F103C8T6"}' adom-desktop -t AdomLapper nbrowser_press_key '{"sessionId":"s1","key":"Enter"}' # Enter/Tab/ArrowDown/… adom-desktop -t AdomLapper nbrowser_hover '{"sessionId":"s1","text":"Hover me"}' # fires :hover + tooltips+adom-desktop -t AdomLapper nbrowser_hover '{"sessionId":"s1","selector":".info-icon"}' # or by selector (surest for hover) adom-desktop -t AdomLapper nbrowser_right_click '{"sessionId":"s1","selector":".ctx"}' # real context menu ```+**Hover targets are often NON-interactive** (info icons, cards, tooltip triggers, plain spans). The+finder first scans clickable elements (button/a/input/…); if `text` matches none, it **falls back to the+smallest visible element whose text matches**, so `hover {text:"…"}` works on a bare `<span>`. A CSS+`:hover`/JS `mouseenter` tooltip needs the pointer to *enter* and dwell, so hover does an approach-move →+settle → ~600ms dwell (tune with `hoverDwellMs`). When in doubt for hover, **a `selector` is surest.**+ **The gliding Adom cursor:** pass `cursor:true` + `intent:"…"` on any input verb and the gorgeous Adom cursor (teal arrow + pulsing ring + "Adom" label) glides to the target **while narrating its intention** ("Clicking the Pay button", "Hovering the card to reveal the tooltip"). Builds user trust — they always