diff --git a/app/src/dev/preview.ts b/app/src/dev/preview.ts index 78c9a345..ff7f1d5d 100644 --- a/app/src/dev/preview.ts +++ b/app/src/dev/preview.ts @@ -14,6 +14,7 @@ import { seedBrowser } from "./seedBrowser"; import { seedChats } from "./seedChats"; import { seedChief } from "./seedChief"; import { seedHands } from "./seedHands"; +import { activeTab, drawPage } from "./seedPage"; import { seedRoutines } from "./seedRoutines"; import { seedScreens } from "./seedScreens"; @@ -115,6 +116,10 @@ function seed(fake: FakeBotloft): void { }); seedRoutines(fake, { scout: scout.id, analyst: analyst.id }); seedBrowser(fake, scout.id); + // Analyst hit the limit in the middle of a search: its browser rests. + fake.browser.open(analyst.id, "https://stats.example/flour-prices", "Flour prices · Stats"); + fake.browser.paint(analyst.id, (size) => drawPage(activeTab(fake, analyst.id), size)); + fake.browser.rest(analyst.id); seedScreens(fake, research.id); seedHands(fake, research.id); const ops = fake.addCrew("Ops"); diff --git a/app/src/features/bots/BotView.tsx b/app/src/features/bots/BotView.tsx index efa8603a..25e93a3f 100644 --- a/app/src/features/bots/BotView.tsx +++ b/app/src/features/bots/BotView.tsx @@ -96,9 +96,10 @@ export function BotView({ bot, crew }: { bot: Bot; crew: Crew }) { }; const fresh = filesOpen ? 0 : files.files.filter((file) => file.modifiedAt > seenAt).length; const routines = useApp(useShallow((state) => routinesOf(state, bot.id))); + // A browser at rest is open, but the bot is not using it (spec 21.2). const browsing = useApp((state) => { - const status = state.browsers[bot.id]?.status; - return status === "open" || status === "starting"; + const browser = state.browsers[bot.id]; + return browser?.status === "starting" || (browser?.status === "open" && !browser.resting); }); const browserOpen = side === "browser"; const asking = useApp((state) => Boolean(state.browsers[bot.id]?.ask)); diff --git a/app/src/features/browser/BrowserPanel.tsx b/app/src/features/browser/BrowserPanel.tsx index a23ce0ae..05f432da 100644 --- a/app/src/features/browser/BrowserPanel.tsx +++ b/app/src/features/browser/BrowserPanel.tsx @@ -3,7 +3,7 @@ // just did. Watching starts when the panel opens and stops when it closes. // The owner can take it into their own hands and give it back (spec 21.10). -import { Globe, LoaderCircle, Maximize2, Minimize2, X } from "lucide-react"; +import { Globe, LoaderCircle, Maximize2, Minimize2, Moon, X } from "lucide-react"; import { useEffect, useRef, useState } from "react"; import { useT } from "../../i18n"; import type { Bot } from "../../lib/protocol.gen"; @@ -43,6 +43,8 @@ export function BrowserPanel({ const [expanded, setExpanded] = useState(false); const caption = useCaption(bot, action); const live = status === "open" && frame !== null; + // Nobody uses it: its pages stand still until someone does (spec 21.2). + const resting = status === "open" && state?.resting === true; const hands = useHands(bot, state); const [focused, setFocused] = useState(false); const ask = state?.ask ?? null; @@ -77,11 +79,21 @@ export function BrowserPanel({

