← Commit history
nb 0.4.3: pending-* connections are DRIVABLE and count as ready (the real 'nbe not connected' bug); revert the zombie reaper
5 files changed
+35−46
bridge/BRIDGE_VERSION+1−1@@ -1 +1 @@-0.4.2+0.4.3
bridge/bridge.json+1−1@@ -2,7 +2,7 @@ "manifest_version": 1, "name": "native-browser", "displayName": "Adom Native Browser",- "version": "0.4.2",+ "version": "0.4.3", "expectedExtensionVersion": "0.2.5", "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.4.2",+ "version": "0.4.3", "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+27−38@@ -105,11 +105,17 @@ function saveBlocklist() { try { fs.writeFileSync(BLOCKFILE, JSON.stringify({ bl function isBlocked(key) { return blocklist.has(key); } function liveConns() { return [...conns.entries()].filter(([k, c]) => c.sock && !c.sock.destroyed && !isBlocked(k)); }-// A `pending-*` key is a connected extension whose PROFILE IDENTITY has not resolved yet. targetConn-// cannot route to it, so it must never count as "ready" - reporting ready:true off one told the AI to-// drive and every drive then failed 'extension not connected' (or hung 30s with no profile arg).+// A `pending-*` key is a connected extension whose PROFILE LABEL has not resolved yet. ⚠️ IT IS STILL+// FULLY DRIVABLE - verified 2026-08-22: nbrowser_ping through a pending key returns pong in 22ms. An+// earlier fix wrongly assumed pending meant dead, excluded it from readiness, and made every drive refuse+// 'extension_resolving'. That is EXACTLY the "nb opens windows but nbe says not connected" report: after+// a bridge restart the hosts reconnect under provisional keys, so layer 1 kept working while layer 2+// declared itself broken on a live, healthy connection. So: pending is drivable and COUNTS AS READY; the+// only thing it cannot do is satisfy a request that NAMES a specific profile. Resolution is enrichment,+// never a gate. const isPending = (k) => /^pending-/.test(String(k || ''));-function drivableConns() { return liveConns().filter(([k]) => !isPending(k)); }+function resolvedConns() { return liveConns().filter(([k]) => !isPending(k)); }+function drivableConns() { const r = resolvedConns(); return r.length ? r : liveConns(); } 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,@@ -144,15 +150,10 @@ function targetConn(args) { for (const [k, c] of conns) if (k === pin && c.sock && !c.sock.destroyed && !isBlocked(k)) return { conn: c, key: k, pinned: true }; // pinned profile no longer live → fall through to the normal default rather than hard-failing. }- const drivable = drivableConns();- const live = drivable.length ? drivable : [];- if (!live.length) {- // Connected but unresolved (pending-*) is NOT drivable - say so fast instead of forwarding into a- // socket that will never answer (issue: readiness said ready, drives then hung for 30s).- const pend = pendingConns();- if (pend.length) return { unresolved: true, pending: pend.map(([k]) => k) };- return null;- }+ // 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 };@@ -734,30 +735,19 @@ let _lastResolveNudge = 0; // offering the install, and the user's other thread reported "nb opens windows but nbe says not // connected". A pending conn that has ignored several whoami nudges is not resolving, it is dead: say so // and reap it, so state falls through to the honest needs-extension.-// ⛔ REAP ON EVIDENCE, NOT ON AGE. The first cut of this reaper used age alone and immediately killed-// HEALTHY connections on John's laptop: after a bridge restart the native host reconnects its socket but-// the extension does not spontaneously re-announce, so a perfectly drivable profile sits at pending-N-// with no hello and looked exactly like a corpse. A pending conn is only dead if it has IGNORED repeated-// whoami probes - silence we actually asked for. Any frame at all on the socket (hello, reply, pong)-// clears the strike count, so a live-but-quiet extension is never reaped.-const PENDING_GRACE_MS = 120000; // never reap inside the first 2 minutes, whatever the probes say-const PENDING_MAX_STRIKES = 4; // 4 unanswered whoami nudges (~40s at the 10s cadence)-const isZombie = (c) => !!(c && c.provisional && c.since- && (Date.now() - c.since) > PENDING_GRACE_MS- && (c.probeStrikes || 0) >= PENDING_MAX_STRIKES);+// ⛔ DO NOT ADD A "ZOMBIE REAPER" HERE. Tried 2026-08-22 and reverted the same hour. The theory was that+// a pending-N key with no hello meant a native host outliving its extension, so it should be destroyed.+// It is false: those sockets answer nbrowser_ping in ~22ms. Reaping on age alone instantly killed both+// healthy profiles on John's laptop. A pending label means "we have not learned the email yet", NOT+// "nothing is behind this socket" - the socket's own close event is the only honest death signal, and+// liveConns() already honours it. If you think you need to reap, ping first; you will find it alive.+const isZombie = () => false; function resolvePendingProfiles() { const now = Date.now(); if (_lastResolveNudge + 10000 > now) return; _lastResolveNudge = now; for (const [k, c] of conns) { if (!c.sock || c.sock.destroyed) continue; const em = c.profile && c.profile.email; if (em && String(em).includes('@')) continue;- if (isZombie(c)) {- dlog(`reaping zombie host ${k} (no hello in ${Math.round((now - c.since) / 1000)}s)`);- try { c.sock.destroy(); } catch {}- conns.delete(k);- if (activeProfile === k) activeProfile = null;- continue;- } try { const id = ++seq; pending.set(id, { resolve: () => {}, timer: setTimeout(() => pending.delete(id), 4000), verb: 'nbrowser_whoami', profileKey: k, _internal: true });@@ -1456,11 +1446,10 @@ function dispatch(verb, args) { // being connected. ab's own bridge_readiness aggregates across bridges; this reports THIS one. if (verb === 'nbrowser_readiness') { const live = drivableConns();- // Only a YOUNG pending conn means "resolving"; an old one is a zombie host (see the reaper) and- // must not masquerade as a profile that is about to come online.- const pendAll = pendingConns();- const pend = pendAll.filter(([, c]) => !isZombie(c));- const zombies = pendAll.filter(([, c]) => isZombie(c)).map(([k]) => k);+ // Pending = drivable but not yet LABELLED. It counts toward ready (live includes it); we still+ // list it so a caller that needs to name a profile knows to resolve first.+ const pend = pendingConns();+ const zombies = []; const ready = live.length > 0; const chExe = detectChrome(); // Per-profile extension version + stale check. A loaded-unpacked extension does NOT auto-update@@ -1507,8 +1496,8 @@ function dispatch(verb, args) { return resolve({ success: true, output: JSON.stringify({ ready, state: ready ? 'ready' : (pend.length ? 'resolving' : 'needs-extension'),- pendingProfiles: pend.map(([k]) => k), // connected but identity unresolved: NOT drivable- zombieHosts: zombies, // sockets alive with NO extension behind them (being reaped)+ pendingProfiles: pend.map(([k]) => k), // DRIVABLE, but the profile email is not resolved yet+ zombieHosts: zombies, // always [] - see the isZombie note; kept for shape stability claudeExtension: claudeExt, // Claude for Chrome presence (human-fallback awareness; never drivable) installing: false, extensionConnected: ready,
native-browser-bridge-manifest.json+5−5@@ -1,8 +1,8 @@ { "manifest_version": 1,- "version": "0.4.2",- "url": "https://wiki.adom.inc/download/adom/nb-bridge/0.4.2/native-browser-bridge-v0.4.2.zip",- "sha256": "7807d3166aae85c0f004f8f56511fcd7a560748560576999dfbe0916b11b5845",- "size": 104737,- "released_at": "2026-08-22T14:06:07Z"+ "version": "0.4.3",+ "url": "https://wiki.adom.inc/download/adom/nb-bridge/0.4.3/native-browser-bridge-v0.4.3.zip",+ "sha256": "efea3c4dbf183caeb34892cacbea9087347779575e4d2eec39f603977136f84d",+ "size": 104682,+ "released_at": "2026-08-22T14:11:28Z" }