From 80ee943cdf88d801adae6f331a4f9a74badfd678 Mon Sep 17 00:00:00 2001
From: Phelipe Lorran <112786693+httpsphl@users.noreply.github.com>
Date: Sun, 4 Oct 2026 21:31:05 -0300
Subject: [PATCH] feat(browser): the owner opens the bot's closed browser for
themselves
A closed browser now has "Open it for me". browser.start brings up the
bot's browser without the bot, which is neither woken nor told, on the
page it showed last (kept in memory per bot), and the app takes it into
the owner's hands as soon as it is open. Given back, it rests again as
after anything the owner does outside a turn. Refused while the bot or
its crew is paused.
---
app/src/features/browser/BrowserPanel.tsx | 59 +++++++++++--
app/src/features/browser/openForMe.test.tsx | 42 ++++++++++
app/src/features/browser/useHands.ts | 29 ++++++-
app/src/i18n/en/browser.ts | 4 +
app/src/i18n/es/browser.ts | 4 +
app/src/i18n/pt-BR/browser.ts | 4 +
app/src/lib/fakeBrowser.ts | 14 ++++
app/src/lib/protocol.gen.ts | 1 +
.../src/protocol/export_browser.rs | 1 +
crates/botloft-core/src/protocol/mod.rs | 2 +
crates/botloftd/src/browser/call.rs | 2 +
crates/botloftd/src/browser/mod.rs | 6 +-
crates/botloftd/src/browser/owner_open.rs | 42 ++++++++++
crates/botloftd/src/browser/sweep.rs | 2 +-
crates/botloftd/src/rpc/mod.rs | 2 +
crates/botloftd/src/rpc/watching.rs | 41 +++++++++-
crates/botloftd/tests/browser_owner_open.rs | 82 +++++++++++++++++++
docs/spec.md | 5 +-
18 files changed, 329 insertions(+), 13 deletions(-)
create mode 100644 app/src/features/browser/openForMe.test.tsx
create mode 100644 crates/botloftd/src/browser/owner_open.rs
create mode 100644 crates/botloftd/tests/browser_owner_open.rs
diff --git a/app/src/features/browser/BrowserPanel.tsx b/app/src/features/browser/BrowserPanel.tsx
index 70617023..57e7b8ef 100644
--- a/app/src/features/browser/BrowserPanel.tsx
+++ b/app/src/features/browser/BrowserPanel.tsx
@@ -4,8 +4,8 @@
// The owner can take it into their own hands and give it back (spec 21.10),
// or open it in a window of its own to sign in (spec 21.11).
-import { Globe, LoaderCircle, Maximize2, Minimize2, Moon, X } from "lucide-react";
-import { type CSSProperties, useEffect, useRef, useState } from "react";
+import { Globe, Hand, LoaderCircle, Maximize2, Minimize2, Moon, X } from "lucide-react";
+import { type CSSProperties, type ReactNode, useEffect, useRef, useState } from "react";
import { useT } from "../../i18n";
import type { Bot } from "../../lib/protocol.gen";
import { SYSTEM } from "../../lib/system";
@@ -24,7 +24,7 @@ import { LessonArea, LessonDialog, useLesson } from "./Lesson";
import { LiveView, useCaption } from "./LiveView";
import { TabStrip } from "./TabStrip";
import { useBrowserView } from "./useBrowserView";
-import { useHands } from "./useHands";
+import { type Hands, useHands } from "./useHands";
import { useFitPage, useRoom } from "./useRoom";
import { WindowBar } from "./WindowBar";
@@ -138,7 +138,12 @@ export function BrowserPanel({
)}
) : status === "closed" && !frame ? (
- !windowed &&
+ !windowed && (
+ <>
+
+
+ >
+ )
) : (
<>
{live && ask && !hands.held && }
@@ -178,12 +183,20 @@ export function BrowserPanel({
{(status !== "open" || !frame) && (
+ >
+ {status === "closed" && !windowed && !hands.opening && (
+
+ )}
+
)}
@@ -223,7 +236,17 @@ export function BrowserPanel({
);
}
-function Overlay({ busy, title, body }: { busy: boolean; title: string; body: string | null }) {
+function Overlay({
+ busy,
+ title,
+ body,
+ children,
+}: {
+ busy: boolean;
+ title: string;
+ body: string | null;
+ children?: ReactNode;
+}) {
return (
@@ -232,11 +255,31 @@ function Overlay({ busy, title, body }: { busy: boolean; title: string; body: st
{title}
{body &&
{body}
}
+ {children}
);
}
+/** The owner opens the closed browser for themselves (spec 21.10). */
+function OpenForMe({ bot, hands }: { bot: Bot; hands: Hands }) {
+ const t = useT().browser;
+ return (
+
+
+
{t.openForMeWhy(bot.name)}
+
+ );
+}
+
function Empty({ bot }: { bot: Bot }) {
const t = useT().browser;
return (
diff --git a/app/src/features/browser/openForMe.test.tsx b/app/src/features/browser/openForMe.test.tsx
new file mode 100644
index 00000000..dbee1f43
--- /dev/null
+++ b/app/src/features/browser/openForMe.test.tsx
@@ -0,0 +1,42 @@
+import { act, cleanup, fireEvent, screen, waitFor, within } from "@testing-library/react";
+import { afterEach, describe, expect, test } from "vitest";
+import { FakeBotloft } from "../../lib/fake";
+import { crewOpened, openBot, renderApp } from "../../test/app";
+
+afterEach(cleanup);
+
+const panel = () => screen.getByRole("complementary", { name: "Scout's browser" });
+
+describe("the owner opens the bot's closed browser", () => {
+ test("on its last page, in their hands, without telling the bot", async () => {
+ const fake = new FakeBotloft();
+ const ops = fake.addCrew("Ops");
+ const scout = fake.addBot(ops.id, "Scout", "Finds sources");
+ fake.setBotState(scout.id, "idle");
+ fake.browser.open(scout.id, "https://example.com/", "Example");
+ renderApp(fake);
+ await crewOpened("Ops");
+ openBot("Scout");
+ await screen.findByRole("list", { name: "Messages" });
+ fireEvent.click(screen.getByRole("button", { name: /Show browser|Hide browser/ }));
+ await waitFor(() => expect(fake.browser.watching).toBe(scout.id));
+ act(() => {
+ fake.browser.frame(scout.id, "BBBB");
+ });
+ act(() => {
+ fake.browser.close(scout.id);
+ });
+ expect(await within(panel()).findByText("Browser closed")).toBeDefined();
+ expect(within(panel()).getByText(/Scout is not woken or told/)).toBeDefined();
+
+ fireEvent.click(within(panel()).getByRole("button", { name: "Open it for me" }));
+ await waitFor(() => expect(fake.browser.state(scout.id).control).toBe("owner"));
+ expect(fake.browser.state(scout.id).url).toBe("https://example.com/");
+ expect(fake.calls.some((call) => call.method === "messages.send")).toBe(false);
+
+ fireEvent.click(
+ await within(panel()).findByRole("button", { name: "Done, give it back to Scout" }),
+ );
+ await waitFor(() => expect(fake.browser.state(scout.id).control).toBe("bot"));
+ });
+});
diff --git a/app/src/features/browser/useHands.ts b/app/src/features/browser/useHands.ts
index 295c52ed..cf70c5ae 100644
--- a/app/src/features/browser/useHands.ts
+++ b/app/src/features/browser/useHands.ts
@@ -4,7 +4,7 @@
// Events go out in order and without waiting for each other: the daemon
// queues them for the page.
-import { useCallback, useState } from "react";
+import { useCallback, useEffect, useState } from "react";
import { useT } from "../../i18n";
import type { Bot, BrowserInput, BrowserState } from "../../lib/protocol.gen";
import { useApi, useApp } from "../../store/context";
@@ -24,6 +24,10 @@ export interface Hands {
switchTab(tabId: string): Promise;
/** Takes the active tab to the address the owner typed. */
open(url: string): Promise;
+ /** Opens the closed browser for the owner, and takes it once open. */
+ start(): Promise;
+ /** Waiting for the browser opened for the owner. */
+ opening: boolean;
}
export function useHands(bot: Pick, state: BrowserState | null): Hands {
@@ -79,6 +83,27 @@ export function useHands(bot: Pick, state: BrowserState | null): Hand
[api, botId, t.goFailed],
);
+ // Opened for the owner (spec 21.10), it is theirs as soon as it is up.
+ const [opening, setOpening] = useState(false);
+ const start = useCallback(async () => {
+ const asked = await attempt(t.openForMeFailed, async () => {
+ putBrowser(await api.call("browser.start", { botId }));
+ });
+ setOpening(asked);
+ }, [api, botId, putBrowser, t.openForMeFailed]);
+ const status = state?.status;
+ useEffect(() => {
+ if (!opening) {
+ return;
+ }
+ if (status === "open") {
+ setOpening(false);
+ void take();
+ } else if (status === "failed") {
+ setOpening(false);
+ }
+ }, [opening, status, take]);
+
return {
held: state?.status === "open" && state.control === "owner",
busy,
@@ -89,5 +114,7 @@ export function useHands(bot: Pick, state: BrowserState | null): Hand
newTab,
switchTab,
open,
+ start,
+ opening,
};
}
diff --git a/app/src/i18n/en/browser.ts b/app/src/i18n/en/browser.ts
index 82c5cfed..3aa5a6f1 100644
--- a/app/src/i18n/en/browser.ts
+++ b/app/src/i18n/en/browser.ts
@@ -32,6 +32,10 @@ export const browser = {
starting: "Opening the browser…",
closed: "Browser closed",
closedBody: "It opens again when the bot needs it. Logins stay.",
+ openForMe: "Open it for me",
+ openForMeWhy: (bot: string) =>
+ `To see what ${bot} left open and use it yourself. ${bot} is not woken or told; give it back when you are done and it rests again.`,
+ openForMeFailed: "Could not open the browser",
failedTitle: "The browser couldn't open",
failedBody: (system: string): string =>
system === "Windows"
diff --git a/app/src/i18n/es/browser.ts b/app/src/i18n/es/browser.ts
index 264f29eb..74a70059 100644
--- a/app/src/i18n/es/browser.ts
+++ b/app/src/i18n/es/browser.ts
@@ -33,6 +33,10 @@ export const browser: Messages["browser"] = {
starting: "Abriendo el navegador…",
closed: "Navegador cerrado",
closedBody: "Se abre de nuevo cuando el bot lo necesite. Las sesiones iniciadas siguen.",
+ openForMe: "Abrirlo para mí",
+ openForMeWhy: (bot) =>
+ `Para ver lo que ${bot} dejó abierto y usarlo tú mismo. ${bot} no se despierta ni se entera; devuélvelo cuando termines y el navegador vuelve a descansar.`,
+ openForMeFailed: "No se pudo abrir el navegador",
failedTitle: "El navegador no se abrió",
failedBody: (system: string) =>
system === "Windows"
diff --git a/app/src/i18n/pt-BR/browser.ts b/app/src/i18n/pt-BR/browser.ts
index 42f66965..26925edb 100644
--- a/app/src/i18n/pt-BR/browser.ts
+++ b/app/src/i18n/pt-BR/browser.ts
@@ -34,6 +34,10 @@ export const browser: Messages["browser"] = {
starting: "Abrindo o navegador…",
closed: "Navegador fechado",
closedBody: "Ele abre de novo quando o bot precisar. Os logins continuam.",
+ openForMe: "Abrir para mim",
+ openForMeWhy: (bot) =>
+ `Para ver o que ${bot} deixou aberto e mexer você mesmo. ${bot} não é acordado nem avisado; devolva quando terminar e o navegador volta a descansar.`,
+ openForMeFailed: "Não foi possível abrir o navegador",
failedTitle: "O navegador não abriu",
failedBody: (system: string) =>
system === "Windows"
diff --git a/app/src/lib/fakeBrowser.ts b/app/src/lib/fakeBrowser.ts
index 85145890..a9309db4 100644
--- a/app/src/lib/fakeBrowser.ts
+++ b/app/src/lib/fakeBrowser.ts
@@ -37,6 +37,9 @@ export class FakeBrowser {
/** Pages that answer the owner's hands, for the preview. */
private readonly pages = new Map void>();
+ /** The page each browser showed when it closed, to open it again. */
+ private readonly lastUrls = new Map();
+
constructor(private readonly fake: FakeBotloft) {}
state(botId: BotId): BrowserState {
@@ -101,6 +104,7 @@ export class FakeBrowser {
close(botId: BotId): BrowserState {
this.frames.delete(botId);
+ this.lastUrls.set(botId, this.state(botId).url ?? "about:blank");
const closed = { status: "closed", loading: false, control: "bot", resting: false } as const;
return this.setTabs(botId, [], { ...closed, ask: null });
}
@@ -224,6 +228,7 @@ export class FakeBrowser {
| "browser.switchTab"
| "browser.open"
| "browser.window"
+ | "browser.start"
> {
return {
"browser.list": () =>
@@ -311,6 +316,15 @@ export class FakeBrowser {
this.repaint(botId);
return null;
},
+ // Up a moment later, on its last page, as the daemon does it.
+ "browser.start": ({ botId }) => {
+ if (this.watching !== botId) {
+ throw conflict("watch this browser before opening it");
+ }
+ const url = this.lastUrls.get(botId) ?? "about:blank";
+ setTimeout(() => this.open(botId, url, new URL(url).hostname));
+ return this.set(botId, { status: "starting" });
+ },
"browser.window": ({ botId }) => {
if (this.watching !== botId) {
throw conflict("watch this browser before opening it in a window");
diff --git a/app/src/lib/protocol.gen.ts b/app/src/lib/protocol.gen.ts
index ad8ccfab..b3779e02 100644
--- a/app/src/lib/protocol.gen.ts
+++ b/app/src/lib/protocol.gen.ts
@@ -1696,6 +1696,7 @@ export interface RpcMethods {
"browser.take": { params: BrowserControlParams; result: BrowserState };
"browser.release": { params: BrowserControlParams; result: BrowserState };
"browser.window": { params: BrowserControlParams; result: BrowserState };
+ "browser.start": { params: BrowserControlParams; result: BrowserState };
"browser.teach": { params: BrowserTeachParams; result: Array };
"browser.input": { params: BrowserInputParams; result: null };
"browser.reload": { params: BrowserControlParams; result: null };
diff --git a/crates/botloft-core/src/protocol/export_browser.rs b/crates/botloft-core/src/protocol/export_browser.rs
index e27d9910..40b995f3 100644
--- a/crates/botloft-core/src/protocol/export_browser.rs
+++ b/crates/botloft-core/src/protocol/export_browser.rs
@@ -54,6 +54,7 @@ pub(super) fn methods(out: &mut Out) {
out.method(method::BROWSER_TAKE, &control, &state);
out.method(method::BROWSER_RELEASE, &control, &state);
out.method(method::BROWSER_WINDOW, &control, &state);
+ out.method(method::BROWSER_START, &control, &state);
out.method(
method::BROWSER_TEACH,
&out.name::(),
diff --git a/crates/botloft-core/src/protocol/mod.rs b/crates/botloft-core/src/protocol/mod.rs
index 24a89126..4e5fd62a 100644
--- a/crates/botloft-core/src/protocol/mod.rs
+++ b/crates/botloft-core/src/protocol/mod.rs
@@ -161,6 +161,8 @@ pub mod method {
pub const BROWSER_OPEN: &str = "browser.open";
pub const BROWSER_WINDOW: &str = "browser.window";
pub const BROWSER_TEACH: &str = "browser.teach";
+ /// The owner opens the bot's closed browser for themselves.
+ pub const BROWSER_START: &str = "browser.start";
pub const SCREENS_LIST: &str = "screens.list";
pub const QUESTIONS_LIST: &str = "questions.list";
pub const QUESTIONS_ANSWER: &str = "questions.answer";
diff --git a/crates/botloftd/src/browser/call.rs b/crates/botloftd/src/browser/call.rs
index 96a8b58d..b9bd2b68 100644
--- a/crates/botloftd/src/browser/call.rs
+++ b/crates/botloftd/src/browser/call.rs
@@ -10,6 +10,7 @@ use botloft_core::protocol::{BrowserState, BrowserStatus};
use tokio::sync::OwnedMutexGuard;
use tracing::debug;
+use super::owner_open::remember;
use super::session::{Hooks, PageInfo, Session};
use super::{BrowserError, Browsers, lock, program, update, watch};
@@ -92,6 +93,7 @@ impl Call<'_> {
bot.clone(),
);
move |info: PageInfo| {
+ remember(&slots, &bot, info.url.as_deref());
update(&slots, &events, clock.as_ref(), &bot, |state| {
state.url = info.url;
state.title = info.title;
diff --git a/crates/botloftd/src/browser/mod.rs b/crates/botloftd/src/browser/mod.rs
index c1a68fa0..896e0954 100644
--- a/crates/botloftd/src/browser/mod.rs
+++ b/crates/botloftd/src/browser/mod.rs
@@ -13,6 +13,7 @@ mod keys;
mod launch;
mod lesson;
mod moves;
+mod owner_open;
mod page;
mod program;
mod read;
@@ -46,7 +47,7 @@ pub use self::page::{Aim, Done, Scroll};
pub use self::program::find as find_program;
pub use self::read::{READ_MAX, Reading};
pub use self::session::Session;
-pub use self::sweep::{Want, run};
+pub use self::sweep::{Want, run, want};
pub use self::viewport::Viewport;
pub use self::watch::Watching;
pub use self::window::WindowError;
@@ -119,6 +120,8 @@ struct Slot {
asking: Option,
/// The owner has the profile open in a window of its own.
window: Option,
+ /// The page it showed last, to open again for the owner.
+ last_url: Option,
}
type Slots = Arc>>;
@@ -170,6 +173,7 @@ impl Browsers {
held: tokio::sync::watch::channel(None).0,
asking: None,
window: None,
+ last_url: None,
})
}
diff --git a/crates/botloftd/src/browser/owner_open.rs b/crates/botloftd/src/browser/owner_open.rs
new file mode 100644
index 00000000..db86f22a
--- /dev/null
+++ b/crates/botloftd/src/browser/owner_open.rs
@@ -0,0 +1,42 @@
+//! The owner opens a bot's closed browser for themselves (spec 21.10): on
+//! the page it showed last, without the bot, which is neither woken nor
+//! told. The app then takes it into the owner's hands.
+
+use std::path::Path;
+
+use botloft_core::ids::BotId;
+use tracing::debug;
+
+use super::{BrowserError, Browsers, lock};
+
+impl Browsers {
+ /// Starts the bot's browser if it is closed and opens the last page it
+ /// showed again; a running one stays as it is.
+ pub async fn open_for_owner(&self, bot: &BotId, downloads: &Path) -> Result<(), BrowserError> {
+ let call = self.begin(bot).await;
+ if call.running().is_some() {
+ return Ok(());
+ }
+ let last = lock(&self.slots)
+ .get(bot)
+ .and_then(|slot| slot.last_url.clone());
+ let session = call.session(downloads).await?;
+ if let Some(url) = last
+ && let Err(err) = session.open(&url).await
+ {
+ debug!(bot = %bot, "browser: the last page did not open again: {err}");
+ }
+ debug!(bot = %bot, "browser: opened for the owner");
+ Ok(())
+ }
+}
+
+/// Keeps `url` as the last page of the bot's browser, unless it is blank.
+pub(super) fn remember(slots: &super::Slots, bot: &BotId, url: Option<&str>) {
+ let Some(url) = url.filter(|url| !url.is_empty() && *url != "about:blank") else {
+ return;
+ };
+ if let Some(slot) = lock(slots).get_mut(bot) {
+ slot.last_url = Some(url.to_owned());
+ }
+}
diff --git a/crates/botloftd/src/browser/sweep.rs b/crates/botloftd/src/browser/sweep.rs
index 7b923bab..25f30175 100644
--- a/crates/botloftd/src/browser/sweep.rs
+++ b/crates/botloftd/src/browser/sweep.rs
@@ -56,7 +56,7 @@ fn working(state: BotState) -> bool {
matches!(state, BotState::Busy | BotState::NeedsApproval)
}
-fn want(daemon: &Daemon, bot: &BotId) -> Want {
+pub fn want(daemon: &Daemon, bot: &BotId) -> Want {
let store = daemon.store();
let Ok(record) = bots::find(&store, bot) else {
return Want::Forget;
diff --git a/crates/botloftd/src/rpc/mod.rs b/crates/botloftd/src/rpc/mod.rs
index 0c0531ae..76ef1d88 100644
--- a/crates/botloftd/src/rpc/mod.rs
+++ b/crates/botloftd/src/rpc/mod.rs
@@ -246,6 +246,8 @@ fn route(
}
// A window of its own outlives the request (spec 21.11).
method::BROWSER_WINDOW => watch.window(daemon, request.params),
+ // The browser starting outlives the request too (spec 21.10).
+ method::BROWSER_START => watch.start(daemon, request.params),
name if ASIDE.contains(&name) => return Routed::Aside(request),
_ => return Routed::InOrder(request),
};
diff --git a/crates/botloftd/src/rpc/watching.rs b/crates/botloftd/src/rpc/watching.rs
index 3472af32..848eee33 100644
--- a/crates/botloftd/src/rpc/watching.rs
+++ b/crates/botloftd/src/rpc/watching.rs
@@ -14,11 +14,12 @@ use botloft_core::protocol::{
};
use serde::Serialize;
use serde_json::Value;
+use tracing::debug;
use super::dispatch::parse;
use super::jsonrpc::{self, RpcError};
use crate::approvals;
-use crate::browser::{Hands, InputError, Watching};
+use crate::browser::{Hands, InputError, Want, Watching, want};
use crate::service::{ApiError, bots};
use crate::state::Daemon;
@@ -155,6 +156,44 @@ impl Watch {
to_value(&state)
}
+ /// Opens the bot's closed browser for the owner, on its last page,
+ /// without the bot (spec 21.10); answers at once, and the state says
+ /// when it is open, for the app to take it.
+ pub(super) fn start(
+ &mut self,
+ daemon: &Arc,
+ params: Option,
+ ) -> Result {
+ let params: BrowserControlParams = parse(params)?;
+ let bot = params.bot_id;
+ if !self.watches(&bot) {
+ return Err(
+ ApiError::Conflict("watch this browser before opening it".to_owned()).into(),
+ );
+ }
+ if !matches!(want(daemon, &bot), Want::Keep | Want::Rest) {
+ return Err(ApiError::Conflict(
+ "the bot or its crew is paused: its browser stays closed".to_owned(),
+ )
+ .into());
+ }
+ let workspace = {
+ let store = daemon.store();
+ let record = bots::find(&store, &bot)?;
+ let crew = crate::service::crews::find(&store, &record.crew_id)?;
+ daemon.paths.bot_workspace(&crew.slug, &record.slug)
+ };
+ let state = daemon.browsers.view(&bot).state;
+ let daemon = Arc::clone(daemon);
+ tokio::spawn(async move {
+ let downloads = workspace.join("downloads");
+ if let Err(err) = daemon.browsers.open_for_owner(&bot, &downloads).await {
+ debug!(bot = %bot, "browser: could not open for the owner: {err}");
+ }
+ });
+ to_value(&state)
+ }
+
/// The owner's hold on the bot's browser, if this connection has it.
fn hands(&self, bot: &BotId) -> Result<&Hands, RpcError> {
self.hands
diff --git a/crates/botloftd/tests/browser_owner_open.rs b/crates/botloftd/tests/browser_owner_open.rs
new file mode 100644
index 00000000..576f165c
--- /dev/null
+++ b/crates/botloftd/tests/browser_owner_open.rs
@@ -0,0 +1,82 @@
+//! The owner opens a bot's closed browser for themselves with the real
+//! Microsoft Edge (spec 21.10): on the page it showed last, without the
+//! bot, then takes it and gives it back. Skipped where Edge is not
+//! installed.
+
+mod common;
+
+use std::time::Duration;
+
+use common::Client;
+use common::browsing::{answer_site, call, setup};
+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()
+}
+
+#[tokio::test(flavor = "multi_thread")]
+async fn the_owner_opens_the_closed_browser_on_its_last_page_and_takes_it() {
+ let Some(mut b) = setup().await else { return };
+ let url = format!("{}/login", b.site);
+ let opening = call(&b.mcp, "browser_open", json!({ "url": url }));
+ answer_site(&mut b.app, true, None).await;
+ opening.await.expect("task").expect("page");
+ b.mcp
+ .tool_text("browser_close", json!({}))
+ .await
+ .expect("closed");
+
+ // Only the connection watching it opens it.
+ let id = json!({ "botId": b.bot["id"] });
+ assert!(b.app.call("browser.start", id.clone()).await.is_err());
+ b.app
+ .call("browser.watch", id.clone())
+ .await
+ .expect("watch");
+ b.app
+ .call("browser.start", id.clone())
+ .await
+ .expect("start");
+
+ // It opens on the page it showed last.
+ let mut state = Value::Null;
+ for _ in 0..150 {
+ state = browser(&mut b.app).await;
+ if state["status"] == "open"
+ && state["url"]
+ .as_str()
+ .is_some_and(|url| url.contains("/login"))
+ {
+ break;
+ }
+ tokio::time::sleep(Duration::from_millis(100)).await;
+ }
+ assert_eq!(state["status"], "open", "{state}");
+ assert!(
+ state["url"]
+ .as_str()
+ .is_some_and(|url| url.contains("/login")),
+ "{state}"
+ );
+
+ let taken = b.app.call("browser.take", id.clone()).await.expect("take");
+ assert_eq!(taken["control"], "owner");
+ let given = b
+ .app
+ .call("browser.release", id.clone())
+ .await
+ .expect("release");
+ assert_eq!(given["control"], "bot");
+
+ // A paused bot's browser stays closed.
+ b.app
+ .call(
+ "bots.setPaused",
+ json!({ "botId": b.bot["id"], "paused": true }),
+ )
+ .await
+ .expect("pause");
+ assert!(b.app.call("browser.start", id).await.is_err());
+}
diff --git a/docs/spec.md b/docs/spec.md
index c79cd1b7..13f8fd2a 100644
--- a/docs/spec.md
+++ b/docs/spec.md
@@ -1300,6 +1300,7 @@ Cada bot tem um navegador próprio para pesquisar e usar sites: um Microsoft Edg
- **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.
- **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.
+- **Abrir para o dono:** fechado, o navegador só sobe por uma tool do bot ou quando o dono o abre para ele mesmo (`browser.start`, 21.10). O daemon guarda, na memória, a última página que ele mostrou (o endereço da aba ativa, sem `about:blank`) e a abre de novo; um daemon que reinicia a esquece, e o navegador abre em branco. O bot não é acordado nem avisado.
- **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.
@@ -1384,6 +1385,7 @@ Esqueceu a senha? [e5 link "Recuperar acesso"]
| `browser.newTab` | `botId` | `null`: abre uma aba em branco, que vira a ativa. Só da conexão que controla |
| `browser.switchTab` | `botId`, `tabId` | `null`: essa aba vira a ativa. Só da conexão que controla; aba que não existe mais é `not_found` |
| `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` |
+| `browser.start` | `botId` | `BrowserState`: abre o navegador fechado do bot para o dono, na última página (21.2), e responde na hora; o estado diz quando ele está aberto, e o app o toma (`browser.take`). Aberto, não muda nada. Da conexão que assiste esse bot; com o bot ou a crew pausados é `conflict` |
| `browser.window` | `botId` | `BrowserState`: abre o navegador numa janela própria, para o dono entrar numa conta (21.11). Da conexão que assiste esse bot; tira o controle dela, se o tinha. Já aberto numa janela, ou sem navegador no computador, é `conflict` |
| `browser.teach` | `botId`, `on` | `LessonStep[]`: começa uma lição (21.13), com o primeiro passo, ou termina e devolve os passos. Só da conexão que controla |
@@ -1400,7 +1402,7 @@ Esqueceu a senha? [e5 link "Recuperar acesso"]
- 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 a mesa em volta (10 px dos lados e em cima, 26 px embaixo) e o lugar da pílula e da legenda (56 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 (o pedido de ajuda e as linhas embaixo da pílula, 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").
- A tela fica sobre uma mesa na cor do bot, para o painel ler como o computador dele: um degradê da cor misturada com o fundo do painel (suave no tema claro, fundo no escuro), com a página como uma janela com sombra, que ocupa a mesa toda menos uma borda fina (10 px; embaixo, 26 px, onde a pílula do controle se pendura sem nunca cobrir a página). Com o navegador fechado, a mesa perde a cor junto com o último quadro.
-- Sem navegador aberto: o mascote e " ainda não abriu o navegador", com a explicação de que tudo que ele fizer num site aparece ali ao vivo. Depois de fechado, o último quadro fica apagado com "Navegador fechado". Em `failed`, o motivo em "Details".
+- Sem navegador aberto: o mascote e " ainda não abriu o navegador", com a explicação de que tudo que ele fizer num site aparece ali ao vivo. Depois de fechado, o último quadro fica apagado com "Navegador fechado" e o botão **Abrir para mim** (21.10); sem quadro, o botão fica embaixo do mascote. Em `failed`, o motivo em "Details".
- No chat, a linha de uma tool `browser_*` ganha o ícone do globo e o botão "Ver no navegador", que abre o painel.
- O pedido de site (21.5) é um cartão próprio: " quer usar o navegador em **wikipedia.org**", o endereço embaixo, Permitir e Negar. Respondido: "Você permitiu wikipedia.org".
- O dono no controle e o pedido de ajuda têm a própria parte da tela (21.10).
@@ -1423,6 +1425,7 @@ Esqueceu a senha? [e5 link "Recuperar acesso"]
O dono pode usar o navegador do bot com as próprias mãos: clicar, arrastar, rolar e digitar na tela ao vivo. Serve para o que o bot não deve ou não consegue fazer sozinho: entrar numa conta com a senha do dono, passar de um captcha, confirmar um código que chegou no celular dele.
+- **Abrir para mim:** com o navegador fechado, o dono o abre sem passar pelo bot, para ver o que ele deixou aberto ou mexer ele mesmo: `browser.start` e, assim que abre, `browser.take`. A explicação embaixo do botão diz que o bot não é acordado nem avisado e que, devolvido, o navegador volta a descansar (21.2). Devolver não avisa o bot, a menos que ele tenha um pedido de ajuda aberto.
- **Assumir:** "Assumir o controle", no painel (21.8), com o navegador aberto. Quem controla é a conexão que assiste o bot (21.7); parar de assistir (fechar o painel, trocar de bot, fechar o app, cair a conexão) devolve o controle na hora, para o bot nunca ficar preso. Cada navegador tem um só controlador: `browser.take` de outra conexão enquanto um controla é `conflict`.
- **O bot espera:** enquanto o dono controla, cada tool `browser_*` do bot espera ele devolver, até 10 minutos. Passou disso, a tool volta com erro: o dono está usando o navegador. `browser_ask_owner` não espera, porque é ela que chama o dono. O bot continua `busy` nessa espera, e o painel diz que ele espera.
- **Devolver:** "Pronto, devolver para " no painel. Fechar o navegador (bot pausado, `browser_close`, processo que caiu) também devolve.