diff --git a/SKILL.md b/SKILL.md
index ffaef271..94d5ee9f 100644
--- a/SKILL.md
+++ b/SKILL.md
@@ -79,6 +79,7 @@ Cloud profile cookie sync reference: https://github.com/browser-use/browser-harn
- Clicking: screenshot -> read pixel -> `click_at_xy(x, y)` -> screenshot again.
- After navigation, call `wait_for_load()`.
- If the current tab is stale or internal, call `ensure_real_tab()`.
+- Driving a *visible* browser the user is watching? Set `BH_NO_ACTIVATE=1` so `new_tab()`/`switch_tab()` don't raise the window and steal OS focus. Per call: `new_tab(url, background=True|False)` or `switch_tab(target, activate=True|False)`. The daemon auto-anchors a tab, so no manual sentinel needed.
- Use `js(...)` for DOM inspection or extraction when coordinates are the wrong tool.
- Login walls: stop and ask. Exception: use available SSO automatically when Chrome is already signed in; still stop for passwords, MFA, consent, or ambiguous account choice.
- Raw CDP is available with `cdp("Domain.method", ...)`.
diff --git a/install.md b/install.md
index 52e655f4..3e8e6ab7 100644
--- a/install.md
+++ b/install.md
@@ -83,4 +83,6 @@ browser-harness --update -y
browser-harness telemetry disable
```
+`BH_NO_ACTIVATE=1` stops `new_tab()` / `switch_tab()` from raising the browser window to the OS foreground — useful when driving a visible browser you're watching. Default off. The daemon auto-anchors a real tab so no manual sentinel is needed; per call, override with `new_tab(url, background=True|False)` or `switch_tab(target, activate=True|False)`.
+
State lives under `${XDG_CONFIG_HOME:-~/.config}/browser-harness` by default: auth, telemetry id, agent workspace, runtime sockets, logs, screenshots, and temp files. Override with `BH_HOME` or `BROWSER_HARNESS_HOME`.
diff --git a/src/browser_harness/daemon.py b/src/browser_harness/daemon.py
index a4d7e00e..242a30e1 100644
--- a/src/browser_harness/daemon.py
+++ b/src/browser_harness/daemon.py
@@ -201,9 +201,18 @@ async def attach_first_page(self):
pages = [t for t in targets if is_real_page(t)]
if not pages:
# No real pages - create one instead of attaching to omnibox popup.
- tid = (await self.cdp.send_raw("Target.createTarget", {"url": "about:blank"}))["targetId"]
- log(f"no real pages found, created about:blank ({tid})")
- pages = [{"targetId": tid, "url": "about:blank", "type": "page"}]
+ no_activate = os.environ.get("BH_NO_ACTIVATE") == "1"
+ # BH_NO_ACTIVATE: anchor with a real loaded data: page (not about:blank,
+ # which Chrome treats as internal) so the window keeps a tab that lets a
+ # later new_tab(background) stay backgrounded — no user sentinel needed.
+ # background:true keeps this bootstrap tab from raising the window too.
+ url = "data:text/html,
browser-harness" if no_activate else "about:blank"
+ create_params = {"url": url}
+ if no_activate:
+ create_params["background"] = True # doesn't raise the window (verified macOS)
+ tid = (await self.cdp.send_raw("Target.createTarget", create_params))["targetId"]
+ log(f"no real pages found, created {url[:40]} ({tid})")
+ pages = [{"targetId": tid, "url": url, "type": "page"}]
self.session = (await self.cdp.send_raw(
"Target.attachToTarget", {"targetId": pages[0]["targetId"], "flatten": True}
))["sessionId"]
diff --git a/src/browser_harness/helpers.py b/src/browser_harness/helpers.py
index d1dfd37a..5dc68b6c 100644
--- a/src/browser_harness/helpers.py
+++ b/src/browser_harness/helpers.py
@@ -291,7 +291,12 @@ def _mark_tab():
try: cdp("Runtime.evaluate", expression="if(!document.title.startsWith('\U0001F434'))document.title='\U0001F434 '+document.title")
except Exception: pass
-def switch_tab(target):
+def _no_activate():
+ """True when BH_NO_ACTIVATE=1 — suppress raising the OS window on tab ops."""
+ return os.environ.get("BH_NO_ACTIVATE") == "1"
+
+
+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") or target.get("target_id")) if isinstance(target, dict) else target
@@ -299,17 +304,28 @@ 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)
+ # BH_NO_ACTIVATE=1 skips raising the tab/window to the foreground — activation only
+ # affects OS focus (the 🐴 title marker already shows the controlled tab) and
+ # stealing focus on every new_tab is disruptive when driving a visible browser.
+ # set_session below still routes cdp()/js() to this tab regardless.
+ # activate None → follow BH_NO_ACTIVATE; True/False overrides per call so
+ # new_tab(background=...) can suppress the raise without the env flag.
+ do_activate = (not _no_activate()) if activate is None else activate
+ if do_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=None):
# 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.
- if url != "about:blank":
+ # Reuse the current blank tab only without an explicit background override —
+ # an explicit background=… wants a fresh tab with that activation behavior,
+ # which this reuse path (no createTarget/switch_tab) can't honor.
+ if url != "about:blank" and background is None:
try:
cur = current_tab()
cur_url = cur.get("url") or ""
@@ -318,8 +334,16 @@ def new_tab(url="about:blank"):
return cur.get("targetId") or cur.get("target_id")
except Exception:
pass
- tid = cdp("Target.createTarget", url="about:blank")["targetId"]
- switch_tab(tid)
+ # background controls whether the new tab raises the OS window: None follows
+ # BH_NO_ACTIVATE, True/False overrides per call. switch_tab's activateTarget
+ # gate alone can't stop the raise — Target.createTarget foregrounds the
+ # window on macOS before switch runs, so gate creation here too.
+ bg = _no_activate() if background is None else background
+ params = {"url": "about:blank"}
+ if bg:
+ params["background"] = True # opens the tab without raising the window (verified macOS)
+ tid = cdp("Target.createTarget", **params)["targetId"]
+ switch_tab(tid, activate=not bg)
if url != "about:blank":
goto_url(url)
return tid
diff --git a/tests/unit/test_daemon.py b/tests/unit/test_daemon.py
index 90c5bc85..c766bb90 100644
--- a/tests/unit/test_daemon.py
+++ b/tests/unit/test_daemon.py
@@ -293,3 +293,30 @@ def test_current_tab_meta_returns_not_attached_when_no_target_id():
assert result == {"error": "not_attached"}
# No CDP call should have been issued.
assert d.cdp.calls == []
+
+
+# --- BH_NO_ACTIVATE: bootstrap createTarget backgrounding ---
+
+def _daemon_no_real_pages():
+ """Daemon whose CDP reports zero real pages, so attach_first_page() hits
+ the create-target bootstrap path."""
+ d = daemon.Daemon()
+ class _FakeCDPNoPages:
+ def __init__(self): self.calls = []
+ async def send_raw(self, method, params=None, session_id=None):
+ self.calls.append((method, params, session_id))
+ if method == "Target.getTargets": return {"targetInfos": []}
+ if method == "Target.createTarget": return {"targetId": "tid-new"}
+ if method == "Target.attachToTarget": return {"sessionId": "sess-new"}
+ return {}
+ d.cdp = _FakeCDPNoPages()
+ return d
+
+
+def test_attach_first_page_creates_background_when_no_activate(monkeypatch):
+ monkeypatch.setenv("BH_NO_ACTIVATE", "1")
+ d = _daemon_no_real_pages()
+ asyncio.run(d.attach_first_page())
+ create = next(p for m, p, _ in d.cdp.calls if m == "Target.createTarget")
+ assert create.get("background") is True
+ assert create["url"].startswith("data:") # keepalive: real anchor, not about:blank
diff --git a/tests/unit/test_helpers.py b/tests/unit/test_helpers.py
index 4a45ee07..4a236d82 100644
--- a/tests/unit/test_helpers.py
+++ b/tests/unit/test_helpers.py
@@ -350,3 +350,63 @@ def fake_send(req):
"session filter, the background rWS/lF pair would have updated "
"last_activity and prevented the idle window from elapsing."
)
+
+
+# --- BH_NO_ACTIVATE: new_tab opens the tab without raising the OS window ---
+
+def _record_tab_cdp(monkeypatch, no_activate):
+ """Patch helpers.cdp + _send, returning the list of (method, kwargs) calls."""
+ calls = []
+ def fake_cdp(method, **kw):
+ calls.append((method, kw))
+ if method == "Target.attachToTarget":
+ return {"sessionId": "sid-1"}
+ if method == "Target.createTarget":
+ return {"targetId": "tid-1"}
+ return {}
+ monkeypatch.setattr(helpers, "cdp", fake_cdp)
+ monkeypatch.setattr(helpers, "_send", lambda msg: None)
+ monkeypatch.delenv("BH_NO_ACTIVATE", raising=False)
+ if no_activate:
+ monkeypatch.setenv("BH_NO_ACTIVATE", "1")
+ return calls
+
+
+def test_new_tab_creates_background_when_no_activate(monkeypatch):
+ calls = _record_tab_cdp(monkeypatch, no_activate=True)
+ helpers.new_tab()
+ create = next(kw for m, kw in calls if m == "Target.createTarget")
+ assert create.get("background") is True
+
+
+def test_new_tab_background_param_overrides_env(monkeypatch):
+ # explicit background=True backgrounds even without the env flag
+ calls = _record_tab_cdp(monkeypatch, no_activate=False)
+ helpers.new_tab(background=True)
+ create = next(kw for m, kw in calls if m == "Target.createTarget")
+ assert create.get("background") is True
+
+
+def test_new_tab_background_param_skips_activate(monkeypatch):
+ # regression: backgrounding createTarget isn't enough — new_tab calls
+ # switch_tab, which must also skip Target.activateTarget or the window raises.
+ calls = _record_tab_cdp(monkeypatch, no_activate=False)
+ helpers.new_tab(background=True)
+ assert not any(m == "Target.activateTarget" for m, _ in calls)
+
+
+def test_new_tab_explicit_background_bypasses_blank_reuse(monkeypatch):
+ # with an explicit background override, new_tab must create a fresh tab, not
+ # reuse the current blank one (which skips the activation logic entirely).
+ calls = []
+ def fake_cdp(method, **kw):
+ calls.append((method, kw))
+ if method == "Target.attachToTarget": return {"sessionId": "s"}
+ if method == "Target.createTarget": return {"targetId": "t"}
+ return {}
+ monkeypatch.setattr(helpers, "cdp", fake_cdp)
+ monkeypatch.setattr(helpers, "_send", lambda msg: {"targetId": "cur", "url": "about:blank", "title": ""})
+ monkeypatch.setattr(helpers, "goto_url", lambda *a, **k: None)
+ monkeypatch.delenv("BH_NO_ACTIVATE", raising=False)
+ helpers.new_tab("https://example.com", background=True)
+ assert any(m == "Target.createTarget" for m, _ in calls)