← Commit history

v1.5.0: Edge launch fix (relaunch-PID grace probe) + inter-candidate lock cleanup + startup self-warm + disk-space guards with specific error codes + browser_use install real Chrome (user-scoped, no UAC) + node UAC/PATH-staleness handoff + field learnings in dev skill

John Lauer ·ac28556224 · ·parent c2824c4
9 files changed +307−34
HANDOFF-TO-ADOM-DESKTOP.md+38
@@ -143,3 +143,41 @@ analogue of why kicad/fusion (Python, no node_modules) stream fine but pup can't with our `updateManifestUrl` persisted would pull the newer source-only zip into the cache and crash. The **skills pkg is unaffected** (separate path, works). Net for now: ship the pup RUNTIME via the bundled seed (#2); treat wiki-streaming as blocked on fix (A).++## ⭐ NEW TOP PRIORITY (found LIVE 2026-07-01 on 3 fresh Windows boxes): Node install = UAC + stale PATH dead-end++Tested cold-start on AdomLapper + two genuinely fresh boxes (AdomTestWin11 vbox, winvm Azure — both+Edge-only, no Node). The **browser** side is now solved by the bridge (v1.5.0: installed-browser-first,+spawn-verified fallback, self-warm, disk guards). The remaining cold-start blockers are **both in AD-core+`desktop_install_node`** and they hard-stop the exact "AI can't recover" scenario:++1. **UAC prompt.** `desktop_install_node` installs Node via the machine-wide MSI/winget, which is+   manifested `requireAdministrator` → a **UAC dialog pops on the user's desktop** ("Node.js — OpenJS+   Foundation, allow changes?"). A cloud AI cannot click it; the user must, and John explicitly does not+   want a UAC here.+2. **Stale PATH after a *successful* install.** Even once Node installs, the already-running AD process+   keeps its OLD environment, so the very next `browser_*` STILL returns `node_not_found` /+   `could not run npm for cache bridge`. Reinstalling doesn't help (winget then says "No available upgrade"+   = already installed). The bridge can never spawn until AD is relaunched.++**Both fixed by one change — install Node the portable, user-scoped way:**+- Download the **Node ZIP** (`node-vXX-win-x64.zip`), extract `node.exe` into a user-writable dir (e.g.+  `%LOCALAPPDATA%\Adom Desktop\node\`). **No installer, no UAC.**+- Spawn bridges with that `node.exe` **by absolute path** (AD already knows where it put it) — don't rely+  on `PATH` at all. This also permanently kills the stale-PATH class of bug.+- Even better: **bundle `node.exe` in the AD installer** (~50 MB) so a fresh AD already has Node — zero+  download, zero UAC, zero PATH dependency, instant first `browser_open_window`.+- If you keep a PATH-based flow, at minimum **probe common Node locations** (`%ProgramFiles%\nodejs`,+  `%LOCALAPPDATA%\...`, nvm dirs) before declaring `node_not_found`, and refresh PATH from the registry+  after install.++**Bridge-side hint already added (SKILL.md):** if `node_not_found` persists after a successful install, the+AI is told AD's PATH is stale → relaunch AD (`desktop_relaunch_self`) rather than looping the installer.++## RESOLVED: the wiki-streamed cache crash (node_modules) is fixed in AD ≥ 1.9.55++The earlier CRITICAL section (wiki-streamed pup CACHE copy crashes with MODULE_NOT_FOUND) is **no longer+reproducing** — on AD 1.9.55–1.9.62 the wiki-cache copy spawns cleanly (AD now reconstructs node_modules+on the cache bridge). All 3 test boxes ran the cache copy of pup 1.3.0→1.5.0 with `source:"cache"`. So the+"ship runtime only via the bundled seed" constraint is lifted: the `updateManifestUrl` auto-pull works.+(Still bump the bundled seed opportunistically so a truly offline first-run is current.)
SKILL.md+5−2
@@ -26,9 +26,12 @@ When you run `browser_open_window`, one of these happens — handle each: | Response | What it means | What you do | |---|---|---| | `ok:true` / a real session | A browser (Chrome or Edge) was found and launched. | Proceed — the common case. |-| `errorCode: "node_not_found"` | Node.js isn't installed (the bridge can't even start). | Run `adom-desktop desktop_install_node '{}'`, wait, then retry. |+| `errorCode: "node_not_found"` (or `could not run npm`) | Node.js isn't on AD's PATH (the bridge can't even start). | Run `adom-desktop desktop_install_node '{}'`, wait ~1–2 min, then retry. **If it STILL says node_not_found after a successful install, AD's PATH is stale** — Node is installed but the running Adom Desktop process can't see it. Relaunch AD on that box (`adom-desktop desktop_relaunch_self '{}'`, or ask the user to quit + reopen Adom Desktop), then retry. Do NOT loop desktop_install_node — reinstalling won't fix a stale PATH. | | `errorCode: "chrome_for_testing_installing"` (`installing:true`) | **RARE** — the machine has NO Chrome AND no Edge, so pup is fetching Chrome for Testing as a fallback (~150 MB, in the background; it did NOT block). | Poll **`browser_readiness`** until `{ready:true}`, then re-issue the same `browser_open_window`. **Faster fix:** ask the user to install Chrome or Edge (Edge ships with Windows) — pup then uses it instantly, no download. Don't report failure. |-| `errorCode: "chrome_install_failed"` | No Chrome/Edge AND the CfT fallback download failed. | Read `readiness.lastError` (usually no network/proxy or low disk). Ask the user to install Chrome/Edge, or drive their real browser via native mode (below). |+| `errorCode: "chrome_install_no_disk"` | No Chrome/Edge and too little free disk to install Chrome for Testing (`readiness.diskFreeMb` shown). | Tell the user how much is free and that ~800 MB is needed; ask them to free space OR install Edge/Chrome (Edge needs ~nothing). Do NOT retry the download — it fails until disk is freed. |+| `errorCode: "chrome_install_failed"` / `chrome_download_network` / `chrome_install_permission` | No Chrome/Edge AND the CfT fallback download failed (network/proxy, permission, or other). | Read `readiness.lastError`. Ask the user to install Chrome/Edge, or drive their real browser via native mode (below). |++**Want real Google Chrome on an Edge-only box?** If the user asks for Chrome specifically, run `browser_use '{"browser":"chrome","install":true}'` — pup downloads Google's official installer and installs Chrome **user-scoped (no admin prompt in most cases)**, then pins it. Poll `browser_readiness` (`installingChrome` / `chromeStablePhase`) until `browserKind:"chrome"`. If it stalls, a UAC prompt may be waiting on the user's desktop — ask them to accept it.  ### "What do I have / is it ready?" 
adom-bridge-puppeteer-manifest.json+4−4
@@ -1,10 +1,10 @@ {   "manifest_version": 1,   "name": "puppeteer",-  "version": "1.4.0",-  "url": "https://wiki.adom.inc/download/adom/adom-desktop-puppeteer-bridge/1.4.0/adom-bridge-puppeteer-v1.4.0.zip",-  "sha256": "1d4db13428c81438b6116b1ff31b2a5602a62e34dd4d36ee6acce678e551fd48",-  "size": 134284,+  "version": "1.5.0",+  "url": "https://wiki.adom.inc/download/adom/adom-desktop-puppeteer-bridge/1.5.0/adom-bridge-puppeteer-v1.5.0.zip",+  "sha256": "ca1e79057f398d7ae00bbaf3e9d3d5f80966e98acb986ec26581e4e37f741e92",+  "size": 138480,   "verbPrefixes": [     "browser_",     "desktop_recorder_",
dev-skills/pup-bridge-dev.md+40
@@ -84,6 +84,46 @@ string. Now: - **The one thing this bridge can't do:** pre-warm before first use (it isn't running until the first   call). AD/HD must call `browser_prewarm` on embedded first-run — tracked in `HANDOFF-TO-ADOM-DESKTOP.md`. +## Field-tested cold-start learnings (v1.3–1.5, from 3 real fresh Windows boxes)++Tested on AdomLapper (all browsers), AdomTestWin11 (fresh vbox, Edge-only), winvm (fresh Azure, Edge-only).+What we learned and baked into the code:++- **Prefer the installed browser, don't download CfT.** Every Windows PC ships with Edge; most have+  Chrome. `resolveBrowser()`/`launchCandidates()` return [pinned default → Chrome → Edge → cached CfT];+  the launch loop **spawn-verifies** each and falls through on failure. A fresh isolated `--user-data-dir`+  keeps the system browser CLEAN (never touches the user's real tabs). CfT is now a genuine last resort.+- **Edge "exits immediately" was a false negative.** Edge (and some Chrome channels) spawn a LAUNCHER that+  relaunches the real browser onto a DIFFERENT PID, then the launcher exits. The old poll treated+  launcher-death as failure → killed good Edge launches. Fix: probe CDP FIRST each tick, and on+  launcher-death do a final CDP grace probe before giving up (`tryDetachedLaunch`).+- **A failed candidate poisons the shared profile dir.** An aborted Edge leaves `SingletonLock` /+  `DevToolsActivePort` in the userDataDir → the next candidate (Chrome/CfT) then also "exits immediately."+  Fix: `killChromeHoldingProfile()` (lock cleanup) BETWEEN candidates, not just between attempts.+- **Persist the chosen browser** (`pup-browser-default.json` in the CfT cache dir). `markVerified()` caches+  the browser that actually launched (auto default); `browser_use` pins one (forced default). Avoids+  re-probing every open. Forced beats auto; launch still falls back so a broken pin never hard-fails.+- **Node install has a PATH-staleness trap (AD-core, but guide the AI).** `desktop_install_node` installs+  Node, but the already-running AD process keeps its OLD PATH — so the very next `browser_*` STILL returns+  `node_not_found` / `could not run npm`. Reinstalling doesn't help. AD must be relaunched+  (`desktop_relaunch_self`) to pick up the new PATH. This is the exact "installed node, still broken"+  dead-end. Documented in SKILL.md + HANDOFF (AD should probe common node install dirs / refresh PATH, not+  rely on the stale env). winget also returns non-zero when Node is ALREADY present ("No available upgrade")+  — that's success, not failure.+- **Self-warm on spawn** (`chrome.warmup()` from `server.listen`): detect browsers + disk the moment AD+  installs/updates the bridge, so the first open is instant. Only background-fetches CfT if the box has NO+  Chrome/Edge AND no cached CfT (never on an Edge-only box — Edge is right there).+- **Disk + specific error codes.** `diskFreeMb()` gates the CfT install and the cold-start; failures map to+  `chrome_install_no_disk` / `chrome_download_network` / `chrome_install_permission` (not a generic string)+  so the AI's hint is actionable. `readiness()` surfaces `diskFreeMb`/`lowDisk`.+- **Install REAL Chrome on request.** `browser_use {browser:"chrome", install:true}` downloads Google's+  official offline installer and runs it silently; run as the (non-admin) user it installs USER-SCOPED into+  `%LOCALAPPDATA%\Google\Chrome` (no UAC), which `listSystemBrowsers()` already finds. Elevated context may+  install machine-wide + raise UAC — surfaced via `chrome_stable_not_detected` hint.+- **Verbose hints everywhere.** Every open returns `browser` (what we drove) + `availableBrowsers` ++  `defaultBrowser` + an `_hint` explaining how to switch/install. Goal: the AI resolves any browser+  situation in as few turns as possible without a human.+ ## Publishing (the wiki has TWO storage layers — don't mix them)  Full recipe in `PUBLISHING.md`. The load-bearing rules:
package.json+1−1
@@ -1,6 +1,6 @@ {   "slug": "adom-desktop-puppeteer-bridge",-  "version": "1.4.0",+  "version": "1.5.0",   "type": "app",   "description": "CONTAINER-SIDE install \u2014 the everyday `pup` user skill only. This pkg drops the `pup` skill (drive the browser bridge: open/close/tabs/screenshot/record, background-by-default) into ~/.claude/skills/ AND ~/.codex/skills/. It does NOT install the developer/publishing skills (pup-bridge-dev, pup-bridge-publish) \u2014 those are source-only; clone the repo (adom-wiki repo clone adom/adom-desktop-puppeteer-bridge) to get them from dev-skills/. It also does NOT contain the bridge runtime: the bridge (Node + Chrome for Testing) lives on the DESKTOP \u2014 bundled inside Adom Desktop and published here on the Releases tab as adom-bridge-puppeteer-v<ver>.zip, which Adom Desktop streams into its bridges-cache. TL;DR: `pkg install` = the pup user skill; dev skills = clone the source; the bridge = the Release zip + bundled in Adom Desktop.",   "keywords": [
src/BRIDGE_VERSION+1−1
@@ -1 +1 @@-1.4.0+1.5.0
src/bridge.json+2−2
@@ -2,8 +2,8 @@   "manifest_version": 1,   "name": "puppeteer",   "displayName": "Puppeteer (Chrome for Testing + Native)",-  "version": "1.4.0",-  "description": "Drives a browser ALREADY on the machine — installed Chrome, else Microsoft Edge (every Windows PC ships with Edge) — 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. 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 → Edge → CfT) and actually launches it, falling through to the next if one won't start, 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. browser_use pins a specific browser (chrome | edge | cft | auto); browser_readiness (READ-ONLY) reports which browser will be used + the full candidate list + the current default. Every open returns a verbose _hint: what was driven, what else is available, and how to switch or install full CfT. 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. v1.0.6+ optional native-drive: pass nativeBrowser+nativeProfile (from desktop_list_browsers) to browser_open_window to drive the user's REAL signed-in Chrome/Edge/Brave (their actual profile, org policies, device enrollment) — with the smart relaunch/consent flow (native_relaunch_required / native_cdp_blocked). The generic path (no nativeBrowser) always works.",+  "version": "1.5.0",+  "description": "Drives a browser ALREADY on the machine — installed Chrome, else Microsoft Edge (every Windows PC ships with Edge) — 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. 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 → Edge → 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, …). 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. v1.0.6+ optional native-drive: pass nativeBrowser+nativeProfile (from desktop_list_browsers) to browser_open_window to drive the user's REAL signed-in Chrome/Edge/Brave (their actual profile, org policies, device enrollment) — with the smart relaunch/consent flow (native_relaunch_required / native_cdp_blocked). The generic path (no nativeBrowser) always works.",   "homepage": "https://wiki.adom.inc/adom/adom-desktop",   "author": "Adom Inc.",   "license": "MIT",
src/chrome.js+163−11
@@ -55,6 +55,19 @@ function fileExists(p) {   try { return !!p && fs.statSync(p).isFile(); } catch { return false; } } +// Free disk (MB) on the volume holding `dir` (defaults to the CfT cache). Walks up+// to a path that exists (cacheDir may not exist yet on a fresh box). null if we+// can't tell (very old Node without statfsSync) — callers must treat null as "unknown",+// never as "zero", so we don't block installs on boxes where we simply can't measure.+function diskFreeMb(dir) {+  for (const d of [dir, cacheDir(), os.homedir()].filter(Boolean)) {+    try { const s = fs.statfsSync(d); return Math.floor((s.bavail * s.bsize) / (1024 * 1024)); } catch { /* try next */ }+  }+  return null;+}+// Chrome for Testing is a ~150 MB download that unpacks to ~600 MB — need headroom.+const CFT_MIN_FREE_MB = 800;+ // Puppeteer's default cache (AD sets no PUPPETEER_CACHE_DIR) → ~/.cache/puppeteer. function cacheDir() {   return process.env.PUPPETEER_CACHE_DIR || path.join(os.homedir(), '.cache', 'puppeteer');@@ -203,7 +216,7 @@ function resolveBrowser() { //   browser: 'auto' | 'chrome' | 'edge' | 'cft' | 'chrome-for-testing'  (or pass executablePath) // 'cft' installs Chrome for Testing in the background if it isn't cached, and pins it // as the default so once it lands every open uses it. Returns a pickSummary.-function setDefaultBrowser({ browser, executablePath } = {}) {+function setDefaultBrowser({ browser, executablePath, install } = {}) {   const b = String(browser || '').toLowerCase().replace(/[\s_-]/g, '');   if (executablePath) {     if (!fileExists(executablePath)) return { ok: false, errorCode: 'browser_not_found', error: `No executable at ${executablePath}.`, ...pickSummary() };@@ -216,12 +229,21 @@ function setDefaultBrowser({ browser, executablePath } = {}) {   }   if (b === 'chrome' || b === 'edge') {     const cand = listSystemBrowsers().find(c => c.kind === b);-    if (!cand) {-      const avail = listSystemBrowsers().map(c => c.kind).join(', ') || 'none';-      return { ok: false, errorCode: 'browser_not_found', error: `No installed ${b} found. Installed system browsers: ${avail}. Use browser:'cft' to fetch Chrome for Testing.`, ...pickSummary() };+    if (cand) {+      writeDefault({ kind: cand.kind, executablePath: cand.executablePath, source: 'system', forced: true, verifiedAt: Date.now() });+      return { ok: true, ...pickSummary() };     }-    writeDefault({ kind: cand.kind, executablePath: cand.executablePath, source: 'system', forced: true, verifiedAt: Date.now() });-    return { ok: true, ...pickSummary() };+    // Not installed. For Chrome we can download+install the REAL browser on request.+    if (b === 'chrome' && install) {+      writeDefault({ kind: 'chrome', executablePath: null, source: 'system', forced: true, pendingInstall: true, verifiedAt: Date.now() });+      installChromeStable(); // background; browser_use polling + readiness track it+      return { ok: true, installing: true, installingChrome: true, ...pickSummary() };+    }+    const avail = listSystemBrowsers().map(c => c.kind).join(', ') || 'none';+    const extra = b === 'chrome'+      ? " Pass {browser:'chrome', install:true} to download & install Google Chrome (~90 MB, user-scoped, no admin prompt), or use browser:'cft' / browser:'edge'."+      : " Edge ships with every Windows PC — if it's genuinely missing, use browser:'chrome' (install:true) or browser:'cft'.";+    return { ok: false, errorCode: 'browser_not_found', error: `No installed ${b} found. Installed system browsers: ${avail}.${extra}`, ...pickSummary() };   }   if (b === 'cft' || b === 'chromefortesting') {     const det = detectChrome();@@ -284,8 +306,20 @@ function installChrome({ onProgress } = {}) {         state.error = 'Could not determine which Chrome-for-Testing build to install (puppeteer.executablePath() gave no build id and resolveBuildId failed — network or a broken puppeteer install).';         return { ok: false, errorCode: state.errorCode, error: state.error };       }+      // Disk-space pre-flight — a CfT download that runs the volume out of space+      // fails deep in the unzip with a cryptic error. Catch it up front with an+      // actionable hint. null free (can't measure) → proceed (don't block).+      const freeMb = diskFreeMb(cacheDir());+      if (freeMb != null && freeMb < CFT_MIN_FREE_MB) {+        state.phase = 'failed';+        state.errorCode = 'chrome_install_no_disk';+        state.error = `Not enough free disk to install Chrome for Testing: ${freeMb} MB free on the drive holding ${cacheDir()}, need ~${CFT_MIN_FREE_MB} MB (150 MB download unpacks to ~600 MB). Free up space, or just install/use Chrome or Edge (Edge already ships with Windows) — that needs no download.`;+        console.error(`[chrome] ${state.error}`);+        return { ok: false, errorCode: state.errorCode, error: state.error, diskFreeMb: freeMb };+      }+       state.buildId = buildId;-      console.log(`[chrome] installing Chrome for Testing ${buildId} (${platform}) into ${cacheDir()} ...`);+      console.log(`[chrome] installing Chrome for Testing ${buildId} (${platform}) into ${cacheDir()} (disk free: ${freeMb == null ? 'unknown' : freeMb + ' MB'}) ...`);        const installed = await install({         browser: Browser.CHROME,@@ -316,10 +350,19 @@ function installChrome({ onProgress } = {}) {       return { ok: true, executablePath: ep, buildId };     } catch (e) {       state.phase = 'failed';-      state.errorCode = state.errorCode || 'chrome_download_failed';-      state.error = (e && e.message) || String(e);+      const msg = (e && e.message) || String(e);+      // Map the common low-level failures to actionable codes so the AI's hint is specific.+      if (!state.errorCode) {+        if (/ENOSPC|no space left|not enough space/i.test(msg)) state.errorCode = 'chrome_install_no_disk';+        else if (/EACCES|EPERM|permission/i.test(msg))          state.errorCode = 'chrome_install_permission';+        else if (/ENOTFOUND|ETIMEDOUT|ECONNRESET|network|getaddrinfo|proxy|tunneling/i.test(msg)) state.errorCode = 'chrome_download_network';+        else state.errorCode = 'chrome_download_failed';+      }+      state.error = state.errorCode === 'chrome_install_no_disk'+        ? `Ran out of disk installing Chrome for Testing (${diskFreeMb(cacheDir())} MB free). Free up space, or use Chrome/Edge (no download). Raw: ${msg}`+        : msg;       state.finishedAt = Date.now();-      console.error(`[chrome] install failed: ${state.error}`);+      console.error(`[chrome] install failed (${state.errorCode}): ${state.error}`);       return { ok: false, errorCode: state.errorCode, error: state.error };     } finally {       _installInFlight = null;@@ -393,12 +436,121 @@ function readiness() {     lastError: state.error,     lastErrorCode: state.errorCode,     cacheDir: cacheDir(),+    diskFreeMb: diskFreeMb(),          // free space on the CfT-cache volume (null = unmeasurable)+    lowDisk: (() => { const f = diskFreeMb(); return f != null && f < CFT_MIN_FREE_MB; })(),+    installingChrome: !!_chromeStableInFlight,   // a real-Google-Chrome install is in flight+    chromeStablePhase: chromeStableState.phase,+    chromeStableError: chromeStableState.error,     puppeteerLoadError: _puppeteerLoadError,   }; } +// ── Real Google Chrome (stable) installer ─────────────────────────────────────+// When the user explicitly wants CHROME (not CfT) on an Edge-only box, fetch Google's+// official offline installer and run it silently. Run as the (non-admin) user, Chrome's+// installer drops a USER-SCOPED install into %LOCALAPPDATA%\Google\Chrome — no UAC —+// which listSystemBrowsers() already finds. An elevated/admin context may install+// machine-wide and could raise a UAC prompt on the desktop (surfaced via the hint).+const chromeStableState = { phase: 'idle', error: null, errorCode: null, startedAt: null, finishedAt: null };+let _chromeStableInFlight = null;+const CHROME_STABLE_URL = 'https://dl.google.com/chrome/install/standalonesetup64.exe';++function _download(url, destPath, redirects = 0) {+  return new Promise((resolve, reject) => {+    if (redirects > 5) return reject(new Error('too many redirects fetching the Chrome installer'));+    const https = require('https');+    const file = fs.createWriteStream(destPath);+    https.get(url, (resp) => {+      if ([301, 302, 303, 307, 308].includes(resp.statusCode) && resp.headers.location) {+        file.close(); try { fs.unlinkSync(destPath); } catch {}+        return resolve(_download(resp.headers.location, destPath, redirects + 1));+      }+      if (resp.statusCode !== 200) { file.close(); try { fs.unlinkSync(destPath); } catch {} return reject(new Error(`HTTP ${resp.statusCode} fetching ${url}`)); }+      resp.pipe(file);+      file.on('finish', () => file.close(() => resolve(destPath)));+      file.on('error', (e) => { try { fs.unlinkSync(destPath); } catch {} reject(e); });+    }).on('error', (e) => { try { fs.unlinkSync(destPath); } catch {} reject(e); });+  });+}++function installChromeStable() {+  if (_chromeStableInFlight) return _chromeStableInFlight;+  _chromeStableInFlight = (async () => {+    chromeStableState.phase = 'installing'; chromeStableState.error = null; chromeStableState.errorCode = null;+    chromeStableState.startedAt = Date.now(); chromeStableState.finishedAt = null;+    try {+      const pre = listSystemBrowsers().find(x => x.kind === 'chrome');+      if (pre) { chromeStableState.phase = 'ready'; chromeStableState.finishedAt = Date.now(); return { ok: true, executablePath: pre.executablePath, alreadyInstalled: true }; }+      if (process.platform !== 'win32') {+        chromeStableState.phase = 'failed'; chromeStableState.errorCode = 'chrome_stable_unsupported_os';+        chromeStableState.error = 'Auto-installing Google Chrome is only wired up for Windows.';+        return { ok: false, errorCode: chromeStableState.errorCode, error: chromeStableState.error };+      }+      const freeMb = diskFreeMb();+      if (freeMb != null && freeMb < 500) {+        chromeStableState.phase = 'failed'; chromeStableState.errorCode = 'chrome_install_no_disk';+        chromeStableState.error = `Not enough disk to install Chrome: ${freeMb} MB free, need ~500 MB.`;+        return { ok: false, errorCode: chromeStableState.errorCode, error: chromeStableState.error, diskFreeMb: freeMb };+      }+      const tmp = path.join(os.tmpdir(), `chrome_installer_${process.pid}.exe`);+      console.log(`[chrome] downloading Google Chrome installer → ${tmp}`);+      await _download(CHROME_STABLE_URL, tmp);+      console.log('[chrome] running Chrome installer silently (/silent /install) ...');+      const { spawn } = require('child_process');+      await new Promise((resolve, reject) => {+        let done = false;+        const child = spawn(tmp, ['/silent', '/install'], { windowsHide: true });+        child.on('error', (e) => { if (!done) { done = true; reject(e); } });+        child.on('exit', () => { if (!done) { done = true; resolve(); } });+        setTimeout(() => { if (!done) { done = true; resolve(); } }, 180000); // installer may background; we poll next+      });+      let found = null;+      for (let i = 0; i < 40; i++) { found = listSystemBrowsers().find(x => x.kind === 'chrome'); if (found) break; await new Promise(r => setTimeout(r, 1500)); }+      try { fs.unlinkSync(tmp); } catch {}+      if (!found) {+        chromeStableState.phase = 'failed'; chromeStableState.errorCode = 'chrome_stable_not_detected';+        chromeStableState.error = 'Ran the Chrome installer but chrome.exe did not appear. A Windows UAC/elevation prompt may be waiting on the user\'s desktop — ask them to accept it — or the install was blocked. pup still works with Edge in the meantime.';+        return { ok: false, errorCode: chromeStableState.errorCode, error: chromeStableState.error };+      }+      chromeStableState.phase = 'ready'; chromeStableState.finishedAt = Date.now();+      console.log(`[chrome] Google Chrome installed at ${found.executablePath}`);+      return { ok: true, executablePath: found.executablePath };+    } catch (e) {+      chromeStableState.phase = 'failed';+      const msg = (e && e.message) || String(e);+      chromeStableState.errorCode = /ENOSPC|space/i.test(msg) ? 'chrome_install_no_disk'+        : /ENOTFOUND|ETIMEDOUT|ECONNRESET|network|proxy|getaddrinfo/i.test(msg) ? 'chrome_download_network'+        : 'chrome_stable_install_failed';+      chromeStableState.error = msg;+      console.error(`[chrome] Chrome stable install failed (${chromeStableState.errorCode}): ${msg}`);+      return { ok: false, errorCode: chromeStableState.errorCode, error: msg };+    } finally { _chromeStableInFlight = null; }+  })();+  return _chromeStableInFlight;+}+function chromeStableStatus() { return { ...chromeStableState, inFlight: !!_chromeStableInFlight }; }++// ── Startup self-warm ─────────────────────────────────────────────────────────+// Called once when the bridge process boots (server.js). Cheap browser detection ++// disk logging so the first open is instant. If the box has NO Chrome/Edge AND no+// cached CfT — the only case that would otherwise force a mid-open download — start+// fetching CfT NOW in the background (guarded on disk) so the user doesn't wait later.+function warmup() {+  try {+    const cands = launchCandidates();+    const free = diskFreeMb();+    console.log(`[warmup] browsers: ${cands.map(c => `${c.kind}(${c.source})`).join(', ') || 'NONE'} | disk free: ${free == null ? '?' : free + 'MB'}`);+    if (!cands.length) {+      if (free != null && free < CFT_MIN_FREE_MB) { console.log(`[warmup] no browser + low disk (${free}MB) — NOT auto-fetching CfT; readiness reports lowDisk`); return; }+      console.log('[warmup] no Chrome/Edge and no cached CfT — background-fetching Chrome for Testing now so the first open is instant');+      ensureChromeReady({ background: true });+    }+  } catch (e) { console.error(`[warmup] ${e && e.message}`); }+}+ module.exports = {   detectChrome, detectSystemBrowser, listSystemBrowsers, launchCandidates, resolveBrowser,-  installChrome, ensureChromeReady, readiness, expectedBuildId, cacheDir,+  installChrome, ensureChromeReady, readiness, expectedBuildId, cacheDir, diskFreeMb,   setDefaultBrowser, readDefault, clearDefault, markVerified, pickSummary,+  installChromeStable, chromeStableStatus, warmup, };
src/server.js+53−13
@@ -1544,25 +1544,40 @@ async function getOrLaunchBrowser(profileName, { freshProfile = false, strictPer       });       child.unref(); -      // Wait for Chrome to start and CDP to be ready (30s — Chrome cold start can be slow)+      // Wait for the browser to start and CDP to be ready (30s — cold start can be slow).+      // IMPORTANT (Edge + some Chrome channels): the process we spawned is often just a+      // LAUNCHER that relaunches the real browser onto a DIFFERENT PID and then exits. So+      // we probe CDP FIRST every tick, and on launcher-death we give CDP one last chance+      // before declaring failure — otherwise a perfectly-good Edge reads as "exited+      // immediately". (The old order killed Edge launches dead.)       let connected = false;       let childDied = false;       for (let i = 0; i < 60; i++) {         await new Promise(r => setTimeout(r, 500));-        // Did the child process die unexpectedly?+        // CDP up? Then we're connected regardless of which PID now hosts the browser.+        try {+          const resp = await fetch(`http://127.0.0.1:${debugPort}/json/version`);+          if (resp.ok) { connected = true; break; }+        } catch {}+        // Is the process we spawned still alive?+        let alive = true;         try {           if (process.platform === 'win32') {             const out = execSync(`tasklist /FI "PID eq ${child.pid}" /NH /FO CSV`, { encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'] });-            if (!out.includes(`"${child.pid}"`)) { childDied = true; break; }+            alive = out.includes(`"${child.pid}"`);           } else {             process.kill(child.pid, 0); // throws if dead           }-        } catch { childDied = true; break; }--        try {-          const resp = await fetch(`http://127.0.0.1:${debugPort}/json/version`);-          if (resp.ok) { connected = true; break; }-        } catch {}+        } catch { alive = false; }+        if (!alive) {+          // Launcher exited — the browser may be up on another PID. Final CDP grace probe.+          await new Promise(r => setTimeout(r, 700));+          try {+            const resp = await fetch(`http://127.0.0.1:${debugPort}/json/version`);+            if (resp.ok) { connected = true; break; }+          } catch {}+          childDied = true; break;+        }       }        if (!connected) {@@ -1619,7 +1634,13 @@ async function getOrLaunchBrowser(profileName, { freshProfile = false, strictPer       }       if (browser) break;       triedList.push({ kind: cand.kind, source: cand.source, executablePath: cand.executablePath || null, error: lastErr && lastErr.message });-      if (launchList.length > 1) console.log(`[launch] ${cand.kind}(${cand.source}) exhausted ${MAX_ATTEMPTS} attempts — trying next browser`);+      if (launchList.length > 1) {+        console.log(`[launch] ${cand.kind}(${cand.source}) exhausted ${MAX_ATTEMPTS} attempts — trying next browser`);+        // A failed candidate (e.g. an Edge that aborted) can leave SingletonLock /+        // DevToolsActivePort in the shared userDataDir that would poison the NEXT+        // browser. Clear them before the next candidate so the fallback is clean.+        await killChromeHoldingProfile();+      }     }      if (browser && chosen) {@@ -3027,7 +3048,7 @@ const server = http.createServer(async (req, res) => {             V('desktop_record_stop', 'Stop the desktop screen recording; returns the file.', {}, 30, {}),             V('browser_readiness', 'Readiness + which browser pup will drive (installed Chrome/Edge, or cached Chrome for Testing) + the full candidate list and current default. READ-ONLY; never downloads.', {}, 10, {}),             V('browser_prewarm', 'Install Chrome for Testing WITHOUT opening a window, and pin it as the default. Use when you specifically want CfT rather than the machine\'s installed Chrome/Edge.', { wait: 'optional bool — block until installed', setDefault: 'optional bool (default true) — pin CfT as the default' }, 240, {}),-            V('browser_use', 'Pin (or clear) the browser pup drives by default: chrome | edge | cft | auto (or an explicit executablePath). "cft" fetches Chrome for Testing then pins it. Persists across restarts; launch still spawn-verifies + falls back.', { browser: '"chrome" | "edge" | "cft" | "auto"', executablePath: 'optional — pin an exact browser binary' }, 30, {}),+            V('browser_use', 'Pin (or clear) the browser pup drives by default: chrome | edge | cft | auto (or an explicit executablePath). "cft" fetches Chrome for Testing then pins it. With {browser:"chrome", install:true} it DOWNLOADS & INSTALLS real Google Chrome (user-scoped, no admin) on an Edge-only box, then pins it. Persists across restarts; launch still spawn-verifies + falls back.', { browser: '"chrome" | "edge" | "cft" | "auto"', install: 'optional bool — with browser:"chrome", install real Chrome if missing', executablePath: 'optional — pin an exact browser binary' }, 240, {}),             V('browser_describe', 'This list — the bridge\'s verbs + schemas, for AD\'s Verbs tab.', {}, 10, {}),           ] });           return;@@ -3065,7 +3086,19 @@ const server = http.createServer(async (req, res) => {                 });                 return;               }-              // No Chrome/Edge AND no cached CfT — fetch CfT as a last resort, in the background.+              // No Chrome/Edge AND no cached CfT. If the disk is too low to hold CfT,+              // don't kick off a doomed 150 MB download — fail fast with a disk hint.+              if (rd.lowDisk) {+                sendJSON(res, {+                  success: false,+                  errorCode: 'chrome_install_no_disk',+                  readiness: rd,+                  error: `No Chrome/Edge on this machine and only ${rd.diskFreeMb} MB free — not enough to install Chrome for Testing (~800 MB needed).`,+                  _hint: `This box has no Chrome or Edge, and only ${rd.diskFreeMb} MB free disk — too low to download Chrome for Testing (~150 MB download, ~600 MB unpacked). Ask the user to free up disk space, then retry; OR install Edge/Chrome (Edge ships with Windows and needs almost nothing). Do NOT keep retrying the download — it will fail until disk is freed.`,+                });+                return;+              }+              // Otherwise fetch CfT as a last resort, in the background.               if (!rd.installing) chrome.ensureChromeReady({ background: true });               sendJSON(res, {                 success: false,@@ -4224,7 +4257,7 @@ const server = http.createServer(async (req, res) => {           // for Testing in the background if not cached, then pins it. Persists across           // bridge restarts. The launch path still spawn-verifies + falls back, so a           // pinned browser that later breaks won't hard-fail an open.-          const result = chrome.setDefaultBrowser({ browser: args && args.browser, executablePath: args && args.executablePath });+          const result = chrome.setDefaultBrowser({ browser: args && args.browser, executablePath: args && args.executablePath, install: !!(args && args.install) });           const rd = chrome.readiness();           const availTxt = (result.candidates || []).map(c => `${c.kind}(${c.source})`).join(', ') || 'none';           let hint;@@ -4232,6 +4265,8 @@ const server = http.createServer(async (req, res) => {             hint = `${result.error} Available on this machine: ${availTxt}. Use browser_use {browser:"chrome"|"edge"|"cft"|"auto"}.`;           } 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) {+            hint = `Downloading & installing REAL Google Chrome (~90 MB, user-scoped — no admin prompt in most cases) and pinning it as the default. Poll browser_readiness: watch installingChrome/chromeStablePhase until Chrome appears (browserKind:"chrome", source:"system"). Until then pup uses ${availTxt}. If it stalls, a UAC prompt may be waiting on the user's desktop — ask them to accept it.`;           } else if (result.installing) {             hint = `Pinned Chrome for Testing as the default and started the ~150 MB download in the background. Poll browser_readiness until ready:true — until then pup falls back to installed Chrome/Edge (${availTxt}). Once cached, every open uses CfT.`;           } else {@@ -4694,6 +4729,11 @@ server.listen(PORT, async () => {   console.log(`Puppeteer Bridge running on http://localhost:${PORT}`);   console.log('Multi-session support: sessions are tabs sharing Chrome profiles via userDataDir');   console.log('Endpoints: /health, /command, /launch, /navigate, /reload, /screenshot, /screenshot.png, /errors, /eval, /wait, /status, /close');+  // Self-warm: detect browsers + disk the moment the bridge spawns (right after AD+  // installs/updates it), so the FIRST open is instant. Only fetches CfT if the box+  // has literally no Chrome/Edge and no cached CfT — otherwise it's a cheap no-op.+  try { chrome.warmup(); } catch (e) { console.error('warmup failed:', e.message); }+   // Recover sessions from disk (reconnect to Chrome windows that survived bridge restart)   try {     await recoverSessions();