feat: add tab and CDP methods to browser bridge

Added methods to control tabs via the Chrome extension:
- create_tab(groupId, url) - create and navigate tabs in user's Chrome
- close_tab(tabId) - close tabs
- list_tabs(groupId?) - list tabs
- cdp_attach(tabId) - attach CDP for automation
- cdp_send(tabId, method, params) - send CDP commands

These enable browser automation through the extension when Playwright
can't connect directly to the user's Chrome.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Timothy
2026-04-02 11:09:07 -07:00
parent 08b0cbc208
commit 1630c1ee7a
+187
View File
@@ -0,0 +1,187 @@
"""
Beeline Bridge - WebSocket server that the Chrome extension connects to.
Lets Python code create/destroy tab groups in the user's existing Chrome and
move Playwright-managed tabs into those groups for visual isolation.
Usage:
bridge = init_bridge()
await bridge.start() # at GCU server startup
await bridge.stop() # at GCU server shutdown
# Per-subagent:
result = await bridge.create_context("my-agent") # {groupId, tabId}
await bridge.group_tab_by_target(cdp_target_id, result["groupId"])
await bridge.destroy_context(result["groupId"])
The bridge is optional all callers check ``bridge.is_connected`` and no-op
when the extension is not present.
"""
from __future__ import annotations
import asyncio
import json
import logging
logger = logging.getLogger(__name__)
BRIDGE_PORT = 9229
class BeelineBridge:
"""WebSocket server that accepts a single connection from the Chrome extension."""
def __init__(self) -> None:
self._ws: object | None = None # websockets.ServerConnection
self._server: object | None = None # websockets.Server
self._pending: dict[str, asyncio.Future] = {}
self._counter = 0
@property
def is_connected(self) -> bool:
return self._ws is not None
async def start(self, port: int = BRIDGE_PORT) -> None:
"""Start the WebSocket server."""
try:
import websockets
except ImportError:
logger.warning(
"websockets not installed — Chrome extension bridge disabled. "
"Install with: uv pip install websockets"
)
return
try:
self._server = await websockets.serve(self._handle_connection, "127.0.0.1", port)
logger.info("Beeline bridge listening on ws://127.0.0.1:%d/bridge", port)
except OSError as e:
logger.warning("Beeline bridge could not start on port %d: %s", port, e)
async def stop(self) -> None:
if self._server:
self._server.close()
try:
await self._server.wait_closed()
except Exception:
pass
self._server = None
async def _handle_connection(self, ws) -> None:
logger.info("Chrome extension connected")
self._ws = ws
try:
async for raw in ws:
try:
msg = json.loads(raw)
except json.JSONDecodeError:
continue
if msg.get("type") == "hello":
logger.info("Extension hello: version=%s", msg.get("version"))
continue
msg_id = msg.get("id")
if msg_id and msg_id in self._pending:
fut = self._pending.pop(msg_id)
if not fut.done():
if "error" in msg:
fut.set_exception(RuntimeError(msg["error"]))
else:
fut.set_result(msg.get("result", {}))
except Exception:
pass
finally:
logger.info("Chrome extension disconnected")
self._ws = None
# Cancel any pending requests
for fut in self._pending.values():
if not fut.done():
fut.cancel()
self._pending.clear()
async def _send(self, type_: str, **params) -> dict:
"""Send a command to the extension and wait for the result."""
if not self._ws:
raise RuntimeError("Extension not connected")
self._counter += 1
msg_id = str(self._counter)
fut: asyncio.Future = asyncio.get_event_loop().create_future()
self._pending[msg_id] = fut
try:
await self._ws.send(json.dumps({"id": msg_id, "type": type_, **params}))
return await asyncio.wait_for(fut, timeout=10.0)
except asyncio.TimeoutError:
self._pending.pop(msg_id, None)
raise RuntimeError(f"Bridge command '{type_}' timed out")
# ── Public API ────────────────────────────────────────────────────────────
async def create_context(self, agent_id: str) -> dict:
"""Create a labelled tab group for this agent.
Returns ``{"groupId": int, "tabId": int}``.
"""
return await self._send("context.create", agentId=agent_id)
async def destroy_context(self, group_id: int) -> dict:
"""Close all tabs in the group and remove it."""
return await self._send("context.destroy", groupId=group_id)
async def group_tab_by_target(self, cdp_target_id: str, group_id: int) -> dict:
"""Move a tab (identified by CDP target ID) into an existing group."""
return await self._send("tab.group_by_target", targetId=cdp_target_id, groupId=group_id)
async def create_tab(self, group_id: int, url: str) -> dict:
"""Create a new tab in the specified group and navigate to URL.
Returns ``{"tabId": int}``.
"""
return await self._send("tab.create", groupId=group_id, url=url)
async def close_tab(self, tab_id: int) -> dict:
"""Close a tab by ID."""
return await self._send("tab.close", tabId=tab_id)
async def list_tabs(self, group_id: int | None = None) -> dict:
"""List tabs, optionally filtered by group.
Returns ``{"tabs": [{"id": int, "url": str, "title": str}, ...]}``.
"""
params = {"groupId": group_id} if group_id is not None else {}
return await self._send("tab.list", **params)
async def cdp_attach(self, tab_id: int) -> dict:
"""Attach CDP session to a tab for automation.
Returns ``{"ok": bool}``.
"""
return await self._send("cdp.attach", tabId=tab_id)
async def cdp_send(self, tab_id: int, method: str, params: dict | None = None) -> dict:
"""Send a CDP command to a tab.
Returns the CDP result.
"""
return await self._send("cdp", tabId=tab_id, method=method, params=params or {})
# ---------------------------------------------------------------------------
# Module-level singleton
# ---------------------------------------------------------------------------
_bridge: BeelineBridge | None = None
def get_bridge() -> BeelineBridge | None:
"""Return the bridge singleton, or None if not initialised."""
return _bridge
def init_bridge() -> BeelineBridge:
"""Create (or return) the bridge singleton."""
global _bridge
if _bridge is None:
_bridge = BeelineBridge()
return _bridge