From 386a7f189187260426971924dcdfbc07be39eaae Mon Sep 17 00:00:00 2001 From: Phelipe Lorran <112786693+httpsphl@users.noreply.github.com> Date: Sun, 4 Oct 2026 17:49:42 -0300 Subject: [PATCH] feat(browser): the owner sees the page as sharp as their screen, and quicker in their hands The bot's page was drawn at one pixel per page pixel and sent at ~15 frames a second, so on a scaled screen its text was blurry and scrolling felt slow. The app now sends its devicePixelRatio with browser.resize, and the page is drawn that sharp (deviceScaleFactor, up to 2x) for the owner. The screencast keeps the page's own pixels whatever the scale (checked with the real Edge, spec 19), so on a sharper screen its small frames only say the page changed and the frame sent is a screenshot, confirmed after it so they never pile up. Frames come ~30 a second while the owner has the browser in their hands, ~15 while they watch the bot. The bot's own screenshots stay the size of the page: clipped where the page is scrolled to and scaled back down, so they cost the bot the same. --- app/src/features/browser/browser.test.tsx | 4 +- app/src/features/browser/useRoom.ts | 9 ++- app/src/lib/protocol.gen.ts | 8 +- crates/botloft-core/src/protocol/browser.rs | 6 ++ crates/botloftd/src/browser/events.rs | 55 +++++++++++--- crates/botloftd/src/browser/hands.rs | 1 + crates/botloftd/src/browser/moves.rs | 9 ++- crates/botloftd/src/browser/page.rs | 17 ----- crates/botloftd/src/browser/rest.rs | 2 +- crates/botloftd/src/browser/session.rs | 21 +++++- crates/botloftd/src/browser/viewport.rs | 82 ++++++++++++++++++++- crates/botloftd/src/browser/watch.rs | 9 ++- crates/botloftd/src/rpc/watching.rs | 9 ++- crates/botloftd/tests/browser_panel.rs | 63 +++++++++++++++- crates/botloftd/tests/common/site.rs | 11 ++- docs/spec.md | 8 +- 16 files changed, 256 insertions(+), 58 deletions(-) diff --git a/app/src/features/browser/browser.test.tsx b/app/src/features/browser/browser.test.tsx index 8f7ef197..a02b3f00 100644 --- a/app/src/features/browser/browser.test.tsx +++ b/app/src/features/browser/browser.test.tsx @@ -82,7 +82,9 @@ describe("browser panel", () => { fireEvent.click(toggle()); const resized = () => fake.calls.filter((call) => call.method === "browser.resize").map((call) => call.params); - await waitFor(() => expect(resized()).toEqual([{ botId: scout.id, width: 560, height: 804 }])); + await waitFor(() => + expect(resized()).toEqual([{ botId: scout.id, width: 560, height: 804, scale: 100 }]), + ); // A narrow room keeps the desktop layout's width, in the room's shape. expect(fake.browser.size(scout.id)).toEqual({ width: 800, height: 1148 }); diff --git a/app/src/features/browser/useRoom.ts b/app/src/features/browser/useRoom.ts index f0b56842..eae647c3 100644 --- a/app/src/features/browser/useRoom.ts +++ b/app/src/features/browser/useRoom.ts @@ -49,21 +49,24 @@ export function useRoom(): [Size | null, (node: HTMLElement | null) => void] { /** * Tells the daemon the room the page has, once it stops changing, while - * this connection watches the bot's browser. + * this connection watches the bot's browser, and how sharp the screen is, + * so the page is drawn with as many pixels as show it (spec 21.3). */ export function useFitPage(bot: Pick, room: Size | null, watched: boolean): void { const api = useApi(); const known = room !== null; const width = Math.max(1, room?.width ?? 0); const height = Math.max(1, room?.height ?? 0); + // A change of the app's zoom changes the room too, which reads it again. + const scale = Math.round((typeof devicePixelRatio === "number" ? devicePixelRatio : 1) * 100); useEffect(() => { if (!watched || !known) { return; } const timer = setTimeout(() => { // The next change of the room asks again. - api.call("browser.resize", { botId: bot.id, width, height }).catch(() => {}); + api.call("browser.resize", { botId: bot.id, width, height, scale }).catch(() => {}); }, SETTLED_MS); return () => clearTimeout(timer); - }, [api, bot.id, watched, known, width, height]); + }, [api, bot.id, watched, known, width, height, scale]); } diff --git a/app/src/lib/protocol.gen.ts b/app/src/lib/protocol.gen.ts index 10b47a15..d3a103b6 100644 --- a/app/src/lib/protocol.gen.ts +++ b/app/src/lib/protocol.gen.ts @@ -1340,7 +1340,13 @@ export type BrowserWatchParams = { botId: BotId, }; * `browser.resize`: the room the app's panel has for the page, in the * app's pixels. The page takes its shape (spec 21.3). */ -export type BrowserResizeParams = { botId: BotId, width: number, height: number, }; +export type BrowserResizeParams = { botId: BotId, width: number, height: number, +/** + * The screen's pixels per pixel of the app, in percent (its + * `devicePixelRatio` times 100): the page is drawn that sharp for the + * owner, up to twice (spec 21.3). Absent, 100. + */ +scale?: number, }; export type MouseAction = "move" | "down" | "up"; diff --git a/crates/botloft-core/src/protocol/browser.rs b/crates/botloft-core/src/protocol/browser.rs index 0360b8f5..7b51fbd7 100644 --- a/crates/botloft-core/src/protocol/browser.rs +++ b/crates/botloft-core/src/protocol/browser.rs @@ -166,4 +166,10 @@ pub struct BrowserResizeParams { pub bot_id: BotId, pub width: u32, pub height: u32, + /// The screen's pixels per pixel of the app, in percent (its + /// `devicePixelRatio` times 100): the page is drawn that sharp for the + /// owner, up to twice (spec 21.3). Absent, 100. + #[serde(default, skip_serializing_if = "Option::is_none")] + #[cfg_attr(test, ts(optional))] + pub scale: Option, } diff --git a/crates/botloftd/src/browser/events.rs b/crates/botloftd/src/browser/events.rs index dfbc0912..10bfeb4b 100644 --- a/crates/botloftd/src/browser/events.rs +++ b/crates/botloftd/src/browser/events.rs @@ -11,7 +11,7 @@ use tokio::sync::mpsc; use tracing::debug; use super::cdp::CdpEvent; -use super::session::{FRAME_GAP, Hooks, MAX_TABS, PageInfo, Session, Tab}; +use super::session::{FRAME_GAP, Hooks, MAX_TABS, PageInfo, QUICK_GAP, Session, Tab}; use super::titles::retitles; impl Session { @@ -196,29 +196,60 @@ impl Session { } fn frame(&self, session: &str, params: &Value, hooks: &Hooks) { - let (active, viewport) = { + let (active, viewport, gap) = { let tabs = self.lock(); let active = tabs.active().is_some_and(|tab| tab.session == session); - (active, tabs.viewport) + let gap = if tabs.quick { QUICK_GAP } else { FRAME_GAP }; + (active, tabs.viewport, gap) }; - if active && let Some(data) = params["data"].as_str() { - let size = |key: &str, fallback: u32| { - params["metadata"][key] - .as_f64() - .map_or(fallback, |value| value.round() as u32) - }; + let size = |key: &str, fallback: u32| { + params["metadata"][key] + .as_f64() + .map_or(fallback, |value| value.round() as u32) + }; + let (width, height) = ( + size("deviceWidth", viewport.width), + size("deviceHeight", viewport.height), + ); + // The screencast draws in the page's own pixels, however sharp the + // page is (spec 19): on a sharper screen its frame only says the page + // changed, and the frame sent is a screenshot, which is that sharp. + let sharp = active && viewport.scale > 100; + if active + && !sharp + && let Some(data) = params["data"].as_str() + { hooks.frames.send_replace(Some(Arc::new(BrowserFrame { bot_id: hooks.bot.clone(), data: data.to_owned(), - width: size("deviceWidth", viewport.width), - height: size("deviceHeight", viewport.height), + width, + height, }))); } let cdp = self.cdp.clone(); let session = session.to_owned(); let ack = json!({ "sessionId": params["sessionId"] }); + let (frames, bot) = (hooks.frames.clone(), hooks.bot.clone()); tokio::spawn(async move { - tokio::time::sleep(FRAME_GAP).await; + if sharp { + let shot = json!({ "format": "jpeg", "quality": 60, "optimizeForSpeed": true }); + let taken = cdp + .call(Some(&session), "Page.captureScreenshot", shot) + .await; + if let Some(data) = taken + .ok() + .and_then(|shot| shot["data"].as_str().map(str::to_owned)) + { + frames.send_replace(Some(Arc::new(BrowserFrame { + bot_id: bot, + data, + width, + height, + }))); + } + } + // Confirmed only after, so screenshots never pile up. + tokio::time::sleep(gap).await; cdp.send(Some(&session), "Page.screencastFrameAck", ack); }); } diff --git a/crates/botloftd/src/browser/hands.rs b/crates/botloftd/src/browser/hands.rs index 5ca72299..e4b9e988 100644 --- a/crates/botloftd/src/browser/hands.rs +++ b/crates/botloftd/src/browser/hands.rs @@ -281,6 +281,7 @@ impl Drop for Hands { mine }); if released { + self.session.owner_gave_back(); update( &self.slots, &self.events, diff --git a/crates/botloftd/src/browser/moves.rs b/crates/botloftd/src/browser/moves.rs index 2b511469..8a3687d8 100644 --- a/crates/botloftd/src/browser/moves.rs +++ b/crates/botloftd/src/browser/moves.rs @@ -136,14 +136,21 @@ impl Session { Ok(()) } - /// The owner took the browser: remember the tab the bot was on. + /// The owner took the browser: remember the tab the bot was on, and + /// send them frames quicker. pub(super) fn owner_took(&self) { let mut tabs = self.lock(); + tabs.quick = true; if tabs.owner_from.is_none() { tabs.owner_from = tabs.active().map(|tab| tab.target.clone()); } } + /// The owner gave the browser back: frames as for watching the bot. + pub(super) fn owner_gave_back(&self) { + self.lock().quick = false; + } + /// Whether the owner had the browser and left the bot on another tab /// than the one it was on; asked once, by the bot's next tool (spec /// 21.10). diff --git a/crates/botloftd/src/browser/page.rs b/crates/botloftd/src/browser/page.rs index 4b629e87..850a3c88 100644 --- a/crates/botloftd/src/browser/page.rs +++ b/crates/botloftd/src/browser/page.rs @@ -294,21 +294,4 @@ impl Session { self.settle(marks).await; Ok(true) } - - /// The screen as a JPEG, base64. - pub async fn screenshot(&self) -> Result { - let (session, _) = self.page()?; - let shot = self - .cdp - .call( - Some(&session), - "Page.captureScreenshot", - json!({ "format": "jpeg", "quality": 70 }), - ) - .await?; - shot["data"] - .as_str() - .map(str::to_owned) - .ok_or_else(|| BrowserError::Page("the browser returned no picture".to_owned())) - } } diff --git a/crates/botloftd/src/browser/rest.rs b/crates/botloftd/src/browser/rest.rs index e9d3c5d8..410ab182 100644 --- a/crates/botloftd/src/browser/rest.rs +++ b/crates/botloftd/src/browser/rest.rs @@ -123,7 +123,7 @@ impl Browsers { if held || session.is_resting() { return; } - if let Ok(data) = session.screenshot().await { + if let Ok(data) = session.picture().await { let page = session.viewport(); frames.send_replace(Some(Arc::new(BrowserFrame { bot_id: bot.clone(), diff --git a/crates/botloftd/src/browser/session.rs b/crates/botloftd/src/browser/session.rs index 657e274d..379779d7 100644 --- a/crates/botloftd/src/browser/session.rs +++ b/crates/botloftd/src/browser/session.rs @@ -21,8 +21,14 @@ use super::{BrowserError, agent}; /// Most tabs a browser keeps; past this, the one active longest ago closes. pub(super) const MAX_TABS: usize = 6; -/// Frames are confirmed after this, so the browser sends ~15 a second. +/// Frames are confirmed after this, so the browser sends ~15 a second +/// while the owner watches the bot work... pub(super) const FRAME_GAP: Duration = Duration::from_millis(66); +/// ...and ~30 while the browser is in the owner's hands, so the page keeps +/// up with them as they scroll and point (spec 21.8). +pub(super) const QUICK_GAP: Duration = Duration::from_millis(33); +/// The largest side of a frame that only says the page changed. +const SIGNAL: u32 = 64; /// How long the first tab may take to show up. const FIRST_TAB_TIMEOUT: Duration = Duration::from_secs(5); @@ -76,6 +82,8 @@ pub struct Tabs { pub(super) resting: bool, pub closed: bool, pub viewport: Viewport, + /// The owner has it in their hands: frames come quicker. + pub(super) quick: bool, /// The tab the bot was on when the owner took the browser, until the /// bot's next tool (spec 21.10). pub(super) owner_from: Option, @@ -251,9 +259,16 @@ impl Session { pub(super) async fn cast(&self, session: &str, on: bool) { let result = if on { - // Frames come the size of the page, however tall it is. + // Frames come the size of the page, however tall it is. On a + // sharper screen they only say the page changed (`events.rs`), + // so they come small. + let (max_width, max_height) = if self.viewport().scale > 100 { + (SIGNAL, SIGNAL) + } else { + (MAX_WIDTH, MAX_HEIGHT) + }; let params = json!({ - "format": "jpeg", "quality": 60, "maxWidth": MAX_WIDTH, "maxHeight": MAX_HEIGHT, + "format": "jpeg", "quality": 60, "maxWidth": max_width, "maxHeight": max_height, }); self.cdp .call(Some(session), "Page.startScreencast", params) diff --git a/crates/botloftd/src/browser/viewport.rs b/crates/botloftd/src/browser/viewport.rs index bae9c821..493a2476 100644 --- a/crates/botloftd/src/browser/viewport.rs +++ b/crates/botloftd/src/browser/viewport.rs @@ -1,10 +1,12 @@ //! The size of the page (spec 21.3): a desktop screen while the bot works //! alone, and the shape and width of the owner's panel while they watch, -//! so the page shows near its own size there. +//! so the page shows near its own size there, drawn as sharp as their +//! screen. The bot's own pictures stay the size of the page. use serde_json::{Value, json}; use tracing::debug; +use super::BrowserError; use super::session::{FRAME_GAP, Session}; /// The page size bots and the owner see, in CSS pixels. The height is the @@ -18,12 +20,17 @@ pub const MAX_WIDTH: u32 = 1600; /// How short and how tall the owner's panel may make the page. pub const MIN_HEIGHT: u32 = 600; pub const MAX_HEIGHT: u32 = 2000; +/// The sharpest the page is drawn for the owner, in percent: two pixels of +/// their screen to one of the page. +pub const MAX_SCALE: u32 = 200; /// The size of the page, the same in every tab. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub struct Viewport { pub width: u32, pub height: u32, + /// The owner's screen pixels per pixel of the page, in percent. + pub scale: u32, } impl Default for Viewport { @@ -31,6 +38,7 @@ impl Default for Viewport { Self { width: WIDTH, height: HEIGHT, + scale: 100, } } } @@ -47,12 +55,26 @@ impl Viewport { height: u32::try_from(tall) .unwrap_or(MAX_HEIGHT) .clamp(MIN_HEIGHT, MAX_HEIGHT), + scale: 100, } } + /// The same page, drawn `scale` percent as sharp, within limits. + pub fn at_scale(self, scale: u32) -> Self { + Self { + scale: scale.clamp(100, MAX_SCALE), + ..self + } + } + + fn factor(self) -> f64 { + f64::from(self.scale) / 100.0 + } + pub(super) fn metrics(self) -> Value { json!({ - "width": self.width, "height": self.height, "deviceScaleFactor": 1, "mobile": false, + "width": self.width, "height": self.height, "deviceScaleFactor": self.factor(), + "mobile": false, }) } @@ -106,6 +128,44 @@ impl Session { self.cast(&page, true).await; } } + + /// The screen as a JPEG, base64, for the bot: the size of the page, + /// however sharp the owner's screen has it drawn. + pub async fn screenshot(&self) -> Result { + self.capture(false).await + } + + /// The screen as a JPEG, base64, as sharp as the owner's panel draws it. + pub async fn picture(&self) -> Result { + self.capture(true).await + } + + async fn capture(&self, sharp: bool) -> Result { + let (session, _) = self.page()?; + let page = self.viewport(); + let mut params = json!({ "format": "jpeg", "quality": 70 }); + if !sharp && page.scale > 100 { + // The page where it is scrolled to, scaled back down (spec 19). + let metrics = self + .cdp + .call(Some(&session), "Page.getLayoutMetrics", json!({})) + .await?; + let view = &metrics["cssVisualViewport"]; + params["clip"] = json!({ + "x": view["pageX"], "y": view["pageY"], + "width": page.width, "height": page.height, + "scale": 1.0 / page.factor(), + }); + } + let shot = self + .cdp + .call(Some(&session), "Page.captureScreenshot", params) + .await?; + shot["data"] + .as_str() + .map(str::to_owned) + .ok_or_else(|| BrowserError::Page("the browser returned no picture".to_owned())) + } } #[cfg(test)] @@ -120,7 +180,8 @@ mod tests { page(1000, 700), Viewport { width: 1000, - height: 700 + height: 700, + scale: 100, } ); // A narrow one keeps the desktop layout, in the room's shape. @@ -128,7 +189,8 @@ mod tests { page(536, 700), Viewport { width: 800, - height: 1044 + height: 1044, + scale: 100, } ); assert_eq!(page(1280, 800), Viewport::default()); @@ -140,6 +202,18 @@ mod tests { assert_eq!(page(1, u32::MAX).height, MAX_HEIGHT); } + #[test] + fn the_page_is_drawn_as_sharp_as_the_screen_up_to_twice() { + let page = Viewport::fitting(1000, 700); + assert_eq!(page.scale, 100); + assert_eq!(page.at_scale(150).scale, 150); + assert_eq!(page.at_scale(300).scale, MAX_SCALE); + // A screen less sharp than the page still gets one pixel each. + assert_eq!(page.at_scale(80).scale, 100); + assert_eq!(page.at_scale(150).metrics()["deviceScaleFactor"], 1.5); + assert_eq!(page.at_scale(150).width, 1000); + } + #[test] fn points_are_on_the_page_up_to_its_edges() { let page = Viewport::fitting(640, 700); diff --git a/crates/botloftd/src/browser/watch.rs b/crates/botloftd/src/browser/watch.rs index 9d73cc06..7416b2b3 100644 --- a/crates/botloftd/src/browser/watch.rs +++ b/crates/botloftd/src/browser/watch.rs @@ -41,12 +41,13 @@ impl Browsers { .is_some_and(|slot| slot.watchers > 0) } - /// The room a watching app has for the page, `width` by `height`: the - /// page takes its shape, now or when the browser opens (spec 21.3). - pub fn resize(&self, bot: &BotId, width: u32, height: u32) { + /// The room a watching app has for the page, `width` by `height`, on + /// a screen `scale` percent as sharp: the page takes its shape and is + /// drawn that sharp, now or when the browser opens (spec 21.3). + pub fn resize(&self, bot: &BotId, width: u32, height: u32, scale: u32) { { let mut slots = lock(&self.slots); - self.slot(&mut slots, bot).viewport = Viewport::fitting(width, height); + self.slot(&mut slots, bot).viewport = Viewport::fitting(width, height).at_scale(scale); } sync_soon(&self.slots, bot); } diff --git a/crates/botloftd/src/rpc/watching.rs b/crates/botloftd/src/rpc/watching.rs index 3eeb0c24..3472af32 100644 --- a/crates/botloftd/src/rpc/watching.rs +++ b/crates/botloftd/src/rpc/watching.rs @@ -83,9 +83,12 @@ impl Watch { ApiError::validation(format!("width and height go from 1 to {ROOM_MAX}")).into(), ); } - daemon - .browsers - .resize(¶ms.bot_id, params.width, params.height); + daemon.browsers.resize( + ¶ms.bot_id, + params.width, + params.height, + params.scale.unwrap_or(100), + ); Ok(Value::Null) } diff --git a/crates/botloftd/tests/browser_panel.rs b/crates/botloftd/tests/browser_panel.rs index 0ae7c893..0088ad25 100644 --- a/crates/botloftd/tests/browser_panel.rs +++ b/crates/botloftd/tests/browser_panel.rs @@ -1,15 +1,35 @@ //! The owner's panel on a bot's browser with the real Microsoft Edge (spec -//! 21.3, 21.7): the page taking the shape of the room the panel has. -//! Skipped where Edge is not installed. +//! 21.3, 21.7): the page taking the shape of the room the panel has, drawn +//! as sharp as the owner's screen. Skipped where Edge is not installed. mod common; use std::time::Duration; +use base64::Engine as _; +use base64::engine::general_purpose::STANDARD as BASE64; use common::Client; use common::browsing::{Browsing, answer_site, call, setup}; use serde_json::{Value, json}; +/// The width and height of a JPEG, base64, from its frame header. +fn jpeg_size(data: &str) -> (u32, u32) { + let bytes = BASE64.decode(data).expect("base64"); + let mut at = 2; + while at + 9 < bytes.len() { + assert_eq!(bytes[at], 0xFF, "a JPEG marker"); + let marker = bytes[at + 1]; + let length = usize::from(bytes[at + 2]) << 8 | usize::from(bytes[at + 3]); + if (0xC0..=0xCF).contains(&marker) && ![0xC4, 0xC8, 0xCC].contains(&marker) { + let height = u32::from(bytes[at + 5]) << 8 | u32::from(bytes[at + 6]); + let width = u32::from(bytes[at + 7]) << 8 | u32::from(bytes[at + 8]); + return (width, height); + } + at += 2 + length; + } + panic!("no size in the picture"); +} + /// Reads the page until it says its window is `size`. async fn until_window(b: &mut Browsing, size: &str) -> String { let mut page = String::new(); @@ -113,3 +133,42 @@ async fn the_page_takes_the_shape_of_the_panel_while_the_owner_watches() { .expect("unwatch"); until_window(&mut b, "1280 x 800").await; } + +#[tokio::test(flavor = "multi_thread")] +async fn the_owner_sees_the_page_as_sharp_as_their_screen_and_the_bot_at_its_size() { + let Some(mut b) = setup().await else { return }; + let id = b.bot["id"].clone(); + b.app + .call("browser.watch", json!({ "botId": id })) + .await + .expect("watch"); + // A screen twice as sharp as the app's pixels. + let room = json!({ "botId": id, "width": 1000, "height": 700, "scale": 200 }); + b.app.call("browser.resize", room).await.expect("resize"); + let opening = call( + &b.mcp, + "browser_open", + json!({ "url": format!("{}/size", b.site) }), + ); + answer_site(&mut b.app, true, None).await; + let page = opening.await.expect("task").expect("page"); + // The page keeps its size; only its pixels double. + assert!(page.contains("Window: 1000 x 700, pixels 2"), "{page}"); + let frame = frame_of(&mut b.app, 700).await; + assert_eq!(frame["width"], 1000); + let data = frame["data"].as_str().expect("picture"); + assert_eq!(jpeg_size(data), (2000, 1400)); + + // The bot's own picture stays the size of the page, scrolled where the + // page is. + b.mcp + .tool_text( + "browser_open", + json!({ "url": format!("{}/tall#end", b.site) }), + ) + .await + .expect("tall page"); + let picture = b.mcp.tool_result("browser_screenshot", json!({})).await; + let data = picture["content"][0]["data"].as_str().expect("picture"); + assert_eq!(jpeg_size(data), (1000, 700)); +} diff --git a/crates/botloftd/tests/common/site.rs b/crates/botloftd/tests/common/site.rs index 043f9e6b..5fbdb53d 100644 --- a/crates/botloftd/tests/common/site.rs +++ b/crates/botloftd/tests/common/site.rs @@ -41,8 +41,14 @@ const LOGIN: &str = "Sign in\ +
\ +
"; /// Counts how many times it went out of sight. const SIGHT: &str = "Sight

Hidden 0 times

\ @@ -97,6 +103,7 @@ pub async fn serve() -> SocketAddr { .route("/links", get(|| async { Html(POPUP) })) .route("/login", get(|| async { Html(LOGIN) })) .route("/size", get(|| async { Html(SIZE) })) + .route("/tall", get(|| async { Html(TALL) })) .route("/sight", get(|| async { Html(SIGHT) })) .route("/letters", get(|| async { Html(LETTERS) })) .route("/signals", get(|| async { Html(SIGNALS) })) diff --git a/docs/spec.md b/docs/spec.md index 65c83e74..985a4b26 100644 --- a/docs/spec.md +++ b/docs/spec.md @@ -1086,7 +1086,7 @@ Conferência na documentação oficial (code.claude.com/docs) em 2026-09-28; esf | Expansão `${VAR}` em headers do `mcp.json` | 10 | Confirmado (`mcp`); alguns nomes de credencial conhecidos são lidos vazios, `BOTLOFT_BOT_TOKEN` não é um deles. **Testado com 2.1.284**: o header chegou com o valor da variável de ambiente | feito (M3) | | Revisão do MCP que o Claude Code usa | 10 | Especificação MCP 2026-07-28 (sem `initialize`) e versões antigas. **Visto com 2.1.284**: manda `server/discover` com os headers de 2026-07-28 e, se falhar, `initialize` com 2025-11-25; o mesmo num servidor stdio | feito (M3) | | Sintaxe de caminho Windows em permission rules | 7.5 | Confirmado (`permissions`) e **testado com 2.1.283** (interativo) e **2.1.284** (`-p --setting-sources project,local`): `Read(//c/.../**)` em `deny` bloqueou a leitura com "File is in a directory that is denied by your permission settings", sem perguntar, e o `result` listou a negação em `permission_denials` | feito (M4.1) | -| Edge sem janela controlado por CDP | 21.2, 21.3 | **Visto com o Edge 154** (Windows 11): `--headless=new --remote-debugging-port=0` escreve `DevToolsActivePort` em ~400 ms; `Target.setAutoAttach` no alvo do navegador prende a aba que já existe e cada popup (`openerId`, esperando o depurador); `Emulation.setDeviceMetricsOverride` deixa a tela em 1280 × 800, e chamado de novo com a página aberta muda o `innerHeight` dela na hora; `Page.startScreencast` manda quadros JPEG com o tamanho da tela, também depois dessa mudança (com `maxHeight` no limite de 21.3), mas o quadro do tamanho novo se perde quando há quadros anteriores ainda sem `screencastFrameAck`, e uma página parada não manda outro: o daemon espera esses serem confirmados e recomeça o screencast (`stopScreencast`, `startScreencast`), que então manda um quadro; clique por `Input.dispatchMouseEvent` segue links; `alert` chega em `Page.javascriptDialogOpening`. `Target.targetInfoChanged` chega com o endereço novo da aba, mas com o endereço no lugar do título, e não vem de novo quando a página ganha o título. Ler o título com `Target.getTargetInfo` depois do `Page.loadEventFired` falhou de vez em quando nos testes (ficava o endereço); o `document.title` lido na página, num mundo isolado, não falhou. `Target.createTarget` com `about:blank` abre uma aba que chega por `attachedToTarget` como as outras; `Target.activateTarget` numa aba que ficou atrás a traz de volta, e o screencast dela volta a mandar quadros. Um Edge parado numa página que se mexe (um timer ocupado e uma animação) gastou 84% de um núcleo sem ninguém assistindo e 126% com o screencast, em ~450 MB e 7 a 10 processos. `Browser.setWindowBounds` com `windowState: "minimized"` na janela do `Browser.getWindowForTarget` deixou a página `hidden`, com o timer a menos de um por segundo e o processador abaixo de 1%; `normal` a trouxe de volta inteira, e `Page.captureScreenshot` funcionou em seguida. `Page.setWebLifecycleState` com `frozen` também parou a página, mas `active` a deixou `hidden` e lenta, e um `Page.bringToFront` depois disso travou o `Runtime.evaluate`: não serve. `Emulation.setCPUThrottlingRate` gastou mais, não menos. O user agent traz `HeadlessChrome`. Num popup esperando o depurador, `Page.enable` não responde até `Runtime.runIfWaitingForDebugger`; um mundo isolado com o mesmo nome (`Page.createIsolatedWorld`) volta o mesmo contexto até a página navegar, não enxerga os globais da página e lê `iframe` do mesmo site | feito | +| Edge sem janela controlado por CDP | 21.2, 21.3 | **Visto com o Edge 154** (Windows 11): `--headless=new --remote-debugging-port=0` escreve `DevToolsActivePort` em ~400 ms; `Target.setAutoAttach` no alvo do navegador prende a aba que já existe e cada popup (`openerId`, esperando o depurador); `Emulation.setDeviceMetricsOverride` deixa a tela em 1280 × 800, e chamado de novo com a página aberta muda o `innerHeight` dela na hora; `Page.startScreencast` manda quadros JPEG com o tamanho da tela, também depois dessa mudança (com `maxHeight` no limite de 21.3), mas o quadro do tamanho novo se perde quando há quadros anteriores ainda sem `screencastFrameAck`, e uma página parada não manda outro: o daemon espera esses serem confirmados e recomeça o screencast (`stopScreencast`, `startScreencast`), que então manda um quadro; clique por `Input.dispatchMouseEvent` segue links; `alert` chega em `Page.javascriptDialogOpening`. `Target.targetInfoChanged` chega com o endereço novo da aba, mas com o endereço no lugar do título, e não vem de novo quando a página ganha o título. Ler o título com `Target.getTargetInfo` depois do `Page.loadEventFired` falhou de vez em quando nos testes (ficava o endereço); o `document.title` lido na página, num mundo isolado, não falhou. `Target.createTarget` com `about:blank` abre uma aba que chega por `attachedToTarget` como as outras; `Target.activateTarget` numa aba que ficou atrás a traz de volta, e o screencast dela volta a mandar quadros. Um Edge parado numa página que se mexe (um timer ocupado e uma animação) gastou 84% de um núcleo sem ninguém assistindo e 126% com o screencast, em ~450 MB e 7 a 10 processos. `Browser.setWindowBounds` com `windowState: "minimized"` na janela do `Browser.getWindowForTarget` deixou a página `hidden`, com o timer a menos de um por segundo e o processador abaixo de 1%; `normal` a trouxe de volta inteira, e `Page.captureScreenshot` funcionou em seguida. `Page.setWebLifecycleState` com `frozen` também parou a página, mas `active` a deixou `hidden` e lenta, e um `Page.bringToFront` depois disso travou o `Runtime.evaluate`: não serve. `Emulation.setCPUThrottlingRate` gastou mais, não menos. O user agent traz `HeadlessChrome`. Num popup esperando o depurador, `Page.enable` não responde até `Runtime.runIfWaitingForDebugger`; um mundo isolado com o mesmo nome (`Page.createIsolatedWorld`) volta o mesmo contexto até a página navegar, não enxerga os globais da página e lê `iframe` do mesmo site | feito Nitidez (visto com o Edge do Windows 11, outubro de 2026): com `deviceScaleFactor` 2 a página diz `devicePixelRatio` 2, mas os quadros do `Page.startScreencast` continuam nos pixels da página (1000 × 700 para uma página de 1000 × 700), com `maxWidth` folgado e até com o `scale` do `setDeviceMetricsOverride`; o `Page.captureScreenshot` vem com os pixels da tela (2000 × 1400). Com `clip` (`x`/`y` de `cssVisualViewport.pageX/pageY`, o tamanho da página e `scale` 0,5), ele volta a 1000 × 700 e mostra a parte para onde a página está rolada. | | O que um site vê do Edge sem janela | 21.3 | **Visto com o Edge 154** (Windows 11), com uma página que mostra os sinais: sem nada, `navigator.webdriver` é `true`, o user agent traz `HeadlessChrome` e `outerWidth` e `outerHeight` são 0. `Emulation.setUserAgentOverride` só com `userAgent` deixa `navigator.userAgentData.brands` vazio; com `userAgentMetadata` (o que `getHighEntropyValues` devolve, com os mesmos nomes), as marcas voltam. `about:blank` não tem `navigator.userAgentData` (não é página segura); `chrome://version` tem. `--user-agent` na linha de comando mantém as marcas, mas esvazia `fullVersionList`, `architecture` e `platformVersion`. `--disable-blink-features=AutomationControlled` deixa `navigator.webdriver` `false` mesmo com `--remote-debugging-port`. `--window-size` deixa a janela com o tamanho pedido (`Browser.getWindowForTarget`), mas `outerWidth` continua 0. O login do Google recusa o navegador ("Esse navegador ou app pode não ser seguro"), também com o dono no controle | feito | | Login numa janela de verdade | 21.11 | **Visto com o Edge 154** (Windows 11): `msedge.exe` com `--user-data-dir` e sem `--remote-debugging-port` fica vivo como o próprio navegador e sai alguns segundos depois de a janela fechar. Um cookie gravado nessa janela aparece no Edge sem janela aberto depois no mesmo perfil. **A verificar** com o Claude Code real: o dono entra numa conta do Google pela janela, fecha, e o bot abre o Gmail já dentro da conta; o bot que pediu ajuda lê que o navegador fechou e abre a página de novo | manual (PR) | | Imagem no resultado de uma tool MCP | 21.4 | Resultado com `{"type": "image", "data", "mimeType"}` em `content` (especificação MCP). **Testado com 2.1.284** (Haiku): pedido só pelas cores do logo no screenshot, o bot respondeu certo, e as cores não estavam no texto da página: o modelo recebe a imagem | feito | @@ -1301,7 +1301,7 @@ Cada bot tem um navegador próprio para pesquisar e usar sites: um Microsoft Edg ### 21.3 Página - **Abas:** o daemon se prende a toda página nova do navegador (`Target.setAutoAttach` no alvo do navegador, com `flatten` e `waitForDebuggerOnStart`) e a configura antes de ela rodar. Os comandos de preparo saem juntos com `Runtime.runIfWaitingForDebugger`, sem esperar resposta um a um: numa aba aberta pela página, `Page.enable` só responde depois que ela roda (visto com o Edge 154). O navegador só conta como aberto, e uma aba nova do dono só recebe um endereço, depois desse preparo: uma página aberta antes carregaria sem o daemon ver os eventos dela. Uma aba aberta pela página (`target=_blank`, `window.open`) vira a aba ativa, como para quem usa o navegador; quando ela fecha, a que era ativa antes dela volta (`Target.activateTarget`, para o navegador mostrar a mesma que o daemon). No máximo 6 abas: acima disso, fecha a que foi ativa há mais tempo. As tools agem sempre na ativa, e o bot não tem tool para trocar de aba; o dono, no controle, troca de aba, abre uma nova e vai a um endereço (21.10). O título de cada aba, para a lista que o dono vê (21.7), é o `document.title`, lido no mundo isolado (21.6) a cada carregamento e a cada mudança de endereço dentro da página: o Edge sem janela avisa quando a aba ganha um endereço, mas nunca o título dela (19). Até a página carregar, a aba fica sem título; um título que a página troca depois disso só aparece no carregamento seguinte. -- **Tela:** 1280 × 800 px, escala 1 (`Emulation.setDeviceMetricsOverride`), enquanto ninguém assiste. Com o painel aberto, a página toma o espaço que ela tem na mesa do painel (`browser.resize`, 21.7), medido onde ela aparece, por dentro da borda da mesa, já sem os avisos e os botões em volta: assim ela o ocupa inteiro, sem faixas da mesa dos lados. Um aviso que aparece ou some (o pedido de ajuda, as notas do controle) muda esse espaço, e a página acompanha. A largura do espaço, entre 800 e 1600 px, para a página aparecer quase no tamanho do app e o dono enxergar e mexer nela, e a altura na mesma proporção do espaço, entre 600 e 2000 px, para a tela ao vivo ocupar o painel em vez de deixar um vazio. Abaixo de 800 px a maioria dos sites troca a versão de computador pela de celular: um painel mais estreito que isso encolhe a imagem, não a página. Alargar o painel ou ocupar todo o espaço dá mais página. O tamanho vale para todas as abas, e volta a 1280 × 800 quando o último que assistia para. O que a página vê do navegador é o de um Edge comum, porque alguns sites recusam o login num navegador que um programa controla (19): o user agent é o do Edge sem a palavra `Headless`, com as marcas e a plataforma de `navigator.userAgentData` (`userAgentMetadata`, lidas uma vez ao abrir, numa página `chrome://version` que fecha em seguida: `about:blank` não tem `userAgentData`), e `navigator.webdriver` é `false` (`--disable-blink-features=AutomationControlled`). O disfarce para aí: nada de script injetado na página, e `outerWidth` e `outerHeight` continuam 0. O login de quem olha mais que isso, como o do Google, se faz numa janela de verdade (21.11). +- **Tela:** 1280 × 800 px, escala 1 (`Emulation.setDeviceMetricsOverride`), enquanto ninguém assiste. Com o painel aberto, a página toma o espaço que ela tem na mesa do painel (`browser.resize`, 21.7), medido onde ela aparece, por dentro da borda da mesa, já sem os avisos e os botões em volta: assim ela o ocupa inteiro, sem faixas da mesa dos lados. Um aviso que aparece ou some (o pedido de ajuda, as notas do controle) muda esse espaço, e a página acompanha. A largura do espaço, entre 800 e 1600 px, para a página aparecer quase no tamanho do app e o dono enxergar e mexer nela, e a altura na mesma proporção do espaço, entre 600 e 2000 px, para a tela ao vivo ocupar o painel em vez de deixar um vazio. Abaixo de 800 px a maioria dos sites troca a versão de computador pela de celular: um painel mais estreito que isso encolhe a imagem, não a página. Alargar o painel ou ocupar todo o espaço dá mais página. Para o dono, a página também tem a nitidez da tela dele (`deviceScaleFactor` igual ao `devicePixelRatio` do app, de 1 a 2): numa tela com escala de 150% ou com o app ampliado, o texto não fica borrado. O tamanho da página em pixels CSS não muda com isso, e as fotos que o bot tira (`browser_screenshot`) continuam do tamanho da página: são recortadas onde a página está rolada (`Page.getLayoutMetrics`) e reduzidas (`clip.scale`), para não custarem mais ao bot. O tamanho vale para todas as abas, e volta a 1280 × 800 quando o último que assistia para. O que a página vê do navegador é o de um Edge comum, porque alguns sites recusam o login num navegador que um programa controla (19): o user agent é o do Edge sem a palavra `Headless`, com as marcas e a plataforma de `navigator.userAgentData` (`userAgentMetadata`, lidas uma vez ao abrir, numa página `chrome://version` que fecha em seguida: `about:blank` não tem `userAgentData`), e `navigator.webdriver` é `false` (`--disable-blink-features=AutomationControlled`). O disfarce para aí: nada de script injetado na página, e `outerWidth` e `outerHeight` continuam 0. O login de quem olha mais que isso, como o do Google, se faz numa janela de verdade (21.11). - **Diálogos** (`alert`, `confirm`, `prompt`, "sair da página?"): aceitos na hora; a resposta seguinte da tool diz ao bot o texto que apareceu. - **Escolher arquivo:** o pedido da página é interceptado e cancelado, e o bot lê que enviar arquivos pelo navegador ainda não é possível. - **Downloads:** vão para `\downloads\` (`Browser.setDownloadBehavior`) e aparecem no painel de arquivos (8.4); a tool diz quando um download termina. @@ -1371,7 +1371,7 @@ Esqueceu a senha? [e5 link "Recuperar acesso"] | `browser.list` | | `BrowserState[]`: os navegadores que não estão fechados | | `browser.watch` | `botId` | `BrowserView {state, frame}`: o estado e o último quadro. A conexão passa a receber os quadros desse bot | | `browser.unwatch` | | `null` | -| `browser.resize` | `botId`, `width`, `height` | `null`: o espaço que a página tem na mesa do painel (21.3), em pixels do app (de 1 a 10 000 cada). O daemon ajusta a altura da página à proporção desse espaço (21.3). Só da conexão que assiste esse bot; vale também com o navegador fechado, para quando ele abrir. Cada navegador tem um tamanho só: o último pedido vale | +| `browser.resize` | `botId`, `width`, `height` | `null`: o espaço que a página tem na mesa do painel (21.3), em pixels do app, e `scale` (opcional), os pixels da tela por pixel do app em porcentagem (`devicePixelRatio` × 100; sem ele, 100): a página é desenhada tão nítida, de 100 a 200 (de 1 a 10 000 cada). O daemon ajusta a altura da página à proporção desse espaço (21.3). Só da conexão que assiste esse bot; vale também com o navegador fechado, para quando ele abrir. Cada navegador tem um tamanho só: o último pedido vale | | `browser.take` | `botId` | `BrowserState`: o dono assume o navegador (21.10). A conexão precisa estar assistindo esse bot, e o navegador aberto | | `browser.release` | `botId` | `BrowserState`: devolve o navegador ao bot; um pedido de ajuda aberto recebe **Pronto** | | `browser.input` | `botId`, `input` | `null`: um evento do dono na página. Só da conexão que controla | @@ -1386,7 +1386,7 @@ Esqueceu a senha? [e5 link "Recuperar acesso"] - `BrowserState`: `botId`, `status` (`closed`, `starting`, `open`, `failed`), `url`, `title` e `loading` (da aba ativa), `tabs` (as abas abertas, na ordem em que abriram: `BrowserTab {id, title, url, active}`, com `id` opaco), `error` (por que não abriu, em `failed`), `control` (`bot` ou `owner`, 21.10), `resting` (o navegador descansa, 21.2), `ask` (o que o bot pediu ao dono com `browser_ask_owner`, enquanto o pedido está aberto), `window` (aberto numa janela própria, 21.11: o estado é `closed` e `control` é `owner`) e `updatedAt`. `browser.list` traz também os `closed` com `window`. - Notificações para todos: `browser.changed` (`BrowserState`) e `browser.action {botId, kind, x, y, label, at}`, com `kind` `open`, `click`, `type`, `select`, `press`, `scroll` ou `back`; `x` e `y` em pixels da página (o tamanho que vem em `browser.frame`) quando a ação tem um ponto; `label` é o nome do elemento, a tecla ou o site, nunca o texto digitado. Clicar, digitar e escolher avisam antes de agir: o daemon acha o elemento e o traz para a tela, manda `browser.action` com o ponto e, se alguém assiste, espera o cursor deslizar até lá no painel (560 ms, o tempo da animação mais uma folga) antes do clique de verdade; sem ninguém assistindo, não espera. As outras ações avisam depois de feitas. Assim a página muda depois que o cursor chega, e não antes. - Só para a conexão que assiste: `browser.frame {botId, data, width, height}`, um JPEG em base64 e o tamanho da página que ele mostra. Cada conexão assiste um bot por vez; `browser.watch` de outro troca, e fechar a conexão para. -- Os quadros vêm do `Page.startScreencast` (JPEG, qualidade 60, do tamanho da página, 21.3), que roda só enquanto alguém assiste, e só quando a tela muda. O daemon confirma cada quadro no máximo ~15 vezes por segundo. Um app lento não acumula quadros: a conexão manda sempre o mais novo quando consegue, e quadros não contam no limite de 1024 notificações (11.1). +- Os quadros vêm do `Page.startScreencast` (JPEG, qualidade 60, do tamanho da página, 21.3), que roda só enquanto alguém assiste, e só quando a tela muda. O daemon confirma cada quadro no máximo ~15 vezes por segundo enquanto o dono assiste o bot, e ~30 com o navegador nas mãos do dono (21.10), para a página acompanhar quando ele rola e aponta. A página é desenhada tão nítida quanto a tela do dono (`scale` de `browser.resize`, até 2×, 21.3). O screencast desenha nos pixels da própria página, por mais nítida que ela esteja (19): numa tela mais nítida, o quadro dele vem pequeno (64 px) e só avisa que a página mudou, e o quadro que vai para o app é um `Page.captureScreenshot` (JPEG, qualidade 60, `optimizeForSpeed`), que tem os pixels da tela; a confirmação só sai depois dele, para as fotos não se acumularem. Com o navegador em descanso, a foto que fica também é nítida. Um app lento não acumula quadros: a conexão manda sempre o mais novo quando consegue, e quadros não contam no limite de 1024 notificações (11.1). ### 21.8 App (N2)