← Commit history
bridge/BRIDGE_VERSION+1−1
@@ -1 +1 @@-0.1.49\ No newline at end of file+0.1.50\ No newline at end of file
bridge/bridge.json+1−1
@@ -2,7 +2,7 @@   "manifest_version": 1,   "name": "native-browser",   "displayName": "Adom Browser Extension",-  "version": "0.1.49",+  "version": "0.1.50",   "expectedExtensionVersion": "0.1.3",   "description": "Drives the user's REAL signed-in Chrome/Edge. MOST verbs require the Adom extension installed in that browser profile (CDP driving: navigate/click/read/screenshot). TWO verbs need NO extension at all and work on any machine: nbrowser_open_os_window (open a URL in the user's real profile, background+flashing - the OAuth/consent path) and nbrowser_os_windows (enumerate browser windows). Also extension-free: nbrowser_profiles, nbrowser_wake_profile, nbrowser_force_close. The bridge binds an ephemeral loopback port and the native-messaging host pumps stdio <-> bridge.",   "homepage": "https://wiki.adom.inc/adom/adom-browser-extension",
bridge/package.json+1−1
@@ -1,6 +1,6 @@ {   "name": "adom-native-browser-bridge",-  "version": "0.1.49",+  "version": "0.1.50",   "private": true,   "description": "adom-desktop bridge for the Adom native-browser extension (Chrome + Edge)",   "main": "server.js",
bridge/server.js+29−2
@@ -1347,6 +1347,31 @@ function dispatch(verb, args) {       }       const extensionStale = staleProfiles.length > 0;       const staleDetail = staleProfiles.map((k) => `${k} has ${extensionVersions[k]}`).join(', ');+      // ON-DISK SKEW (Drew Owens, wiki #305, 2026-07-24): the version check above only sees CONNECTED+      // profiles - but a skew OLD ENOUGH to break the native-messaging handshake means the extension+      // NEVER connects, so a connection-based check can never fire (the host just exits: Chrome shows+      // "Native host has exited"). So ALSO read each known profile's extension version straight off disk+      // (Secure Preferences) and flag one that is installed-but-older-than-expected yet NOT connected.+      // This converts the mystery into a one-call, actionable answer.+      // Surface the host's own last exit reason if it wrote one (host-side, wiki #305). Chrome only shows+      // the user "Native host has exited"; the host now records WHY next to the discovery file.+      let hostExit = null;+      try { const he = JSON.parse(fs.readFileSync(path.join(os.homedir(), '.adom', 'bridges', 'native-browser', 'host-exit-reason.json'), 'utf8')); if (he && he.reason && (Date.now() - Date.parse(he.at || 0)) < 10 * 60 * 1000) hostExit = he; } catch {}+      const connectedKeys = new Set(live.map(([k]) => k));+      const staleOnDisk = [];+      if (EXPECTED_EXT) {+        for (const kp of knownProfiles()) {+          if (connectedKeys.has(kp.key)) continue;                 // connected path already handled above+          let st; try { st = adomExtStateForProfile(kp.browser, kp.profileDir); } catch { continue; }+          if (st && st.installed && st.version && cmpVer(st.version, EXPECTED_EXT) < 0) {+            staleOnDisk.push({ profile: kp.key, installed: st.version, expected: EXPECTED_EXT });+          }+        }+      }+      const hostHint = hostExit ? ` The native host last exited because: "${hostExit.reason}" (host v${hostExit.hostVersion || '?'}, ${hostExit.at}).` : '';+      const skewHint = staleOnDisk.length+        ? ` ⛔ VERSION SKEW (likely the cause of "Native host has exited"): ${staleOnDisk.map((x) => `${x.profile} has extension ${x.installed}`).join(', ')}, but this bridge (${VERSION}) expects extension ${EXPECTED_EXT}. An extension/host that old cannot complete the native-messaging handshake, so the host exits with no message and the SW never connects. FIX: update the extension in that profile to ${EXPECTED_EXT} (re-sync the extension/ + host/ folders per installing-the-extension), then nbrowser_dev_reload {"profile":"<profile>"}. Note: the extension line (${EXPECTED_EXT}) and the skills-package line (a 0.4.x number on the wiki page) are DIFFERENT version streams - match the extension against expectedExtensionVersion, not the pkg version.`+        : '';       return resolve({ success: true, output: JSON.stringify({         ready,         state: ready ? 'ready' : 'needs-extension',@@ -1357,14 +1382,16 @@ function dispatch(verb, args) {         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)+        staleOnDisk,                            // installed-but-old extensions that are TOO old to even connect (the "native host has exited" cause)+        hostExit,                               // the native host's own last exit reason, if it recorded one (wiki #305)         unidentifiedProfiles,                   // connected but pre-hello (version unknown yet); resolves on next hello         chromeInstalled: !!chExe,               // is native Google Chrome present on the box?         chromeInstallPhase: chExe ? 'ready' : chromeState.phase,  // idle|installing|awaiting_uac|ready|failed         _hint: extensionStale-          ? `STALE EXTENSION: ${staleDetail}; this bridge expects ${EXPECTED_EXT}. New verbs (wait_for/keepalive/download_wait/set_date/totp/robust-click) will return "verb not implemented yet" on the stale profile(s). SELF-HEAL: (1) refresh the unpacked extension dir to the current build (installing-the-extension -> Updating, or re-sync the extension/ folder), then (2) nbrowser_dev_reload {"profile":"<staleProfile>"} - it re-reads disk in 2 passes and reconnects in ~10s. If the on-disk build is ALREADY current, step (2) alone heals it.`+          ? `STALE EXTENSION: ${staleDetail}; this bridge expects ${EXPECTED_EXT}. New verbs (wait_for/keepalive/download_wait/set_date/totp/robust-click) will return "verb not implemented yet" on the stale profile(s). SELF-HEAL: (1) refresh the unpacked extension dir to the current build (installing-the-extension -> Updating, or re-sync the extension/ folder), then (2) nbrowser_dev_reload {"profile":"<staleProfile>"} - it re-reads disk in 2 passes and reconnects in ~10s. If the on-disk build is ALREADY current, step (2) alone heals it.` + skewHint           : ready           ? `The Adom browser extension is connected (${live.length} profile(s)) and current (v${EXPECTED_EXT || '?'}); drive it with nbrowser_open_window. Read-only probe - it never spawns work or installs.`-          : `The Adom browser extension is NOT connected. ${chExe ? '' : 'This box has NO native Google Chrome (Edge may be present). If the user wants the AI to drive their REAL Chrome, offer nbrowser_install_chrome (installs Google Chrome, mostly no prompt). '}It may be not-yet-installed (a Level-2 proactively-offered install: OFFER to install it, then run installing-the-extension - never make them do it) OR installed-but-asleep (the SW sleeps when a profile has no open window). Full onboarding: the setting-up-native-chrome skill. Read-only: never installs or spawns.`,+          : `The Adom browser extension is NOT connected. ${chExe ? '' : 'This box has NO native Google Chrome (Edge may be present). If the user wants the AI to drive their REAL Chrome, offer nbrowser_install_chrome (installs Google Chrome, mostly no prompt). '}It may be not-yet-installed (a Level-2 proactively-offered install: OFFER to install it, then run installing-the-extension - never make them do it) OR installed-but-asleep (the SW sleeps when a profile has no open window). Full onboarding: the setting-up-native-chrome skill. Read-only: never installs or spawns.` + skewHint + hostHint,       }) });     }     // nbrowser_install_chrome - install native Google Chrome for the user (Windows). Non-blocking:
host/native-host.js+16−6
@@ -51,6 +51,19 @@ function isLiveHost(pid) { function profileSlug(key) {   return String(key || 'default').replace(/[^a-z0-9._@-]+/gi, '_').slice(0, 64) || 'default'; }+// The host talks to Chrome over a stdio pipe, so it can never show the user a message - Chrome only ever+// surfaces "Native host has exited." So on ANY deliberate exit we record WHY to a small file the bridge+// reads (nbrowser_readiness surfaces it), turning a silent code-0 death into a diagnosable reason.+// (Drew Owens, wiki #305, 2026-07-24.)+function exitWithReason(reason, code) {+  try {+    let ver = null; try { ver = require('./package.json').version; } catch {}+    fs.mkdirSync(BRIDGE_DIR, { recursive: true });+    fs.writeFileSync(path.join(BRIDGE_DIR, 'host-exit-reason.json'), JSON.stringify({ reason, hostVersion: ver, pid: process.pid, at: new Date().toISOString() }));+  } catch {}+  try { process.stderr.write(`[native-host] exiting: ${reason}\n`); } catch {}+  process.exit(code || 0);+} let singletonDone = false; function acquireSingleton(profileKey) {   if (singletonDone) return; singletonDone = true;@@ -59,8 +72,7 @@ function acquireSingleton(profileKey) {   try {     const existing = parseInt(String(fs.readFileSync(PIDFILE, 'utf8')).trim(), 10);     if (isLiveHost(existing)) {-      process.stderr.write(`[native-host] another host (pid ${existing}) already drives profile ${profileSlug(profileKey)} — exiting (singleton)\n`);-      process.exit(0);+      exitWithReason(`another host (pid ${existing}) already holds the singleton for profile ${profileSlug(profileKey)}`);     }   } catch {} // no/garbage pidfile -> we take ownership   try { fs.writeFileSync(PIDFILE, String(process.pid)); } catch {}@@ -307,12 +319,10 @@ function readHostPort() { function scheduleReconnect() {   if (!everConnected) {     if (attempts >= MAX_ATTEMPTS) {-      process.stderr.write(`[native-host] bridge unreachable after ${attempts} attempts — exiting\n`);-      process.exit(0);+      exitWithReason(`bridge unreachable after ${attempts} attempts (possible version skew: this host cannot complete the handshake with the running bridge)`);     }     if (Date.now() - startedAt > MAX_UNREACHABLE_MS) {-      process.stderr.write(`[native-host] bridge unreachable for >${MAX_UNREACHABLE_MS}ms — exiting\n`);-      process.exit(0);+      exitWithReason(`bridge unreachable for >${MAX_UNREACHABLE_MS}ms (possible version skew: this host cannot complete the handshake with the running bridge)`);     }   }   const jitter = Math.floor(Math.random() * 250);
native-browser-bridge-manifest.json+5−5
@@ -1,8 +1,8 @@ {   "manifest_version": 1,-  "version": "0.1.49",-  "url": "https://wiki.adom.inc/download/adom/adom-browser-extension/0.1.49/native-browser-bridge-v0.1.49.zip",-  "sha256": "3d9b4b0768ebe249f81460d94f3bc44289b1baa88dd96bc9f73d4db96cad4135",-  "size": 95934,-  "released_at": "2026-07-22T00:00:00Z"+  "version": "0.1.50",+  "url": "https://wiki.adom.inc/download/adom/adom-browser-extension/0.1.50/native-browser-bridge-v0.1.50.zip",+  "sha256": "6d7144fe1911f57cd40a0ea8102ee6e451dece5559f5b88d43d79e7ecaf474f8",+  "size": 97050,+  "released_at": "2026-07-24T00:00:00Z" }