← Commit history

1.8.45: user-AI window balance, real flash clear via off-screen activation, programmatic flash state; capture rules in dev skill

John Lauer ·dceed25b06 · ·parent 4d460e5
6 files changed +216−11
CHANGELOG.md+18
@@ -1,5 +1,23 @@ # Changelog — Adom Desktop Puppeteer Bridge +## 1.8.45 — 2026-07-14 — User↔AI window balance + real flash clear + programmatic flash state++Three related fixes, all from John's live testing. Governing rule now written into `pup-bridge-dev`+("The user↔AI balance"): pup windows are background AI tasks that must not disturb the user, AND real+windows the human must be able to click, SEE, and use normally — both at once.++- **A user click now actually SHOWS the window.** v1.8.44 made the re-park respect a deliberate user+  raise, but it *bailed without moving the window on-screen* — so clicking a still-off-screen window+  activated an invisible window (user saw nothing). Now the user-raise path brings the window ON-SCREEN+  (centered ~80%, `SWP_SHOWWINDOW`, HWND_TOP) and leaves it foreground. Background↔foreground handoff.+- **`browser_alert_window {stop}` truly clears the flash.** `FLASHW_STOP` only stops the *pulse*; the+  Win11 attention tint persists until the window is ACTIVATED. New `osClearFlashByActivate` clears it by+  activating the window OFF-SCREEN (invisible → tint clears) then handing focus back — no pop, no focus+  steal. Reports a real `cleared` / `already-foreground`, never a fire-and-forget guess.+- **Programmatic flash state.** `getSessionInfo.flash` = `none|pending|cleared` (from `_flashState` vs an+  injected `window.__pupFocusTs` focus latch) — answers "which windows are still flashing?" without a+  screenshot.+ ## 1.8.44 — 2026-07-14 — Respect a deliberate user raise (click-to-watch no longer forced back)  A user clicking a background pup window's taskbar button to WATCH a page load saw it snap
adom-bridge-puppeteer-manifest.json+4−4
@@ -1,10 +1,10 @@ {   "manifest_version": 1,   "name": "puppeteer",-  "version": "1.8.44",-  "url": "https://wiki.adom.inc/download/adom/adom-desktop-puppeteer-bridge/1.8.44/adom-bridge-puppeteer-v1.8.44.zip",-  "sha256": "03faafed505047a9d05bccae21e5368b796b89b2f864c5eccd14affc8c76d21a",-  "size": 160960,+  "version": "1.8.45",+  "url": "https://wiki.adom.inc/download/adom/adom-desktop-puppeteer-bridge/1.8.45/adom-bridge-puppeteer-v1.8.45.zip",+  "sha256": "0f649ea50c4b367b5925734ee76d039b1dc02a157ec48f5b99aa857037735d25",+  "size": 163240,   "verbPrefixes": [     "browser_",     "desktop_recorder_",
dev-skills/pup-bridge-dev/SKILL.md+50
@@ -283,6 +283,56 @@ must clear it first. - **A pup-side localhost reachability probe** → redundant; AD core already reprimands a localhost URL when   it fails to load (and HD's working localhost passes clean). Don't re-add it. +## The user↔AI balance — background for the AI, but the HUMAN can take any window (John's rule)++**This is the governing principle, stated by John (2026-07-14), and it OVERRIDES a naive "always keep it+backgrounded" reading of the playbook above.** pup windows are *predominantly* background tasks that must+not disturb the user — BUT the same windows are real windows a human has to be able to **open, click, see,+and use normally**. Both must hold at once. Get the handoff right:++- **Default = background** (off-screen launch → on-screen z-bottom, focus handed back). For AI automation.+- **The moment the user takes a window, it becomes a normal foreground window** — visible, on-screen,+  usable, and it **STAYS** that way. pup must never fight a deliberate user action.++**How pup tells a user-raise from Chrome's self-raise (both make the pup window foreground):**+`GetLastInputInfo` — a physical input (click/key) within ~1.5s while the pup window is foreground means the+**user** did it; Chrome's startup self-raise is programmatic (no input). See the `respectUser` path in+`osBackgroundWindowByPid` and the `_userForeground` latch in `tryDetachedLaunch`.++**When a user-raise is detected, the re-park must do TWO things (missing either is a bug we shipped):**+1. **Bring the window ON-SCREEN and SHOW it** (`SetWindowPos HWND_TOP … SWP_SHOWWINDOW`, centered ~80%).+   The window is still at its off-screen launch coords, so *just bailing out* activates an **invisible**+   window — the user clicks the taskbar button and sees NOTHING. (This was the exact v1.8.44 regression:+   the bail exited without moving it on-screen. Fixed in v1.8.45.)+2. **Latch `_userForeground`** so the remaining re-park passes AND the post-resize re-park leave it alone.+   Once the human has it, pup does not background it again for that session.++**Dead end (do NOT reintroduce):** detecting a user-raise and just `exit`-ing the re-park **without moving+the window on-screen** → the window stays parked off-screen, foreground but invisible, and the user's click+shows nothing. Always transition it to a real visible on-screen window.++## The taskbar flash — pulse vs. the persistent tint (clearing it programmatically)++The flash SET routes through **AD-core `desktop_flash_window`** (`flashViaAD`); do not re-add pup's own+`FlashWindowEx`. But clearing is subtle and cost us a false "it's cleared" claim:++- **`FLASHW_STOP` (AD `{stop}`) only stops the PULSING animation.** On Win11 the button keeps its+  **attention TINT** (orange/pink bg + accent underline) until the window is **ACTIVATED**. So `{stop}`+  returning `applied:true` does **NOT** mean the button went clean — verify, don't claim.+- **The only thing that clears the tint is activating the window.** That's why a user clicking the button+  clears it. To clear it *programmatically without a visible pop*, activate it **off-screen**:+  move off-screen → `SetForegroundWindow` (invisible, clears the tint) → park back on-screen z-bottom →+  hand focus back (`AttachThreadInput`). This is `osClearFlashByActivate`, wired into `browser_alert_window+  {stop}`. It reports `cleared` / `already-foreground` (not a fire-and-forget guess).+- **Programmatic flash STATE** (answer "which windows are still flashing?" without a screenshot):+  `getSessionInfo.flash` = `none|pending|cleared`, from the server-side flash ts (`_flashState`) vs the+  in-page focus latch `window.__pupFocusTs` (injected `focus` listener). `pending` = flashed, user has not+  activated it since; `cleared` = user activated it after the flash.++**Meta-rule (John, 2026-07-14):** think through ALL the variables before shipping a window/focus change —+human-usability AND AI-automation together. When John gives new feedback here, **write it into THIS skill**+so it is never re-derived or re-lost.+ ## Publishing (the wiki has TWO storage layers — don't mix them)  Full recipe in `PUBLISHING.md`. The load-bearing rules:
src/BRIDGE_VERSION+1−1
@@ -1 +1 @@-1.8.44\ No newline at end of file+1.8.45\ No newline at end of file
src/bridge.json+1−1
@@ -2,7 +2,7 @@   "manifest_version": 1,   "name": "puppeteer",   "displayName": "Puppeteer (Chrome for Testing + Native)",-  "version": "1.8.44",+  "version": "1.8.45",   "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+142−5
@@ -248,6 +248,20 @@ function attachTab(session, sessionId, page, { makeActive = true, opener = 'user   // v1.8.21: install the shadow-DOM + scroll helpers on every document so browser_eval   // can use $deep / $$deep / deepText without hand-rolling a shadow-root walk.   try { page.evaluateOnNewDocument(PUP_DEEP_HELPERS).catch(() => {}); } catch (e) {}+  // v1.8.45: track when the USER activates this window (e.g. clicks its taskbar button to+  // watch it) so pup can report per-window flash state PROGRAMMATICALLY. The window 'focus'+  // event fires when the OS window is activated; we stamp the time. A focus AFTER the last+  // flash means the user cleared that flash. Timestamps use the desktop's own clock — the+  // same machine the bridge runs on — so they're directly comparable to _flashState.+  try {+    page.evaluateOnNewDocument(() => {+      try {+        if (window.__pupFocusHook) return; window.__pupFocusHook = true;+        if (typeof window.__pupFocusTs !== 'number') window.__pupFocusTs = 0;+        window.addEventListener('focus', () => { try { window.__pupFocusTs = Date.now(); } catch (e) {} }, true);+      } catch (e) {}+    }).catch(() => {});+  } catch (e) {}   // Tag console logs with tabId so the caller can tell which tab emitted them   page.on('console', msg => {     try {@@ -1621,7 +1635,18 @@ function osBackgroundWindowByPid(pid, cdpPort, restoreHwnd, respectUser) {       '    $lii.cbSize = [uint32][System.Runtime.InteropServices.Marshal]::SizeOf($lii)',       '    [WinBgP]::GetLastInputInfo([ref]$lii) | Out-Null',       '    $idle = [WinBgP]::GetTickCount() - $lii.dwTime',-      '    if ($idle -lt 1500) { Write-Output "user-foreground"; exit }',+      '    if ($idle -lt 1500) {',+      // The USER deliberately raised this window (clicked its taskbar button) to SEE it. It may+      // still be sitting at its off-screen LAUNCH coords, so just "leaving it alone" would show+      // them NOTHING. Transition it into a normal, visible, on-screen window (centered ~80% of+      // the monitor), SHOW it, and bring it to the TOP — and DON'T hand focus back, because the+      // user wants to use it. This is the background↔foreground handoff: AI runs it in the bg,+      // the moment the human takes it, it becomes a real usable window.+      '      $vw = [int]($scrW * 0.8); $vh = [int]($scrH * 0.8)',+      '      $vx = [int](($scrW - $vw) / 2); $vy = [int](($scrH - $vh) / 2)',+      '      foreach($hh in $handles){ [WinBgP]::SetWindowPos($hh, [IntPtr]0, $vx, $vy, $vw, $vh, 0x40) | Out-Null }', // HWND_TOP, SWP_SHOWWINDOW+      '      Write-Output "user-foreground"; exit',+      '    }',       '  }',       '}',       // The window launched OFF-SCREEN (nothing popped up). Move it ON-SCREEN, sized to the@@ -1657,6 +1682,85 @@ function osBackgroundWindowByPid(pid, cdpPort, restoreHwnd, respectUser) {   } catch (e) { console.error(`[bg] pid ${pid} port ${cdpPort}: ${e.message}`); return 'error'; } } +// v1.8.45: TRULY clear a window's taskbar attention (the orange/pink Win11 "flash" tint).+// FLASHW_STOP only stops the PULSE — the tint persists until the window is ACTIVATED. So we+// clear it the only way Windows allows: activate the window. To avoid a visible pop, we move+// it OFF-SCREEN first, activate it there (invisible → clears the tint), move it back on-screen+// at z-bottom, then hand focus straight back to whatever the user was in (AttachThreadInput).+// Net effect: the flash clears, nothing pops, focus is never stolen. Returns "cleared" /+// "cleared-still-foreground" / "already-foreground" (user already had it) / "no-window".+function osClearFlashByActivate(pid, cdpPort) {+  if (process.platform !== 'win32' || (!pid && !cdpPort)) return 'error';+  try {+    const port = Number(cdpPort) || 0;+    const scriptPath = path.join(require('os').tmpdir(), `_adom_clrflash_${pid || port}.ps1`);+    const ps = [+      'Add-Type @"',+      'using System; using System.Runtime.InteropServices;',+      'public class WinClr {',+      '  [DllImport("user32.dll")] public static extern bool SetWindowPos(IntPtr h, IntPtr a, int x, int y, int cx, int cy, uint f);',+      '  [DllImport("user32.dll")] public static extern IntPtr GetForegroundWindow();',+      '  [DllImport("user32.dll")] public static extern int GetSystemMetrics(int i);',+      '  [DllImport("user32.dll")] public static extern uint GetWindowThreadProcessId(IntPtr h, out uint pid);',+      '  [DllImport("user32.dll")] public static extern bool AttachThreadInput(uint a, uint b, bool f);',+      '  [DllImport("user32.dll")] public static extern bool SetForegroundWindow(IntPtr h);',+      '  [DllImport("user32.dll")] public static extern bool BringWindowToTop(IntPtr h);',+      '  [DllImport("kernel32.dll")] public static extern uint GetCurrentThreadId();',+      '}',+      '"@',+      `$targetPid = ${Number(pid) || 0}`,+      `$cdpPort = ${port}`,+      '$BOTTOM = [IntPtr]1',                  // HWND_BOTTOM+      '$NOACT = 0x10',                         // SWP_NOACTIVATE+      '$scrW = [WinClr]::GetSystemMetrics(0)',+      '$scrH = [WinClr]::GetSystemMetrics(1)',+      '$handles = New-Object System.Collections.ArrayList',+      '$pids = New-Object System.Collections.ArrayList',+      'if ($targetPid -ne 0) { [void]$pids.Add($targetPid) }',+      'if ($cdpPort -ne 0) { try { $own = Get-NetTCPConnection -LocalPort $cdpPort -State Listen -ErrorAction SilentlyContinue | Select-Object -First 1 -ExpandProperty OwningProcess; if ($own) { [void]$pids.Add([int]$own) } } catch {} }',+      'foreach ($tp in $pids) {',+      '  $p = Get-Process -Id $tp -ErrorAction SilentlyContinue',+      '  if ($p -and $p.MainWindowHandle -ne 0) { [void]$handles.Add($p.MainWindowHandle) }',+      '  Get-CimInstance Win32_Process -Filter "ParentProcessId=$tp" -ErrorAction SilentlyContinue | Where-Object { $_.Name -in @("chrome.exe","msedge.exe") } | ForEach-Object { $cp = Get-Process -Id $_.ProcessId -ErrorAction SilentlyContinue; if ($cp -and $cp.MainWindowHandle -ne 0) { [void]$handles.Add($cp.MainWindowHandle) } }',+      '}',+      'if ($handles.Count -eq 0) { Write-Output "no-window"; exit }',+      '$pup = $handles[0]',+      '$myTid = [WinClr]::GetCurrentThreadId()',+      // Snapshot the user's current foreground so we can restore it. If it's ALREADY the pup+      // window, the user has it up (flash is moot) — do nothing.+      '$userFg = [WinClr]::GetForegroundWindow()',+      'foreach($h in $handles){ if($h -eq $userFg){ Write-Output "already-foreground"; exit } }',+      // 1. Move every handle OFF-SCREEN (invisible), without activating.+      'foreach($h in $handles){ [WinClr]::SetWindowPos($h, $BOTTOM, -32000, -32000, $scrW, $scrH, $NOACT) | Out-Null }',+      // 2. Activate the pup window off-screen → clears the taskbar attention tint. Bypass the+      //    foreground lock by attaching our input thread to the current foreground's thread.+      '$uProc = [uint32]0',+      '$uTid = [WinClr]::GetWindowThreadProcessId($userFg, [ref]$uProc)',+      '[WinClr]::AttachThreadInput($uTid, $myTid, $true) | Out-Null',+      '[WinClr]::SetForegroundWindow($pup) | Out-Null',+      '[WinClr]::BringWindowToTop($pup) | Out-Null',+      '[WinClr]::AttachThreadInput($uTid, $myTid, $false) | Out-Null',+      'Start-Sleep -Milliseconds 60',+      // 3. Park back on-screen at z-bottom (invisible behind the user's windows).+      'foreach($h in $handles){ [WinClr]::SetWindowPos($h, $BOTTOM, 0, 0, $scrW, $scrH, $NOACT) | Out-Null }',+      // 4. Hand focus back to the user's window.+      '$pProc = [uint32]0',+      '$pTid = [WinClr]::GetWindowThreadProcessId($pup, [ref]$pProc)',+      '[WinClr]::AttachThreadInput($pTid, $myTid, $true) | Out-Null',+      '[WinClr]::SetForegroundWindow($userFg) | Out-Null',+      '[WinClr]::BringWindowToTop($userFg) | Out-Null',+      '[WinClr]::AttachThreadInput($pTid, $myTid, $false) | Out-Null',+      '$fg2 = [WinClr]::GetForegroundWindow()',+      '$stillPup = $false',+      'foreach($h in $handles){ if($h -eq $fg2){ $stillPup = $true } }',+      'if ($stillPup) { Write-Output "cleared-still-foreground" } else { Write-Output "cleared" }',+    ].join('\r\n');+    fs.writeFileSync(scriptPath, ps);+    const out = runPsHidden(scriptPath).trim();+    return out || 'error';+  } catch (e) { console.error(`[clrflash] pid ${pid} port ${cdpPort}: ${e.message}`); return 'error'; }+}+ // NOTE: localhost-URL reprimand (container-vs-desktop) is handled by AD CORE, which // appends a container-topology hint to a failed response whose URL is localhost. pup // doesn't duplicate it — and because AD keys on the connection FAILURE, Hydrogen@@ -2845,6 +2949,22 @@ async function getSessionInfo(sessionId, session) {     // 'still-foreground' (focus steal — a bug), 'no-window', or null (foreground open     // or non-Windows). Self-verified by re-reading the OS foreground window post-open.     background: (() => { try { return browsers.get(session.profileName)?.browser?._bgResult || null; } catch (e) { return null; } })(),+    // v1.8.45: PROGRAMMATIC flash state. 'none' = not flashed; 'pending' = flashed and the+    // user has NOT activated it since (taskbar still lit); 'cleared' = the user activated it+    // (clicked its taskbar button) after the flash, so the tint is gone. Computed from the+    // server-side flash timestamp vs the in-page focus latch (window.__pupFocusTs, set by the+    // injected 'focus' listener). Lets a caller answer "which windows are still flashing?"+    // without a screenshot.+    flash: await (async () => {+      try {+        const key = session._sessionId || sessionId;+        const flashTs = _flashState.get(key) || 0;+        if (!flashTs) return 'none';+        let focusTs = 0;+        try { focusTs = await session.page.evaluate(() => window.__pupFocusTs || 0); } catch (e) {}+        return (focusTs && focusTs > flashTs) ? 'cleared' : 'pending';+      } catch (e) { return null; }+    })(),   };   // One-shot: surface launch-time metadata (restored-tab cleanup count)   // so callers can verify session-restore prevention worked. Drop after read.@@ -3440,13 +3560,30 @@ const server = http.createServer(async (req, res) => {           const session = sessions.get(sessionId);           try {             const stop = !!(args.stop || args.clear);-            // v1.8.43: route through AD's desktop_flash_window (reliable, native). AWAIT the-            // real result — no more fire-and-forget cleared:true lies.+            // v1.8.43: route the FLASH through AD's desktop_flash_window (reliable, native).             const r = await flashViaAD(session._sessionId || sessionId, { stop });             let applied = false;             try { const o = r && (typeof r.output === 'string' ? JSON.parse(r.output) : r.output || r); applied = !!(o && (o.ok || o.count)); } catch (e) {}-            console.log(`${stop ? 'Cleared' : 'Flashed'} taskbar for session "${sessionId}" (AD applied=${applied})`);-            sendJSON(res, { success: true, output: JSON.stringify({ ok: true, sessionId, alerted: !stop, cleared: stop, applied, _hint: applied ? (stop ? 'Cleared the taskbar flash for this window via AD desktop_flash_window.' : 'Flashed the taskbar (finite 4-count) via AD, no focus steal. Pass {stop:true} to clear.') : 'AD desktop_flash_window did not match the window (title changed? window closed?) — nothing applied.' }) });+            // v1.8.45: FLASHW_STOP only stops the PULSE — the Win11 attention tint persists+            // until the window is ACTIVATED. So on {stop} we ALSO clear the tint for real via an+            // off-screen activate-and-restore (no pop, focus handed straight back). This is the+            // fix for "you said you cleared it but the button is still lit."+            let clearMethod = null;+            if (stop && process.platform === 'win32') {+              try {+                const br = browsers.get(session.profileName) && browsers.get(session.profileName).browser;+                if (br && (br._detachedPid || br._cdpPort)) clearMethod = osClearFlashByActivate(br._detachedPid, br._cdpPort);+              } catch (e) {}+              try { _flashState.delete(session._sessionId || sessionId); } catch (e) {}+            }+            const trulyCleared = stop && ['cleared', 'cleared-still-foreground', 'already-foreground'].includes(clearMethod);+            console.log(`${stop ? 'Cleared' : 'Flashed'} taskbar for session "${sessionId}" (AD applied=${applied}, clear=${clearMethod})`);+            const hint = stop+              ? (trulyCleared+                  ? `Flash truly cleared — activated the window off-screen to drop the Win11 attention tint (${clearMethod}), then handed focus back. No pop, no focus steal.`+                  : `Could not fully clear (${clearMethod || 'window not found'}). FLASHW_STOP ran (applied=${applied}) but the persistent tint needs activation and that step failed.`)+              : (applied ? 'Flashed the taskbar (finite 4-count) via AD, no focus steal. Pass {stop:true} to clear.' : 'AD desktop_flash_window did not match the window (title changed? window closed?).');+            sendJSON(res, { success: true, output: JSON.stringify({ ok: true, sessionId, alerted: !stop, cleared: stop ? trulyCleared : false, applied, clearMethod, _hint: hint }) });           } catch (e) {             sendJSON(res, { success: false, error: `Alert failed: ${e.message}` });           }