← Commit history

Parallel browser-aware keying: connections keyed browser:profile so Chrome+Edge (+work/personal) coexist & are driven in parallel by separate threads (pkg 0.3.2, ext 0.0.5)

John Lauer ·09b5d43b0b ·4mo ago ·parent a80266c
6 files changed +60−11
bridge/server.js+29−7
@@ -61,7 +61,15 @@ let lastVerb = null, lastActivityAt = 0; 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'; }+// Connections are keyed by BROWSER + PROFILE so the same account signed into Chrome AND Edge (or any+// browser/profile combo) are DISTINCT, independently-addressable connections that coexist — letting+// multiple AI threads each drive a different browser/profile in parallel without colliding. e.g.+// "chrome:[email protected]", "edge:[email protected]", "chrome:[email protected]".+function profileKey(p) {+  const b = (p && p.browser) || 'chrome';+  const id = (p && (p.email || p.id)) || 'unknown';+  return `${b}:${id}`;+} 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.@@ -77,9 +85,19 @@ function anyConnected() { return liveConns().length > 0; } // 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);+  const wantBrowser = args && args.browser; // optional: disambiguate the SAME email across browsers   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 };+    // `want` may be a full key ("edge:[email protected]") or just an email — match either, and if a browser+    // is also given require it (so parallel threads can pin "this email in THIS browser").+    for (const [k, c] of conns) {+      if (!(c.sock && !c.sock.destroyed)) continue;+      if ((k === want || c.profile.email === want) && (!wantBrowser || c.profile.browser === wantBrowser)) return { conn: c, key: k };+    }+    return null;+  }+  if (wantBrowser) { // browser-only target → that browser's active/first live profile+    for (const [k, c] of conns) if (c.profile.browser === wantBrowser && c.sock && !c.sock.destroyed && !isBlocked(k)) return { conn: c, key: k };     return null;   }   const live = liveConns();@@ -155,8 +173,12 @@ function queryCredentials({ browser, profile, match } = {}) {   return out; } -function registerConn(sock, profile) {-  const key = profileKey(profile);+function registerConn(sock, profile, browser) {+  // Normalize to an object + tag the browser so chrome:me and edge:me are DISTINCT coexisting conns.+  const p = (profile && typeof profile === 'object') ? { ...profile } : { email: String(profile || ''), id: String(profile || '') };+  p.browser = browser || p.browser || 'chrome';+  const key = profileKey(p);+  profile = p;   // 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);@@ -354,8 +376,8 @@ function dispatch(verb, args) {     }     // --- 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.' }) });+      const list = [...conns.entries()].map(([k, c]) => ({ profile: k, label: profileLabel(c.profile), email: c.profile.email || '', browser: c.profile.browser || 'chrome', 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 BROWSER×PROFILE the AI can drive — `profile` is the addressable key like "edge:[email protected]" (browser:email). Target any of them in PARALLEL by passing profile:"<key>" (or profile:"<email>"+browser:"edge") on any verb — different AI threads can each drive a different one at once. ▶active is only the default when no target is passed. Switch the default with nbrowser_use_profile; exclude one with nbrowser_profile_block.' }) });     }     if (verb === 'nbrowser_use_profile') {       const want = String((args && (args.profile || args.email)) || '');@@ -564,7 +586,7 @@ const hostSrv = net.createServer((sock) => {       if (!line.trim()) continue;       let frame;       try { frame = JSON.parse(line); } catch { continue; }-      if (frame && frame.type === 'hello') { registerConn(sock, frame.profile); continue; }+      if (frame && frame.type === 'hello') { registerConn(sock, frame.profile, frame.browser); continue; }       onHostFrame(frame);     }   });
extension/manifest.json+1−1
@@ -2,7 +2,7 @@   "manifest_version": 3,   "name": "Adom for Chrome & Edge",   "short_name": "Adom",-  "version": "0.0.4",+  "version": "0.0.5",   "description": "Drive your real, logged-in Chrome/Edge from the Adom ecosystem — the native-browser counterpart to pup.",   "minimum_chrome_version": "116",   "background": {
extension/src/sw.js+1−1
@@ -88,7 +88,7 @@ async function sendHello() {     ]);     if (info && info.email) profile.email = info.email;   } catch {}-  try { if (port) { port.postMessage({ type: 'hello', profile, version: VERSION }); helloSent = true; } } catch {}+  try { if (port) { port.postMessage({ type: 'hello', profile, version: VERSION, browser: detectBrowser() }); helloSent = true; } } catch {} }  function reply(id, ok, payload, error, hint) {
host/native-host.js+5−1
@@ -117,7 +117,11 @@ function pumpStdin() {     // 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');+      // Key the singleton by BROWSER + profile so the same account in Chrome AND Edge each get their+      // own host (instead of the 2nd browser's host exiting on a shared lock — the "Native host has+      // exited" collision). The bridge keys connections the same way, so all coexist in parallel.+      const _id = (msg.profile && (msg.profile.email || msg.profile.id)) || 'default';+      acquireSingleton(`${msg.browser || 'chrome'}:${_id}`);     }     sendToBridge(msg);   }
package.json+1−1
@@ -1,6 +1,6 @@ {   "slug": "adom-browser-extension",-  "version": "0.3.1",+  "version": "0.3.2",   "type": "app",   "title": "Adom Browser Extension (Chrome + Edge)",   "description": "Drive your real, logged-in Chrome/Edge from the Adom ecosystem \u2014 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.",
skills/driving-the-extension/SKILL.md+23
@@ -76,6 +76,29 @@ Mouser uses a **Username** field (not email) — check `input` by name, not just - **demo/UX:** `caption`, `focus`, **`flash`/`flash_stop`/`flash_mode`/`taskbar`** - **recording:** `record_start`/`stop`/`status`/`list` — `method: wgc|getdisplaymedia|tabcapture` +## Parallel multi-browser / multi-profile control+Every **BROWSER × PROFILE is its own independently-addressable connection**, keyed `browser:email`+(e.g. `chrome:[email protected]`, `edge:[email protected]`, `chrome:[email protected]`). The same account signed into+Chrome AND Edge are DISTINCT connections that coexist — the host singleton AND the bridge both key by+`browser:profile`, so the 2nd browser's host no longer exits with *"Native host has exited."*++- **List them:** `nbrowser_profiles` → each entry's `profile` field is its addressable key, plus+  `browser`, `email`, `live`, `active`.+- **Target any of them, in PARALLEL:** pass `profile:"<key>"` on ANY verb —+  `nbrowser_navigate {profile:"edge:[email protected]", url:…}`. Also accepts `profile:"<email>"`++  `browser:"edge"`, or `browser:"edge"` alone (that browser's active profile). A wrong/absent target+  no-routes cleanly (doesn't fall through to the wrong browser).+- **Multiple AI threads at once:** separate Claude/Codex threads can each drive their own+  browser/profile simultaneously — every verb carries its own explicit target, so there's no shared+  default to clobber. `nbrowser_use_profile` only sets the convenience default for verbs that omit a+  target; for parallel work, ALWAYS pass an explicit `profile`/`browser`.+- **Exclude one:** `nbrowser_profile_block {profile}` (persisted) — a security whitelist that keeps a+  browser/profile from ever being driven.++For a browser to appear, that profile needs an open window AND the Adom extension loaded in THAT+browser (`chrome://extensions` / `edge://extensions` → Developer mode → Load unpacked → the extension+folder). The hello carries the browser (Chrome vs Edge via the UA), so each connection self-keys.+ ## Input — click, type, hover, right-click, and how xy is found 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.