diff --git a/src/browser_harness/helpers.py b/src/browser_harness/helpers.py index 2014887..2887b3f 100644 --- a/src/browser_harness/helpers.py +++ b/src/browser_harness/helpers.py @@ -157,6 +157,7 @@ def _has_return_statement(expression): # --- navigation / page --- def goto_url(url): + _tab_event("goto_url", f"url={url[:90]}") r = cdp("Page.navigate", url=url) if os.environ.get("BH_DOMAIN_SKILLS") != "1": return r @@ -295,12 +296,55 @@ def current_tab(): r = _send({"meta": "current_tab"}) return {"targetId": r["targetId"], "url": r["url"], "title": r["title"]} +def _tab_event(fn, detail=""): + """One stderr line per tab-mutating CDP call, stamped with the entry + script. These calls (Target.createTarget / activateTarget / closeTarget) + can ACTIVATE Chrome on macOS — raise the window and steal app focus. The + S4L menubar's browser-foreground telemetry (s4l_browser_foreground.py, + [browser-foreground] lines in menubar.err.log) records every activation + with a timestamp; this line is the other half of the correlation: WHICH + harness call fired at that moment, from WHICH pipeline step. Added + 2026-07-14 to attribute chronic 1-5x/hour focus steals.""" + try: + ts = time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime()) + entry = os.path.basename(sys.argv[0] or "?") if sys.argv else "?" + print( + f"[bh_tab_event] {ts} fn={fn} entry={entry} " + f"bu={os.environ.get('BU_NAME', '?')} {detail}", + file=sys.stderr, flush=True, + ) + except Exception: + pass + + def _mark_tab(): """Prepend horse emoji to tab title so the user can see which tab the agent controls.""" try: cdp("Runtime.evaluate", expression="if(!document.title.startsWith('\U0001F434'))document.title='\U0001F434 '+document.title") except Exception: pass -def switch_tab(target): +def _is_offscreen_harness(): + """True when this harness window is parked offscreen (BH_WINDOW_POS with a + negative coordinate), i.e. a background automation harness that must NEVER + steal the user's OS focus. The three S4L harnesses (twitter/reddit/linkedin) + all launch at y=-1032; interactive browsers sit onscreen (e.g. 100,100). + Cached; env is fixed for a process's life.""" + global _OFFSCREEN_CACHE + try: + return _OFFSCREEN_CACHE + except NameError: + pass + off = False + try: + xy = os.environ.get("BH_WINDOW_POS", "") + if xy: + off = any(int(float(v)) < 0 for v in xy.split(",")[:2]) + except Exception: + off = False + globals()["_OFFSCREEN_CACHE"] = off + return off + + +def switch_tab(target, activate=None): # Accept either a raw targetId string or the dict returned by current_tab() / list_tabs(), # so `switch_tab(current_tab())` works without a manual ["targetId"] dance. target_id = target.get("targetId") if isinstance(target, dict) else target @@ -308,18 +352,48 @@ def switch_tab(target): # plus the trailing space = 3 code units, so slice(3) cleanly removes the prefix. try: cdp("Runtime.evaluate", expression="if(document.title.startsWith('\U0001F434 '))document.title=document.title.slice(3)") except Exception: pass - cdp("Target.activateTarget", targetId=target_id) + _tab_event("switch_tab", f"target={target_id}") + # Target.activateTarget raises/focuses the OS window (2026-07-15: 7/7 + # same-second harness_browser_foregrounded correlation) but is NOT needed + # for CDP automation — Page.captureScreenshot and Input.dispatchMouseEvent + # are session-scoped and the occlusion flags keep offscreen tabs painting. + # So on an offscreen automation harness (twitter/reddit/linkedin) skip it: + # this is the SINGLE shared fix for the reddit focus-pops that twitter had + # to work around per-caller. Callers wanting the old behavior pass + # activate=True; interactive (onscreen) harnesses still activate by default. + if activate is None: + activate = not _is_offscreen_harness() + if activate: + cdp("Target.activateTarget", targetId=target_id) sid = cdp("Target.attachToTarget", targetId=target_id, flatten=True)["sessionId"] _send({"meta": "set_session", "session_id": sid, "target_id": target_id}) _mark_tab() return sid -def new_tab(url="about:blank"): +def new_tab(url="about:blank", background=False): # Always create blank, then goto: passing url to createTarget races with # attach, so the brief about:blank is "complete" by the time the caller # polls and wait_for_load() returns before navigation actually starts. - tid = cdp("Target.createTarget", url="about:blank")["targetId"] - switch_tab(tid) + # + # background=True creates the target without focusing it AND attaches + # without Target.activateTarget — activation is proven to raise/focus the + # Chrome window on macOS (2026-07-15: 7/7 same-second correlation with + # harness_browser_foregrounded). Recovery paths that mint a replacement + # tab for a wedged one must never pop the window. + # On an offscreen automation harness, ALWAYS create in background (no + # window raise) regardless of the caller's default — same rationale as + # switch_tab. This is what makes the reddit DM/engage flow (which calls + # new_tab(background=False) per destination) stop popping without touching + # every caller. Onscreen/interactive harnesses keep the requested behavior. + bg = background or _is_offscreen_harness() + _tab_event("new_tab", f"url={url} background={bg}") + tid = cdp("Target.createTarget", url="about:blank", background=bg)["targetId"] + if bg: + sid = cdp("Target.attachToTarget", targetId=tid, flatten=True)["sessionId"] + _send({"meta": "set_session", "session_id": sid, "target_id": tid}) + _mark_tab() + else: + switch_tab(tid) if url != "about:blank": goto_url(url) return tid @@ -330,6 +404,7 @@ def close_tab(target=None): target_id = target.get("targetId") if isinstance(target, dict) else target if target_id is None: target_id = current_tab()["targetId"] + _tab_event("close_tab", f"target={target_id}") cdp("Target.closeTarget", targetId=target_id)