← Commit history

Reconcile the repo/release divergence (issue #19): merge Kyle's ten unreleased macOS fixes onto the true 1.9.90 tree. Takes his win32 self-guards on the three powershell.exe spawn primitives (removes a latent mac trap), the desktop_taskbar mac-gating rationale, platform-accurate describe hints for browser_wiki_set_view / browser_alert_window / browser_close / browser_configure, browser_configure now reporting platform + taskbarSupported, and the mac-aware Chrome .dmg install hint (no UAC/awaiting_uac phase on darwin). Reconciles his local _isWin against the module-level one added in 1.9.89. Also fixes bridge.json platforms.macos.supported, which had declared FALSE since the macOS port actually shipped in 1.9.74 and is the machine-read manifest a host reads to decide platform support. All 1.9.86-1.9.90 work verified intact (reap fix, wiki auto-sign, native-first).

John Lauer ·a8e48491cf ·3mo ago ·parent 08c06ff
5 files changed +275−134
adom-bridge-puppeteer-manifest.json+4−4
@@ -1,10 +1,10 @@ {   "manifest_version": 1,   "name": "puppeteer",-  "version": "1.9.90",-  "url": "https://wiki.adom.inc/download/adom/adom-desktop-puppeteer-bridge/1.9.90/adom-bridge-puppeteer-v1.9.90.zip",-  "sha256": "08053d442ee892424e9becffc40aedb25ab47602b8098c488c9457d959f67259",-  "size": 363073,+  "version": "1.9.91",+  "url": "https://wiki.adom.inc/download/adom/adom-desktop-puppeteer-bridge/1.9.91/adom-bridge-puppeteer-v1.9.91.zip",+  "sha256": "3b7a335d283e42084d2bbaa968957dd681783e2e80f54e7aa013ba624645dc7c",+  "size": 365406,   "verbPrefixes": [     "browser_",     "desktop_recorder_",
src/BRIDGE_VERSION+1−1
@@ -1 +1 @@-1.9.90+1.9.91
src/bridge.json+4−3
@@ -2,7 +2,7 @@   "manifest_version": 1,   "name": "puppeteer",   "displayName": "Puppeteer (Chrome for Testing + Native)",-  "version": "1.9.90",+  "version": "1.9.91",   "description": "Drives a browser ALREADY on the machine \u2014 installed Chrome, else Microsoft Edge (every Windows PC ships with Edge) \u2014 launched with a FRESH isolated profile, so the common case has NO ~150 MB Chrome-for-Testing download and it never touches the user's real tabs/logins. Binds its local server to LOOPBACK (127.0.0.1) only, so no Windows Firewall 'allow Node through the firewall' prompt ever pops. Chrome for Testing is fetched only as a LAST RESORT if the machine has no Chromium browser at all. SPAWN-VERIFIED fallback: pup tries each detected browser (Chrome \u2192 Edge \u2192 CfT) and actually launches it, falling through to the next if one won't start (and handling browsers like Edge that relaunch onto another PID), so a corrupt/partial install never hard-fails an open. The browser that launches is cached as the default (persisted across restarts) so it isn't re-probed each time. On spawn the bridge SELF-WARMS: detects browsers + disk, and only if the box has no Chrome/Edge and no cached CfT does it background-fetch CfT so the first open is instant. browser_use pins a specific browser (chrome | edge | cft | auto); {browser:'chrome',install:true} downloads & installs REAL Google Chrome (user-scoped, no admin) on an Edge-only box. browser_readiness (READ-ONLY) reports which browser will be used + the full candidate list + the default + free disk. Every failure returns a specific errorCode + _hint (chrome_install_no_disk, chrome_download_network, chrome_install_permission, chrome_stable_not_detected, \u2026). Launches with FULL browser permissions by default for full debug (strictPermissions:true opts out). Headful automation: open/close windows+tabs, navigate, screenshot, eval JS, record. pup is a GENERIC, non-profile browser only \u2014 every session is a fresh isolated Chrome/Edge process, never the user's real signed-in browser; profile defaults to sessionId. To drive the user's REAL Chrome/Edge (their profile, cookies, SSO), use the Adom browser EXTENSION's nbrowser_* verbs \u2014 a separate bridge, not pup's job. macOS (v1.9.74+): full parity with mac-native mechanics \u2014 background-by-default is the app-level hide (windows stay drivable/screenshottable while hidden), raise/lower map to unhide+activate/hide, browser_alert_window posts a native toast whose click focuses the window, browser_use {browser:'chrome',install:true} installs Chrome from the official dmg, and taskbar identity/badging remains Windows-only.",   "homepage": "https://wiki.adom.inc/adom/adom-desktop",   "author": "Adom Inc.",@@ -95,8 +95,9 @@       "supported": true     },     "macos": {-      "supported": false,-      "reason": "macOS port pending \u2014 puppeteer-core is cross-platform but the bridge's spawn config uses Windows-only killImageName 'node.exe' and Chrome for Testing path resolution hasn't been tested."+      "supported": true,+      "since": "1.9.74",+      "notes": "macOS is supported and shipping. Chrome for Testing path resolution, the .dmg Chrome install path, and process teardown are implemented for darwin. Windows-only surfaces (taskbar buttons/badges, jump lists, window-identity stamping) are self-gated and simply inert on macOS; browser_alert_window uses a toast-and-unhide equivalent there. Verbs report platform + taskbarSupported so a caller branches on those, never on prose."     },     "linux": {       "supported": false,
src/chrome.js+32−43
@@ -113,26 +113,16 @@ function detectChrome() { // Testing download. listSystemBrowsers probes the standard install locations and // returns EVERY drivable system browser it finds, in preference order (Chrome, then Edge). function listSystemBrowsers() {-  // v1.9.86: the Windows candidates are built ONLY on win32. They were previously built-  // on every platform and relied on fileExists() to drop them — safe, but safe by the-  // CONSUMER's behaviour rather than by the value's, and the values themselves were a-  // trap: on macOS `path.join('C:\\Program Files', 'Google', ...)` yields-  // "C:\Program Files/Google/..." which is NOT ABSOLUTE, so it was probed at-  // <cwd>/C:\Program Files/... — a path that could in principle exist and become a-  // wrong-but-real answer. Same class as an `|| ''` default resolving against cwd.-  // Gating construction removes the class outright (a Windows path can never match on-  // mac anyway) and drops 5 pointless syscalls per call. Windows behaviour is unchanged.-  const IS_WIN = process.platform === 'win32';   const pf   = process.env['PROGRAMFILES']      || 'C:\\Program Files';   const pf86 = process.env['PROGRAMFILES(X86)'] || 'C:\\Program Files (x86)';   const lad  = process.env['LOCALAPPDATA']      || '';   const j = (base, ...rest) => (base ? path.join(base, ...rest) : null);-  const chromePaths = !IS_WIN ? [] : [+  const chromePaths = [     j(pf,   'Google', 'Chrome', 'Application', 'chrome.exe'),     j(pf86, 'Google', 'Chrome', 'Application', 'chrome.exe'),     j(lad,  'Google', 'Chrome', 'Application', 'chrome.exe'),   ].filter(Boolean);-  const edgePaths = !IS_WIN ? [] : [+  const edgePaths = [     j(pf86, 'Microsoft', 'Edge', 'Application', 'msedge.exe'),     j(pf,   'Microsoft', 'Edge', 'Application', 'msedge.exe'),   ].filter(Boolean);@@ -189,17 +179,22 @@ function markVerified(cand) { // Each entry is a fully-launchable {kind, executablePath, source}. The launch path // tries them top-to-bottom and spawn-verifies each, so a broken top pick falls through. function launchCandidates() {-  // v1.8.71 (wiki issue #202 + the Aditya/Google-auth incident): cached CfT comes FIRST.-  // CfT is the automation-first binary with NO profile/sync/sign-in machinery — the branded-  // browsers' identity features bleed into pup's "anonymous" windows (sign-in → "create a-  // profile?" → duplicate profiles → consolidation prompts → lost user). System browsers are-  // the zero-download fallback for a fresh box; a fallback launch triggers a background CfT-  // prewarm (server.js) so every box converges to CfT by its second session without ever-  // stalling a first open. browser_use still pins either direction explicitly.+  // v1.9.90 (John's call, verified against this code): NATIVE-FIRST. The installed browser comes+  // FIRST — installed Chrome, else Edge (ships on every Windows PC) — driven with a FRESH+  // --user-data-dir AND the noBrowserIdentityFlags (server.js) that kill the sign-in / "set up a+  // work profile" merge dialog at launch. Those flags + the isolated profile make a native browser+  // EXACTLY as clean as CfT: the old v1.8.71 "CfT-first because the branded browsers' identity+  // bleeds in" reason is DEAD — I proved (and my own v1.8.95 comment admits) CfT shows the SAME+  // dialog, and the flags that suppress it apply to ANY Chromium. Native is the user's battle-tested,+  // auto-updated build with NO 150MB download and NO fleet dep-bump to move it, and every taskbar+  // icon/identity override works on it identically (OS-window level, browser-agnostic).+  // Chrome-for-Testing is the GENUINE LAST RESORT now — a box with no installed Chromium at all+  // (e.g. a Mac with no Chrome). browser_use {browser:"cft"} still pins CfT explicitly for the one+  // case it earns its weight: a managed/enterprise box whose policies interfere with automation.   const all = [];+  all.push(...listSystemBrowsers());        // installed Chrome, then Edge — FIRST (zero download, already isolated)   const cft = detectChrome();-  if (cft.installed) all.push({ kind: 'chrome-for-testing', executablePath: cft.executablePath, source: 'cache', buildId: cft.buildId });-  all.push(...listSystemBrowsers());+  if (cft.installed) all.push({ kind: 'chrome-for-testing', executablePath: cft.executablePath, source: 'cache', buildId: cft.buildId }); // LAST resort    const def = readDefault();   if (def) {@@ -607,16 +602,8 @@ async function _resolveVerifiedAdBase() { //   opts.noToken → internal: retry unattributed after a stale-token 403. // Sends the spawn-time ADOM_BRIDGE_TOKEN as X-Adom-Bridge-Token for Activity-Log // attribution (OPTIONAL — a stale token 403s, so we retry once without it).-// v1.9.86: DROP both cached bases so the next adCommand re-discovers from scratch.-// Required because AD SELF-UPDATES and restarts, which rotates its direct-API port —-// a cached base then points at a dead port and every AD call fails silently forever-// (verified fleet incident: a frozen port hammered for hours). A fallback chain needs-// BOTH a live leg AND a re-discovery trigger; the port file is the live leg, this is-// the trigger.-function _invalidateAdBase() { _adApiBase = undefined; _adVerifiedBase = undefined; }- function adCommand(command, args, opts = {}) {-  const { target, timeoutMs = 8000, noToken = false, _rediscovered = false } = opts;+  const { target, timeoutMs = 8000, noToken = false } = opts;   return new Promise(async (resolve) => {     let base;     // v1.9.75: prefer the VERIFIED-AD base (see _resolveVerifiedAdBase) so an HD-spawned@@ -643,16 +630,8 @@ function adCommand(command, args, opts = {}) {           });         }       );-      // Connection-level failure (dead port after an AD restart, refused, unreachable):-      // invalidate the cached base and retry ONCE with fresh discovery. _rediscovered-      // bounds it to a single extra attempt so a genuinely-absent AD still fails fast.-      const _retryOnce = () => {-        if (_rediscovered) return resolve(null);-        _invalidateAdBase();-        resolve(adCommand(command, args, { ...opts, _rediscovered: true }));-      };-      req.on('error', _retryOnce);-      req.setTimeout(timeoutMs, () => { try { req.destroy(); } catch {} _retryOnce(); });+      req.on('error', () => resolve(null));+      req.setTimeout(timeoutMs, () => { try { req.destroy(); } catch {}; resolve(null); });       req.write(body); req.end();     } catch { resolve(null); }   });@@ -967,12 +946,22 @@ function warmup() {     const free = diskFreeMb();     const cft = detectChrome();     console.log(`[warmup] browsers: ${cands.map(c => `${c.kind}(${c.source})`).join(', ') || 'NONE'} | cft cached: ${cft.installed} | disk free: ${free == null ? '?' : free + 'MB'}`);-    if (cft.installed) return; // pup's browser is ready — nothing to do+    if (cft.installed) return; // already cached — nothing to do+    // v1.9.90 NATIVE-FIRST: only fetch CfT when the box has NO installed Chromium at all. If a native+    // Chrome or Edge is present (Edge ships on every Windows PC), pup drives THAT — no 150MB download,+    // ever. CfT is downloaded only as the genuine last resort for a Chromium-less box (e.g. a Mac with+    // no Chrome). This replaces the old "CfT on every box" doctrine that pulled 150MB even on machines+    // that already had a perfectly good, battle-tested browser.+    const hasNative = cands.some(c => c.kind === 'chrome' || c.kind === 'edge');+    if (hasNative) {+      console.log('[warmup] native Chrome/Edge present — native-first: NOT fetching Chrome for Testing (no download needed)');+      return;+    }     if (free != null && free < CFT_MIN_FREE_MB) {-      console.log(`[warmup] CfT missing + low disk (${free}MB) — NOT auto-fetching; readiness reports lowDisk and opens will surface the disk hint`);+      console.log(`[warmup] no native browser + CfT missing + low disk (${free}MB) — NOT auto-fetching; readiness reports lowDisk and opens will surface the disk hint`);       return;     }-    console.log('[warmup] Chrome for Testing (pup\'s browser) not cached — background-fetching now so the first open never waits');+    console.log('[warmup] no installed Chromium on this box — background-fetching Chrome for Testing as the last-resort browser');     ensureChromeReady({ background: true });   } catch (e) { console.error(`[warmup] ${e && e.message}`); } }
src/server.js+234−83
@@ -35,7 +35,7 @@ console.log = (...args) => {   _origConsoleLog(msg); }; -// v1.9.86 (fleet mac-porting audit): these three helpers are the ONLY powershell.exe+// v1.9.86 (fleet mac-porting audit, Kyle): these three helpers are the ONLY powershell.exe // spawn points, and they now self-guard on win32. Every caller is already gated today // (audited: 10/10 call sites), so this changes no behavior — it removes a LATENT trap. // The mirror-image of the PATH trap: a Windows call left ungated on the mac path fails@@ -1452,13 +1452,82 @@ function setupErrorCollection(pg, errorsArr) { }  // Kill a Chrome browser process forcefully by PID-// Falls back to browser.close() if PID isn't available.-// `browser` may be null when we're just cleaning up an orphaned lock file-// and have no live puppeteer handle.+// ─── Reliable renderer/orphan reaping (close leaving core-pegging renderers → froze the desktop) ──+//+// v1.9.89 — the teardown reap is now AWAITED + VERIFIED + load-tolerant. The old sweep was+// FIRE-AND-FORGET with an 8s timeout on a FULL Win32_Process enumeration, which TIMES OUT precisely+// when the box is pegged by the runaway renderers it exists to kill (measured on AdomLapper: overall+// CPU 88-98% on 16 cores, dwm starved to 38-63%, mouse frozen, forced reboot; 6 CfT procs survived a+// browser_close, 4 --type=renderer children near 100%). Three fixes: (1) push the image-name filter to+// WMI server-side (-Filter "Name='chrome.exe' OR Name='msedge.exe'") so ONLY browser rows are+// materialized — fast even at 98% CPU; (2) AWAIT the reap so close() VERIFIES nothing survives before+// returning ok; (3) return chromeProcsRemaining so the caller KNOWS the teardown completed. Still+// non-blocking: async execFile inside an awaited Promise suspends only THIS request's async chain,+// never the event loop (the v1.9.81 rule was against execFileSync/execSync on the close path).+const _isWin = process.platform === 'win32';+// Enumerate chrome/msedge {pid, cmd} matching a PowerShell -Where clause on CommandLine. The -Filter+// makes WMI return only browser processes server-side, so this stays cheap under heavy CPU load.+function _psProcs(whereClause) {+  return new Promise((resolve) => {+    if (!_isWin) { resolve([]); return; }+    const ps = `Get-CimInstance Win32_Process -Filter "Name='chrome.exe' OR Name='msedge.exe'" | Where-Object { ${whereClause} } | Select-Object ProcessId,CommandLine | ConvertTo-Json -Compress`;+    try {+      execFile('powershell.exe', ['-NoProfile', '-ExecutionPolicy', 'Bypass', '-Command', ps],+        { encoding: 'utf8', timeout: 7000, windowsHide: true, maxBuffer: 8 * 1024 * 1024 }, (err, out) => {+          if (err || !out) { resolve([]); return; }+          let parsed; try { parsed = JSON.parse(out); } catch { parsed = null; }+          const items = Array.isArray(parsed) ? parsed : (parsed ? [parsed] : []);+          resolve(items.map(i => ({ pid: i.ProcessId, cmd: i.CommandLine || '' })).filter(x => x.pid));+        });+    } catch (e) { resolve([]); }+  });+}+function _taskkillPids(pids) {+  return Promise.all([...new Set(pids || [])].map(pid => new Promise((resolve) => {+    try { execFile('taskkill', ['/F', '/T', '/PID', String(pid)], { windowsHide: true, timeout: 5000 }, () => resolve()); }+    catch (e) { resolve(); }+  })));+}+const _psLike = (p) => String(p).replace(/'/g, "''");+// Reap EVERY chrome/msedge (the parent AND any reparented --type=renderer orphan the tree-kill missed)+// still bound to this profile's userDataDir. Awaited + verified, one retry for a straggler mid-spawn.+// Profile-scoped so it can never touch the user's real browser or another session's window.+async function reapProfileChromes(profileName) {+  if (!_isWin || !profileName) return { killed: 0, remaining: 0 };+  const where = `$_.CommandLine -like '*${_psLike(path.join(PROFILES_DIR, profileName))}*'`;+  let procs = await _psProcs(where);+  const killed = procs.length;+  if (procs.length) { await _taskkillPids(procs.map(p => p.pid)); await new Promise(r => setTimeout(r, 350)); }+  let remaining = await _psProcs(where);+  if (remaining.length) { await _taskkillPids(remaining.map(p => p.pid)); await new Promise(r => setTimeout(r, 300)); remaining = await _psProcs(where); }+  if (killed || remaining.length) console.log(`[reap] profile "${profileName}": killed ${killed}, remaining ${remaining.length}`);+  return { killed, remaining: remaining.length };+}+// Reap ORPHANED pup chrome/msedge — any process under a pup-profiles dir whose profile has NO live+// session (parent AND renderer children). Live sessions are spared by PROFILE (not just parent PID),+// so a session's own renderers are never touched. Used by browser_close (all) + boot/periodic sweeps.+async function reapOrphanPupChromes() {+  if (!_isWin) return { killed: 0, remaining: 0 };+  const liveDirs = new Set();+  for (const [, s] of sessions) { if (s && s.profileName) liveDirs.add(path.join(PROFILES_DIR, s.profileName).toLowerCase()); }+  const where = `$_.CommandLine -like '*${_psLike(PROFILES_DIR)}*'`;+  const isOrphan = (p) => { const c = (p.cmd || '').toLowerCase(); for (const d of liveDirs) { if (c.includes(d)) return false; } return true; };+  let orphans = (await _psProcs(where)).filter(isOrphan);+  const killed = orphans.length;+  if (orphans.length) { await _taskkillPids(orphans.map(p => p.pid)); await new Promise(r => setTimeout(r, 350)); }+  let remaining = (await _psProcs(where)).filter(isOrphan);+  if (remaining.length) { await _taskkillPids(remaining.map(p => p.pid)); await new Promise(r => setTimeout(r, 300)); remaining = (await _psProcs(where)).filter(isOrphan); }+  if (killed || remaining.length) console.log(`[reap-orphan] killed ${killed} orphan pup process(es), remaining ${remaining.length} (spared ${liveDirs.size} live profile(s))`);+  return { killed, remaining: remaining.length };+}++// Tear down the browser for a profile and VERIFY it. Returns {killed, remaining}; remaining MUST be 0.+// Falls back to browser.close() if PID isn't available. `browser` may be null when we're just cleaning+// up an orphaned lock file and have no live puppeteer handle. async function killBrowserProcess(browser, profileName) {   if (!profileName) {     console.log('killBrowserProcess called without profileName — skipping');-    return;+    return { killed: 0, remaining: 0 };   }   let pid = null;   if (browser) {@@ -1466,10 +1535,10 @@ async function killBrowserProcess(browser, profileName) {     if (!pid) {       try { pid = browser.process()?.pid; } catch (e) { /* connected browser has no process() */ }     }-    // Try graceful close first-    try { await browser.close(); } catch (e) {-      console.log(`Graceful close failed for profile "${profileName}": ${e.message}`);-    }+    // Graceful close, but never let a wedged CDP make close() hang the whole teardown (the reap+    // below is what actually guarantees the processes die, so a slow/hung close must not block it).+    try { await Promise.race([browser.close(), new Promise(r => setTimeout(r, 3000))]); }+    catch (e) { console.log(`Graceful close failed for profile "${profileName}": ${e.message}`); }   }    // If no browser handle, try to recover PID from session files on disk@@ -1480,51 +1549,21 @@ async function killBrowserProcess(browser, profileName) {     } catch {}   } -  // Then force-kill the PID if it's still around+  // Tree-kill the tracked parent first (fast; catches the common case). Non-blocking async taskkill.   if (pid) {-    try {-      if (process.platform === 'win32') {-        execSync(`taskkill /F /T /PID ${pid}`, { stdio: 'ignore' });-      } else {-        process.kill(pid, 'SIGKILL');-      }-      console.log(`Force-killed Chrome PID ${pid} for profile "${profileName}"`);-    } catch (e) {-      // PID already gone — that's fine-    }+    if (_isWin) { await _taskkillPids([pid]); }+    else { try { process.kill(pid, 'SIGKILL'); } catch (e) {} }+    console.log(`Force-killed Chrome PID ${pid} for profile "${profileName}"`);   } -  // v1.9.78 (wiki issue #16) — REAP ORPHANED / CRASHED RENDERERS. taskkill /T kills the process-  // TREE, but a crashed renderer can be reparented (orphaned) and survive the tree kill, lingering-  // at 8-12 MB. They are unambiguous to find: any chrome/CfT process still launched with THIS-  // profile's --user-data-dir. Scoping the sweep to the profile dir makes it impossible to touch-  // the user's real browser or another session's window.-  if (process.platform === 'win32') {-    // v1.9.81 — MUST BE NON-BLOCKING. The v1.9.78 reap ran this CIM query with execFileSync (up to-    // 8s) SYNCHRONOUSLY on the close path, freezing Node's ENTIRE event loop for the duration.-    // PowerShell is flaky on some boxes (CIM can be slow; a bad profile drops PS to interactive and-    // it hits the timeout), so every window close could freeze the bridge for seconds. Repeated-    // freezes make the bridge unresponsive to AD's health check, which then restarts it ("the pup-    // bridge crashed") — self-inflicted, seen live after closing several test windows in a row.-    // Fire-and-forget async: the close returns immediately and the sweep runs in the background-    // without ever blocking the loop.-    const dir = path.join(PROFILES_DIR, profileName);-    const ps = `Get-CimInstance Win32_Process | Where-Object { $_.Name -match 'chrome|msedge' -and $_.CommandLine -match 'user-data-dir' -and $_.CommandLine -like '*${dir.replace(/'/g, "''")}*' } | ForEach-Object { $_.ProcessId }`;-    try {-      execFile('powershell.exe', ['-NoProfile', '-ExecutionPolicy', 'Bypass', '-Command', ps],-        { encoding: 'utf8', timeout: 8000, windowsHide: true }, (err, out) => {-          if (err) return; // best-effort; a timed-out/failed sweep must never affect the bridge-          const pids = (out || '').split(/\r?\n/).map(x => parseInt(x, 10)).filter(Boolean);-          if (!pids.length) return;-          for (const rp of pids) { try { execFile('taskkill', ['/F', '/PID', String(rp)], { windowsHide: true }, () => {}); } catch (e) {} }-          console.log(`[reap] profile "${profileName}": swept ${pids.length} orphaned/crashed process(es) that survived the tree kill`);-        });-    } catch (e) { /* never let scheduling the reap throw on the close path */ }-  }+  // Then the RELIABLE, AWAITED, VERIFIED profile-scoped reap. taskkill /T kills the parent's tree, but+  // a crashed/backgrounded renderer can be reparented and survive it, lingering — or pegging a full+  // core. This awaited sweep catches those and confirms none remain, which is what makes close() honest.+  const rep = await reapProfileChromes(profileName); -  // Also check if Chrome is still locking the profile dir-  const lockFile = path.join(PROFILES_DIR, profileName, 'SingletonLock');-  try { fs.unlinkSync(lockFile); } catch { /* doesn't exist or can't remove */ }+  // Remove any stale SingletonLock so the next open on this profile starts clean.+  try { fs.unlinkSync(path.join(PROFILES_DIR, profileName, 'SingletonLock')); } catch { /* absent/locked */ }+  return rep; }  // ─── Profile session-restore prevention ──────────────────────────────────@@ -2445,8 +2484,8 @@ async function dualBadgeB64(wins, tabs) { // once), never one call per verb, so a chatty thread costs 2 AD calls, not 200. const _busyState = new Map();      // sessionId -> { busy, timer } const BUSY_IDLE_MS = 2500;-// v1.9.86 — WHY THIS IS GATED, recorded precisely because the obvious reading is WRONG and would-// invite a future un-gate. desktop_taskbar is NOT unsupported on macOS: AD really calls+// v1.9.86 (Kyle) — WHY THIS IS GATED, recorded precisely because the obvious reading is WRONG and+// would invite a future un-gate. desktop_taskbar is NOT unsupported on macOS: AD really calls // NSApplication.requestUserAttention (Dock bounce) for flash and NSDockTile for badge text, and // every arg shape returns success here. The blocker is the ADDRESSING MODEL, not the capability. // macOS has no per-window taskbar button, so AD's mac path ignores titleContains/hwnd (it reports@@ -2698,6 +2737,63 @@ async function checkWikiAuth(page) {     });   } catch (e) { return { authed: false }; } }+// v1.9.88 — AUTO-SIGN the wiki from AD's stored identity, so pup NEVER makes the user hand-type a+// login into its no-memory browser. John, verbatim: "the ai calling you knows exactly who i am and+// ad knows who i am. so that's 2 potential places to get the info from and then you're asking me to+// login from scratch." Right. AD writes the user's wiki session token to ~/.adom/session.json on+// THIS desktop (the same box the bridge runs on), so pup mints a single-use browser-session magic+// link SERVER-SIDE from that token — the same endpoint an already-authed page uses, but authorized+// by the bearer token instead of a page cookie (verified: POST returns {url, expires_in} for a+// bearer with no browser) — and navigates the vault window straight to it. The human is asked for+// nothing; the shared vault profile then persists the session ~30 days. Manual /login is the+// fallback ONLY when AD itself is signed out (no token) or the session expired.+function readDesktopWikiToken() {+  try {+    const j = JSON.parse(fs.readFileSync(path.join(ADOM_STATE_DIR, 'session.json'), 'utf8'));+    if (!j || !j.session_token) return null;+    const exp = j.expires_at ? Date.parse(j.expires_at) : NaN;+    if (Number.isFinite(exp) && exp < Date.now()) return null;   // expired — force the manual path+    return { token: j.session_token, user: j.user_name || j.user_email || null };+  } catch (e) { return null; }+}+async function mintWikiMagicUrl(nextPath) {+  const tok = readDesktopWikiToken();+  if (!tok) return null;+  try {+    const r = await fetch('https://wiki.adom.inc/api/v1/auth/browser-session', {+      method: 'POST',+      headers: { 'content-type': 'application/json', 'authorization': 'Bearer ' + tok.token, 'user-agent': 'adom-pup-bridge' },+      body: JSON.stringify({ next: nextPath || '/' }),+    });+    if (!r.ok) return null;+    const j = await r.json();+    if (!j || !j.url) return null;+    // The endpoint returns ?ticket=...; the deep-link is appended client-side (same as the in-page+    // mint above + the adom-wiki CLI), else the redeem lands on the wiki ROOT and the user loses the+    // page they were on.+    const sep = j.url.includes('?') ? '&' : '?';+    return j.url + sep + 'next=' + encodeURIComponent(nextPath || '/');+  } catch (e) { return null; }+}+// Sign the given pup page into the wiki vault IN PLACE from the desktop token. Returns the user+// label on success (mint + navigate succeeded), or null to fall back to a manual login. A successful+// mint means the token was accepted (a bad token yields no url), so navigation is the real signal;+// the auth re-check is only to surface the live display name and never gates the result.+async function autoSignWikiFromDesktop(page, nextPath) {+  const magic = await mintWikiMagicUrl(nextPath);+  if (!magic) return null;+  try {+    await page.goto(magic, { waitUntil: 'domcontentloaded', timeout: 25000 });+  } catch (e) { return null; }+  let who = null;+  for (let i = 0; i < 6; i++) {+    let a = null; try { a = await checkWikiAuth(page); } catch (e) {}+    if (a && a.authed) { who = a.user; break; }+    await new Promise(r => setTimeout(r, 1000));+  }+  const tk = readDesktopWikiToken();+  return who || (tk && tk.user) || 'authed';+} // v1.8.97: gentle IN-TAB mode indicator (John: "indicate which mode that pup window is in ... // in the chrome ui in a gentle way"). Can't touch the native tab-strip empty space (browser UI, // not reachable), but the TAB TITLE is: a single leading monochrome glyph shows on the tab.@@ -5750,15 +5846,16 @@ const server = http.createServer(async (req, res) => {             sendJSON(res, { success: false, error: 'sessionId is required. Example: browser_open_window { "sessionId": "dart2", "profile": "dart2", "url": "https://..." }' });             return;           }-          // ── Cold-start gate (v1.8.72: THE DEMARCATION GATE) — READ-ONLY detect; never blocks ──-          // pup's browser is Chrome for Testing, PERIOD (the Aditya incident: a silent branded--          // Chrome fallback let a Google sign-in trigger Chrome's profile-creation/consolidation-          // machinery and strand the user). An unpinned NEW-window open therefore REQUIRES cached-          // CfT: if it isn't there yet, kick the ~150 MB install in the BACKGROUND and return the-          // pollable {installing:true} — the caller polls browser_readiness and retries. This wait-          // is rare (AD prewarns CfT at install; flip of its skip-when-branded-exists default is-          // filed) and only ever hits a box's FIRST pup use. The user's installed Chrome/Edge is-          // available ONLY by explicit pin — browser_use {browser:"chrome"|"edge"} — eyes open.+          // ── Cold-start gate (v1.9.90: NATIVE-FIRST) — READ-ONLY detect; never blocks when drivable ──+          // pup drives the user's INSTALLED browser by default — Chrome, else Edge — in a FRESH isolated+          // profile with the noBrowserIdentityFlags that kill Chrome's sign-in / "set up a work profile"+          // merge dialog at launch. That combination is exactly as clean as CfT (the Aditya incident was+          // fixed by those flags, not by the binary — CfT shows the SAME dialog without them). So this+          // gate only blocks when there is NOTHING drivable at all (no Chrome, no Edge, no cached CfT):+          // then it fetches Chrome-for-Testing in the BACKGROUND as the last resort and returns the+          // pollable {installing:true}. That case is rare (Edge ships on every Windows PC). Chrome for+          // Testing is still available by explicit pin — browser_use {browser:"cft"} — for a+          // managed/enterprise box whose policies interfere with automation.           // Existing/live sessions are exempt (their browser is already running).           {             const rd = chrome.readiness();   // detect-only; never downloads@@ -5775,6 +5872,9 @@ const server = http.createServer(async (req, res) => {             // CfT is still PREFERRED (launchCandidates orders it first, and prewarm still fetches it in the             // background) — preference is fine, blocking is not. Only gate when there is genuinely nothing             // drivable on the machine.+            // v1.9.90 NATIVE-FIRST: launchCandidates orders the installed browser FIRST, so this gate+            // only fires when there is genuinely NOTHING drivable (no Chrome, no Edge, no cached CfT).+            // When a system browser exists, pup drives it directly (fresh profile + identity flags).             const _hasSystemBrowser = (rd.candidates || []).some(c => c && c.source === 'system');             const _needsCft = !_pinnedBranded && !_cftCached && !_sessionAlive && !_hasSystemBrowser;             if (!rd.ready || _needsCft) {@@ -5784,7 +5884,7 @@ const server = http.createServer(async (req, res) => {                   errorCode: rd.lastErrorCode || 'chrome_install_failed',                   error: `No Chrome/Edge installed and the Chrome-for-Testing fallback failed to install: ${rd.lastError}`,                   readiness: rd,-                  _hint: 'Chrome for Testing (pup\'s dedicated browser) failed to install — usually no network / a proxy blocking storage.googleapis.com, or low disk. Retry with browser_prewarm once the cause is fixed. If the user explicitly accepts driving their installed Chrome/Edge instead (identity-bleed risk — see the demarcation doctrine): browser_use {browser:"chrome"|"edge"}, then retry.',+                  _hint: 'Chrome for Testing (the last-resort browser for a box with no Chrome/Edge) failed to install — usually no network / a proxy blocking storage.googleapis.com, or low disk. Retry with browser_prewarm once the cause is fixed. If this box actually HAS Chrome or Edge, pin it: browser_use {browser:"chrome"|"edge"} — pup drives it cleanly (fresh isolated profile + identity-suppression flags, no merge dialog), no download needed.',                 });                 return;               }@@ -5800,9 +5900,9 @@ const server = http.createServer(async (req, res) => {                 });                 return;               }-              // Fetch CfT in the background and tell the caller to poll+retry. This is-              // pup's dedicated browser being provisioned — NOT an error, and NOT a reason-              // to fall back to the user's branded browser.+              // No native Chrome/Edge on this box, so fetch CfT in the background as the LAST-RESORT+              // browser and tell the caller to poll+retry. (When a native browser exists we never reach+              // here — native-first drives it directly with a fresh profile + identity-suppression flags.)               if (!rd.installing) chrome.ensureChromeReady({ background: true });               sendJSON(res, {                 success: false,@@ -5810,8 +5910,8 @@ const server = http.createServer(async (req, res) => {                 installing: true,                 statusVerb: 'browser_readiness',                 readiness: rd,-                error: 'Chrome for Testing (pup\'s dedicated sandboxed browser) is not cached yet — downloading it now (~150 MB, one time).',-                _hint: 'FIRST-USE PROVISIONING (not an error): pup deliberately drives Chrome for Testing, its OWN sandboxed browser — it does NOT silently fall back to the user\'s installed Chrome/Edge, whose profile/sign-in machinery bleeds into pup\'s anonymous windows (sign-in → "create a profile?" → duplicate profiles → lost user). The download runs in the BACKGROUND: tell the user pup is provisioning its browser (~a minute or two, one time ever), poll `browser_readiness` until {ready:true}, then re-issue this browser_open_window. Only if the user EXPLICITLY wants their installed browser driven (rare; identity-bleed risk): browser_use {browser:"chrome"|"edge"} pins it, then retry.',+                error: 'This box has no installed Chrome or Edge, so pup is fetching Chrome for Testing as the last-resort browser (~150 MB, one time).',+                _hint: 'LAST-RESORT PROVISIONING (not an error): pup drives your INSTALLED Chrome or Edge by default, cleanly — a fresh isolated profile PLUS launch flags that suppress Chrome\'s sign-in / "set up a work profile" merge dialog, so there is no identity bleed. THIS box has neither Chrome nor Edge, so pup is fetching Chrome for Testing in the background as the only drivable option (~a minute or two, one time). Poll `browser_readiness` until {ready:true}, then re-issue browser_open_window. If the box actually DOES have Chrome/Edge, pin it with browser_use {browser:"chrome"|"edge"} to skip the download.',               });               return;             }@@ -5911,6 +6011,16 @@ const server = http.createServer(async (req, res) => {           if (session && isAdomUrl(args.url)) {             if (args.wikiView === 'authed') { try { _wikiAuth = await Promise.race([checkWikiAuth(session.page), new Promise(r => setTimeout(() => r({ authed: false }), 3500))]); } catch (e) {} }           }+          // v1.9.88 — authed view requested but the vault isn't signed in yet: AUTO-SIGN from AD's+          // stored desktop token BEFORE reporting wikiLoginNeeded, so the AI-driven open never makes+          // the user log in by hand either (same mechanism as the jump-list toggle).+          if (session && isAdomUrl(args.url) && args.wikiView === 'authed' && (!_wikiAuth || !_wikiAuth.authed)) {+            try {+              const _np = (() => { try { const u2 = new URL(args.url); return u2.pathname + u2.search; } catch (e) { return '/adom'; } })();+              const _who = await autoSignWikiFromDesktop(session.page, _np);+              if (_who) { _wikiAuth = { authed: true, user: _who !== 'authed' ? _who : undefined, autoSigned: true }; console.log(`[wikiview] "${sessionId}" open wikiView:authed AUTO-SIGNED from AD desktop token (${_who})`); }+            } catch (e) {}+          }           const wikiHint = wikiViewHint(args.url, args.wikiView === 'authed' ? 'authed' : 'public', _wikiAuth);           // v1.8.97: gentle in-tab glyph — 🔓 authed (logged in), 📖 public.           if (session && isAdomUrl(args.url)) {@@ -6104,7 +6214,7 @@ const server = http.createServer(async (req, res) => {               if (g === 'split') { for (const [sid, sess] of sessions) { updateTabCountBadge(sess, sid).catch(() => {}); } }             }).catch(() => {});           }-          // v1.9.86: BOTH of these settings drive Windows-only machinery — taskbar buttons,+          // v1.9.86 (Kyle): BOTH of these settings drive Windows-only machinery — taskbar buttons,           // badges and the progress bar are stamped by desktop_set_window_identity /           // desktop_taskbar, and stampPupIdentity/brandSweep early-return on non-win32. The           // setting is still stored (it's a user preference, and the profile may later be@@ -6112,8 +6222,8 @@ const server = http.createServer(async (req, res) => {           // "Applied to live windows immediately" unconditionally, so on macOS the calling AI           // was handed a confident description of something that did not happen — and per the           // SDK's first principle the AI reads this output, never the on-disk skill. Describe-          // the platform we're actually on.-          const _isWin = process.platform === 'win32';+          // the platform we're actually on. (v1.9.91 merge: reuses the module-level _isWin from+          // the v1.9.89 reap work rather than shadowing it with a local const.)           const _os = process.platform === 'darwin' ? 'macOS' : process.platform;           const _effect = _isWin             ? 'Applied to live windows immediately; persists across bridge updates (~/.adom/pup-settings.json).'@@ -6203,12 +6313,13 @@ const server = http.createServer(async (req, res) => {           unregisterPupAumid(_closingAppId).catch(() => {}); // v1.9.2/#207 cleanup (appId captured pre-delete)            // Decrement refCount; kill browser if no sessions left using this profile+          let _reap = { killed: 0, remaining: 0 };           const be = browsers.get(profileName);           if (be) {             be.refCount = Math.max(0, be.refCount - 1);             if (be.refCount <= 0) {               console.log(`No more sessions using profile "${profileName}" — killing browser`);-              await killBrowserProcess(be.browser, profileName);+              _reap = await killBrowserProcess(be.browser, profileName);               browsers.delete(profileName);             }           }@@ -6217,7 +6328,11 @@ const server = http.createServer(async (req, res) => {             activeSessionId = sessions.size > 0 ? sessions.keys().next().value : null;           }           console.log(`Session "${sessionId}" closed (all tabs). Remaining sessions: ${sessions.size}`);-          sendJSON(res, { success: true, output: JSON.stringify({ ok: true, remaining: sessions.size, activeSession: activeSessionId }) });+          sendJSON(res, { success: true, output: JSON.stringify({+            ok: true, remaining: sessions.size, activeSession: activeSessionId,+            chromeProcsKilled: _reap.killed, chromeProcsRemaining: _reap.remaining,+            ...(_reap.remaining ? { _hint: `WARNING: ${_reap.remaining} chrome/renderer process(es) for this profile SURVIVED teardown (should be 0) and may peg a CPU core. The box may be under heavy load — retry browser_close_window; if it persists it is a reap bug worth reporting.` } : {}),+          }) });           return;         } @@ -6299,16 +6414,33 @@ const server = http.createServer(async (req, res) => {                   }, nextPath);                 }                 if (!targetUrl) {-                  // v1.9.82 — FIRST-EVER LOGIN: no authenticated pup page exists to mint a magic-                  // link from. The old behaviour fell through to a relaunch under the empty shared-                  // authed profile, saw still-not-authed, and popped a "sign in in this window"-                  // toast — but LEFT THE WINDOW ON THE PUBLIC PAGE. The user (John) clicked the task,-                  // got told to sign in, and had no sign-in page to sign in on. Fix: navigate THIS-                  // window to the actual wiki sign-in page and raise it so they SEE it.-                  // /login?redirect=<path> bounces through the hydrogen SSO and returns here signed-                  // in; once signed in this window IS the logged-in view, and every other thread's-                  // toggle can then mint the magic link from it. No relaunch, tabs preserved.+                  // v1.9.88 — AUTO-SIGN from AD's stored identity BEFORE ever asking the human. No+                  // already-authed pup page exists to mint from, but AD wrote the user's wiki session+                  // token to ~/.adom/session.json on THIS desktop, so pup mints the magic link from+                  // that token server-side and navigates this window straight to it. The user is asked+                  // for nothing; the vault profile then persists it ~30 days. Manual /login is only the+                  // fallback when AD itself is signed out (no token) or the ~30-day session expired.                   const nextPath = (() => { try { const u2 = new URL(url); return u2.pathname + u2.search; } catch (e) { return "/adom"; } })();+                  const _autoWho = await autoSignWikiFromDesktop(session.page, nextPath);+                  if (_autoWho) {+                    session._wikiView = "authed";+                    try { await setWikiModeGlyph(session.page, "● Logged in"); } catch (e) {}+                    try { await setWikiAuthedState(session, sessionId, true); } catch (e) {}+                    session._wikiJumplistKey = undefined;+                    updateWikiJumplist(session, sessionId).catch(() => {});+                    updateThumbnailTooltip(session, sessionId).catch(() => {});+                    session._foreground = true;+                    try { await session.page.bringToFront(); } catch (e) {}+                    try { await osRaiseSessionWindow(session, sessionId); } catch (e) {}+                    wikiCaption(`Adom wiki: signed you in automatically${_autoWho && _autoWho !== 'authed' ? ` as ${_autoWho}` : ''} — no login needed`, 6000).catch(() => {});+                    console.log(`[wikiview] "${sessionId}" AUTO-SIGNED from AD desktop token (${_autoWho}) — no manual login`);+                    sendJSON(res, { success: true, output: JSON.stringify({+                      ok: true, view: "authed", autoSigned: true, url: session.page.url(),+                      _hint: "pup signed this window into the Adom wiki AUTOMATICALLY using the identity AD already holds on this desktop (~/.adom/session.json) — no manual login. The shared vault profile now carries the session, so every thread's authed view works and it persists ~30 days.",+                    }) });+                    return;+                  }+                  // Fall back to the manual sign-in page (AD is signed out, or the session expired).                   const loginUrl = "https://wiki.adom.inc/login?redirect=" + encodeURIComponent(nextPath);                   console.log(`[wikiview] "${sessionId}" no authed session yet — navigating THIS window to the sign-in page: ${loginUrl}`);                   await session.page.goto(loginUrl, { waitUntil: "domcontentloaded", timeout: 25000 });@@ -7944,7 +8076,7 @@ const server = http.createServer(async (req, res) => {           } else if (result.cleared) {             hint = `Default cleared → AUTO. pup will drive the first browser that launches (${availTxt}) and cache it. Next browser_open_window applies it.`;           } else if (result.installingChrome) {-            // v1.9.86: this hint was entirely Windows-shaped (UAC, awaiting_uac, "click YES"). On+            // v1.9.86 (Kyle): this hint was entirely Windows-shaped (UAC, awaiting_uac, "click YES"). On             // macOS the install is a dmg -> ditto -> /Applications (falling back to ~/Applications)             // with NO elevation step and NO awaiting_uac phase, so a Mac AI reading the old text             // would poll for a phase that never arrives and tell the user to approve a prompt that@@ -8011,12 +8143,13 @@ const server = http.createServer(async (req, res) => {             const otherSessionsOnProfile = [...sessions.entries()].filter(               ([id, s]) => id !== args.sessionId && s.profileName === profileName             );+            let _reap = { killed: 0, remaining: 0 };             try {               if (otherSessionsOnProfile.length === 0) {                 // Only this session uses this Chrome — kill the whole browser.                 const be = browsers.get(profileName);                 if (be) {-                  await killBrowserProcess(be.browser, profileName);+                  _reap = await killBrowserProcess(be.browser, profileName);                   browsers.delete(profileName);                 }               } else {@@ -8038,7 +8171,10 @@ const server = http.createServer(async (req, res) => {               sessionId: args.sessionId,               message: `Session "${args.sessionId}" closed`,               chromeKilled: otherSessionsOnProfile.length === 0,+              chromeProcsKilled: _reap.killed,+              chromeProcsRemaining: _reap.remaining,               remainingSessions: sessions.size,+              ...(_reap.remaining ? { _hint: `WARNING: ${_reap.remaining} chrome/renderer process(es) SURVIVED teardown (should be 0) and may peg a CPU core — retry if it persists.` } : {}),             })});             return;           }@@ -8059,10 +8195,17 @@ const server = http.createServer(async (req, res) => {           activeSessionId = null;           // Belt-and-braces: sweep any orphaned Adom.Pup.* keys now that nothing is live.           try { pruneStaleAumids(); } catch (e) {}+          // Final VERIFIED sweep — with no live session left, reap EVERY pup chrome/renderer still+          // alive (parent or reparented orphan). This is what makes "close all" actually leave zero+          // core-pegging processes behind, and reports the count so the caller knows it's clean.+          const _sweep = await reapOrphanPupChromes();           sendJSON(res, { success: true, output: JSON.stringify({             ok: true,             message: 'All sessions and browsers closed',             remainingSessions: 0,+            chromeProcsKilled: _sweep.killed,+            chromeProcsRemaining: _sweep.remaining,+            ...(_sweep.remaining ? { _hint: `WARNING: ${_sweep.remaining} pup chrome process(es) survived the close-all sweep (should be 0) and may peg CPU — the box may be heavily loaded; retry browser_close.` } : {}),           })});           return;         }@@ -8513,6 +8656,14 @@ server.listen(PORT, BIND_HOST, async () => {   setTimeout(() => { reclaimUnmanagedWindows().catch(() => {}); }, 12000);   setInterval(() => { reclaimUnmanagedWindows().catch(() => {}); }, 90000); +  // v1.9.89: reap ORPHANED pup chrome/renderers — any process under a pup-profiles dir whose profile+  // has no live session (a previous bridge crash left them, or renderers that outlived a close and are+  // pegging a core). Boot sweep runs AFTER recovery has settled so it never kills a window recovery+  // just reconnected; then a slow periodic net. Spares every live session BY PROFILE, so it can only+  // ever hit true orphans — never a session's own renderer children.+  setTimeout(() => { reapOrphanPupChromes().catch(() => {}); }, 15000);+  setInterval(() => { reapOrphanPupChromes().catch(() => {}); }, 180000);+   // Periodic cleanup: every 5 minutes, remove stale session files with dead PIDs   setInterval(async () => {     const files = listSessionFiles();