{t.heading} - {live && ( - - - {t.live} + {resting ? ( + + + {t.resting} + ) : ( + live && ( + + + {t.live} + + ) )}

diff --git a/app/src/features/browser/browser.test.tsx b/app/src/features/browser/browser.test.tsx index 41d96e48..c9429ebf 100644 --- a/app/src/features/browser/browser.test.tsx +++ b/app/src/features/browser/browser.test.tsx @@ -102,6 +102,37 @@ describe("browser panel", () => { await waitFor(() => expect(fake.browser.size(scout.id)).toEqual({ width: 1280, height: 800 })); }); + test("says when the browser rests, and it is live again once someone uses it", async () => { + const { fake, scout } = crew(); + fake.browser.open(scout.id, "https://example.com/", "Example"); + await openScout(fake); + fireEvent.click(toggle()); + await waitFor(() => expect(fake.browser.watching).toBe(scout.id)); + act(() => { + fake.browser.frame(scout.id, "AAAA"); + }); + expect(within(panel()).getByText("Live")).toBeDefined(); + + // The bot's turn ended: the page stays, and the panel says it rests. + act(() => { + fake.browser.rest(scout.id); + }); + const resting = within(panel()).getByText("Resting"); + expect(resting.title).toContain("Scout isn't using the browser"); + expect(within(panel()).queryByText("Live")).toBeNull(); + expect(within(panel()).getByRole("figure").querySelector("img")).not.toBeNull(); + // Closed, its button does not say the bot is using it. + fireEvent.click(toggle()); + expect(toggle().getAttribute("aria-label")).toBe("Show browser"); + + // The owner's hands wake it. + fireEvent.click(toggle()); + fireEvent.click(await within(panel()).findByRole("button", { name: "Take control" })); + expect(await within(panel()).findByText("You are in control")).toBeDefined(); + expect(within(panel()).queryByText("Resting")).toBeNull(); + expect(fake.browser.state(scout.id).resting).toBe(false); + }); + test("opens by itself when the bot starts browsing, and the button marks it once closed", async () => { const { fake, scout } = crew(); await openScout(fake); diff --git a/app/src/i18n/en/browser.ts b/app/src/i18n/en/browser.ts index 6aaabaad..2f7685c9 100644 --- a/app/src/i18n/en/browser.ts +++ b/app/src/i18n/en/browser.ts @@ -16,6 +16,9 @@ export const browser = { openOutside: "Open in my browser", openFailed: "Could not open the page", live: "Live", + resting: "Resting", + restingWhy: (bot: string) => + `${bot} isn't using the browser, so it rests and costs your computer nothing. It wakes the moment ${bot} or you need it.`, loading: "Loading…", address: "Address", addressHint: "Type an address and press Enter", diff --git a/app/src/i18n/es/browser.ts b/app/src/i18n/es/browser.ts index 2fcf8b84..7e835875 100644 --- a/app/src/i18n/es/browser.ts +++ b/app/src/i18n/es/browser.ts @@ -17,6 +17,9 @@ export const browser: Messages["browser"] = { openOutside: "Abrir en mi navegador", openFailed: "No se pudo abrir la página", live: "En vivo", + resting: "En reposo", + restingWhy: (bot) => + `${bot} no está usando el navegador, así que descansa y no gasta tu ordenador. Vuelve en cuanto ${bot} o tú lo necesiten.`, loading: "Cargando…", address: "Dirección", addressHint: "Escribe una dirección y pulsa Enter", diff --git a/app/src/i18n/pt-BR/browser.ts b/app/src/i18n/pt-BR/browser.ts index e26e9b54..95c6372e 100644 --- a/app/src/i18n/pt-BR/browser.ts +++ b/app/src/i18n/pt-BR/browser.ts @@ -18,6 +18,9 @@ export const browser: Messages["browser"] = { openOutside: "Abrir no meu navegador", openFailed: "Não foi possível abrir a página", live: "Ao vivo", + resting: "Em descanso", + restingWhy: (bot) => + `${bot} não está usando o navegador, então ele descansa e não gasta o seu computador. Volta na hora em que ${bot} ou você precisar.`, loading: "Carregando…", address: "Endereço", addressHint: "Digite um endereço e aperte Enter", diff --git a/app/src/lib/fakeBrowser.ts b/app/src/lib/fakeBrowser.ts index 256c8ad1..6e23e376 100644 --- a/app/src/lib/fakeBrowser.ts +++ b/app/src/lib/fakeBrowser.ts @@ -50,6 +50,7 @@ export class FakeBrowser { tabs: [], error: null, control: "bot", + resting: false, ask: null, updatedAt: this.fake.now, } @@ -79,7 +80,13 @@ export class FakeBrowser { tabs.length === 0 ? [{ id: this.fake.id("tab"), url, title, active: true }] : tabs.map((tab) => (tab.active ? { ...tab, url, title } : tab)); - return this.setTabs(botId, next, { status: "open", loading: false, error: null }); + const awake = { status: "open", loading: false, error: null, resting: false } as const; + return this.setTabs(botId, next, awake); + } + + /** The bot's turn ended: its browser rests until someone uses it. */ + rest(botId: BotId): BrowserState { + return this.set(botId, { resting: true }); } /** Another tab opened, and became the active one. */ @@ -92,7 +99,8 @@ export class FakeBrowser { close(botId: BotId): BrowserState { this.frames.delete(botId); - return this.setTabs(botId, [], { status: "closed", loading: false, control: "bot", ask: null }); + const closed = { status: "closed", loading: false, control: "bot", resting: false } as const; + return this.setTabs(botId, [], { ...closed, ask: null }); } /** The bot asks the owner for a hand: a request in the chat and in the panel. */ @@ -232,7 +240,7 @@ export class FakeBrowser { if (this.state(botId).status !== "open") { throw conflict("the browser is not open"); } - return this.set(botId, { control: "owner" }); + return this.set(botId, { control: "owner", resting: false }); }, "browser.release": ({ botId }) => { const approvalId = this.asks.get(botId); @@ -255,6 +263,7 @@ export class FakeBrowser { throw conflict("the browser is not open"); } this.reloads.push(botId); + this.set(botId, { resting: false }); this.repaint(botId); return null; }, diff --git a/app/src/lib/protocol.gen.ts b/app/src/lib/protocol.gen.ts index 3b0b4bc3..49d35c97 100644 --- a/app/src/lib/protocol.gen.ts +++ b/app/src/lib/protocol.gen.ts @@ -872,6 +872,11 @@ error: string | null, * Who uses it now (spec 21.10). */ control: BrowserControl, +/** + * Nobody uses it, so it rests: its pages stand still until the bot or + * the owner needs them (spec 21.2). + */ +resting: boolean, /** * What the bot asked the owner to do in it, while it waits. */ diff --git a/crates/botloft-core/src/protocol/browser.rs b/crates/botloft-core/src/protocol/browser.rs index 9b2ab766..90be0617 100644 --- a/crates/botloft-core/src/protocol/browser.rs +++ b/crates/botloft-core/src/protocol/browser.rs @@ -50,6 +50,9 @@ pub struct BrowserState { pub error: Option, /// Who uses it now (spec 21.10). pub control: BrowserControl, + /// Nobody uses it, so it rests: its pages stand still until the bot or + /// the owner needs them (spec 21.2). + pub resting: bool, /// What the bot asked the owner to do in it, while it waits. pub ask: Option, /// Unix time in milliseconds. @@ -67,6 +70,7 @@ impl BrowserState { tabs: Vec::new(), error: None, control: BrowserControl::Bot, + resting: false, ask: None, updated_at: now, } diff --git a/crates/botloftd/src/browser/hands.rs b/crates/botloftd/src/browser/hands.rs index e861347a..e32c5b77 100644 --- a/crates/botloftd/src/browser/hands.rs +++ b/crates/botloftd/src/browser/hands.rs @@ -4,7 +4,7 @@ use std::sync::Arc; use std::sync::atomic::Ordering; -use std::time::Duration; +use std::time::{Duration, Instant}; use botloft_core::ids::{ApprovalId, BotId}; use botloft_core::protocol::{BrowserControl, BrowserInput}; @@ -13,6 +13,7 @@ use tracing::debug; use super::input::fits; use super::moves::{Move, feed}; +use super::rest::wake; use super::session::Session; use super::{Browsers, Slots, Viewport, lock, sites, update}; use crate::clock::Clock; @@ -74,7 +75,17 @@ impl Browsers { self.set_state(bot, |state| state.control = BrowserControl::Owner); debug!(bot = %bot, "browser: the owner took it"); let (moves, queue) = mpsc::unbounded_channel(); - tokio::spawn(feed(Arc::clone(&session), queue)); + let (slots, events, clock) = ( + Arc::clone(&self.slots), + self.events.clone(), + Arc::clone(&self.clock), + ); + let (page, owner) = (Arc::clone(&session), bot.clone()); + tokio::spawn(async move { + // A browser at rest wakes for the owner's hands. + wake(&page, &slots, &events, clock.as_ref(), &owner).await; + feed(page, queue).await; + }); Ok(Hands { slots: Arc::clone(&self.slots), events: self.events.clone(), @@ -156,12 +167,16 @@ impl Drop for Asking<'_> { } impl Hands { - /// The page's size, while the owner still has the browser. + /// The page's size, while the owner still has the browser. What they + /// do counts as using it. fn page(&self) -> Result { lock(&self.slots) - .get(&self.bot) + .get_mut(&self.bot) .filter(|slot| *slot.held.borrow() == Some(self.hold)) - .map(|slot| slot.viewport) + .map(|slot| { + slot.used = Instant::now(); + slot.viewport + }) .ok_or(InputError::NotHeld) } diff --git a/crates/botloftd/src/browser/mod.rs b/crates/botloftd/src/browser/mod.rs index 4db10c74..04f0902a 100644 --- a/crates/botloftd/src/browser/mod.rs +++ b/crates/botloftd/src/browser/mod.rs @@ -13,6 +13,7 @@ mod launch; mod moves; mod page; mod read; +mod rest; mod session; mod settle; pub mod sites; @@ -41,7 +42,7 @@ pub use self::launch::find as find_program; pub use self::page::{Done, Scroll}; pub use self::read::{READ_MAX, Reading}; pub use self::session::Session; -pub use self::sweep::run; +pub use self::sweep::{Want, run}; pub use self::viewport::Viewport; pub use self::watch::Watching; use crate::clock::Clock; @@ -93,16 +94,6 @@ impl Default for BrowserSettings { } } -/// What should happen to a bot's browser. -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub enum Want { - Keep, - /// The bot or its crew is paused. - Close, - /// The bot or its crew is archived: the profile goes too. - Forget, -} - type Frames = tokio::sync::watch::Sender>>; /// Which hold of the owner's has the browser, if any (spec 21.10). type Held = tokio::sync::watch::Sender>; @@ -174,7 +165,8 @@ impl Browsers { }) } - /// Waits for the bot's browser to be free and holds it for one call. + /// Waits for the bot's browser to be free and holds it for one call, + /// awake. pub async fn begin(&self, bot: &BotId) -> Call<'_> { let calls = { let mut slots = lock(&self.slots); @@ -182,6 +174,7 @@ impl Browsers { }; let guard = calls.lock_owned().await; self.touch(bot); + self.wake(bot).await; Call { browsers: self, bot: bot.clone(), @@ -189,7 +182,7 @@ impl Browsers { } } - fn touch(&self, bot: &BotId) { + pub(super) fn touch(&self, bot: &BotId) { if let Some(slot) = lock(&self.slots).get_mut(bot) { slot.used = Instant::now(); } diff --git a/crates/botloftd/src/browser/moves.rs b/crates/botloftd/src/browser/moves.rs index 2be731f7..0c9036f1 100644 --- a/crates/botloftd/src/browser/moves.rs +++ b/crates/botloftd/src/browser/moves.rs @@ -12,6 +12,7 @@ use serde_json::json; use tokio::sync::mpsc; use tracing::debug; +use super::rest::wake; use super::session::{Session, Tab}; use super::{BrowserError, Browsers}; @@ -32,12 +33,20 @@ pub(super) enum Move { impl Browsers { /// Reloads the active tab of the bot's browser, in the owner's hands or - /// not. `false` when the browser is not open. + /// not, waking it if it rests. `false` when the browser is not open. pub fn reload(&self, bot: &BotId) -> bool { let Some(session) = self.running(bot) else { return false; }; + self.touch(bot); + let (slots, events, clock) = ( + Arc::clone(&self.slots), + self.events.clone(), + Arc::clone(&self.clock), + ); + let bot = bot.clone(); tokio::spawn(async move { + wake(&session, &slots, &events, clock.as_ref(), &bot).await; if let Err(err) = session.reload().await { debug!("browser: the page did not reload: {err}"); } diff --git a/crates/botloftd/src/browser/rest.rs b/crates/botloftd/src/browser/rest.rs new file mode 100644 index 00000000..e9d3c5d8 --- /dev/null +++ b/crates/botloftd/src/browser/rest.rs @@ -0,0 +1,143 @@ +//! A browser nobody is using rests (spec 21.2): its window is minimized, so +//! its pages stop working as in any window out of sight, and it comes back +//! the moment the bot or the owner needs it. Nothing in the pages is lost. + +use std::sync::Arc; + +use botloft_core::ids::BotId; +use botloft_core::protocol::BrowserFrame; +use serde_json::{Value, json}; +use tokio::sync::broadcast; +use tracing::debug; + +use super::session::Session; +use super::{Browsers, Slots, lock, update}; +use crate::clock::Clock; +use crate::state::Event; + +impl Session { + pub fn is_resting(&self) -> bool { + self.lock().resting + } + + /// Minimizes every window of the browser, or brings them back. + async fn show(&self, shown: bool) { + let tabs: Vec = { + let tabs = self.lock(); + tabs.list.iter().map(|tab| tab.target.clone()).collect() + }; + let mut windows: Vec = Vec::new(); + for tab in tabs { + let params = json!({ "targetId": tab }); + // A tab that closed meanwhile has no window to look for. + if let Ok(found) = self + .cdp + .call(None, "Browser.getWindowForTarget", params) + .await + && !windows.contains(&found["windowId"]) + { + windows.push(found["windowId"].clone()); + } + } + let state = if shown { "normal" } else { "minimized" }; + for window in windows { + let params = json!({ "windowId": window, "bounds": { "windowState": state } }); + if let Err(err) = self.cdp.call(None, "Browser.setWindowBounds", params).await { + debug!("browser: changing a window to {state}: {err}"); + } + } + } +} + +/// Brings a resting browser back before something uses it, with frames for +/// whoever watches. +pub(super) async fn wake( + session: &Session, + slots: &Slots, + events: &broadcast::Sender, + clock: &dyn Clock, + bot: &BotId, +) { + let _turn = session.syncing.lock().await; + if !session.is_resting() { + return; + } + session.show(true).await; + let watched = { + let mut tabs = session.lock(); + tabs.resting = false; + tabs.active() + .filter(|_| tabs.watching) + .map(|tab| tab.session.clone()) + }; + if let Some(page) = watched { + session.cast(&page, false).await; + session.cast(&page, true).await; + } + update(slots, events, clock, bot, |state| state.resting = false); + debug!(bot = %bot, "browser: awake"); +} + +impl Browsers { + /// Wakes the bot's browser if it rests. + pub(super) async fn wake(&self, bot: &BotId) { + if let Some(session) = self.running(bot) { + wake( + &session, + &self.slots, + &self.events, + self.clock.as_ref(), + bot, + ) + .await; + } + } + + /// Puts the bot's browser to rest, unless a tool or the owner is using + /// it. The picture of its page stays for whoever looks meanwhile. + pub fn rest(&self, bot: &BotId) { + let found = lock(&self.slots).get(bot).and_then(|slot| { + let (_, session) = slot.session.as_ref()?; + if slot.held.borrow().is_some() || session.is_resting() || session.is_closed() { + return None; + } + let turn = Arc::clone(&slot.calls).try_lock_owned().ok()?; + Some((Arc::clone(session), turn, slot.frames.clone())) + }); + let Some((session, turn, frames)) = found else { + return; + }; + let (slots, events, clock) = ( + Arc::clone(&self.slots), + self.events.clone(), + Arc::clone(&self.clock), + ); + let bot = bot.clone(); + tokio::spawn(async move { + // No tool starts while the browser goes to rest. + let _turn = turn; + let _one = session.syncing.lock().await; + let held = lock(&slots) + .get(&bot) + .is_none_or(|slot| slot.held.borrow().is_some()); + if held || session.is_resting() { + return; + } + if let Ok(data) = session.screenshot().await { + let page = session.viewport(); + frames.send_replace(Some(Arc::new(BrowserFrame { + bot_id: bot.clone(), + data, + width: page.width, + height: page.height, + }))); + } + session.show(false).await; + session.lock().resting = true; + update(&slots, &events, clock.as_ref(), &bot, |state| { + state.resting = true; + }); + debug!(bot = %bot, "browser: resting"); + }); + } +} diff --git a/crates/botloftd/src/browser/session.rs b/crates/botloftd/src/browser/session.rs index 2d59f88c..5f0366ff 100644 --- a/crates/botloftd/src/browser/session.rs +++ b/crates/botloftd/src/browser/session.rs @@ -72,6 +72,8 @@ pub struct Tabs { pub notes: Vec, pub(super) downloads: HashMap, pub watching: bool, + /// Its window is minimized while nobody uses it (`rest.rs`). + pub(super) resting: bool, pub closed: bool, pub viewport: Viewport, /// The tab the bot was on when the owner took the browser, until the diff --git a/crates/botloftd/src/browser/sweep.rs b/crates/botloftd/src/browser/sweep.rs index 6b035ca8..7b923bab 100644 --- a/crates/botloftd/src/browser/sweep.rs +++ b/crates/botloftd/src/browser/sweep.rs @@ -1,26 +1,61 @@ -//! Closing the browsers nobody needs (spec 21.2): idle ones, and those of -//! bots that are paused or archived. +//! Looking after the browsers nobody needs (spec 21.2): one whose bot ended +//! its turn rests at once, idle ones close, and so do those of bots that +//! are paused or archived. use std::sync::Arc; use std::time::Duration; use botloft_core::ids::BotId; +use botloft_core::protocol::BotState; +use tokio::sync::broadcast::error::RecvError; -use super::{Browsers, Want, lock}; +use super::{Browsers, lock}; use crate::service::{bots, crews}; -use crate::state::Daemon; +use crate::state::{Daemon, Event}; const SWEEP_EVERY: Duration = Duration::from_secs(30); +/// How long after its last use an awake browser goes back to rest, when +/// its bot is not in a turn. +const REST_AFTER: Duration = Duration::from_secs(20); + +/// What should happen to a bot's browser. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Want { + /// The bot is in a turn: the browser is its to use. + Keep, + /// The bot is not working: the browser may rest. + Rest, + /// The bot or its crew is paused. + Close, + /// The bot or its crew is archived: the profile goes too. + Forget, +} pub async fn run(daemon: Arc) { let mut tick = tokio::time::interval(SWEEP_EVERY); tick.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Delay); + let mut events = daemon.subscribe(); loop { - tick.tick().await; - daemon.browsers.sweep(|bot| want(&daemon, bot)); + tokio::select! { + _ = tick.tick() => daemon.browsers.sweep(|bot| want(&daemon, bot)), + event = events.recv() => match event { + // The turn ended: the browser rests right away. + Ok(Event::BotState(change)) if !working(change.state) => { + daemon.browsers.rest(&change.bot_id); + } + // What was missed, the next sweep sees. + Ok(_) | Err(RecvError::Lagged(_)) => {} + Err(RecvError::Closed) => return, + }, + } } } +/// Whether a bot in `state` is in a turn, its browser in use or about to be. +fn working(state: BotState) -> bool { + matches!(state, BotState::Busy | BotState::NeedsApproval) +} + fn want(daemon: &Daemon, bot: &BotId) -> Want { let store = daemon.store(); let Ok(record) = bots::find(&store, bot) else { @@ -33,29 +68,59 @@ fn want(daemon: &Daemon, bot: &BotId) -> Want { Want::Forget } else if record.paused || crew.paused { Want::Close - } else { + } else if daemon + .supervisor + .status(bot) + .is_some_and(|(state, _)| working(state)) + { Want::Keep + } else { + Want::Rest + } +} + +/// What the sweep does with an open browser that is in no tool call. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum Step { + Stay, + Rest, + Close, + Forget, +} + +/// `held` by the owner, and unused by the bot and by them for `quiet`. +/// Watching is not using: the panel opens by itself and would keep the +/// browser open for good. +fn step(want: Want, held: bool, quiet: Duration, idle: Duration) -> Step { + match want { + Want::Forget => Step::Forget, + Want::Close => Step::Close, + _ if held => Step::Stay, + _ if quiet >= idle => Step::Close, + Want::Rest if quiet >= REST_AFTER => Step::Rest, + Want::Keep | Want::Rest => Step::Stay, } } impl Browsers { - /// Closes the browsers nobody used or watched for a while, and those - /// of bots that should not run (spec 21.2). Browsers in a call stay. + /// Closes the browsers nobody used for a while and those of bots that + /// should not run, and rests the ones whose bot is not working (spec + /// 21.2). Browsers in a call stay as they are. pub fn sweep(&self, want: impl Fn(&BotId) -> Want) { - let open: Vec<(BotId, bool)> = lock(&self.slots) + let open: Vec<(BotId, bool, Duration)> = lock(&self.slots) .iter() .filter(|(_, slot)| slot.session.is_some() && slot.calls.try_lock().is_ok()) .map(|(bot, slot)| { - let unused = slot.watchers == 0 && slot.used.elapsed() >= self.settings.idle; - (bot.clone(), unused) + let held = slot.held.borrow().is_some(); + (bot.clone(), held, slot.used.elapsed()) }) .collect(); - for (bot, unused) in open { - match want(&bot) { - Want::Forget => self.forget(&bot), - Want::Close => self.close(&bot), - Want::Keep if unused => self.close(&bot), - Want::Keep => {} + for (bot, held, quiet) in open { + match step(want(&bot), held, quiet, self.settings.idle) { + Step::Forget => self.forget(&bot), + Step::Close => self.close(&bot), + Step::Rest => self.rest(&bot), + Step::Stay => {} } } } @@ -85,3 +150,42 @@ impl Browsers { } } } + +#[cfg(test)] +mod tests { + use super::*; + + const IDLE: Duration = Duration::from_secs(600); + + #[test] + fn a_browser_rests_when_its_bot_is_not_working_and_closes_when_unused() { + let secs = Duration::from_secs; + // In a turn, it stays; out of one, it rests once it is quiet. + assert_eq!(step(Want::Keep, false, secs(120), IDLE), Step::Stay); + assert_eq!(step(Want::Rest, false, secs(5), IDLE), Step::Stay); + assert_eq!(step(Want::Rest, false, secs(20), IDLE), Step::Rest); + // Unused for long enough, it closes, in a turn or not. + assert_eq!(step(Want::Rest, false, IDLE, IDLE), Step::Close); + assert_eq!(step(Want::Keep, false, IDLE, IDLE), Step::Close); + // In the owner's hands it neither rests nor closes by itself. + assert_eq!(step(Want::Rest, true, IDLE, IDLE), Step::Stay); + // A paused or archived bot's browser goes, whoever holds it. + assert_eq!(step(Want::Close, true, secs(0), IDLE), Step::Close); + assert_eq!(step(Want::Forget, true, secs(0), IDLE), Step::Forget); + } + + #[test] + fn only_a_turn_keeps_a_browser_awake() { + assert!(working(BotState::Busy)); + assert!(working(BotState::NeedsApproval)); + for state in [ + BotState::Idle, + BotState::RateLimited, + BotState::AuthError, + BotState::Backoff, + BotState::Offline, + ] { + assert!(!working(state), "{state:?}"); + } + } +} diff --git a/crates/botloftd/tests/browser_rest.rs b/crates/botloftd/tests/browser_rest.rs new file mode 100644 index 00000000..a1ee75d7 --- /dev/null +++ b/crates/botloftd/tests/browser_rest.rs @@ -0,0 +1,104 @@ +//! A bot's browser resting when nobody uses it, with the real Microsoft Edge +//! (spec 21.2): it rests the moment the bot's turn ends, keeps a picture +//! for whoever looks, and wakes for the bot's next tool, the owner's hands +//! and a reload. Skipped where Edge is not installed. + +mod common; + +use std::sync::Arc; +use std::time::Duration; + +use botloft_core::ids::BotId; +use botloft_core::protocol::BotState; +use common::browsing::{Browsing, answer_site, call, setup}; +use common::{Client, stream}; +use serde_json::{Value, json}; + +async fn browser(app: &mut Client) -> Value { + let list = app.call("browser.list", Value::Null).await.expect("list"); + list[0].clone() +} + +/// Waits until the bot's browser rests, or stops resting. +async fn until_resting(app: &mut Client, resting: bool) { + for _ in 0..100 { + if browser(app).await["resting"] == resting { + return; + } + tokio::time::sleep(Duration::from_millis(50)).await; + } + panic!("the browser never got to resting = {resting}"); +} + +async fn look(b: &mut Browsing) -> String { + b.mcp + .tool_text("browser_look", json!({})) + .await + .expect("look") +} + +#[tokio::test(flavor = "multi_thread")] +async fn the_browser_rests_when_the_turn_ends_and_wakes_when_it_is_used() { + let Some(mut b) = setup().await else { return }; + tokio::spawn(botloftd::browser::run(Arc::clone(&b.t.daemon))); + let id = json!({ "botId": b.bot["id"] }); + let bot: BotId = b.bot["id"].as_str().expect("id").parse().expect("bot id"); + let process = b.t.process_of(&b.bot).await; + + // A turn: the owner asks, and the bot opens a page. + let ask = json!({ "botId": b.bot["id"], "body": "Open the page" }); + b.app.call("messages.send", ask).await.expect("send"); + let line = process.wait_lines(1).await.pop().expect("line"); + b.t.until_state(&b.bot, BotState::Busy).await; + let url = format!("{}/sight", b.site); + let opening = call(&b.mcp, "browser_open", json!({ "url": url })); + answer_site(&mut b.app, true, None).await; + let page = opening.await.expect("task").expect("page"); + assert!(page.contains("Hidden 0 times"), "{page}"); + assert_eq!(browser(&mut b.app).await["resting"], false); + + // The turn ends: the browser rests at once, with nobody asking. + stream::answer(&process, &line, "It is open.").await; + b.t.until_state(&b.bot, BotState::Idle).await; + until_resting(&mut b.app, true).await; + + // Whoever looks now sees the page as it was, though nobody watched. + let view = b + .app + .call("browser.watch", id.clone()) + .await + .expect("watch"); + assert_eq!(view["state"]["resting"], true); + assert_eq!(view["state"]["status"], "open"); + assert!(view["frame"]["data"].as_str().expect("picture").len() > 100); + + // The bot's next tool wakes it: the page was out of sight once, and + // nothing in it was lost. Frames come again. + b.app.forget("browser.frame"); + let page = look(&mut b).await; + assert!(page.contains("Hidden 1 times"), "{page}"); + assert_eq!(browser(&mut b.app).await["resting"], false); + b.app.notification("browser.frame").await; + + // So do the owner's hands, and a browser in them does not rest. + b.t.daemon.browsers.rest(&bot); + until_resting(&mut b.app, true).await; + b.app.call("browser.take", id.clone()).await.expect("take"); + until_resting(&mut b.app, false).await; + b.t.daemon.browsers.rest(&bot); + tokio::time::sleep(Duration::from_millis(300)).await; + assert_eq!(browser(&mut b.app).await["resting"], false); + b.app + .call("browser.release", id.clone()) + .await + .expect("release"); + + // And a reload, without taking the browser. + b.t.daemon.browsers.rest(&bot); + until_resting(&mut b.app, true).await; + b.app.call("browser.reload", id).await.expect("reload"); + until_resting(&mut b.app, false).await; + let page = look(&mut b).await; + assert!(page.contains("The owner reloaded the page."), "{page}"); + assert!(page.contains("Hidden 0 times"), "{page}"); +} diff --git a/crates/botloftd/tests/common/site.rs b/crates/botloftd/tests/common/site.rs index 483f5f58..bca71719 100644 --- a/crates/botloftd/tests/common/site.rs +++ b/crates/botloftd/tests/common/site.rs @@ -1,7 +1,8 @@ //! A small web site on 127.0.0.1 for the browser tests: a form, the page //! it leads to, a link that opens a new tab, a button that shows a dialog, //! a sign-in page with its fields at fixed points, for the owner's hands, -//! a page that says how big its window is, and one that counts its loads. +//! a page that says how big its window is, one that counts its loads and +//! one that counts the times it went out of sight. use std::collections::HashMap; use std::net::SocketAddr; @@ -37,6 +38,12 @@ const SIZE: &str = "Size

"; + async fn account(Form(form): Form>) -> Html { let user = form.get("user").cloned().unwrap_or_default(); let length = form @@ -78,6 +85,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("/sight", get(|| async { Html(SIGHT) })) .route("/account", post(account)); let listener = TcpListener::bind("127.0.0.1:0").await.expect("bind"); let addr = listener.local_addr().expect("addr"); diff --git a/docs/spec.md b/docs/spec.md index 4b03193b..f6cf88d1 100644 --- a/docs/spec.md +++ b/docs/spec.md @@ -885,7 +885,7 @@ Conferência na documentação oficial (code.claude.com/docs) em 2026-09-28. "Co | 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. 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 | | 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 | | Tools do navegador com o Claude Code real | 21 | **Testado com 2.1.284** (daemon e app de dev, Haiku, modo Manual): o bot carregou `browser_open`, `browser_click`, `browser_look` e `browser_screenshot` pelo `ToolSearch`; o pedido de example.com apareceu no chat e, permitido, ele abriu a página e clicou no link, que levou ao iana.org; o `browser_screenshot` lá pediu o iana.org, e o segundo screenshot não pediu de novo. O painel mostrou a página e o cursor ao vivo. Pausar o bot fechou o navegador (os processos do Edge sumiram). O log de `debug` só teve nomes de tools, métodos e ids | feito | | Rascunho de tela a partir do `Write` | 22.3 | **Visto com 2.1.284**: com `--include-partial-messages`, o `Write` chega em `stream_event` (`content_block_start` com `tool_use` e `name: "Write"`, depois `input_json_delta`; 379 pedaços para um arquivo de 3 KB). **Testado com 2.1.284** (daemon e app de dev, Haiku, modo Manual): pedida uma landing page, a área de design abriu sozinha e a página se montou versão a versão (umas 70 de 8 KB) antes do OK para gravar; permitido, a tela passou a vir do disco. Numa segunda página, negar o `Write` tirou a tela da área de design. O log de `debug` só teve status e ids. O painel do navegador do app de desktop do Claude bloqueia `iframe` para 127.0.0.1 (`ERR_BLOCKED_BY_CLIENT`), então a prévia foi vista num Edge sem janela | feito | @@ -1033,7 +1033,7 @@ Sem jargão (15.2): o dono não vê "cron", "overlap" nem "timezone" no caminho ## 21. Navegador -Status: **N1, N2, N3, N3.1 e N3.2 implementados** (21.11); N4 é a seção 22. +Status: **N1, N2, N3, N3.1, N3.2 e N3.3 implementados** (21.11); N4 é a seção 22. ### 21.1 O que é @@ -1050,7 +1050,9 @@ Cada bot tem um navegador próprio para pesquisar e usar sites: um Microsoft Edg - Como o `claude.exe`: sem janela (`CREATE_NO_WINDOW`), num Job Object próprio com `KILL_ON_JOB_CLOSE` (morre junto com o daemon) e com o ambiente padrão do usuário (7.4). - Achar o Edge: `[browser] path`, senão `msedge.exe` em `%ProgramFiles(x86)%` e `%ProgramFiles%` (`Microsoft\Edge\Application`) e em `%LOCALAPPDATA%`. Sem navegador, a tool responde ao bot que ele não está disponível, e o painel diz o mesmo. - **Perfil:** `\browsers\\`, fora da pasta do bot e em `%LOCALAPPDATA%` (5): cookies e sessões são do navegador, não dos arquivos que o bot faz. O perfil fica entre aberturas, então um login feito continua valendo. Arquivar ou excluir o bot fecha o navegador e apaga o perfil. -- **Fechar:** depois de `[browser] idle_minutes` sem tool do bot e sem ninguém assistindo; quando o bot ou a crew pausa; com `browser_close`. Se já há `[browser] max_open` navegadores abertos, abrir outro fecha o que está parado há mais tempo, mas nunca um que esteja no meio de uma tool. +- **Descansar:** um navegador aberto que ninguém usa não deve gastar o computador. Assim que o turno do bot termina (o estado dele deixa de ser `busy` ou `needs_approval`, 7.1), o daemon põe o navegador para descansar: tira um quadro da aba ativa, para o painel ter o que mostrar, e minimiza a janela do Edge (`Browser.setWindowBounds` com `windowState: "minimized"`; a janela já não aparecia, mas para o Edge ela existe). As páginas passam a valer como as de uma janela minimizada: `visibilityState` vira `hidden`, os timers caem para um por segundo ou menos, animações e quadros param. Medido com o Edge 154, uma página que gastava 90% de um núcleo fica abaixo de 1% (19). Nada se perde: abas, formulários e sessões continuam como estavam. Não descansa no meio de uma tool nem nas mãos do dono (21.10). +- **Acordar:** na hora, com a janela de volta a `normal`, na próxima tool `browser_*` do bot, quando o dono assume o controle e quando ele recarrega a página. Depois de algo que o dono fez com o bot fora de um turno, o navegador volta a descansar sozinho: 20 s sem uso, conferido a cada 30 s. +- **Fechar:** depois de `[browser] idle_minutes` sem uso, que é uma tool do bot ou algo que o dono fez com o navegador nas mãos; quando o bot ou a crew pausa; com `browser_close`. Assistir não é uso: o painel abre sozinho (21.8) e seguraria o navegador aberto para sempre. Nas mãos do dono ele não fecha. Se já há `[browser] max_open` navegadores abertos, abrir outro fecha o que está parado há mais tempo, mas nunca um que esteja no meio de uma tool. - Se o processo cai ou o WebSocket fecha, o estado vira `closed` e a próxima tool sobe de novo. ### 21.3 Página @@ -1135,14 +1137,14 @@ Esqueceu a senha? [e5 link "Recuperar acesso"] | `browser.open` | `botId`, `url` | `null`: leva a aba ativa a um endereço `http` ou `https`; sem esquema, vale `https://`. Só da conexão que controla; o que não é endereço da web é `validation` | - O que muda o navegador responde assim que o pedido é aceito; o resultado chega em `browser.changed` e nos quadros. -- `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), `ask` (o que o bot pediu ao dono com `browser_ask_owner`, enquanto o pedido está aberto) e `updatedAt`. +- `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) e `updatedAt`. - 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. - 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). ### 21.8 App (N2) -- No cabeçalho do bot, o botão **Navegador** (globo), ao lado de Arquivos. Com quadros chegando, o título do painel ganha a etiqueta "Ao vivo". Com o navegador aberto e o painel fechado, o botão ganha um ponto que pulsa. O painel abre sozinho quando o navegador do bot sobe e quando ele pede uma mão (21.10); um navegador que já estava aberto quando o dono abriu o bot fica atrás do botão (15.1). +- No cabeçalho do bot, o botão **Navegador** (globo), ao lado de Arquivos. Com quadros chegando, o título do painel ganha a etiqueta "Ao vivo"; com o navegador descansando (21.2), "Em descanso", e a dica diz que ele não gasta o computador e volta na hora em que o bot ou o dono precisar. A tela mostra o quadro tirado ao descansar. Com o navegador aberto e em uso e o painel fechado, o botão ganha um ponto que pulsa; descansando, não. O painel abre sozinho quando o navegador do bot sobe e quando ele pede uma mão (21.10); um navegador que já estava aberto quando o dono abriu o bot fica atrás do botão (15.1). - O painel divide o lugar com detalhes e arquivos (15.1) e é redimensionável como eles (`botloft.panel.browser`, 560 px de início), com um botão para ocupar todo o espaço que o chat pode ceder. - Em cima, as abas: uma por página aberta, na ordem em que abriram, com o título e, embaixo dele, o site (ou o nome do arquivo); a ativa em destaque, "Nova aba" numa aba em branco, e **+** no fim. Não cabendo, a fila rola para o lado. Sem o controle elas só mostram o que o bot abriu, e a dica diz para assumir o controle; no controle, um clique troca de aba e **+** abre uma nova, já com o cursor na barra do endereço (21.10). - Depois, a barra do endereço: **Recarregar** (a seta circular), que vale a qualquer momento (21.10); o endereço, com o cadeado ou a roda de carregando; e "Abrir no meu navegador" (`open_url`, 15.2). O endereço é só leitura e selecionável; no controle vira um campo: Enter leva a aba até o que o dono digitou (`browser.open`), Esc desfaz. Embaixo, a tela ao vivo ocupa o espaço que sobra no painel: o app mede o painel abaixo da barra do endereço, tira o lugar da legenda e do botão de assumir (80 px) e manda o resto ao daemon (`browser.resize`, 300 ms depois da última mudança, e de novo ao voltar a assistir); cada quadro aparece no maior tamanho que cabe, na proporção da página. Arrastar a borda do painel, alargá-lo ou mudar o tamanho da janela muda a página junto. O que entra e sai do painel (a legenda, o pedido de ajuda, a faixa do controle, 21.10) não entra na conta: a página nunca muda de tamanho por causa disso, só a imagem encolhe um pouco para caber. Sobre a tela, o cursor do bot (a seta, na cor dele) indo até cada ponto de `browser.action`, um anel no clique e uma legenda curta do que ele fez ("Clicou em Entrar", "Escreveu em E-mail", "Abriu exemplo.com"). @@ -1159,7 +1161,7 @@ Esqueceu a senha? [e5 link "Recuperar acesso"] ```toml [browser] path = "" # vazio = Microsoft Edge do Windows - idle_minutes = 10 # sem uso e sem ninguém assistindo, fecha + idle_minutes = 10 # sem uso, fecha (21.2) max_open = 4 # navegadores abertos ao mesmo tempo ``` @@ -1214,6 +1216,7 @@ O dono pode usar o navegador do bot com as próprias mãos: clicar, arrastar, ro | **N3** Dono no controle | `browser.take`, `browser.release`, `browser.input`, `browser_ask_owner` e o pedido de ajuda, a espera das tools, o controle no painel e o cartão no chat, textos nos três idiomas, testes com o Edge real | com o Claude Code real, um bot numa página de login pede ajuda, o dono assume pelo cartão, digita, devolve, e o bot continua na página já dentro da conta | | **N3.1** Tela do tamanho do painel | `browser.resize`, a página com a altura que o painel pede, a tela ao vivo ocupando o espaço do painel, testes com o Edge real | num painel alto, a página do bot ocupa a altura toda em vez de deixar um vazio; arrastar a borda do painel muda a página junto | | **N3.2** Abas e recarregar | a lista de abas em `BrowserState`, `browser.reload`, `browser.newTab`, `browser.switchTab`, `browser.open`, o aviso ao bot quando o dono troca de aba, as abas e o botão de recarregar no painel, o endereço que o dono digita, textos nos três idiomas, testes com o Edge real | pelo app, o dono vê as abas que o bot abriu, recarrega a página, assume, abre uma aba nova num endereço, troca de aba e devolve; o bot lê que a aba mudou antes de agir | +| **N3.3** Descanso | o navegador descansa quando o turno do bot termina e acorda quando é usado, `resting` em `BrowserState`, fechar por falta de uso mesmo com o painel aberto, a etiqueta "Em descanso", textos nos três idiomas, testes com o Edge real | com um bot real parado numa página que se mexe, o Edge dele não gasta processador; o bot volta a usar a página sem perder nada | | **N4** Telas | o bot desenhando telas (HTML) que aparecem lado a lado numa área de design, atualizadas enquanto ele escreve | seção 22 | ## 22. Telas