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
11 changes: 11 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,16 @@
# Changelog

## v0.6.3
- **Stop the "live" dot flapping offline.** The stream now delivers frames by **coalescing to
the newest** — under load it drops stale frames instead of letting `send` block, which was
building backpressure until the socket idle-closed and reconnected in a loop. The route also
**races operator input against the CDP socket dying**, so if the page target goes away the panel
reconnects to the live page in ~1.2s instead of freezing. Faster reconnect backoff.
- **Live viewport size in the status bar** — the "live" pill now reads `live · <w>×<h>` so you can
see exactly what size the browser is rendering at (and tell whether the panel-fit is landing).
- **Resize self-corrects** — the panel re-sends its size shortly after connecting, in case the
dock hadn't finished laying out when the socket opened.

## v0.6.2
- **Keeps live-updating when the panel isn't focused.** The screencast used to stall unless the
panel had focus. Fixed on three fronts: the page is now pinned focused/visible over CDP
Expand Down
56 changes: 43 additions & 13 deletions browser_panel.py
Original file line number Diff line number Diff line change
Expand Up @@ -94,23 +94,49 @@ async def _stream(ws: WebSocket):
await ws.send_json({"t": "error", "msg": note})
await ws.close()
return
dims: dict = {"wh": None}
# Coalescing delivery: on_frame just stashes the NEWEST frame and signals; a sender
# task drains it. Under load this DROPS stale frames instead of letting ws.send_bytes
# block — the backpressure buildup was what stalled the socket (frozen frames → the
# proxy idle-closes it → the "live" dot flaps offline).
latest: dict = {"jpeg": None, "wh": None}
pending = asyncio.Event()

async def on_frame(jpeg: bytes, md: dict):
wh = (md.get("deviceWidth"), md.get("deviceHeight"))
latest["jpeg"] = jpeg
latest["wh"] = (md.get("deviceWidth"), md.get("deviceHeight"))
pending.set()

async def sender():
sent_wh = None
try:
if wh != dims["wh"]:
dims["wh"] = wh
await ws.send_json({"t": "meta", "w": wh[0], "h": wh[1]})
await ws.send_bytes(jpeg)
except Exception: # noqa: BLE001 — client vanished mid-frame; teardown follows
return
while True:
await pending.wait()
pending.clear()
jpeg, wh = latest["jpeg"], latest["wh"]
if jpeg is None:
continue
if wh != sent_wh:
sent_wh = wh
await ws.send_json({"t": "meta", "w": wh[0], "h": wh[1]})
await ws.send_bytes(jpeg)
except Exception: # noqa: BLE001 — client gone / cancelled on teardown
pass

send_task = None
try:
async with browser_stream.CDPStream(page_ws, on_frame, quality=quality) as cdp:
await cdp.start_screencast()
send_task = asyncio.create_task(sender())
while True:
await cdp.dispatch(await ws.receive_json())
# race operator input against the CDP socket dying — if the page target
# goes away, break so the client reconnects to the live page fast.
recv = asyncio.create_task(ws.receive_json())
done, _ = await asyncio.wait({recv, cdp.reader_task},
return_when=asyncio.FIRST_COMPLETED)
if recv not in done:
recv.cancel()
break
await cdp.dispatch(recv.result())
except WebSocketDisconnect:
pass
except Exception: # noqa: BLE001 — a CDP/stream fault must not take down the worker
Expand All @@ -119,6 +145,9 @@ async def on_frame(jpeg: bytes, md: dict):
await ws.close()
except Exception: # noqa: BLE001
pass
finally:
if send_task:
send_task.cancel()

@router.post("/nav")
async def _nav(body: dict = Body(...)):
Expand Down Expand Up @@ -205,6 +234,7 @@ async def _nav(body: dict = Body(...)):
$("url").addEventListener("keydown",(e)=>{ if(e.key==="Enter") go(); });

function setStatus(s,label){ $("dot").className="dot"+(s?(" "+s):""); $("cs").textContent=label; }
function live(){ return (devW&&devH) ? ("live · "+devW+"×"+devH) : "live"; } // show the real viewport size
function showMsg(t,html){ $("mt").textContent=t; $("md").innerHTML=html||""; $("msg").style.display="flex"; }
function hideMsg(){ $("msg").style.display="none"; }

Expand Down Expand Up @@ -237,24 +267,24 @@ async def _nav(body: dict = Body(...)):
const ticket=(await r.json()).ticket;
ws=new WebSocket(wsUrl(ticket));
ws.binaryType="arraybuffer";
ws.onopen=()=>{ connected=true; setStatus("ok","live"); sendResize(); };
ws.onopen=()=>{ connected=true; setStatus("ok",live()); sendResize(); setTimeout(sendResize,500); };
ws.onmessage=onMsg;
ws.onclose=()=>{ connected=false; setStatus("err","offline"); scheduleRetry(); };
ws.onerror=()=>{ try{ ws.close(); }catch(_){} };
}catch(_){ setStatus("err","offline"); scheduleRetry(); }
}
function scheduleRetry(){ clearTimeout(retry); retry=setTimeout(connect,2500); }
function scheduleRetry(){ clearTimeout(retry); retry=setTimeout(connect,1200); }
async function onMsg(ev){
if(typeof ev.data==="string"){
let m; try{ m=JSON.parse(ev.data); }catch(_){ return; }
if(m.t==="meta"){ if(m.w) devW=m.w; if(m.h) devH=m.h; }
if(m.t==="meta"){ if(m.w) devW=m.w; if(m.h) devH=m.h; setStatus("ok",live()); }
else if(m.t==="error"){ setStatus("err","no page"); showStart(m.msg); }
return;
}
try{ // binary → a JPEG screencast frame
const bmp=await createImageBitmap(new Blob([ev.data]));
if(cv.width!==bmp.width||cv.height!==bmp.height){ cv.width=bmp.width; cv.height=bmp.height; }
ctx.drawImage(bmp,0,0); bmp.close(); hideMsg(); setStatus("ok","live");
ctx.drawImage(bmp,0,0); bmp.close(); hideMsg(); setStatus("ok",live());
}catch(_){}
}

Expand Down
7 changes: 7 additions & 0 deletions browser_stream.py
Original file line number Diff line number Diff line change
Expand Up @@ -238,6 +238,13 @@ async def __aexit__(self, *exc):
if self._ws:
await self._ws.close()

@property
def reader_task(self):
"""The background CDP read loop; it completes when Chrome's page socket closes
(tab gone / target replaced). The route races it so a dead stream reconnects fast
instead of freezing until an idle-close."""
return self._reader

async def _send(self, method: str, params: dict | None = None) -> int:
async with self._lock: # serialize writes: reader (acks/re-arm) + request task (input/resize)
self._id += 1
Expand Down
2 changes: 1 addition & 1 deletion protoagent.plugin.yaml
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
id: agent_browser
name: Agent Browser
version: 0.6.2
version: 0.6.3
description: >-
Browser automation for protoAgent, backed by **agent-browser** (vercel-labs) — a
fast native-Rust CLI/daemon that drives Chrome over CDP with accessibility-tree
Expand Down
2 changes: 1 addition & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
[project]
name = "agent-browser-plugin"
version = "0.6.2"
version = "0.6.3"
description = "Browser-automation plugin for protoAgent, backed by vercel-labs/agent-browser (tools + skill + workflows + an interactive, drivable browser panel)."
requires-python = ">=3.11"

Expand Down
Loading