← Commit history

v0.16.25 (insiders): nb never routes to pup's own browser: each connection is classified by the browser that launched it, pup's windows are excluded from the default target and from readiness, and an unnamed verb goes to the user's signed-in, last-used profile

John Lauer ·ad349f0324 ·1mo ago ·parent 343d8f0
7 files changed +130−26
bin/nb-dashboard+1−1
@@ -20,7 +20,7 @@ const { execFile, spawn } = require('child_process'); const fs = require('fs'); const crypto = require('crypto'); -const VERSION = '0.16.24';           // keep in lockstep with package.json (pkg version)+const VERSION = '0.16.25';           // keep in lockstep with package.json (pkg version) // SEND TO AI IS UN-PARKED (John: "yeah, we are re-enabling the send to ai. it seems to be good now"). // The ah build thread certified the crash family dead on the gate-fixed build and the delivery leg is // proven end to end. The park const is DELETED rather than flipped, so no dead branch can resurrect it.
bridge/BRIDGE_VERSION+1−1
@@ -1 +1 @@-0.16.24\ No newline at end of file+0.16.25\ No newline at end of file
bridge/bridge.json+1−1
@@ -2,7 +2,7 @@   "manifest_version": 1,   "name": "native-browser",   "displayName": "Adom Native Browser",-  "version": "0.16.24",+  "version": "0.16.25",   "expectedExtensionVersion": "0.2.15",   "description": "Drives the user's REAL signed-in Chrome/Edge, in two layers. Layer 1 (nb) needs NOTHING installed and works on any machine: nbrowser_open_os_window (open a URL in the user's real profile, background+flashing, the OAuth/consent path), nbrowser_os_windows, nbrowser_profiles, nbrowser_wake_profile, nbrowser_force_close. Layer 2 (nbe, the Adom Native Browser Extension) unlocks the rest: CDP driving (navigate/click/read/screenshot) inside their logged-in profile. The bridge binds an ephemeral loopback port and the native-messaging host pumps stdio <-> bridge.",   "homepage": "https://wiki.adom.inc/adom/nb-bridge",
bridge/package.json+1−1
@@ -1,6 +1,6 @@ {   "name": "adom-native-browser-bridge",-  "version": "0.16.24",+  "version": "0.16.25",   "private": true,   "description": "Native-browser bridge for Bridge (ab): layer 1 (nb) plus layer 2 via nbe, the Adom Native Browser Extension (Chrome + Edge).",   "main": "server.js",
bridge/server.js+114−21
@@ -116,6 +116,83 @@ function liveConns() { return [...conns.entries()].filter(([k, c]) => c.sock && const isPending = (k) => /^pending-/.test(String(k || '')); function resolvedConns() { return liveConns().filter(([k]) => !isPending(k)); } function drivableConns() { const r = resolvedConns(); return r.length ? r : liveConns(); }+// CONNECTION ORIGIN (John, 2026-09-21: an "open in nb" put his meeting agenda in a pup window). pup runs its+// OWN Chrome (Chrome for Testing under ~/.cache/puppeteer, profiles under ~/.adom/pup-profiles) and loads nbe+// into it, so pup's windows dial this bridge exactly like the user's real browser does, under unsigned id+// keys (chrome:p_xxxx). They are NOT the user's native browser. The bridge used to make whichever browser+// connected first the default target, so an unnamed verb could land in pup. Each connection is now+// classified by the browser process that spawned its native host (classifyConnOrigin): pup's are never the+// default, and readiness / the stale-version nag ignore them. They stay drivable when named explicitly.+function isPupConn(c) { return !!(c && c.origin && c.origin.owner === 'pup'); }+function userConns() { return drivableConns().filter(([, c]) => !isPupConn(c)); }+// The profile the user last used in that browser (Local State profile.last_used), a tie-break between two+// real signed-in profiles of the same Chrome. Cached briefly: it is a file read per ranking otherwise.+const _lastUsedCache = {};+function lastUsedProfileDir(browser) {+  const b = browser || 'chrome'; const hit = _lastUsedCache[b];+  if (hit && Date.now() - hit.at < 30000) return hit.dir;+  let dir = null;+  try { const j = JSON.parse(fs.readFileSync(path.join(BROWSER_UD[b], 'Local State'), 'utf8')); dir = (j.profile && j.profile.last_used) || null; } catch {}+  _lastUsedCache[b] = { at: Date.now(), dir };+  return dir;+}+// Rank for the DEFAULT target when nobody chose one: the user's default-folder browser first, a signed-in+// profile next, the browser's last-used profile as the tie-break; an unclassified connection ranks last (it+// may still turn out to be pup's - classification lands about a second after the hello).+function connRank(k, c) {+  let s = 0;+  if (c.origin && c.origin.owner === 'user') s += 4;+  if (!c.origin || c.origin.owner === 'unknown') s -= 1;+  if (c.profile && c.profile.email) s += 2;+  try { const av = avatarForProfile(k); if (av && av.profileDir && av.profileDir === lastUsedProfileDir(c.profile && c.profile.browser)) s += 1; } catch {}+  return s;+}+let activeExplicit = false;  // true only after nbrowser_use_profile: then the named profile IS the default+function defaultConnEntry() {+  const live = userConns();+  if (!live.length) return null;+  if (activeExplicit) { const act = live.find(([k]) => k === activeProfile); if (act) return act; }+  let best = null, bestRank = -Infinity;+  for (const e of live) { const r = connRank(e[0], e[1]); if (r >= bestRank) { best = e; bestRank = r; } }  // ties: most recent wins+  return best;+}+function defaultConnKey() { const e = defaultConnEntry(); return e ? e[0] : null; }+// Classify one connection: find the native host process that owns this socket's far end, walk up to the+// browser that spawned it, and read its executable + --user-data-dir. One PowerShell per real (hello'd)+// connection, capped in flight so a host connect-storm cannot fan out into a PowerShell storm.+const _originInFlight = new WeakSet();+let _originBusy = 0;+function classifyConnOrigin(sock, tries) {+  if (process.platform !== 'win32' || !sock || sock.destroyed || _originInFlight.has(sock)) return;+  if (_originBusy >= 4) { if ((tries || 0) < 5) setTimeout(() => classifyConnOrigin(sock, (tries || 0) + 1), 2000); return; }+  const port = sock.remotePort; let hp = 0; try { hp = hostSrv.address().port; } catch {}+  if (!port || !hp) return;+  _originInFlight.add(sock); _originBusy++;+  const ps = [+    `$c = Get-NetTCPConnection -LocalPort ${port} -RemotePort ${hp} -State Established -ErrorAction SilentlyContinue | Select-Object -First 1`,+    "if (-not $c) { '{}'; exit }",+    '$by = @{}; foreach ($q in (Get-CimInstance Win32_Process -Property ProcessId,ParentProcessId,Name,ExecutablePath,CommandLine)) { $by[[int]$q.ProcessId] = $q }',+    '$p = $by[[int]$c.OwningProcess]; $h = 0',+    "while ($p -and $h -lt 8 -and $p.Name -notmatch '^(chrome|msedge|brave)\\.exe$') { $p = $by[[int]$p.ParentProcessId]; $h++ }",+    "if (-not $p) { '{}'; exit }",+    "$ud = ''; if ($p.CommandLine -match '--user-data-dir=\"?([^\"]+?)\"?(\\s--|$)') { $ud = $matches[1] }",+    '@{ hostPid = [int]$c.OwningProcess; browserPid = [int]$p.ProcessId; exe = [string]$p.ExecutablePath; userDataDir = $ud } | ConvertTo-Json -Compress',+  ].join('; ');+  execFile('powershell.exe', ['-NoProfile', '-NonInteractive', '-Command', ps], { timeout: 15000, windowsHide: true }, (err, out) => {+    _originBusy--;+    let o = {}; try { o = JSON.parse(String(out || '').trim() || '{}'); } catch {}+    for (const [k, c] of conns) {+      if (c.sock !== sock) continue;+      const exe = String(o.exe || ''), ud = String(o.userDataDir || '');+      if (!exe) { c.origin = { owner: 'unknown', checkedAt: Date.now(), error: err ? String(err.code || err.message || err).slice(0, 80) : 'no browser process above the host' }; continue; }+      const pup = /[\\/]\.adom[\\/]pup-profiles[\\/]/i.test(ud) || /[\\/]\.cache[\\/]puppeteer[\\/]/i.test(exe);+      const b = (c.profile && c.profile.browser) || 'chrome';+      const defUd = !ud || !!(BROWSER_UD[b] && path.resolve(ud).toLowerCase() === path.resolve(BROWSER_UD[b]).toLowerCase());+      c.origin = { owner: pup ? 'pup' : (defUd ? 'user' : 'other'), exe, userDataDir: ud || null, checkedAt: Date.now() };+      dlog(`origin: ${k} -> ${c.origin.owner} (${exe}${ud ? ' ud=' + ud : ''})`);+    }+  });+} function pendingConns() { return liveConns().filter(([k]) => isPending(k)); } function anyConnected() { return liveConns().length > 0; } // Pick the connection an action verb should target: explicit args.profile, else the active profile,@@ -140,7 +217,7 @@ function targetConn(args) {     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 };+    for (const [k, c] of conns) if (c.profile.browser === wantBrowser && c.sock && !c.sock.destroyed && !isBlocked(k) && !isPupConn(c)) return { conn: c, key: k };     return null;   }   // A session PINNED to a profile (via open_window or nbrowser_pin_session) routes there by default -@@ -152,11 +229,10 @@ function targetConn(args) {   }   // No profile named: drive whatever is live, preferring a resolved label but HAPPILY using a pending   // one - it answers exactly the same (see the isPending note). Refusing here was the bug.-  const live = drivableConns();-  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 };+  // Never pup's own browser (see isPupConn): an unnamed nb verb means the USER's browser.+  const e = defaultConnEntry();+  if (!e) return null;+  return { conn: e[1], key: e[0] }; } // 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@@ -164,7 +240,7 @@ function targetConn(args) { function registerProvisional(sock) {   const key = 'pending-' + (++provSeq);   conns.set(key, { sock, profile: { id: key, email: '' }, since: Date.now(), provisional: true });-  if (!activeProfile) activeProfile = key;+  // activeProfile only matters once a caller chose one (activeExplicit); defaults come from defaultConnEntry.   // AUTO-RESOLVE THE LABEL (2026-08-22): resolvePendingProfiles was only ever called from the badge   // fallback and the manual nbrowser_resolve_profiles verb, so after a bridge restart the reconnected   // hosts sat at pending-N FOREVER unless someone happened to call one of those. The connection was@@ -248,15 +324,16 @@ function registerConn(sock, profile, browser, version) {   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.+  // Re-key any provisional/old entry for THIS socket to the real profile (keeping its origin, if known).+  const carried = [...conns.values()].find((c) => c.sock === sock && c.origin);   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   // Keep the version the extension reported in its hello (used by readiness to flag a stale build).   // Fall back to a same-profile prior value if this hello somehow omitted it.-  conns.set(key, { sock, profile: profile || {}, since: Date.now(), version: version || (prev && prev.version) || null });+  conns.set(key, { sock, profile: profile || {}, since: Date.now(), version: version || (prev && prev.version) || null, origin: (carried && carried.origin) || null });+  if (!carried) classifyConnOrigin(sock);   setTimeout(() => { const c = conns.get(key); if (c && c.sock === sock) pushExtSettings(c, key); }, 800);   // toolbarIcon etc. reach a fresh service worker-  if (!activeProfile || !liveConns().some(([k]) => k === activeProfile)) activeProfile = isBlocked(key) ? activeProfile : key;   dlog(`hello: profile ${key} registered (${conns.size} total)`); } @@ -275,7 +352,8 @@ function statusReport() {   }   const live = liveConns();   if (live.length) {-    const names = live.map(([k]) => (k === activeProfile ? `▶ ${k}` : k)).join(', ');+    const defK = defaultConnKey();+    const names = live.map(([k, c]) => (k === defK ? `▶ ${k}` : isPupConn(c) ? `${k} (pup's browser)` : 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 {@@ -1778,7 +1856,10 @@ function dispatch(verb, args) {     // installs. This bridge has no heavy runtime (tiny Node), so "readiness" == the Level-2 extension     // being connected. ab's own bridge_readiness aggregates across bridges; this reports THIS one.     if (verb === 'nbrowser_readiness') {-      const live = drivableConns();+      // pup's own browser is not the user's: it never makes nb "ready" and is never nagged as stale.+      const allLive = drivableConns();+      const live = allLive.filter(([, c]) => !isPupConn(c));+      const pupLive = allLive.filter(([, c]) => isPupConn(c));       // Pending = not yet LABELLED. Whether it counts as ready is decided by EVIDENCE, not inference:       // a healthy post-restart connection answers ping in ~20ms (AdomLapper), while a native-host       // connect LOOP with no extension behind it (ConfRoomROG hit provSeq 15749) holds a socket that@@ -1795,7 +1876,7 @@ function dispatch(verb, args) {         : Promise.resolve(false);       return probe.then((pendAnswered) => {       // Ready = a resolved profile exists, OR an unlabelled connection PROVED itself with a pong.-      const ready = resolvedConns().length > 0 || pendAnswered;+      const ready = resolvedConns().filter(([, c]) => !isPupConn(c)).length > 0 || pendAnswered;       const chExe = detectChrome();       // Per-profile extension version + stale check. A loaded-unpacked extension does NOT auto-update       // (only the bridge does), so a box can sit on an old build whose new verbs return "not implemented@@ -1849,6 +1930,9 @@ function dispatch(verb, args) {         extensionConnected: ready,         profiles: live.map(([k]) => k),         extensionVersions,                      // { "chrome:you@x": "0.0.18", ... } as reported in each hello+        // pup's own browser windows (they load nbe too). Listed so an AI can see them, never counted as the+        // user's browser, never nagged as stale (pup ships its own copy), never the default target.+        pupConnections: pupLive.map(([k, c]) => ({ profile: k, version: c.version || null })),         expectedExtensionVersion: EXPECTED_EXT, // what THIS bridge release ships against (null = not checked)         extensionStale,                         // true if any connected profile runs a KNOWN older build than expected         staleProfiles,                          // the profile keys that are behind (pass one to nbrowser_dev_reload)@@ -2036,8 +2120,11 @@ function dispatch(verb, args) {       // Chrome/Edge ("John Personal", "adom.inc"), the profile DIRECTORY, and whether it has an avatar.       // Without this an AI cannot reliably tell work from personal (the incident: "the extension status       // doesn't expose the Chrome profile's label").+      const defKey = defaultConnKey();       const list = [...conns.entries()].map(([k, c]) => {-        const row = { 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) };+        const row = { profile: k, label: profileLabel(c.profile), email: c.profile.email || '', browser: c.profile.browser || 'chrome', active: k === defKey, blocked: isBlocked(k), live: !!(c.sock && !c.sock.destroyed) };+        row.origin = c.origin ? c.origin.owner : 'checking';+        if (isPupConn(c)) row.note = "pup's own browser window, NOT the user's browser. Never the default target; name it explicitly only if you really mean pup's window (and prefer pup_* verbs for that).";         const av = avatarForProfile(k);         if (av) { row.profileDir = av.profileDir; row.hasAvatar = !!av.avatarPath; }         try {@@ -2066,14 +2153,15 @@ function dispatch(verb, args) {         if (st.checked) { row.extensionInstalled = !!st.installed; if (st.installed) row.extensionEnabled = !!st.enabled; if (!st.installed || !st.enabled) row.fix = extFixHint(kp.browser, kp.profileDir, st); }         list.push(row);       }-      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). displayName = the human label the USER sees on the browser\'s own profile button ("John Personal", "adom.inc") - use it together with the email DOMAIN to pick work vs personal (corporate domain = work; gmail.com etc = personal). NEVER assume "the" browser: the same email can exist in Chrome AND Edge - match `browser` too. unresolved:true = connected but email not announced yet (nbrowser_resolve_profiles first, or you may drive the wrong account). asleep:true = the profile EXISTS on this machine but has NO OPEN WINDOW, so its extension service worker is asleep and it CANNOT be driven yet - wake it with nbrowser_wake_profile {profile:\'<key>\'} (opens NO window, foregrounds NOTHING), then it appears live here. NEVER launch a foreground browser window just to wake a profile. Target profiles in PARALLEL by passing profile:"<key>" on any verb. ▶active is only the default when no target is passed. Switch with nbrowser_use_profile; exclude with nbrowser_profile_block.' }) });+      return resolve({ success: true, output: JSON.stringify({ ok: true, profiles: list, active: defKey, blocklist: [...blocklist], _hint: 'Each entry is one BROWSER×PROFILE the AI can drive - `profile` is the addressable key like "edge:[email protected]" (browser:email). displayName = the human label the USER sees on the browser\'s own profile button ("John Personal", "adom.inc") - use it together with the email DOMAIN to pick work vs personal (corporate domain = work; gmail.com etc = personal). NEVER assume "the" browser: the same email can exist in Chrome AND Edge - match `browser` too. unresolved:true = connected but email not announced yet (nbrowser_resolve_profiles first, or you may drive the wrong account). asleep:true = the profile EXISTS on this machine but has NO OPEN WINDOW, so its extension service worker is asleep and it CANNOT be driven yet - wake it with nbrowser_wake_profile {profile:\'<key>\'} (opens NO window, foregrounds NOTHING), then it appears live here. NEVER launch a foreground browser window just to wake a profile. Target profiles in PARALLEL by passing profile:"<key>" on any verb. ▶active is only the default when no target is passed. Switch with nbrowser_use_profile; exclude with nbrowser_profile_block.' }) });     }     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;+      if (isPupConn(conns.get(hit))) return resolve({ success: false, errorCode: 'pup_browser_not_default', error: `'${hit}' is pup's own browser window, not the user's browser, so it cannot be nb's default.`, _hint: "To act in pup's window use pup_* verbs, or name this profile on the single call that needs it. nbrowser_profiles lists the user's real profiles (origin:\"user\")." });+      activeProfile = hit; activeExplicit = true;       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>".' }) });     }     // PIN a session to a profile: every later verb carrying this sessionId auto-routes there (without@@ -2558,10 +2646,10 @@ function dispatch(verb, args) {     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; }+      if (verb === 'nbrowser_profile_block') { blocklist.add(want); if (activeProfile === want) { activeProfile = null; activeExplicit = false; } }       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.' }) });+      return resolve({ success: true, output: JSON.stringify({ ok: true, blocklist: [...blocklist], active: defaultConnKey(), _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@@ -2814,10 +2902,15 @@ function dispatch(verb, args) {           if (kp0) { const st0 = adomExtStateForProfile(kp0.browser, kp0.profileDir); diag = ` DIAGNOSIS for ${kp0.key} (${kp0.displayName}, ${kp0.profileDir}): ${extFixHint(kp0.browser, kp0.profileDir, st0)} If it IS installed+enabled, nbrowser_wake_profile {profile:"${kp0.key}"} wakes it with NO window and NO foreground.`; }         }       } catch {}+      // Only pup's own browser is connected: say so plainly, so the AI does not conclude "nb is broken" or+      // quietly fall back to pup (both happened, 2026-09-21). The user's browser is asleep, not missing.+      const pupOnly = liveConns().some(([, c]) => isPupConn(c)) && !userConns().length;+      const pupNote = pupOnly ? "Only pup's OWN browser windows are connected right now, and nb never drives those by default (they are not the user's browser). The user's real browser is simply asleep: wake it with nbrowser_wake_profile {profile} (nbrowser_profiles lists the real profiles with origin:\"user\" or asleep:true), or just open the page there with nbrowser_open_os_window. Do NOT fall back to pup for an nb request. " : '';       return resolve({         success: false,         error: 'native browser extension not connected',-        _hint: 'No drivable profile is connected. A profile\'s extension DIES if that profile has NO OPEN WINDOWS (its service worker sleeps) - nbrowser_wake_profile wakes it without any window. nbrowser_profiles lists every profile (live + asleep) with per-profile extension state.' + diag,+        pupOnly: pupOnly || undefined,+        _hint: pupNote + 'No drivable profile is connected. A profile\'s extension DIES if that profile has NO OPEN WINDOWS (its service worker sleeps) - nbrowser_wake_profile wakes it without any window. nbrowser_profiles lists every profile (live + asleep) with per-profile extension state.' + diag,       });     }     const sock = tgt.conn.sock;@@ -3020,7 +3113,7 @@ const httpSrv = http.createServer((req, res) => {   };    if (req.method === 'GET' && req.url === '/status') {-    return reply(200, { ok: true, bridge: BRIDGE, version: VERSION, extConnected: anyConnected(), profiles: [...conns.keys()], active: activeProfile, ...statusReport() });+    return reply(200, { ok: true, bridge: BRIDGE, version: VERSION, extConnected: anyConnected(), profiles: [...conns.keys()], active: defaultConnKey(), ...statusReport() });   }    if (req.method === 'POST' && req.url === '/command') {@@ -3105,7 +3198,7 @@ const hostSrv = net.createServer((sock) => {       onHostFrame(frame);     }   });-  const drop = () => { for (const [k, c] of conns) if (c.sock === sock) { conns.delete(k); if (activeProfile === k) activeProfile = (liveConns()[0] || [])[0] || null; } };+  const drop = () => { for (const [k, c] of conns) if (c.sock === sock) { conns.delete(k); } };  // an explicit choice survives a reconnect of the same profile   sock.on('close', drop);   sock.on('error', drop); });
package.json+1−1
@@ -1,6 +1,6 @@ {   "slug": "nb-bridge",-  "version": "0.16.24",+  "version": "0.16.25",   "type": "app",   "title": "NB - Native Browser Bridge",   "description": "This package installs the SKILLS into your container so your AI knows how to drive your real signed-in Chrome and Edge; Bridge loads the bridge runtime itself from the release zip.",
skills/driving-the-extension/SKILL.md+11
@@ -109,6 +109,17 @@ name one or it errors `ambiguous_target`: ```bash adom-bridge --target AdomLapper nbrowser_status ```+**pup's own browser also shows up here, and it is never the user's browser.** pup runs its own Chrome+and loads this extension into it, so its windows connect to nb as unsigned profiles like `chrome:p_xxxx`.+From bridge 0.16.25 nb knows which browser launched each connection: `nbrowser_profiles` marks pup's with+`origin:"pup"` and a note, and `nbrowser_readiness` lists them under `pupConnections` instead of counting+them. An unnamed verb always goes to the user's real browser (`origin:"user"`, signed-in first, then the+profile they last used), never to pup's. If only pup's windows are connected, verbs answer `native browser+extension not connected` with `pupOnly:true`: the user's browser is asleep, not broken. Wake it with+`nbrowser_wake_profile`, or open the page there with `nbrowser_open_os_window`. Never fall back to pup for+an nb request, and never tell the user nb "is not connected to your Chrome" without reading+`nbrowser_profiles` first.+ A "session" = a real Chrome window (`sessionId` you choose, or auto `s1`…). `tabId` = the real chrome tab id. The session→window map persists to `chrome.storage`, so sessions survive reloads.