← Commit history

v1.4.0: spawn-verified browser fallback (Chrome->Edge->CfT, launch-tested), persisted default, new browser_use verb to pin chrome/edge/cft/auto, verbose browser hints on every open

John Lauer ·a1a6b8a53d · ·parent f068ae4
6 files changed +327−73
SKILL.md+18−4
@@ -32,11 +32,24 @@ When you run `browser_open_window`, one of these happens — handle each:  ### "What do I have / is it ready?" -- **`browser_readiness`** (this bridge, **READ-ONLY**) → `{ready, browserKind, browserSource, browserExecutablePath, …}` — tells you which browser pup will drive (`chrome` / `edge` / `chrome-for-testing`) and whether it's ready. Never triggers a download.+- **`browser_readiness`** (this bridge, **READ-ONLY**) → `{ready, browserKind, browserSource, browserExecutablePath, candidates, defaultBrowser, …}` — tells you which browser pup will drive (`chrome` / `edge` / `chrome-for-testing`), the **full candidate list** it will fall through, the current **default**, and whether it's ready. Never triggers a download. - **`adom-desktop bridge_readiness`** (AD-core) is the platform-level "is this bridge ready?" probe — use it for the general readiness question. **`browser_status` is NOT a readiness check** (it only lists open sessions).  **Always surface a verb's `_hint` verbatim** when something isn't ready. +### Picking the browser — `browser_use`++pup auto-picks the first browser that actually launches (Chrome → Edge → cached CfT) and **spawn-verifies** it — if a browser is present but won't start (corrupt install), it falls through to the next, so an open never hard-fails on a broken browser. The one that launches is **cached as the default** so it isn't re-probed each time. To override:++```bash+adom-desktop browser_use '{"browser":"chrome"}'   # pin installed Chrome+adom-desktop browser_use '{"browser":"edge"}'     # pin Edge+adom-desktop browser_use '{"browser":"cft"}'      # install Chrome for Testing (~150 MB once) + pin it+adom-desktop browser_use '{"browser":"auto"}'     # back to auto-pick+```++Every `browser_open_window` response includes `browser` (what was driven), `availableBrowsers`, `defaultBrowser`, and an `_hint` spelling out how to switch — surface it if the user asks which browser pup is using.+ ## How to Run Commands  ```bash@@ -74,9 +87,10 @@ adom-desktop browser_alert_window '{"sessionId":"job"}'      # non-intrusive "do  | Command | Description | Key args | |---|---|---|-| `browser_readiness` | Cold-start probe: is Chrome for Testing installed/installing? | none |-| `browser_prewarm` | Install Chrome for Testing WITHOUT opening a window | `wait` (optional bool) |-| `browser_open_window` | Spawn a new session/window (CfT, or native via `nativeBrowser`+`nativeProfile`) | `sessionId`, `profile`, `url` |+| `browser_readiness` | Which browser pup will drive + candidate list + default (READ-ONLY) | none |+| `browser_prewarm` | Install Chrome for Testing WITHOUT opening a window, and pin it | `wait` (optional bool) |+| `browser_use` | Pin the browser pup drives: `chrome`/`edge`/`cft`/`auto` | `browser`, `executablePath?` |+| `browser_open_window` | Spawn a new session/window (installed Chrome/Edge or CfT; native via `nativeBrowser`+`nativeProfile`) | `sessionId`, `profile`, `url` | | `browser_open_tab` | Add a tab to an existing session | `sessionId`, `url` | | `browser_navigate` | Drive an existing tab to a URL (reuses — no new window) | `sessionId`, `url`, `tabId?` | | `browser_screenshot` | Capture the page (lossless PNG) | `sessionId`, `fullPage?` |
adom-bridge-puppeteer-manifest.json+4−4
@@ -1,10 +1,10 @@ {   "manifest_version": 1,   "name": "puppeteer",-  "version": "1.3.0",-  "url": "https://wiki.adom.inc/download/adom/adom-desktop-puppeteer-bridge/1.3.0/adom-bridge-puppeteer-v1.3.0.zip",-  "sha256": "8b80afbe95e0af54dfa2db88a12ff3b71f86711dfc3b479226b6db526cdc86f6",-  "size": 128877,+  "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,   "verbPrefixes": [     "browser_",     "desktop_recorder_",
src/BRIDGE_VERSION+1−1
@@ -1 +1 @@-1.3.0\ No newline at end of file+1.4.0
src/bridge.json+3−2
@@ -2,8 +2,8 @@   "manifest_version": 1,   "name": "puppeteer",   "displayName": "Puppeteer (Chrome for Testing + Native)",-  "version": "1.3.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. browser_readiness (READ-ONLY) reports which browser will be used (browserKind/browserSource) + whether it's ready; browser_open never blocks on a download. 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.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.",   "homepage": "https://wiki.adom.inc/adom/adom-desktop",   "author": "Adom Inc.",   "license": "MIT",@@ -38,6 +38,7 @@     "browser_input_dispatch",     "browser_readiness",     "browser_prewarm",+    "browser_use",     "browser_record_start",     "browser_record_stop",     "browser_record_status",
src/chrome.js+139−18
@@ -97,8 +97,9 @@ function detectChrome() { // Every Windows PC ships with Edge, and most have Chrome — both are Chromium and // both are drivable by puppeteer. Preferring an ALREADY-INSTALLED browser (launched // with a FRESH isolated profile) means the common case needs NO ~150 MB Chrome-for--// Testing download. detectSystemBrowser probes the standard install locations.-function detectSystemBrowser() {+// Testing download. listSystemBrowsers probes the standard install locations and+// returns EVERY drivable system browser it finds, in preference order (Chrome, then Edge).+function listSystemBrowsers() {   const pf   = process.env['PROGRAMFILES']      || 'C:\\Program Files';   const pf86 = process.env['PROGRAMFILES(X86)'] || 'C:\\Program Files (x86)';   const lad  = process.env['LOCALAPPDATA']      || '';@@ -112,27 +113,139 @@ function detectSystemBrowser() {     j(pf86, 'Microsoft', 'Edge', 'Application', 'msedge.exe'),     j(pf,   'Microsoft', 'Edge', 'Application', 'msedge.exe'),   ].filter(Boolean);-  for (const p of chromePaths) if (fileExists(p)) return { found: true, kind: 'chrome', executablePath: p };-  for (const p of edgePaths)   if (fileExists(p)) return { found: true, kind: 'edge',   executablePath: p };-  // POSIX fallbacks (pup is Windows-first, but keep dev boxes working):-  for (const p of ['/usr/bin/google-chrome', '/usr/bin/chromium', '/usr/bin/chromium-browser',-                   '/Applications/Google Chrome.app/Contents/MacOS/Google Chrome',-                   '/Applications/Microsoft Edge.app/Contents/MacOS/Microsoft Edge']) {-    if (fileExists(p)) return { found: true, kind: p.toLowerCase().includes('edge') ? 'edge' : 'chrome', executablePath: p };+  const posix = [+    ['chrome', '/usr/bin/google-chrome'], ['chrome', '/usr/bin/chromium'], ['chrome', '/usr/bin/chromium-browser'],+    ['chrome', '/Applications/Google Chrome.app/Contents/MacOS/Google Chrome'],+    ['edge',   '/Applications/Microsoft Edge.app/Contents/MacOS/Microsoft Edge'],+  ];+  const out = [];+  const seen = new Set();+  const push = (kind, p) => { if (p && !seen.has(p) && fileExists(p)) { seen.add(p); out.push({ kind, executablePath: p, source: 'system' }); } };+  for (const p of chromePaths) push('chrome', p);+  for (const p of edgePaths)   push('edge', p);+  for (const [kind, p] of posix) push(kind, p);+  return out;+}++// Back-compat: the FIRST system browser (or {found:false}).+function detectSystemBrowser() {+  const list = listSystemBrowsers();+  return list.length ? { found: true, kind: list[0].kind, executablePath: list[0].executablePath } : { found: false };+}++// ── Persisted browser default ─────────────────────────────────────────────────+// So the bridge doesn't re-probe/re-verify on every open. Two ways it gets set:+//   • markVerified(): after a launch actually SUCCEEDS, we cache that exact browser+//     (forced:false) — the auto default. Cheap re-use next time.+//   • setDefaultBrowser(): the AI PINS a browser via browser_use (forced:true) — we+//     honor it strictly (but still fall back if it can't spawn, so it stays airtight).+// Stored as JSON next to the CfT cache so it survives bridge restarts.+function defaultFile() { return path.join(cacheDir(), 'pup-browser-default.json'); }+function readDefault() {+  try { return JSON.parse(fs.readFileSync(defaultFile(), 'utf8')); } catch { return null; }+}+function writeDefault(obj) {+  try { fs.mkdirSync(cacheDir(), { recursive: true }); fs.writeFileSync(defaultFile(), JSON.stringify(obj, null, 2)); return true; }+  catch (e) { console.error(`[chrome] could not persist browser default: ${e.message}`); return false; }+}+function clearDefault() { try { fs.unlinkSync(defaultFile()); } catch {} }++// Cache the browser that just launched successfully as the auto-default. Never+// overrides an explicit (forced) choice the AI made via browser_use.+function markVerified(cand) {+  if (!cand || !cand.executablePath) return;+  const d = readDefault();+  if (d && d.forced) return;+  writeDefault({ kind: cand.kind, executablePath: cand.executablePath, source: cand.source || 'system', buildId: cand.buildId || null, forced: false, verifiedAt: Date.now() });+}++// Every drivable browser, in the order pup will TRY them:+//   [stored default, if still present] → installed Chrome → installed Edge → cached CfT.+// 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() {+  const all = [...listSystemBrowsers()];+  const cft = detectChrome();+  if (cft.installed) all.push({ kind: 'chrome-for-testing', executablePath: cft.executablePath, source: 'cache', buildId: cft.buildId });++  const def = readDefault();+  if (def) {+    let idx = -1;+    if (def.executablePath) idx = all.findIndex(c => c.executablePath === def.executablePath);+    if (idx < 0 && def.kind)  idx = all.findIndex(c => c.kind === def.kind);+    if (idx > 0) { const [d] = all.splice(idx, 1); all.unshift(d); }+    else if (idx < 0 && def.executablePath && fileExists(def.executablePath)) {+      all.unshift({ kind: def.kind || 'custom', executablePath: def.executablePath, source: def.source || 'default', buildId: def.buildId });+    }+    // A forced-but-not-yet-present default (e.g. 'cft' still downloading) simply+    // doesn't reorder — the system browsers serve as the fallback until it arrives.   }-  return { found: false };+  return all; }  // The browser pup will actually launch, in preference order:-//   installed Chrome → installed Edge → cached Chrome-for-Testing → (none → fetch CfT).+//   [pinned/verified default] → installed Chrome → installed Edge → cached Chrome-for-Testing. // Launch uses a fresh --user-data-dir, so a system browser is driven CLEAN — it never-// touches the user's real tabs/logins (that's native-mode's job).+// touches the user's real tabs/logins (that's native-mode's job). `candidates` is the+// full ordered fallback list; the launch path spawn-verifies each in turn. function resolveBrowser() {-  const sys = detectSystemBrowser();-  if (sys.found) return { found: true, kind: sys.kind, executablePath: sys.executablePath, source: 'system' };-  const cft = detectChrome();-  if (cft.installed) return { found: true, kind: 'chrome-for-testing', executablePath: cft.executablePath, source: 'cache', buildId: cft.buildId };-  return { found: false, kind: 'none', executablePath: null };+  const candidates = launchCandidates();+  const def = readDefault();+  const top = candidates[0];+  if (!top) return { found: false, kind: 'none', executablePath: null, source: null, candidates: [], forced: !!(def && def.forced) };+  return {+    found: true, kind: top.kind, executablePath: top.executablePath, source: top.source, buildId: top.buildId || null,+    candidates, forced: !!(def && def.forced), defaultBrowser: def || null,+  };+}++// browser_use: set (or clear) the browser pup drives by default.+//   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 } = {}) {+  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() };+    writeDefault({ kind: 'custom', executablePath, source: 'explicit', forced: true, verifiedAt: Date.now() });+    return { ok: true, ...pickSummary() };+  }+  if (b === 'auto' || b === 'clear' || b === 'reset' || b === 'default' || b === '') {+    clearDefault();+    return { ok: true, cleared: true, ...pickSummary() };+  }+  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() };+    }+    writeDefault({ kind: cand.kind, executablePath: cand.executablePath, source: 'system', forced: true, verifiedAt: Date.now() });+    return { ok: true, ...pickSummary() };+  }+  if (b === 'cft' || b === 'chromefortesting') {+    const det = detectChrome();+    if (det.installed) {+      writeDefault({ kind: 'chrome-for-testing', executablePath: det.executablePath, source: 'cache', buildId: det.buildId, forced: true, verifiedAt: Date.now() });+      return { ok: true, ...pickSummary() };+    }+    // Not cached — pin the intent and start the download; once it lands it becomes default.+    writeDefault({ kind: 'chrome-for-testing', executablePath: null, source: 'cache', forced: true, pendingInstall: true, verifiedAt: Date.now() });+    ensureChromeReady({ background: true });+    return { ok: true, installing: true, ...pickSummary() };+  }+  return { ok: false, errorCode: 'bad_browser', error: `Unknown browser '${browser}'. Use 'chrome', 'edge', 'cft', or 'auto' (or pass an explicit executablePath).`, ...pickSummary() };+}++// Compact snapshot of the browser decision — reused by readiness + every browser_* hint.+function pickSummary() {+  const r = resolveBrowser();+  const d = readDefault();+  return {+    picked: r.found ? { kind: r.kind, source: r.source, executablePath: r.executablePath } : null,+    candidates: (r.candidates || []).map(c => ({ kind: c.kind, source: c.source, executablePath: c.executablePath })),+    defaultBrowser: d ? { kind: d.kind, source: d.source || null, forced: !!d.forced, pendingInstall: !!d.pendingInstall } : null,+  }; }  // Programmatic install of the pinned CfT build. Resolves to { ok, executablePath, buildId }@@ -258,11 +371,15 @@ function readiness() {   const det = detectChrome();   const installing = !!_installInFlight || state.phase === 'installing';   const ready = b.found && !installing;   // a drivable browser is available (system OR cached CfT)+  const d = readDefault();   return {     ready,     browserKind: b.kind,                  // 'chrome' | 'edge' | 'chrome-for-testing' | 'none'     browserSource: b.source || null,      // 'system' | 'cache' | null     browserExecutablePath: b.executablePath || null,+    // Full ordered fallback list the launcher will spawn-verify, + the persisted default.+    candidates: (b.candidates || []).map(c => ({ kind: c.kind, source: c.source, executablePath: c.executablePath })),+    defaultBrowser: d ? { kind: d.kind, source: d.source || null, forced: !!d.forced, pendingInstall: !!d.pendingInstall } : null,     nodeInstalled: true,     nodeVersion: process.version,     chromeForTestingInstalled: det.installed,@@ -280,4 +397,8 @@ function readiness() {   }; } -module.exports = { detectChrome, detectSystemBrowser, resolveBrowser, installChrome, ensureChromeReady, readiness, expectedBuildId, cacheDir };+module.exports = {+  detectChrome, detectSystemBrowser, listSystemBrowsers, launchCandidates, resolveBrowser,+  installChrome, ensureChromeReady, readiness, expectedBuildId, cacheDir,+  setDefaultBrowser, readDefault, clearDefault, markVerified, pickSummary,+};
src/server.js+162−44
@@ -1305,6 +1305,35 @@ async function applyDownloadBehavior(page, downloadPath) {   } } +// The browser that most recently launched (kind/source/executablePath + any fallback+// chain), so browser_open_window's response + hints report what pup ACTUALLY drove —+// which can differ from resolveBrowser()'s top pick if the top candidate had to fall+// through. Read right after getOrLaunchBrowser() returns.+let _lastChosenBrowser = null;++// Build the verbose browser report the AI gets on every open: what we drove, what+// else is available, the current default, and how to switch or install full CfT.+function browserPickReport() {+  const sum = chrome.pickSummary();+  const chosen = _lastChosenBrowser || sum.picked;+  const availTxt = (sum.candidates || []).map(c => `${c.kind}(${c.source})`).join(', ') || 'none';+  const def = sum.defaultBrowser;+  const defTxt = def+    ? `Default is ${def.kind}${def.forced ? ' (pinned via browser_use)' : ' (auto — cached from a prior successful launch)'}${def.pendingInstall ? ', still downloading' : ''}.`+    : 'Default is auto (pup drives the first browser that launches, then caches it).';+  const fellBack = chosen && chosen.fallbackFrom && chosen.fallbackFrom.length+    ? ` NOTE: fell back past ${chosen.fallbackFrom.join(', ')} which would not spawn.` : '';+  const hint = chosen+    ? `Driving ${chosen.kind}${chosen.source ? ` (${chosen.source})` : ''}${chosen.executablePath ? ` — ${chosen.executablePath}` : ''}, in a FRESH isolated profile (the user's real tabs/logins are untouched).${fellBack} Available on this machine: ${availTxt}. ${defTxt} To switch the browser pup drives: browser_use {browser:"chrome"|"edge"|"cft"|"auto"}. To install & pin full Chrome for Testing: browser_use {browser:"cft"} (fetches ~150 MB once, then every open uses it) or browser_prewarm.`+    : `No browser resolved. Available on this machine: ${availTxt}.`;+  return {+    browser: chosen ? { kind: chosen.kind, source: chosen.source, executablePath: chosen.executablePath || null } : null,+    availableBrowsers: sum.candidates,+    defaultBrowser: def,+    _hint: hint,+  };+}+ async function getOrLaunchBrowser(profileName, { freshProfile = false, strictPermissions = false, nativeSite = false } = {}) {   // Check if browser already running for this profile.   // isConnected() can be stale after laptop sleep/wake — the WebSocket hasn't@@ -1332,15 +1361,23 @@ async function getOrLaunchBrowser(profileName, { freshProfile = false, strictPer   // so the common case needs NO ~150 MB Chrome-for-Testing download. Falls back to a   // cached CfT. A fresh --user-data-dir (below) keeps it CLEAN — it never touches the   // user's real tabs/logins (that's native-mode's job).-  const resolvedBrowser = chrome.resolveBrowser();-  if (resolvedBrowser.found) {-    console.log(`[launch] browser: ${resolvedBrowser.kind} (${resolvedBrowser.source}) — ${resolvedBrowser.executablePath}`);+  //+  // launchCandidates() returns the FULL ordered fallback list ([pinned default] →+  // Chrome → Edge → cached CfT). The launch loop below tries each in turn and+  // SPAWN-VERIFIES it — a broken/partial top pick (e.g. a corrupt Chrome install)+  // falls through to the next real browser instead of hard-failing the open. The+  // browser that actually launches is cached as the default (chrome.markVerified).+  const candidates = chrome.launchCandidates();+  if (candidates.length) {+    console.log(`[launch] ${candidates.length} browser candidate(s): ` + candidates.map(c => `${c.kind}(${c.source})`).join(' → '));+  } else {+    console.log('[launch] no system browser or cached CfT detected — falling back to puppeteer.executablePath()');   }    const launchOpts = {     headless: false,     defaultViewport: null,-    ...(resolvedBrowser.executablePath ? { executablePath: resolvedBrowser.executablePath } : {}),+    // executablePath is set PER-CANDIDATE in the spawn-verified launch loop below.     args: [       '--no-sandbox', '--disable-setuid-sandbox', '--disable-web-security',       '--start-maximized', '--remote-debugging-port=0',@@ -1487,10 +1524,12 @@ async function getOrLaunchBrowser(profileName, { freshProfile = false, strictPer     };      // ─── Helper: one detached-launch attempt, returns browser or throws ───-    const tryDetachedLaunch = async (attemptNum) => {+    const tryDetachedLaunch = async (attemptNum, chromePathArg) => {       const { spawn } = require('child_process');-      // Launch the resolved browser (installed Chrome/Edge preferred; cached CfT fallback).-      const chromePath = resolvedBrowser.executablePath || puppeteer.executablePath();+      // Launch the candidate the loop is currently trying (installed Chrome/Edge+      // preferred; cached CfT fallback). Falls back to puppeteer's own path only if+      // no candidate path was resolved (the no-system-browser edge case).+      const chromePath = chromePathArg || puppeteer.executablePath();       const debugPort = 9200 + Math.floor(Math.random() * 800);       const chromeArgs = [         ...launchOpts.args.filter(a => a !== '--remote-debugging-port=0'),@@ -1543,46 +1582,74 @@ async function getOrLaunchBrowser(profileName, { freshProfile = false, strictPer       return b;     }; -    // ─── Launch with up to 3 attempts and orphan cleanup between attempts ───+    // ─── Spawn-verified launch: try each candidate browser in order, up to 3 ───+    // attempts each (with orphan/lock cleanup between attempts). If a browser can't+    // actually spawn (corrupt install, wrong arch, locked), fall THROUGH to the next+    // candidate rather than failing the open. First one that launches wins + is cached.     const isWinDetached = process.platform === 'win32' && userDataDir;     const MAX_ATTEMPTS = 3;     let lastErr = null;--    for (let attempt = 1; attempt <= MAX_ATTEMPTS; attempt++) {-      try {-        if (isWinDetached) {-          browser = await tryDetachedLaunch(attempt);-        } else {-          browser = await puppeteer.launch(launchOpts);+    let chosen = null;+    const triedList = [];+    // No detected candidates (no system browser + no cached CfT) → let puppeteer pick+    // its own executablePath. This is the rare cold-start edge the gate usually catches.+    const launchList = candidates.length ? candidates : [{ kind: 'default', source: 'puppeteer', executablePath: null }];++    for (const cand of launchList) {+      if (cand.executablePath) launchOpts.executablePath = cand.executablePath;+      else delete launchOpts.executablePath;+      lastErr = null;+      for (let attempt = 1; attempt <= MAX_ATTEMPTS; attempt++) {+        try {+          if (isWinDetached) {+            browser = await tryDetachedLaunch(attempt, cand.executablePath);+          } else {+            browser = await puppeteer.launch(launchOpts);+          }+          chosen = cand;+          lastErr = null;+          break;+        } catch (err) {+          lastErr = err;+          console.log(`Launch attempt ${attempt}/${MAX_ATTEMPTS} for ${cand.kind}(${cand.source}) failed: ${err.message}`);+          if (attempt >= MAX_ATTEMPTS) break;+          // Recovery before retry: kill any lingering Chrome holding the profile + clear locks+          await killChromeHoldingProfile();         }-        lastErr = null;-        break;-      } catch (err) {-        lastErr = err;-        console.log(`Launch attempt ${attempt}/${MAX_ATTEMPTS} failed: ${err.message}`);-        if (attempt >= MAX_ATTEMPTS) break;-        // Recovery before retry: kill any lingering Chrome holding the profile + clear locks-        await killChromeHoldingProfile();       }+      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 (browser && chosen) {+      // Record what actually launched so the open response + hints are accurate, and+      // cache it as the auto-default so we don't re-probe next time.+      browser._pupChosen = { kind: chosen.kind, source: chosen.source, executablePath: chosen.executablePath || null };+      _lastChosenBrowser = { ...browser._pupChosen, fallbackFrom: triedList.map(t => `${t.kind}(${t.source})`) };+      if (chosen.executablePath) chrome.markVerified(chosen);+      if (triedList.length) console.log(`[launch] launched ${chosen.kind}(${chosen.source}) after ${triedList.length} browser(s) failed to spawn`);     }      if (!browser) {-      // Belt-and-suspenders: the open_window cold-start gate normally catches a-      // missing Chrome for Testing BEFORE we get here. If we still failed, branch-      // on whether CfT is actually present so the error is actionable, not a dev-      // string telling the AI to run npx.+      // Every candidate failed to spawn. Branch on whether ANY browser was even+      // detectable so the error is actionable, not a dev string telling the AI to run npx.+      const triedTxt = triedList.length+        ? triedList.map(t => `${t.kind}(${t.source}): ${t.error}`).join(' | ')+        : '(no browsers were detected to try)';       const cdet = chrome.detectChrome();-      if (!cdet.installed) {-        const e = new Error('Chrome for Testing is not installed — pup cannot launch a browser yet.');-        e.errorCode = 'chrome_for_testing_not_installed';-        e._hint = 'Chrome for Testing (the browser pup drives) is missing. Call browser_prewarm to install it (~150 MB, one time), poll browser_readiness until ready:true, then retry. You do not need to install anything manually.';+      if (!candidates.length && !cdet.installed) {+        const e = new Error('No Chrome/Edge on this machine and Chrome for Testing is not installed — pup cannot launch a browser yet.');+        e.errorCode = 'no_browser_available';+        e._hint = 'This box has no installed Chrome or Edge and no cached Chrome for Testing. Fastest fix: have the user install Chrome or Edge (Edge ships with Windows) — pup uses it instantly, no download. Or call browser_prewarm / browser_use {browser:"cft"} to fetch Chrome for Testing (~150 MB, one time), poll browser_readiness until ready:true, then retry.';         throw e;       }-      throw new Error(-        `Failed to launch Chrome for profile "${profileName}" after ${MAX_ATTEMPTS} attempts. ` +-        `Last error: ${lastErr && lastErr.message}. ` +-        `Chrome for Testing IS installed (${cdet.executablePath}) — this is more likely low memory, a locked/busy profile, or a crashed Chrome. Retry; if it persists, surface the last error to the user.`+      const e = new Error(+        `Every detected browser failed to spawn for profile "${profileName}" (${MAX_ATTEMPTS} attempts each). Tried — ${triedTxt}.`       );+      e.errorCode = 'browser_launch_failed';+      e._hint = `pup tried every browser it found and none would launch (usually low memory, a locked/busy profile, or corrupt installs). Tried: ${triedList.map(t => `${t.kind}(${t.source})`).join(', ')}. Retry once; if it persists, pin a specific browser with browser_use {browser:"chrome"|"edge"|"cft"}, or surface the last error to the user.`;+      throw e;     }   } @@ -2958,8 +3025,9 @@ const server = http.createServer(async (req, res) => {             V('desktop_recorder_open', 'Open the desktop screen-recorder UI.', {}, 20, {}),             V('desktop_record_start', 'Start a desktop screen recording.', { outPath: 'optional str' }, 20, {}),             V('desktop_record_stop', 'Stop the desktop screen recording; returns the file.', {}, 30, {}),-            V('browser_readiness', 'Cold-start readiness: is Chrome for Testing installed / installing? Poll before+after a first open on a fresh PC.', {}, 10, {}),-            V('browser_prewarm', 'Install Chrome for Testing WITHOUT opening a window (warm the cold-start before first use).', { wait: 'optional bool — block until installed' }, 240, {}),+            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_describe', 'This list — the bridge\'s verbs + schemas, for AD\'s Verbs tab.', {}, 10, {}),           ] });           return;@@ -3085,6 +3153,9 @@ const server = http.createServer(async (req, res) => {           // tab open or popup auto-attach in this session.           if (session) session._downloadPath = downloadPath;           const info = await getSessionInfo(sessionId, session);+          // Verbose browser report: which browser we actually drove, what else is+          // available, the current default, and how to switch / install full CfT.+          const brep = browserPickReport();           sendJSON(res, {             success: true,             output: JSON.stringify({@@ -3101,6 +3172,11 @@ const server = http.createServer(async (req, res) => {               // it to desktop_watch_files without re-deriving).               downloadsEnabled: !args.strictPermissions,               downloadPath,+              // v1.4.0: which browser pup drove + the full fallback picture.+              browser: brep.browser,+              availableBrowsers: brep.availableBrowsers,+              defaultBrowser: brep.defaultBrowser,+              _hint: brep._hint,               ...info,             }),           });@@ -4093,17 +4169,22 @@ const server = http.createServer(async (req, res) => {           // "what do I have / is it ready" question prefer AD's `bridge_readiness` (it also           // reports AD-core's Tier-1 prewarm progress); this verb is pup's own detailed view.           const rd = chrome.readiness();+          const availTxt = (rd.candidates || []).map(c => `${c.kind}(${c.source})`).join(', ') || 'none';+          const defTxt = rd.defaultBrowser+            ? ` Default: ${rd.defaultBrowser.kind}${rd.defaultBrowser.forced ? ' (pinned)' : ' (auto)'}${rd.defaultBrowser.pendingInstall ? ', downloading' : ''}.`+            : ' Default: auto (first browser that launches).';+          const switchTxt = ' Switch/install with browser_use {browser:"chrome"|"edge"|"cft"|"auto"}.';           sendJSON(res, {             success: true,             output: JSON.stringify(rd),             ...rd,             _hint: rd.ready-              ? 'Ready — Chrome for Testing is cached and the server is up; pup can open windows. Proceed with browser_open_window.'+              ? `Ready — pup will drive ${rd.browserKind} (${rd.browserSource}) at ${rd.browserExecutablePath}. Available: ${availTxt}.${defTxt} No download needed. Proceed with browser_open_window.${switchTxt}`               : rd.installing-                ? `Chrome for Testing is being fetched (${rd.installProgressPct}%). Poll bridge_readiness (or this verb) until ready:true, then browser_open_window.`+                ? `Fetching Chrome for Testing (${rd.installProgressPct}%) — this box had no Chrome/Edge. Poll this verb (or bridge_readiness) until ready:true, then browser_open_window. Faster: have the user install Chrome or Edge (Edge ships with Windows) and pup uses it instantly.`                 : rd.installPhase === 'failed'-                  ? `Chrome for Testing fetch FAILED: ${rd.lastError}. Retry browser_prewarm; if it persists, surface this to the user (usually network/proxy blocking the download, or low disk).`-                  : 'Chrome for Testing is not cached yet. Adom Desktop fetches it in the background (Tier-1 prewarm) — poll AD\'s bridge_readiness; or call browser_prewarm to have the bridge fetch it.',+                  ? `No Chrome/Edge on this box and the Chrome-for-Testing fallback FAILED: ${rd.lastError}. Have the user install Chrome or Edge (fastest, no download), or retry browser_prewarm (usually network/proxy blocking the download, or low disk).`+                  : `No Chrome/Edge detected and no cached Chrome for Testing. Available: ${availTxt}. Have the user install Chrome or Edge (Edge ships with Windows — instant, no download), or call browser_use {browser:"cft"} / browser_prewarm to fetch Chrome for Testing.`,           });           return;         }@@ -4113,6 +4194,14 @@ const server = http.createServer(async (req, res) => {           // embedded first-run; pass {wait:true} to block until installed.           const block = !!(args && (args.wait === true || args.block === true));           await chrome.ensureChromeReady({ background: !block });+          // Prewarm = the AI explicitly wants Chrome for Testing → pin it as the default+          // (unless a different browser was already explicitly pinned). Once cached, every+          // open drives CfT. Pass {setDefault:false} to only download without pinning.+          const pinCft = args && args.setDefault === false ? false : true;+          const curDef = chrome.readDefault();+          if (pinCft && !(curDef && curDef.forced && curDef.kind !== 'chrome-for-testing')) {+            chrome.setDefaultBrowser({ browser: 'cft' });+          }           const rd = chrome.readiness();           sendJSON(res, {             success: rd.installPhase !== 'failed',@@ -4120,15 +4209,44 @@ const server = http.createServer(async (req, res) => {             ...rd,             statusVerb: 'browser_readiness',             _hint: rd.ready-              ? 'Chrome for Testing is ready — pup can open windows instantly now.'+              ? `Chrome for Testing is ready${pinCft ? ' and pinned as the default' : ''} — pup will drive it on the next browser_open_window. (browser_use {browser:"auto"} reverts to installed Chrome/Edge.)`               : rd.installing-                ? `Warming up: Chrome for Testing downloading (${rd.installProgressPct}%). Poll browser_readiness until ready:true. No window is opened by prewarm.`+                ? `Downloading Chrome for Testing (${rd.installProgressPct}%)${pinCft ? '; it is pinned as the default so every open uses it once ready' : ''}. Poll browser_readiness until ready:true. No window is opened by prewarm.`                 : rd.installPhase === 'failed'-                  ? `Prewarm FAILED: ${rd.lastError}. Usually network/proxy blocking storage.googleapis.com or low disk.`+                  ? `Prewarm FAILED: ${rd.lastError}. Usually network/proxy blocking storage.googleapis.com or low disk. pup still works with installed Chrome/Edge — browser_use {browser:"auto"}.`                   : 'Prewarm started; poll browser_readiness for completion.',           });           return;         }+        case 'browser_use': {+          // Pin (or clear) the browser pup drives by default. browser: 'chrome' | 'edge'+          // | 'cft' | 'auto' (or pass an explicit executablePath). 'cft' fetches Chrome+          // 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 rd = chrome.readiness();+          const availTxt = (result.candidates || []).map(c => `${c.kind}(${c.source})`).join(', ') || 'none';+          let hint;+          if (!result.ok) {+            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.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 {+            hint = `Default pinned → ${result.defaultBrowser ? result.defaultBrowser.kind : args.browser}. The next browser_open_window drives it. Available: ${availTxt}. Revert anytime with browser_use {browser:"auto"}.`;+          }+          sendJSON(res, {+            success: !!result.ok,+            output: JSON.stringify({ ...result, readiness: rd }),+            ...result,+            errorCode: result.ok ? undefined : (result.errorCode || 'browser_use_failed'),+            statusVerb: 'browser_readiness',+            _hint: hint,+          });+          return;+        }         case 'browser_status': {           const list = [];           for (const [id, s] of sessions) {