Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,10 @@ PY
- Invoke as `browser-harness`. Use heredocs for multi-line commands.
- Helpers are pre-imported. `run.py` calls `ensure_daemon()` before `exec`.
- First navigation is `new_tab(url)`, not `goto_url(url)`.
- `new_tab()` and `switch_tab()` attach and move the horse marker without
changing Chrome's visible tab. Screenshots and normal CDP input work in the
background; call `activate_tab(target)` only when the user explicitly asks
or a page demonstrably pauses rendering while hidden.
- The normal local flow attaches to the running Chrome/Chromium CDP endpoint. No browser ids or local profile selection.

## Local Chrome
Expand Down
12 changes: 8 additions & 4 deletions interaction-skills/connection.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,15 +4,15 @@

When Chrome opens fresh, the only CDP `type: "page"` targets are `chrome://inspect` and `chrome://omnibox-popup.top-chrome/` (a 1px invisible viewport). If the daemon attaches to the omnibox popup, all subsequent work — including `new_tab()` and `goto_url()` — happens on tabs that exist in CDP but may not be visible in the Chrome UI.

The daemon's `attach_first_page()` handles this by creating an `about:blank` tab when no real pages exist. If you still end up on an invisible tab, use `switch_tab()` which calls `Target.activateTarget` to bring the tab to front.
The daemon's `attach_first_page()` handles this by creating an `about:blank` tab when no real pages exist. If you still end up on an invisible tab, use `switch_tab()` to attach to the real tab; call `activate_tab()` only when Chrome must visibly show it.

## Startup sequence

1. Check if a daemon is already running with `daemon_alive()`
2. If stale sockets exist but daemon is dead, clean them up
3. List open tabs with `list_tabs()` to see what's available
4. `ensure_real_tab()` attaches to a real page
5. `switch_tab(target_id)` both attaches AND activates (brings to front)
5. `switch_tab(target_id)` attaches without changing the visible Chrome tab; use `activate_tab(target_id)` for an explicit visible switch

```python
if not daemon_alive():
Expand All @@ -31,16 +31,20 @@ tab = ensure_real_tab()

## Bringing Chrome to front

If Chrome is behind other windows or on another desktop:
If Chrome is behind other windows or on another desktop and the user explicitly wants it shown:

```python
import subprocess
subprocess.run(["osascript", "-e", 'tell application "Google Chrome" to activate'])
```

For normal agent work, do not activate Chrome. Screenshots and CDP input work
on the attached background tab; activate only for a page that demonstrably
pauses visibility-dependent rendering while hidden.

## Navigating

Prefer navigating an existing tab over `new_tab()`. Tabs created via CDP's `Target.createTarget` are visible but may open behind the active tab.
Prefer navigating an existing tab over `new_tab()`. Harness-created tabs open in the background.

```python
tab = ensure_real_tab()
Expand Down
10 changes: 6 additions & 4 deletions interaction-skills/tabs.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,9 +7,9 @@ Use **CDP for control**, **UI automation for user-visible order**.
```python
tabs = list_tabs() # includes chrome:// pages too
real_tabs = list_tabs(include_chrome=False)
tid = new_tab("https://example.com") # create + attach
switch_tab(tid) # attach harness to tab
cdp("Target.activateTarget", targetId=tid) # show it in Chrome
tid = new_tab("https://example.com") # create + attach in the background
switch_tab(tid) # attach harness, move the horse marker
activate_tab(tid) # optional: explicitly show it in Chrome
print(current_tab())
print(page_info())
```
Expand Down Expand Up @@ -61,7 +61,9 @@ Typical tools:

## Rules that held up in practice

- `switch_tab()` is **not enough** if the user expects Chrome to visibly change.
- `switch_tab()` intentionally does **not** change Chrome's visible tab.
- Static screenshots and normal CDP input work on the attached background tab.
- `activate_tab()` is the explicit opt-in for visibility-dependent rendering or a user-requested visible switch.
- `Target.activateTarget` is the CDP-side "show this tab".
- `list_tabs()` includes `chrome://newtab/` by default; ask for `include_chrome=False` when you want only real pages.
- `chrome://omnibox-popup.top-chrome/` can appear as a fake page target; ignore it for user-facing tab lists.
Expand Down
114 changes: 100 additions & 14 deletions src/browser_harness/daemon.py
Original file line number Diff line number Diff line change
Expand Up @@ -360,13 +360,56 @@ def __init__(self):
self.cdp = None
Comment thread
MagMueller marked this conversation as resolved.
self.session = None
self.target_id = None
self.dedicated_target_id = None
self._dedicated_target_lock = asyncio.Lock()
self._session_state_lock = asyncio.Lock()
self._session_replacements = {}
self.events = deque(maxlen=BUF)
self.dialog = None
self.stop = None # asyncio.Event, set inside start()

