diff --git a/plugins/_browser/AGENTS.md b/plugins/_browser/AGENTS.md
index e80bbe016..6a434bbd5 100644
--- a/plugins/_browser/AGENTS.md
+++ b/plugins/_browser/AGENTS.md
@@ -19,6 +19,8 @@
- Preserve Playwright lifecycle cleanup and WebSocket viewer compatibility across regular host browsers and Electron WebContentsView embedding.
- Keep the WebUI Browser inside its own modal/canvas affordance; do not replace it with page-level navigation.
- Default the visible WebUI Browser to live CDP screencast for responsiveness. Keep lightweight CDP/DOM state snapshots as the fallback transport.
+- Paint live screencast frames through the Browser panel canvas/ImageBitmap path when available; keep the ``/data URL path for snapshots and fallback rendering.
+- Push internal screencast frames from the runtime to the WebSocket consumer after subscription; keep `read/pop_screencast_frame` as fallback/tooling APIs, not the WebUI hot path.
- Keep Browser viewer frame transport capability-negotiated: updated clients may request binary/slim screencast frames, while older clients must keep the base64/full-metadata fallback. Do not let the WebUI advertise binary frames unless its Socket.IO client reconstructs attachments as real `Blob`, `ArrayBuffer`, or typed-array values.
- Keep narrow WebUI Browser controls usable by grouping navigation with Annotate/settings above a full-width address bar.
- Browser URL-intent handling must only claim web URL schemes and leave custom Agent Zero schemes to their owning surfaces.
@@ -36,6 +38,7 @@
## Verification
- Smoke-test browser launch, navigation, DOM capture, and WebUI viewer after runtime changes.
+- For viewer render-path changes, verify the live Browser panel paints a screencast frame on canvas with `frameSrc` empty and snapshots still falling back to the image path.
- Run browser prompt/skill regression tests after changing browser prompt or Browser plugin skills.
## Child DOX Index
diff --git a/plugins/_browser/api/ws_browser.py b/plugins/_browser/api/ws_browser.py
index 827e91df4..566ec10b8 100644
--- a/plugins/_browser/api/ws_browser.py
+++ b/plugins/_browser/api/ws_browser.py
@@ -451,7 +451,37 @@ class WsBrowser(WsHandler):
last_state_refresh = 0.0
last_state_signature = self._state_signature(active_id, browsers)
frame_sequence = 0
+ server_loop = asyncio.get_running_loop()
+ stop_event = asyncio.Event()
+
+ async def emit_frame(frame: dict[str, Any]) -> None:
+ nonlocal frame_sequence
+ try:
+ frame_sequence += 1
+ payload = self._frame_payload(
+ frame,
+ context_id=context_id,
+ viewer_id=viewer_id,
+ browser_id=active_id,
+ sequence=frame_sequence,
+ binary_frames=binary_frames,
+ )
+ if not slim_frames:
+ payload["browsers"] = browsers
+ payload["state"] = state
+ await self._emit_to_connected_viewer(sid, "browser_viewer_frame", payload)
+ except BaseException:
+ stop_event.set()
+ raise
+
+ def frame_consumer(frame: dict[str, Any]):
+ return asyncio.run_coroutine_threadsafe(emit_frame(frame), server_loop)
+
+ await runtime.call("attach_screencast_consumer", stream_id, frame_consumer)
+
while True:
+ if stop_event.is_set():
+ break
now = time.monotonic()
if now - last_state_refresh >= FRAME_STATE_REFRESH_SECONDS:
listing = await runtime.call("list")
@@ -475,29 +505,10 @@ class WsBrowser(WsHandler):
last_state_refresh = now
try:
- frame = await runtime.call(
- "read_screencast_frame",
- stream_id,
- timeout=FRAME_READ_TIMEOUT_SECONDS,
- )
- except KeyError:
+ await asyncio.wait_for(stop_event.wait(), timeout=FRAME_READ_TIMEOUT_SECONDS)
break
except TimeoutError:
continue
-
- frame_sequence += 1
- payload = self._frame_payload(
- frame,
- context_id=context_id,
- viewer_id=viewer_id,
- browser_id=active_id,
- sequence=frame_sequence,
- binary_frames=binary_frames,
- )
- if not slim_frames:
- payload["browsers"] = browsers
- payload["state"] = state
- await self._emit_to_connected_viewer(sid, "browser_viewer_frame", payload)
except asyncio.CancelledError:
raise
except Exception:
diff --git a/plugins/_browser/helpers/runtime.py b/plugins/_browser/helpers/runtime.py
index 94524071d..6031a7f5f 100644
--- a/plugins/_browser/helpers/runtime.py
+++ b/plugins/_browser/helpers/runtime.py
@@ -317,6 +317,7 @@ class _BrowserScreencast:
self.browser_id = browser_id
self.session = session
self.mime = mime
+ self.frame_consumer: Any | None = None
self.queue = asyncio.Queue(maxsize=1)
self.stopped = False
self._ack_tasks: set[asyncio.Task] = set()
@@ -398,6 +399,12 @@ class _BrowserScreencast:
raise RuntimeError("Browser screencast stopped.")
return frame
+ async def attach_consumer(self, frame_consumer: Any) -> None:
+ self.frame_consumer = frame_consumer
+ frame = await self.pop_frame()
+ if frame:
+ await self._deliver_frame(frame)
+
async def stop(self) -> None:
if self.stopped:
return
@@ -423,6 +430,7 @@ class _BrowserScreencast:
task.add_done_callback(self._ack_tasks.discard)
async def _handle_frame(self, params: dict[str, Any]) -> None:
+ stop_after_ack = False
try:
data = params.get("data") or ""
if data:
@@ -432,7 +440,7 @@ class _BrowserScreencast:
metadata["jpegWidth"], metadata["jpegHeight"] = size
metadata["expectedWidth"] = self._expected_width
metadata["expectedHeight"] = self._expected_height
- self._queue_latest(
+ await self._deliver_frame(
{
"browser_id": self.browser_id,
"mime": self.mime,
@@ -440,6 +448,13 @@ class _BrowserScreencast:
"metadata": metadata,
}
)
+ except asyncio.CancelledError:
+ stop_after_ack = True
+ except Exception:
+ if self.frame_consumer:
+ stop_after_ack = True
+ else:
+ raise
finally:
session_id = params.get("sessionId")
if session_id is not None and not self.stopped:
@@ -448,6 +463,16 @@ class _BrowserScreencast:
"Page.screencastFrameAck",
{"sessionId": int(session_id)},
)
+ if stop_after_ack:
+ self.stopped = True
+
+ async def _deliver_frame(self, frame: dict[str, Any]) -> None:
+ if not self.frame_consumer:
+ self._queue_latest(frame)
+ return
+ future = self.frame_consumer(frame)
+ if future is not None:
+ await asyncio.wrap_future(future)
def _queue_latest(self, frame: dict[str, Any]) -> None:
self._drop_queued_frames()
@@ -1669,6 +1694,12 @@ class _BrowserRuntimeCore:
raise KeyError("Browser screencast is not active.")
return await screencast.pop_frame()
+ async def attach_screencast_consumer(self, stream_id: str, frame_consumer: Any) -> None:
+ screencast = self.screencasts.get(str(stream_id or ""))
+ if not screencast:
+ raise KeyError("Browser screencast is not active.")
+ await screencast.attach_consumer(frame_consumer)
+
async def stop_screencast(self, stream_id: str) -> None:
screencast = self.screencasts.pop(str(stream_id or ""), None)
if screencast:
diff --git a/plugins/_browser/webui/browser-panel.html b/plugins/_browser/webui/browser-panel.html
index 5692dd063..1abcde86f 100644
--- a/plugins/_browser/webui/browser-panel.html
+++ b/plugins/_browser/webui/browser-panel.html
@@ -187,12 +187,17 @@