From fe7913cb34f0b8deb5295f1a1fa82a53846d3532 Mon Sep 17 00:00:00 2001 From: alectimison-maker Date: Sat, 1 Aug 2026 03:29:26 +0800 Subject: [PATCH] feat(session): support unfocused agent windows --- .../__tests__/agent-window.test.ts | 36 +++++++++++++++++++ .../session-manager/__tests__/manager.test.ts | 13 +++++-- .../src/session-manager/agent-window.ts | 6 ++-- apps/extension/src/session-manager/manager.ts | 4 +-- .../src/tools/__tests__/dispatcher.test.ts | 19 ++++++++++ apps/extension/src/tools/session.ts | 4 ++- crates/bsk-cli/skill/SKILL.md | 3 +- crates/bsk-cli/src/cli/session.rs | 17 ++++++++- crates/bsk-cli/src/daemon/ipc.rs | 10 ++++-- crates/bsk-cli/src/daemon/sessions.rs | 22 ++++++++++++ crates/bsk-cli/tests/cli_parse.rs | 13 +++++++ crates/bsk-cli/tests/sessions_ipc.rs | 5 ++- .../schema/tool_session_start_params.json | 7 ++++ crates/bsk-protocol/src/tools/session.rs | 21 +++++++++++ skill/SKILL.md | 3 +- 15 files changed, 168 insertions(+), 15 deletions(-) diff --git a/apps/extension/src/session-manager/__tests__/agent-window.test.ts b/apps/extension/src/session-manager/__tests__/agent-window.test.ts index 92585f1..011cbbe 100644 --- a/apps/extension/src/session-manager/__tests__/agent-window.test.ts +++ b/apps/extension/src/session-manager/__tests__/agent-window.test.ts @@ -44,3 +44,39 @@ describe("chromeAgentWindowApi.ensureActiveTab", () => { expect(update).not.toHaveBeenCalled(); }); }); + +describe("chromeAgentWindowApi.create", () => { + const create = vi.fn(); + + beforeEach(() => { + vi.stubGlobal("chrome", { + windows: { create }, + }); + create.mockReset(); + create.mockResolvedValue({ id: 100 }); + }); + + afterEach(() => { + vi.unstubAllGlobals(); + }); + + it("focuses Agent Windows by default", async () => { + await chromeAgentWindowApi.create(AGENT_WINDOW_HOME); + + expect(create).toHaveBeenCalledWith({ + type: "normal", + focused: true, + url: AGENT_WINDOW_HOME, + }); + }); + + it("can create an Agent Window without stealing focus", async () => { + await chromeAgentWindowApi.create(AGENT_WINDOW_HOME, false); + + expect(create).toHaveBeenCalledWith({ + type: "normal", + focused: false, + url: AGENT_WINDOW_HOME, + }); + }); +}); diff --git a/apps/extension/src/session-manager/__tests__/manager.test.ts b/apps/extension/src/session-manager/__tests__/manager.test.ts index cfeed29..79ecd30 100644 --- a/apps/extension/src/session-manager/__tests__/manager.test.ts +++ b/apps/extension/src/session-manager/__tests__/manager.test.ts @@ -8,7 +8,7 @@ function fakeAgentWindow(): AgentWindowApi & { ensureActiveTabMock: ReturnType; } { let nextId = 100; - const createMock = vi.fn(async (_url: string) => { + const createMock = vi.fn(async (_url: string, _focused?: boolean) => { const id = nextId++; return id; }); @@ -30,7 +30,7 @@ describe("SessionManager", () => { const sm = new SessionManager({ agentWindow: aw, now: () => 1700000000000 }); const ctx = await sm.start("aa11"); expect(aw.createMock).toHaveBeenCalledOnce(); - expect(aw.createMock).toHaveBeenCalledWith("about:blank"); + expect(aw.createMock).toHaveBeenCalledWith("about:blank", true); expect(aw.ensureActiveTabMock).toHaveBeenCalledOnce(); expect(aw.ensureActiveTabMock).toHaveBeenCalledWith(100, "about:blank"); expect(ctx.sessionId).toBe("aa11"); @@ -40,6 +40,15 @@ describe("SessionManager", () => { expect(ctx.borrowedTabs.size).toBe(0); }); + it("forwards an explicit unfocused start to the Agent Window", async () => { + const aw = fakeAgentWindow(); + const sm = new SessionManager({ agentWindow: aw }); + + await sm.start("aa11", false); + + expect(aw.createMock).toHaveBeenCalledWith("about:blank", false); + }); + it("indexes the session by sessionId and agent window id", async () => { const aw = fakeAgentWindow(); const sm = new SessionManager({ agentWindow: aw }); diff --git a/apps/extension/src/session-manager/agent-window.ts b/apps/extension/src/session-manager/agent-window.ts index 108ede6..af4fcf7 100644 --- a/apps/extension/src/session-manager/agent-window.ts +++ b/apps/extension/src/session-manager/agent-window.ts @@ -8,7 +8,7 @@ */ export interface AgentWindowApi { - create(url: string): Promise; + create(url: string, focused?: boolean): Promise; remove(windowId: number): Promise; /** * Guarantee the Agent Window has an active, CDP-navigable tab. @@ -22,10 +22,10 @@ export interface AgentWindowApi { export const AGENT_WINDOW_HOME = "about:blank"; export const chromeAgentWindowApi: AgentWindowApi = { - async create(url: string): Promise { + async create(url: string, focused = true): Promise { const win = await chrome.windows.create({ type: "normal", - focused: true, + focused, url, }); if (typeof win?.id !== "number") { diff --git a/apps/extension/src/session-manager/manager.ts b/apps/extension/src/session-manager/manager.ts index 0cbdb72..f940b36 100644 --- a/apps/extension/src/session-manager/manager.ts +++ b/apps/extension/src/session-manager/manager.ts @@ -128,11 +128,11 @@ export class SessionManager { * Returns the created window id so callers can echo it back to the * daemon in the `tool.session_start` reply. */ - async start(sessionId: string): Promise { + async start(sessionId: string, focused = true): Promise { if (this.sessions.has(sessionId)) { throw new Error(`[bh] session ${sessionId} already exists`); } - const windowId = await this.agentWindow.create(AGENT_WINDOW_HOME); + const windowId = await this.agentWindow.create(AGENT_WINDOW_HOME, focused); await this.agentWindow.ensureActiveTab(windowId, AGENT_WINDOW_HOME); const ctx: SessionContext = { sessionId, diff --git a/apps/extension/src/tools/__tests__/dispatcher.test.ts b/apps/extension/src/tools/__tests__/dispatcher.test.ts index b2f00c5..c16ba08 100644 --- a/apps/extension/src/tools/__tests__/dispatcher.test.ts +++ b/apps/extension/src/tools/__tests__/dispatcher.test.ts @@ -67,6 +67,25 @@ describe("ToolDispatcher", () => { }); }); + it("forwards an unfocused session start to the Agent Window", async () => { + const { transport, deliver } = fakeTransport(); + const create = vi.fn(async () => 4242); + const sessions = new SessionManager({ + agentWindow: { + create, + remove: vi.fn(), + ensureActiveTab: vi.fn(async () => {}), + }, + }); + const dispatcher = new ToolDispatcher({ transport, sessions }); + dispatcher.start(); + + deliver(makeRequest("tool.session_start", { session_id: "aa11", focused: false })); + await flushMicrotasks(); + + expect(create).toHaveBeenCalledWith("about:blank", false); + }); + it("routes tool.session_stop and replies with empty result", async () => { const { transport, sent, deliver } = fakeTransport(); const sessions = new SessionManager({ diff --git a/apps/extension/src/tools/session.ts b/apps/extension/src/tools/session.ts index c83a2cd..76cf9eb 100644 --- a/apps/extension/src/tools/session.ts +++ b/apps/extension/src/tools/session.ts @@ -6,6 +6,8 @@ import { returnBorrowedTab, type TabManagementDeps } from "./tabs"; export interface SessionStartParams { session_id: string; browser_instance_id?: string; + /** Defaults to true so existing clients preserve visible Agent Windows. */ + focused?: boolean; } export interface SessionStartResult { @@ -53,7 +55,7 @@ export async function handleSessionStart( }; } try { - const ctx = await manager.start(params.session_id); + const ctx = await manager.start(params.session_id, params.focused ?? true); return { agent_window_id: ctx.agentWindowId }; } catch (err) { // chrome.windows.create / SessionManager failures are not CDP diff --git a/crates/bsk-cli/skill/SKILL.md b/crates/bsk-cli/skill/SKILL.md index 0eecabf..f456065 100644 --- a/crates/bsk-cli/skill/SKILL.md +++ b/crates/bsk-cli/skill/SKILL.md @@ -42,7 +42,7 @@ Every automation task **must** follow this lifecycle. Do **not** rely on idle ti 3. bsk session stop → REQUIRED when done (even on error paths) ``` -Optional: `bsk session start --browser ` when multiple browsers are connected (`bsk browsers` / error output lists them). +Optional: `bsk session start --browser ` when multiple browsers are connected (`bsk browsers` / error output lists them). Add `--no-focus` to open the Agent Window in the background without stealing focus from the user's current window. Emergency cleanup: `bsk session stop --all` or the Agent Window overlay **Stop all**. @@ -124,6 +124,7 @@ Details and flags: **`bsk --help`** | Command | Summary | |---------|---------| | `bsk session start` | Open Agent Window; prints **4-letter session id** | +| `bsk session start --no-focus` | Open Agent Window in the background without stealing focus | | `bsk session stop ` | End session, close Agent Window, auto-return borrowed tabs | | `bsk session stop --all` | Stop every active session | | `bsk session list` | List active sessions | diff --git a/crates/bsk-cli/src/cli/session.rs b/crates/bsk-cli/src/cli/session.rs index 198f568..88635d9 100644 --- a/crates/bsk-cli/src/cli/session.rs +++ b/crates/bsk-cli/src/cli/session.rs @@ -54,6 +54,10 @@ pub struct SessionStartArgs { /// are connected). #[arg(long)] pub browser: Option, + + /// Open the Agent Window in the background without stealing focus. + #[arg(long)] + pub no_focus: bool, } #[derive(Debug, Clone, Args)] @@ -71,6 +75,8 @@ pub struct SessionStopArgs { struct StartParams { #[serde(skip_serializing_if = "Option::is_none")] browser_instance_id: Option, + #[serde(skip_serializing_if = "Option::is_none")] + focused: Option, } #[derive(Debug, Deserialize)] @@ -140,7 +146,7 @@ fn run_start(sock: PathBuf, args: SessionStartArgs, format: Format) -> Result<() } }); } - let result = start_session(sock, args.browser); + let result = start_session_with_focus(sock, args.browser, args.no_focus.then_some(false)); waited.store(true, Ordering::SeqCst); match result { Ok(reply) => match format { @@ -166,11 +172,20 @@ fn run_start(sock: PathBuf, args: SessionStartArgs, format: Format) -> Result<() /// Start a session and open the Agent Window. Used by `session start` and `record start`. pub fn start_session(sock: PathBuf, browser: Option) -> Result { + start_session_with_focus(sock, browser, None) +} + +fn start_session_with_focus( + sock: PathBuf, + browser: Option, + focused: Option, +) -> Result { call( sock, Method::SessionStart, Some(StartParams { browser_instance_id: browser, + focused, }), SESSION_START_IPC_TIMEOUT, ) diff --git a/crates/bsk-cli/src/daemon/ipc.rs b/crates/bsk-cli/src/daemon/ipc.rs index f76cc0a..98b4c01 100644 --- a/crates/bsk-cli/src/daemon/ipc.rs +++ b/crates/bsk-cli/src/daemon/ipc.rs @@ -42,8 +42,8 @@ use tracing::{debug, warn}; use super::abort::AbortRegistry; use super::queue::{DEFAULT_TOOL_TIMEOUT, DispatchError}; use super::sessions::{ - SessionId, StartSessionError, StopSessionError, snapshot_status_entries, start_session, - stop_session, + SessionId, StartSessionError, StopSessionError, snapshot_status_entries, + start_session_with_focus, stop_session, }; use super::state::{DAEMON_VERSION, DaemonState, PROTOCOL_VERSION}; @@ -538,6 +538,8 @@ fn tool_dispatch_timeout(params: &Value) -> Result { struct CliSessionStartParams { #[serde(default)] pub browser_instance_id: Option, + #[serde(default)] + pub focused: Option, } #[derive(Debug, Clone, Serialize, Deserialize)] @@ -629,6 +631,7 @@ async fn handle_session_start(state: &Arc, params: Value) -> Result let params: CliSessionStartParams = if params.is_null() { CliSessionStartParams { browser_instance_id: None, + focused: None, } } else { serde_json::from_value(params).map_err(|err| RpcError { @@ -637,11 +640,12 @@ async fn handle_session_start(state: &Arc, params: Value) -> Result data: None, })? }; - match start_session( + match start_session_with_focus( &state.browsers, &state.sessions, &state.tool_queues, params.browser_instance_id.as_deref(), + params.focused, state.config.extension_connect_wait, DEFAULT_RPC_TIMEOUT, ) diff --git a/crates/bsk-cli/src/daemon/sessions.rs b/crates/bsk-cli/src/daemon/sessions.rs index 60cf0fd..ceed623 100644 --- a/crates/bsk-cli/src/daemon/sessions.rs +++ b/crates/bsk-cli/src/daemon/sessions.rs @@ -403,6 +403,27 @@ pub async fn start_session( requested: Option<&str>, connect_wait: Duration, timeout_dur: Duration, +) -> Result { + start_session_with_focus( + registry, + sessions, + queues, + requested, + None, + connect_wait, + timeout_dur, + ) + .await +} + +pub async fn start_session_with_focus( + registry: &Arc, + sessions: &Arc, + queues: &Arc, + requested: Option<&str>, + focused: Option, + connect_wait: Duration, + timeout_dur: Duration, ) -> Result { let client: Arc = registry .select_with_connect_wait(requested, connect_wait) @@ -427,6 +448,7 @@ pub async fn start_session( let params = SessionStartParams { session_id: session_id.0.clone(), browser_instance_id: Some(client.id.0.clone()), + focused, }; let rpc_id = next_rpc_id("sess-start"); let request = RequestFrame { diff --git a/crates/bsk-cli/tests/cli_parse.rs b/crates/bsk-cli/tests/cli_parse.rs index 25c6739..0ad45ba 100644 --- a/crates/bsk-cli/tests/cli_parse.rs +++ b/crates/bsk-cli/tests/cli_parse.rs @@ -5,6 +5,7 @@ use std::time::Duration; use bsk::cli::daemon::{DaemonCmd, parse_duration}; use bsk::cli::navigate::NavigateCmd; use bsk::cli::record::{RecordCmd, RecordSub}; +use bsk::cli::session::{SessionCmd, SessionSub}; use bsk::{Cli, Command}; use clap::Parser; @@ -227,3 +228,15 @@ fn parses_record_start_without_url() { assert_eq!(args.browser.as_deref(), Some("022ca8ac")); assert!(args.url.is_none()); } + +#[test] +fn parses_session_start_no_focus() { + let cli = parse(&["bsk", "session", "start", "--no-focus"]); + let Command::Session(SessionCmd { + sub: SessionSub::Start(args), + }) = cli.command + else { + panic!("expected session start subcommand"); + }; + assert!(args.no_focus); +} diff --git a/crates/bsk-cli/tests/sessions_ipc.rs b/crates/bsk-cli/tests/sessions_ipc.rs index 29cff59..0361e5e 100644 --- a/crates/bsk-cli/tests/sessions_ipc.rs +++ b/crates/bsk-cli/tests/sessions_ipc.rs @@ -144,8 +144,9 @@ async fn session_start_stop_round_trip_via_ipc() { if let Frame::Request(req) = frame { let reply = match req.method { Method::ToolSessionStart => { - let _: SessionStartParams = + let params: SessionStartParams = serde_json::from_value(req.params.clone().unwrap()).unwrap(); + assert_eq!(params.focused, Some(false)); let result = SessionStartResult { agent_window_id: Some(4242), }; @@ -177,6 +178,7 @@ async fn session_start_stop_round_trip_via_ipc() { #[derive(serde::Serialize)] struct StartParams { browser_instance_id: Option, + focused: Option, } #[derive(serde::Deserialize, Debug)] struct StartReply { @@ -191,6 +193,7 @@ async fn session_start_stop_round_trip_via_ipc() { Method::SessionStart, Some(StartParams { browser_instance_id: None, + focused: Some(false), }), Duration::from_secs(5), ) diff --git a/crates/bsk-protocol/schema/tool_session_start_params.json b/crates/bsk-protocol/schema/tool_session_start_params.json index 3c912dd..286e0cf 100644 --- a/crates/bsk-protocol/schema/tool_session_start_params.json +++ b/crates/bsk-protocol/schema/tool_session_start_params.json @@ -12,6 +12,13 @@ "null" ] }, + "focused": { + "description": "Whether the new Agent Window should take focus. Omitted means the extension's default (`true`) for compatibility with older clients.", + "type": [ + "boolean", + "null" + ] + }, "session_id": { "type": "string" } diff --git a/crates/bsk-protocol/src/tools/session.rs b/crates/bsk-protocol/src/tools/session.rs index e50e8df..6058379 100644 --- a/crates/bsk-protocol/src/tools/session.rs +++ b/crates/bsk-protocol/src/tools/session.rs @@ -10,6 +10,10 @@ pub struct SessionStartParams { pub session_id: String, #[serde(default, skip_serializing_if = "Option::is_none")] pub browser_instance_id: Option, + /// Whether the new Agent Window should take focus. Omitted means the + /// extension's default (`true`) for compatibility with older clients. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub focused: Option, } #[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)] @@ -43,6 +47,23 @@ mod tests { use super::*; use serde_json::json; + #[test] + fn session_start_focus_is_optional_and_round_trips_false() { + let defaulted: SessionStartParams = serde_json::from_value(json!({ + "session_id": "aa11" + })) + .unwrap(); + assert_eq!(defaulted.focused, None); + + let background: SessionStartParams = serde_json::from_value(json!({ + "session_id": "aa11", + "focused": false + })) + .unwrap(); + assert_eq!(background.focused, Some(false)); + assert_eq!(serde_json::to_value(background).unwrap()["focused"], false); + } + #[test] fn session_stop_result_round_trips_auto_return_payload() { let result: SessionStopResult = serde_json::from_value(json!({ diff --git a/skill/SKILL.md b/skill/SKILL.md index 0eecabf..f456065 100644 --- a/skill/SKILL.md +++ b/skill/SKILL.md @@ -42,7 +42,7 @@ Every automation task **must** follow this lifecycle. Do **not** rely on idle ti 3. bsk session stop → REQUIRED when done (even on error paths) ``` -Optional: `bsk session start --browser ` when multiple browsers are connected (`bsk browsers` / error output lists them). +Optional: `bsk session start --browser ` when multiple browsers are connected (`bsk browsers` / error output lists them). Add `--no-focus` to open the Agent Window in the background without stealing focus from the user's current window. Emergency cleanup: `bsk session stop --all` or the Agent Window overlay **Stop all**. @@ -124,6 +124,7 @@ Details and flags: **`bsk --help`** | Command | Summary | |---------|---------| | `bsk session start` | Open Agent Window; prints **4-letter session id** | +| `bsk session start --no-focus` | Open Agent Window in the background without stealing focus | | `bsk session stop ` | End session, close Agent Window, auto-return borrowed tabs | | `bsk session stop --all` | Stop every active session | | `bsk session list` | List active sessions |