async def attach_first_page(self):
async def attach_first_page(self, replaces_session=None, enable_domains=True):
"""Attach to a real page (or any page). Sets self.session. Returns attached target or None."""
targets = (await self.cdp.send_raw("Target.getTargets"))["targetInfos"]
# Named daemons (BU_NAME != "default") share one browser with other
# daemons — attaching to the first page makes parallel daemons fight
# over a single tab (navigations clobber each other). Give each named
# daemon its own dedicated tab instead. REMOTE_ID (cloud) browsers are
# already exclusive to this daemon, so first-page attach stays.
if NAME != "default" and not REMOTE_ID:
# The permission recovery flow can leave chrome://inspect open.
# Clean it up before returning from this early path as well.
if BROWSER_KIND == "local":
await self._close_inspect_tabs(targets)
pages_by_id = {t["targetId"]: t for t in targets if t["type"] == "page"}
# A stale CDP session does not necessarily mean its tab disappeared.
# Reattach to the current tab first, then the daemon's dedicated tab.
page = pages_by_id.get(self.target_id) or pages_by_id.get(self.dedicated_target_id)
if page is None:
# Two stale IPC requests can recover concurrently. Recheck
# inside a narrow lock so they share one replacement tab.
async with self._dedicated_target_lock:
refreshed = (await self.cdp.send_raw("Target.getTargets"))["targetInfos"]
pages_by_id = {t["targetId"]: t for t in refreshed if t["type"] == "page"}
page = pages_by_id.get(self.target_id) or pages_by_id.get(self.dedicated_target_id)
if page is None:
tid = (await self.cdp.send_raw(
"Target.createTarget", {"url": "about:blank", "background": True}
))["targetId"]
self.dedicated_target_id = tid
log(f"named daemon {NAME}: created dedicated tab ({tid})")
page = {"targetId": tid, "url": "about:blank", "type": "page"}
tid = page["targetId"]
self.session = (await self.cdp.send_raw(
"Target.attachToTarget", {"targetId": tid, "flatten": True}
))["sessionId"]
self._record_session_replacement(replaces_session, self.session)
Comment thread
MagMueller marked this conversation as resolved.
self.target_id = tid
log(f"attached {tid} ({page.get('url','')[:80]}) session={self.session}")
if enable_domains:
await self._enable_default_domains(self.session)
return page

pages = [t for t in targets if is_real_page(t)]
if not pages:
# Fresh browser (ex: BU cloud) starts w about:blank; reuse it
Expand All @@ -385,12 +428,15 @@ async def attach_first_page(self):
take_over = inspect_tabs[0]["targetId"]
if not pages:
# No usable pages - create one instead of attaching to omnibox popup.
tid = (await self.cdp.send_raw("Target.createTarget", {"url": "about:blank"}))["targetId"]
tid = (await self.cdp.send_raw(
"Target.createTarget", {"url": "about:blank", "background": True}
))["targetId"]
log(f"no real pages found, created about:blank ({tid})")
pages = [{"targetId": tid, "url": "about:blank", "type": "page"}]
self.session = (await self.cdp.send_raw(
"Target.attachToTarget", {"targetId": pages[0]["targetId"], "flatten": True}
))["sessionId"]
self._record_session_replacement(replaces_session, self.session)
self.target_id = pages[0]["targetId"]
log(f"attached {pages[0]['targetId']} ({pages[0].get('url','')[:80]}) session={self.session}")
if take_over:
Expand All @@ -401,7 +447,8 @@ async def attach_first_page(self):
log(f"take over inspect tab {take_over}: {e}")
if BROWSER_KIND == "local":
await self._close_inspect_tabs(targets)
await self._enable_default_domains(self.session)
if enable_domains:
await self._enable_default_domains(self.session)
return pages[0]

async def _close_inspect_tabs(self, targets):
Expand Down Expand Up @@ -444,6 +491,19 @@ async def enable_one(d):
log(f"enable {d} on {session_id}: {e}")
await asyncio.gather(*(enable_one(d) for d in ("Page", "DOM", "Runtime", "Network")))

def _record_session_replacement(self, stale_session, replacement_session):
"""Remember which recovered session still controls the same tab."""
if not stale_session or not replacement_session or stale_session == replacement_session:
return
# Preserve chains so requests delayed across multiple recoveries still
# land on their original tab, never whichever tab is current now.
for source, replacement in list(self._session_replacements.items()):
if replacement == stale_session:
self._session_replacements[source] = replacement_session
self._session_replacements[stale_session] = replacement_session
while len(self._session_replacements) > 32:
self._session_replacements.pop(next(iter(self._session_replacements)))

