← Commit history

Power fix: Chrome power-saving flags are now OPT-IN via highFps instead of applied to every window forever (issue #14 battery drain). A highFps window holds a lease (default 20 min, max 120), an active recording holds it open indefinitely so a take is never cut off, and on lapse the window is DOWNGRADED by relaunch carrying its tabs rather than closed. Adds browser_highfps_extend, plus hints on every open so an AI discovers the capability

John Lauer ·22b76f6dee ·3mo ago ·parent 8fb5505
4 files changed +153−14
adom-bridge-puppeteer-manifest.json+4−4
@@ -1,10 +1,10 @@ {   "manifest_version": 1,   "name": "puppeteer",-  "version": "1.9.75",-  "url": "https://wiki.adom.inc/download/adom/adom-desktop-puppeteer-bridge/1.9.75/adom-bridge-puppeteer-v1.9.75.zip",-  "sha256": "27f445e47abcd1892c84d21df3530e7f7acc4df8a30419aaedf3e2688cf84ca2",-  "size": 309405,+  "version": "1.9.76",+  "url": "https://wiki.adom.inc/download/adom/adom-desktop-puppeteer-bridge/1.9.76/adom-bridge-puppeteer-v1.9.76.zip",+  "sha256": "7183d1e29b832684698b121a8f32a88a083c333da43a7125119c538c69d4a708",+  "size": 313377,   "verbPrefixes": [     "browser_",     "desktop_recorder_",
src/BRIDGE_VERSION+1−1
@@ -1 +1 @@-1.9.75+1.9.76
src/bridge.json+1−1
@@ -2,7 +2,7 @@   "manifest_version": 1,   "name": "puppeteer",   "displayName": "Puppeteer (Chrome for Testing + Native)",-  "version": "1.9.75",+  "version": "1.9.76",   "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.",   "homepage": "https://wiki.adom.inc/adom/adom-desktop",   "author": "Adom Inc.",
src/server.js+147−8
@@ -3609,6 +3609,102 @@ function osBottomUnlessForeground(hwnd, force) { //      windowId, so the catch-2 failure mode (parked the wrong window) is impossible. //   5. Verify POSITION via CDP getWindowBounds (catch-2's park "verified" by focus alone and //      lied) → 'backgrounded' | 'park-failed'.+// ── HIGH-FPS LEASES + WATCHDOG (v1.9.76) ────────────────────────────────────────+//+// A highFps window has Chrome's four power-saving mechanisms DISABLED, so it renders at+// full rate forever even when occluded. That is exactly what a background recording needs+// and exactly what flattened a user's battery when it outlived its purpose (issue #14).+// So a highFps window is never open-ended: it holds a LEASE, and a watchdog downgrades it+// when the lease lapses.+//+// The three activity signals, in priority order:+//   1. AN ACTIVE RECORDING IS AUTHORITATIVE. If a recording we manage is running on the+//      session, the lease CANNOT lapse. This is the "never kill someone mid-video"+//      guarantee, and it is knowable (not guessed) because recording runs through our verbs.+//   2. VERB ACTIVITY extends the lease — someone is demonstrably using the window.+//   3. An explicit browser_highfps_extend renews it, so an AI that knows it needs longer+//      can negotiate instead of being surprised.+//+// On lapse we DOWNGRADE (relaunch the same session, same tabs, without the flags) rather+// than close: the window may hold the user's content, and the goal is to stop burning power,+// not to destroy work.+const HIGHFPS_DEFAULT_MINUTES = 20;   // long enough for a real demo take, short enough that+                                      // an abandoned window cannot cost a battery overnight+const HIGHFPS_MAX_MINUTES = 120;      // hard ceiling; ask again rather than lease a whole day+const HIGHFPS_WARN_BEFORE_MS = 2 * 60 * 1000;+const _highFps = new Map();           // sessionId -> { expiresAt, minutes, warned, grantedAt }++function highFpsGrant(sessionId, minutes) {+  const m = Math.max(1, Math.min(HIGHFPS_MAX_MINUTES, Number(minutes) || HIGHFPS_DEFAULT_MINUTES));+  const rec = { minutes: m, grantedAt: Date.now(), expiresAt: Date.now() + m * 60000, warned: false };+  _highFps.set(sessionId, rec);+  console.log(`[highfps] "${sessionId}" lease granted: ${m} min (expires ${new Date(rec.expiresAt).toISOString()})`);+  return rec;+}+function highFpsExtend(sessionId, minutes) {+  const cur = _highFps.get(sessionId);+  if (!cur) return null;+  const m = Math.max(1, Math.min(HIGHFPS_MAX_MINUTES, Number(minutes) || HIGHFPS_DEFAULT_MINUTES));+  cur.expiresAt = Date.now() + m * 60000; cur.minutes = m; cur.warned = false;+  console.log(`[highfps] "${sessionId}" lease extended: +${m} min`);+  return cur;+}+function highFpsInfo(sessionId) {+  const r = _highFps.get(sessionId);+  if (!r) return null;+  return { active: true, minutes: r.minutes, expiresAt: new Date(r.expiresAt).toISOString(),+           msRemaining: Math.max(0, r.expiresAt - Date.now()), recordingActive: isRecordingActive(sessionId) };+}+// Is a recording we manage currently running against this session? Authoritative, not a guess.+function isRecordingActive(sessionId) {+  // TAB recording: tabRecordStatusImpl is the authoritative per-session view (it already+  // filters out recordings that are mid-stop). Anything it returns means frames are being+  // pulled from this window right now.+  try {+    const active = tabRecordStatusImpl({ sessionId });+    if (Array.isArray(active) ? active.length : (active && active.length)) return true;+  } catch (e) {}+  // DESKTOP recording: one global recorder; .current is set only while a take is running.+  try { if (desktopRecorder && desktopRecorder.current) return true; } catch (e) {}+  return false;+}+// Drop a session back to Chrome's normal power behaviour by relaunching it WITHOUT the flags,+// carrying every tab across (same machinery the wiki view toggle uses).+async function highFpsDowngrade(sessionId, why) {+  const session = sessions.get(sessionId);+  _highFps.delete(sessionId);+  if (!session) return false;+  try {+    const urls = []; let activeIdx = 0;+    (session.tabs || []).forEach((t, i) => {+      try { const u = t.page.url(); if (u && u !== 'about:blank') { if (t.tabId === session.activeTabId) activeIdx = urls.length; urls.push(u); } } catch (e) {}+    });+    const owner = session.owner || null, profile = session.profileName;+    console.log(`[highfps] "${sessionId}" DOWNGRADING to normal power (${why}) — relaunching ${urls.length} tab(s) without the high-fps flags`);+    await launchSession(sessionId, urls[activeIdx] || 'about:blank', { profile, owner, takeover: true, highFps: false });+    for (let i = 0; i < urls.length; i++) { if (i === activeIdx) continue; try { await addTabToSession(sessionId, urls[i]); } catch (e) {} }+    return true;+  } catch (e) { console.log(`[highfps] "${sessionId}" downgrade failed: ${e.message}`); return false; }+}+setInterval(() => {+  const now = Date.now();+  for (const [sid, rec] of [..._highFps]) {+    if (!sessions.has(sid)) { _highFps.delete(sid); continue; }+    if (isRecordingActive(sid)) {+      // Recording in flight: hold the lease open and keep pushing it out, so a long take can+      // never be cut off. Logged once per tick at most because the janitor collapses repeats.+      rec.expiresAt = Math.max(rec.expiresAt, now + 5 * 60000);+      continue;+    }+    const left = rec.expiresAt - now;+    if (left <= 0) { highFpsDowngrade(sid, `lease of ${rec.minutes} min lapsed with no active recording`).catch(() => {}); }+    else if (left <= HIGHFPS_WARN_BEFORE_MS && !rec.warned) {+      rec.warned = true;+      console.log(`[highfps] "${sid}" lease expires in ~${Math.ceil(left / 60000)} min — call browser_highfps_extend {sessionId,minutes} to keep it, or start a recording (a running recording holds it open indefinitely)`);+    }+  }+}, 30000);+ async function parkSessionWindowDirect(page, sessionId) {   if (process.platform !== 'win32') return null;   let cdp = null;@@ -4009,7 +4105,7 @@ async function setSessionWindowBounds(session, { x, y, width, height } = {}) {   } catch (e) { console.error(`[bounds] setSessionWindowBounds failed: ${e.message}`); return false; } } -async function getOrLaunchBrowser(profileName, { freshProfile = false, strictPermissions = false, nativeSite = false, foreground = false } = {}) {+async function getOrLaunchBrowser(profileName, { freshProfile = false, strictPermissions = false, nativeSite = false, foreground = false, highFps = false } = {}) {   // Check if browser already running for this profile.   // isConnected() can be stale after laptop sleep/wake — the WebSocket hasn't   // noticed the disconnect yet. Validate with a real CDP call (pages()) with@@ -4096,10 +4192,20 @@ async function getOrLaunchBrowser(profileName, { freshProfile = false, strictPer       //                                              skip the occlusion-detection probe       // Without these, recording a tab while the user is in another app       // produces a 1-2 fps video.-      '--disable-renderer-backgrounding',-      '--disable-backgrounding-occluded-windows',-      '--disable-background-timer-throttling',-      '--disable-features=CalculateNativeWinOcclusion',+      //+      // v1.9.76 — THESE ARE NOW OPT-IN (highFps). They were applied to EVERY window,+      // forever, which is why an idle pup window could never enter Chrome's low-power+      // state: we had disabled all four of the mechanisms that let it. Three idle windows+      // held a Modern Standby laptop out of deep idle overnight and flattened the battery+      // (wiki issue #14). A NORMAL Chrome window does not do this because it throttles when+      // occluded — ours structurally could not. Default is now Chrome's own power behaviour;+      // callers that genuinely need continuous frames ask for highFps and get a LEASE.+      ...(highFps ? [+        '--disable-renderer-backgrounding',+        '--disable-backgrounding-occluded-windows',+        '--disable-background-timer-throttling',+        '--disable-features=CalculateNativeWinOcclusion',+      ] : []),     ],     ignoreDefaultArgs: ['--enable-automation'],     // Let Chrome survive if the bridge process dies — enables reconnect on restart@@ -4479,7 +4585,7 @@ async function createPageInNewWindow(browser) { // Launch a session as a tab in a Chrome browser // profile: which Chrome profile to use (defaults to sessionId) // If another session already uses that profile, this creates a NEW TAB in the same Chrome-async function launchSession(sessionId, url, { freshProfile = false, profile, strictPermissions = false, downloadPath = null, nativeSite = false, owner = null, takeover = false, foreground = false, silent = false } = {}) {+async function launchSession(sessionId, url, { freshProfile = false, profile, strictPermissions = false, downloadPath = null, nativeSite = false, owner = null, takeover = false, foreground = false, silent = false, highFps = false } = {}) {   const profileName = profile || sessionId;    // If session already exists and page is still alive, just navigate@@ -4552,7 +4658,9 @@ async function launchSession(sessionId, url, { freshProfile = false, profile, st   }    // Get or launch Chrome for this profile-  const browser = await getOrLaunchBrowser(profileName, { freshProfile, strictPermissions, nativeSite, foreground });+  const browser = await getOrLaunchBrowser(profileName, { freshProfile, strictPermissions, nativeSite, foreground, highFps });+  // v1.9.76: a high-fps window is leased, never open-ended. See HIGHFPS_DEFAULT_MINUTES.+  if (highFps) { try { highFpsGrant(sessionId, undefined); } catch (e) {} }    // Prefer to REUSE the startup blank tab (every fresh Chrome opens one) instead   // of creating a new tab and then closing the blank. The startup tab's URL@@ -5548,6 +5656,7 @@ const server = http.createServer(async (req, res) => {               strictPermissions: !!args.strictPermissions,               downloadPath,               nativeSite: !!args.nativeSite,+              highFps: !!args.highFps,               owner: (typeof args.owner === 'string' && args.owner.trim()) ? args.owner.trim() : null,               takeover: !!args.takeover,               // v1.8.24: background-by-default is enforced DURING launch (focus handed@@ -5702,10 +5811,18 @@ const server = http.createServer(async (req, res) => {             // navigated away or closed by any other AI thread without pup refusing).             ownHint = ` TIP: you created this window WITHOUT owner — pass owner:"<your-app-or-task>" on browser_open_window so pup REFUSES other threads' attempts to navigate/close it (session_owned_by_another_thread) instead of letting them take it. Especially important for a long-lived shared window.`;           }+          // v1.9.76 — HIGH-FPS / POWER hint on EVERY open. An AI cannot discover this+          // capability otherwise, and the wrong choice stays invisible until a video comes+          // out at 1-2 fps or a user's battery is flat overnight (issue #14).+          const hf = highFpsInfo(sessionId);+          const fpsHint = hf+            ? ` HIGH-FPS WINDOW, LEASED ${hf.minutes} MIN (expires ${hf.expiresAt}). Chrome's four power-saving mechanisms are DISABLED here, so it renders at FULL RATE even while occluded — that is what makes a background video recording usable. It also means it never idles down, so it is LEASED: when the lease lapses pup DOWNGRADES it to normal power by relaunching the same session with the same tabs (it is NOT closed — your content survives). YOU CANNOT BE CUT OFF MID-RECORDING: while a recording is actually running the lease cannot lapse. If you know you need longer WITHOUT recording the whole time, negotiate now: browser_highfps_extend {sessionId:"${sessionId}", minutes:N} (max ${HIGHFPS_MAX_MINUTES}). pup warns ~2 min before it downgrades.`+            : ` POWER: NORMAL window on Chrome's own power behaviour — it throttles when occluded, so it costs nothing sitting in the background. Right default for driving, scraping and screenshots. If you need to RECORD VIDEO of this window while it is occluded, a normal window only yields ~1-2 fps: reopen with browser_open_window {highFps:true} for full-rate background frames. highFps is leased (default ${HIGHFPS_DEFAULT_MINUTES} min) and auto-downgrades when idle, so it can never flatten the user's battery.`;           sendJSON(res, {             success: true,             output: JSON.stringify({               ok: true,+              highFps: hf || false,               defaultPermissionsApplied: !args.strictPermissions,               defaultPermissions: args.strictPermissions ? [] : PERMISSIVE_DEFAULT_PERMISSIONS.map(p => `${p.name}=${p.setting}`),               downloadsEnabled: !args.strictPermissions,@@ -5725,7 +5842,7 @@ const server = http.createServer(async (req, res) => {               wikiView: isAdomUrl(args.url) ? (args.wikiView === 'authed' ? 'authed' : 'public') : undefined,               wikiLoginNeeded: (args.wikiView === 'authed' && _wikiAuth && !_wikiAuth.authed) || undefined,               foregroundDenied: fgDenied ? 'foreground_reason_required' : undefined,-              _hint: `${wikiHint}${authSteerHint(args.url)}${fgDenyHint}${appIconHint}${bgHint}${ownHint} ${brep._hint}${staleWindowsHint()}${adUpgradeHint()}`,+              _hint: `${wikiHint}${authSteerHint(args.url)}${fgDenyHint}${appIconHint}${bgHint}${ownHint}${fpsHint} ${brep._hint}${staleWindowsHint()}${adUpgradeHint()}`,               ...info,             }),           });@@ -7748,6 +7865,23 @@ const server = http.createServer(async (req, res) => {         }          // ─── Tab recording (CDP screencast, no HUD) ─────────────────────+        // v1.9.76 — renew a high-fps lease. The watchdog WARNS before it downgrades, and a+        // running recording holds the lease open on its own, so an AI only needs this when it+        // knows up front it wants longer than the default without recording the whole time.+        case 'browser_highfps_extend': {+          const sid = args.sessionId || activeSessionId;+          if (!sid || !sessions.has(sid)) { sendJSON(res, { success: false, error: `Session "${sid}" not found` }); return; }+          const info = highFpsInfo(sid);+          if (!info) {+            sendJSON(res, { success: false, error: `Session "${sid}" is not a high-fps window — nothing to extend.`,+              _hint: 'This window runs on Chrome\'s normal power behaviour (the default, and the right choice for almost everything). Only a window opened with browser_open_window {highFps:true} holds a lease. If you need continuous full-rate frames while the window is occluded (background video recording), reopen it with highFps:true.' });+            return;+          }+          const rec = highFpsExtend(sid, args.minutes);+          sendJSON(res, { success: true, output: JSON.stringify({ ok: true, sessionId: sid, highFps: highFpsInfo(sid),+            _hint: `High-fps lease extended to ${rec.minutes} min. Reminder: while a recording is actually running the lease CANNOT lapse, so you do not need to keep extending during a take.` }) });+          return;+        }         case 'browser_record_start': {           try {             const sessionId = args.sessionId || activeSessionId;@@ -7755,6 +7889,11 @@ const server = http.createServer(async (req, res) => {               sendJSON(res, { success: false, error: 'sessionId is required' });               return;             }+            // v1.9.76: recording a window that is NOT high-fps yields ~1-2 fps whenever the+            // window is occluded, because Chrome throttles background paints. That is now the+            // DEFAULT power behaviour (issue #14), so warn loudly instead of shipping a bad take.+            const _hf = highFpsInfo(sessionId);+            if (!_hf) console.log(`[highfps] "${sessionId}" recording started on a NORMAL-power window — frames will drop to ~1-2 fps while it is occluded. Reopen with browser_open_window {highFps:true} for a full-rate background take.`);             const r = await tabRecordStartImpl({ ...args, sessionId });             sendJSON(res, { success: true, output: JSON.stringify({ ok: true, ...r })});           } catch (e) {