← Commit history

THE FIGHT-THE-USER FIX (John: 'pup windows still don't behave well and seem to freeze a lot' — he was right, pup was doing something deeply wrong). Root cause: pup could not tell 'the user is using this window' from 'this window drifted on-screen', so recovery re-parked every window to the bottom after every bridge restart (the foreground flag was never persisted to the session file), and reclaim-adoption parked windows the user was actively reading after every crash. Fixes: (1) session files persist the foreground choice and recovery restores it BEFORE the rewrite; (2) new user-activity watcher: the 15s sweep observes when the OS-foreground window IS a pup window and stamps it foreground (persisted) — taskbar clicks now count exactly like a granted raise, implementing PLACEMENT-INVARIANT item 1; (3) reclaim-adoption skips the park when the window is the user's foreground; (4) recovery park guarded by userIsUsingWindow (10-min recency). Load reduction so pup stops pounding the machine: tab reconciler judges liveness from browser.targets() instead of waking every throttled renderer with page.evaluate every 45s; the unstick sweep probes only the one foreground-matched window (was: every session every 15s); the 20s progress reconciler only fires for bars actually lit (was: an AD PowerShell call per session per tick forever); session hwnds cached 10 min.

John Lauer ·c44227fa93 ·3mo ago ·parent 4bff490
4 files changed +105−25
adom-bridge-puppeteer-manifest.json+4−4
@@ -1,10 +1,10 @@ {   "manifest_version": 1,   "name": "puppeteer",-  "version": "1.9.180",-  "url": "https://wiki.adom.inc/download/adom/adom-desktop-puppeteer-bridge/1.9.180/adom-bridge-puppeteer-v1.9.180.zip",-  "sha256": "75758452dd7e61f1eea7b34e4a8ac9806eaf633d48db6e52e1eb714cdb6e94bc",-  "size": 416877,+  "version": "1.9.181",+  "url": "https://wiki.adom.inc/download/adom/adom-desktop-puppeteer-bridge/1.9.181/adom-bridge-puppeteer-v1.9.181.zip",+  "sha256": "4a073f79a8eb5f633ce637b5aa928a83c46a026fc4e637f826dc4689a5c18117",+  "size": 418796,   "verbPrefixes": [     "browser_",     "desktop_recorder_",
src/BRIDGE_VERSION+1−1
@@ -1 +1 @@-1.9.180+1.9.181
src/bridge.json+1−1
@@ -2,7 +2,7 @@   "manifest_version": 1,   "name": "puppeteer",   "displayName": "Puppeteer (Chrome for Testing + Native)",-  "version": "1.9.180",+  "version": "1.9.181",   "description": "Drives a browser ALREADY on the machine \u2014 installed Chrome, else Microsoft Edge (every Windows PC ships with Edge) \u2014 launched with a FRESH isolated profile, so the common case has NO ~150 MB Chrome-for-Testing download and it never touches the user's real tabs/logins. Binds its local server to LOOPBACK (127.0.0.1) only, so no Windows Firewall 'allow Node through the firewall' prompt ever pops. Chrome for Testing is fetched only as a LAST RESORT if the machine has no Chromium browser at all. SPAWN-VERIFIED fallback: pup tries each detected browser (Chrome \u2192 Edge \u2192 CfT) and actually launches it, falling through to the next if one won't start (and handling browsers like Edge that relaunch onto another PID), so a corrupt/partial install never hard-fails an open. The browser that launches is cached as the default (persisted across restarts) so it isn't re-probed each time. On spawn the bridge SELF-WARMS: detects browsers + disk, and only if the box has no Chrome/Edge and no cached CfT does it background-fetch CfT so the first open is instant. browser_use pins a specific browser (chrome | edge | cft | auto); {browser:'chrome',install:true} downloads & installs REAL Google Chrome (user-scoped, no admin) on an Edge-only box. browser_readiness (READ-ONLY) reports which browser will be used + the full candidate list + the default + free disk. Every failure returns a specific errorCode + _hint (chrome_install_no_disk, chrome_download_network, chrome_install_permission, chrome_stable_not_detected, \u2026). Launches with FULL browser permissions by default for full debug (strictPermissions:true opts out). Headful automation: open/close windows+tabs, navigate, screenshot, eval JS, record. pup is a GENERIC, non-profile browser only \u2014 every session is a fresh isolated Chrome/Edge process, never the user's real signed-in browser; profile defaults to sessionId. To drive the user's REAL Chrome/Edge (their profile, cookies, SSO), use the Adom browser EXTENSION's nbrowser_* verbs \u2014 a separate bridge, not pup's job. macOS (v1.9.74+): full parity with mac-native mechanics \u2014 background-by-default is the app-level hide (windows stay drivable/screenshottable while hidden), raise/lower map to unhide+activate/hide, browser_alert_window posts a native toast whose click focuses the window, browser_use {browser:'chrome',install:true} installs Chrome from the official dmg, and taskbar identity/badging remains Windows-only.",   "homepage": "https://wiki.adom.inc/adom/adom-desktop",   "author": "Adom Inc.",
src/server.js+99−19
@@ -872,17 +872,26 @@ function _pageTargetId(page) { // merely-slow tab is never wrongly dropped. async function reconcileSessionTabs(session, sessionId) {   if (!session || !Array.isArray(session.tabs) || session.tabs.length === 0) return 0;+  // v1.9.181: liveness via the BROWSER-process target list, not a per-tab page.evaluate. The eval+  // probe woke every throttled renderer on every sweep (the issue-#14 battery sin, re-committed) and+  // added constant CDP load. browser.targets() answers from the browser process alone: a tab whose+  // targetId is absent is dead; two tabs sharing one targetId are the reconnect-duplicate case.+  let liveTargets = null;+  try {+    const be = browsers.get(session.profileName);+    if (be && be.browser && be.browser.isConnected()) {+      liveTargets = new Set(be.browser.targets().filter(t => t.type() === 'page').map(t => t._targetId || (t._targetInfo && t._targetInfo.targetId)).filter(Boolean));+    }+  } catch (e) {}+  if (!liveTargets) return 0;   // no browser handle → can't judge; never drop tabs blind   const kept = []; const seenTargets = new Set(); let dropped = 0;   for (const t of session.tabs) {     let dead = false;     try {       if (!t.page || t.page.isClosed()) dead = true;-      else {-        try { await withTimeout(t.page.evaluate('1'), 1500, 'tab-reconcile'); }-        catch (e) { if (_isCrashedFrameError(e)) dead = true; /* timeout = busy → keep */ }-      }     } catch (e) { dead = true; }     const tid = dead ? null : _pageTargetId(t.page);+    if (!dead && tid && !liveTargets.has(tid)) dead = true;     if (dead || (tid && seenTargets.has(tid))) { dropped++; continue; }     if (tid) seenTargets.add(tid);     kept.push(t);@@ -970,6 +979,11 @@ function saveSessionFile(sessionId, profileName, url) {   const data = {     sessionId, profile: profileName, url: url || null,     pid, cdpPort,+    // v1.9.181: persist the user's foreground choice. Without this, EVERY bridge restart forgot that+    // the user had this window up and recovery re-parked it to the bottom mid-use — the single+    // biggest "pup windows don't behave / keep vanishing" driver (John, 2026-07-29). The flag is set+    // by a granted raise OR by pup OBSERVING the user foreground the window themselves (taskbar click).+    foreground: !!((sessions.get(sessionId) || {})._foreground),     createdAt: new Date().toISOString(),     updatedAt: new Date().toISOString(),   };@@ -1626,6 +1640,10 @@ async function recoverSessions() {       const session = createSession(profileName);       attachTab(session, sessionId, page);       session._sessionId = sessionId; // v1.8.43: for AD taskbar/flash titleContains resolution+      // v1.9.181: restore the persisted foreground choice BEFORE the saveSessionFile rewrite below+      // (which reads it back out of the session) — otherwise every restart wiped the flag and the+      // park below yanked a window the user was actively using to the bottom (John, 2026-07-29).+      if (sf.foreground) { session._foreground = true; session._userActiveAt = Date.now(); }       sessions.set(sessionId, session);       const be = browsers.get(profileName);       if (be) be.refCount++;@@ -1641,10 +1659,12 @@ async function recoverSessions() {       // top of the user's screen and nothing ever pushed it back down. That is John's "after my reboot       // almost all pup windows are opening in the foreground". Park unless the session was explicitly       // left in the foreground by a user-granted raise (that state is recorded on the session file).-      if (!session._foreground) {+      if (!userIsUsingWindow(session)) {         parkSessionWindowDirect(page, sessionId)           .then(r => console.log(`[park] "${sessionId}" (recovered): ${r}`))           .catch(e => console.log(`[park] "${sessionId}" (recovered) failed: ${e.message}`));+      } else {+        console.log(`[park] "${sessionId}" (recovered): SKIPPED — user had this window foregrounded (persisted flag); leaving it exactly where they had it`);       }     } catch (e) {       console.error(`Failed to recover session "${sessionId}": ${e.message}`);@@ -2285,17 +2305,40 @@ function forceActivateHwnd(hwnd) {   } catch (e) { return false; } } +// v1.9.181: cached session hwnds so periodic sweeps don't call AD's find_window per session per tick.+// TTL 10 min; a stale hwnd just fails a foreground match harmlessly and refreshes on the next miss.+const _hwndCache = new Map();     // sessionId -> { hwnd, ts }+async function cachedSessionHwnd(sessionId) {+  const c = _hwndCache.get(sessionId);+  if (c && Date.now() - c.ts < 10 * 60000) return c.hwnd;+  const hwnd = await sessionHwnd(sessionId);+  if (hwnd) _hwndCache.set(sessionId, { hwnd, ts: Date.now() });+  else _hwndCache.delete(sessionId);+  return hwnd;+}++// v1.9.181: is the user actively using this window? True when a granted raise OR an OBSERVED user+// foreground (taskbar click, alt-tab) marked it, with a 10-minute recency window. Every park/demote+// path must check this — pup must NEVER pull a window out from under the user (John, 2026-07-29:+// "pup windows still don't behave well"; root cause was exactly these unguarded demotions).+function userIsUsingWindow(session) {+  if (!session) return false;+  if (session._foreground) return true;+  return !!(session._userActiveAt && Date.now() - session._userActiveAt < 10 * 60000);+}+ const _unstickLast = new Map();   // sessionId -> ts of last unstick attempt (don't nag every tick)-async function unstickIfHiddenWhileFront(sessionId, session, fgHwnd) {+// v1.9.181: hwnd arrives precomputed from the sweep (which already matched it against the OS+// foreground), so this probes the ONE matched window — the old version evaluated visibility in EVERY+// session every tick, waking throttled renderers bridge-wide (the exact battery/load sin of issue #14).+async function unstickIfHiddenWhileFront(sessionId, session, fgHwnd, hwnd) {   if (process.platform !== 'win32' || !session) return false;   const tab = (session.tabs || []).find(t => t.tabId === session.activeTabId) || (session.tabs || [])[0];   if (!tab || !tab.page || tab._crashed) return false;+  if (!hwnd || String(fgHwnd) !== String(hwnd)) return false;   let vis = null;   try { vis = await withTimeout(tab.page.evaluate('document.visibilityState'), 1500, 'vis-probe'); } catch (e) { return false; }   if (vis !== 'hidden') return false;-  // hidden is NORMAL for a background window — only frontmost-yet-hidden is the broken state.-  const hwnd = await sessionHwnd(sessionId);-  if (!hwnd || String(fgHwnd) !== String(hwnd)) return false;   const last = _unstickLast.get(sessionId) || 0;   if (Date.now() - last < 30000) return false;   _unstickLast.set(sessionId, Date.now());@@ -4005,9 +4048,24 @@ async function reclaimUnmanagedWindows() {         // v1.9.70: a RECLAIMED window was never parked either. These are windows pup did not launch         // (an openerless popup, or an orphan whose session died), so they arrive wherever the shell         // put them — usually on top. Background them like any pup-launched window.-        parkSessionWindowDirect(group[0], newId)-          .then(r => console.log(`[park] "${newId}" (reclaimed): ${r}`))-          .catch(() => {});+        // v1.9.181: …UNLESS the user is LOOKING AT IT. Adoption often re-claims a window whose+        // session a crash dropped — a window the user may be actively reading. Parking it then+        // yanks it to the bottom mid-use (John hit this repeatedly). If it is the OS-foreground+        // window, respect the user: mark it foreground instead of parking.+        (async () => {+          try {+            const fg = osGetForegroundWindow();+            const hw = fg ? await cachedSessionHwnd(newId) : null;+            if (fg && hw && String(fg) === String(hw)) {+              ns._foreground = true; ns._userActiveAt = Date.now();+              try { saveSessionFile(newId, profileName, u); } catch (e) {}+              console.log(`[park] "${newId}" (reclaimed): SKIPPED — it is the user's foreground window; marked foreground instead`);+              return;+            }+            const r = await parkSessionWindowDirect(group[0], newId);+            console.log(`[park] "${newId}" (reclaimed): ${r}`);+          } catch (e) {}+        })();         try { saveSessionFile(newId, profileName, u); } catch (e) {}         reclaimed++;       }@@ -8472,7 +8530,8 @@ const server = http.createServer(async (req, res) => {               try {                 const s = sessions.get(sessionId);                 const fg = osGetForegroundWindow();-                if (s && fg) await unstickIfHiddenWhileFront(sessionId, s, fg);+                const hw = s ? await cachedSessionHwnd(sessionId) : null;+                if (s && fg && hw) await unstickIfHiddenWhileFront(sessionId, s, fg, hw);               } catch (e) {}             }, 2000);             sendJSON(res, { success: true, output: JSON.stringify({ ok: true, sessionId, focused: true }) });@@ -10407,22 +10466,43 @@ server.listen(PORT, BIND_HOST, async () => {         _healingIds.delete(sid);       }     }-    // (3) STUCK-OCCLUSION check (v1.9.180) — a pup window that IS the OS-foreground window while its-    // page claims hidden is frame-frozen in front of the user's eyes; unstick it. One cheap OS read-    // per tick, and the per-session probe is timeout-bounded.+    // (3) USER-ACTIVITY WATCHER + STUCK-OCCLUSION check (v1.9.180/181). One OS foreground read per+    // tick. When the foreground IS a pup window: (a) stamp it user-active + foreground (PERSISTED),+    // so no park/recovery/adoption path ever demotes a window the user chose to bring up — the+    // missing primitive behind "pup keeps yanking my windows" (PLACEMENT-INVARIANT item 1, John's+    // design, finally implemented); (b) run the stuck-occlusion unstick on that ONE window only.     try {       if (sessions.size > 0) {         const fg = osGetForegroundWindow();-        if (fg) { for (const [sid, sess] of [...sessions]) { try { await unstickIfHiddenWhileFront(sid, sess, fg); } catch (e) {} } }+        if (fg) {+          for (const [sid, sess] of [...sessions]) {+            try {+              const hwnd = await cachedSessionHwnd(sid);+              if (!hwnd || String(fg) !== String(hwnd)) continue;+              sess._userActiveAt = Date.now();+              if (!sess._foreground) {+                sess._foreground = true;+                console.log(`[useractive] "${sid}" observed as the USER'S foreground window — marking foreground (persisted); pup will not re-park it`);+                try { let u = null; try { u = sess.page && sess.page.url(); } catch (e) {} saveSessionFile(sid, sess.profileName, u); } catch (e) {}+              }+              await unstickIfHiddenWhileFront(sid, sess, fg, hwnd);+              break;   // only one window can be foreground+            } catch (e) {}+          }+        }       }     } catch (e) {}   }, 15000);   // v1.9.166: taskbar-progress reconciler — clear any bar that is lit but shouldn't be.+  // v1.9.181: only for sessions whose bar is ACTUALLY lit (_progressSince set). The old loop fired an+  // AD desktop_taskbar call (a PowerShell round-trip on the desktop) for EVERY session EVERY 20s+  // forever — with 10 windows that was 30 pointless PS invocations a minute pounding the machine pup+  // is supposed to stay invisible on. Steady-state is now zero calls.   setInterval(async () => {     if (process.platform !== 'win32' || pupSettings().agentActivity === 'off') return;-    for (const [sid] of [...sessions]) {+    for (const [sid, sess] of [...sessions]) {       const st = _busyState.get(sid);-      if (!st || !st.busy) { try { await setSessionProgress(sid, false); } catch (e) {} }+      if ((!st || !st.busy) && sess && sess._progressSince) { try { await setSessionProgress(sid, false); } catch (e) {} }     }   }, 20000);   // v1.9.145 (John found v21-review stranded at -32000 all day) — PLACEMENT SWEEP. The healer