async def start(self):
self.stop = asyncio.Event()
url = get_ws_url()
Expand Down Expand Up @@ -526,9 +586,11 @@ async def handle(self, req):
}
return {"target_id": self.target_id, "session_id": self.session, "page": page}
if meta == "set_session":
old_session = self.session
self.session = req.get("session_id")
self.target_id = req.get("target_id") or self.target_id
async with self._session_state_lock:
old_session = self.session
self.session = req.get("session_id")
self.target_id = req.get("target_id") or self.target_id
new_session = self.session
# Run the old-session Network.disable (defense in depth — keeps
# background-tab traffic out of the global event buffer; the
# consumer-side filter in wait_for_network_idle is the actual
Expand All @@ -538,7 +600,7 @@ async def handle(self, req):
# even on a remote daemon — sequentially these would have stacked
# to ~22s worst case.
tasks = []
if old_session and old_session != self.session:
if old_session and old_session != new_session:
async def disable_old():
try:
await asyncio.wait_for(
Expand All @@ -547,19 +609,19 @@ async def disable_old():
)
except Exception: pass
tasks.append(disable_old())
tasks.append(self._enable_default_domains(self.session))
tasks.append(self._enable_default_domains(new_session))
await asyncio.gather(*tasks)
# 🐴 tab-marker title prefix is purely cosmetic — fire-and-forget so
# it doesn't add to the synchronous IPC budget.
asyncio.create_task(_silent(asyncio.wait_for(
self.cdp.send_raw(
"Runtime.evaluate",
{"expression": "if(!document.title.startsWith('\U0001F434'))document.title='\U0001F434 '+document.title"},
session_id=self.session,
session_id=new_session,
),
timeout=2,
)))
return {"session_id": self.session}
return {"session_id": new_session}
if meta == "pending_dialog": return {"dialog": self.dialog}
if meta == "shutdown": self.stop.set(); return {"ok": True}

Expand All @@ -572,10 +634,34 @@ async def disable_old():
return {"result": await self.cdp.send_raw(method, params, session_id=sid)}
except Exception as e:
msg = str(e)
if "Session with given id not found" in msg and sid == self.session and sid:
log(f"stale session {sid}, re-attaching")
if await self.attach_first_page():
return {"result": await self.cdp.send_raw(method, params, session_id=self.session)}
if "Session with given id not found" in msg and sid:
# Explicit session callers asked for that exact session; do not
# silently redirect them to the daemon's current tab.
if req.get("session_id"):
return {"error": msg}
recovered_here = False
async with self._session_state_lock:
replacement_session = self._session_replacements.get(sid)
if replacement_session is None and sid == self.session:
log(f"stale session {sid}, re-attaching")
if not await self.attach_first_page(
replaces_session=sid, enable_domains=False
):
return {"error": msg}
replacement_session = self._session_replacements.get(sid)
recovered_here = replacement_session is not None
if recovered_here:
await self._enable_default_domains(replacement_session)
# Retry only on a session known to replace this exact stale
# session. self.session may instead have changed because the
# user deliberately switched tabs while this request waited.
if replacement_session:
try:
return {"result": await self.cdp.send_raw(
method, params, session_id=replacement_session
)}
except Exception as retry_error:
return {"error": str(retry_error)}
return {"error": msg}


Expand Down
30 changes: 25 additions & 5 deletions src/browser_harness/helpers.py
Original file line number Diff line number Diff line change
Expand Up @@ -291,15 +291,35 @@ 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 _target_id(target):
"""Accept a raw target id or a tab dict returned by the helpers."""
return (target.get("targetId") or target.get("target_id")) if isinstance(target, dict) else target

def activate_tab(target):
"""Make a target the visible Chrome tab.

This is intentionally separate from switch_tab(): attaching the agent to a
target does not require taking over the user's visible Chrome tab.
"""
target_id = _target_id(target)
cdp("Target.activateTarget", targetId=target_id)
return target_id

def switch_tab(target, activate=False):
"""Attach the agent without changing Chrome's visible tab by default.

Pass activate=True only when Chrome must visibly show the target. The horse
marker still moves to the attached target so the user can find it.
"""
# 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
target_id = _target_id(target)
# Unmark old tab. Horse emoji is a surrogate pair in JS UTF-16 strings (2 code units),
# 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)
if activate:
activate_tab(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()
Expand All @@ -323,7 +343,7 @@ 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"]
tid = cdp("Target.createTarget", url="about:blank", background=True)["targetId"]
switch_tab(tid)
if url != "about:blank":
goto_url(url)
Expand All @@ -332,7 +352,7 @@ def new_tab(url="about:blank"):
def close_tab(target=None):
"""Close a tab. If `target` is omitted, closes the currently attached tab.
Accepts a raw targetId string or a dict from list_tabs()/current_tab()."""
target_id = (target.get("targetId") or target.get("target_id")) if isinstance(target, dict) else target
target_id = _target_id(target)
if target_id is None:
target_id = current_tab()["targetId"]
cdp("Target.closeTarget", targetId=target_id)
Expand Down
Loading