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..e99118c 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,53 @@ 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, { focused: false }); + + expect(create).toHaveBeenCalledWith({ + type: "normal", + focused: false, + url: AGENT_WINDOW_HOME, + }); + }); + + it("passes an optional window size through the options object", async () => { + await chromeAgentWindowApi.create(AGENT_WINDOW_HOME, { + size: { width: 1280, height: 800 }, + }); + + expect(create).toHaveBeenCalledWith({ + type: "normal", + focused: true, + url: AGENT_WINDOW_HOME, + width: 1280, + height: 800, + }); + }); +}); diff --git a/apps/extension/src/session-manager/__tests__/manager.test.ts b/apps/extension/src/session-manager/__tests__/manager.test.ts index 9196126..c58b5d1 100644 --- a/apps/extension/src/session-manager/__tests__/manager.test.ts +++ b/apps/extension/src/session-manager/__tests__/manager.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it, vi } from "vitest"; -import type { AgentWindowApi } from "../agent-window"; +import type { AgentWindowApi, AgentWindowCreateOptions } from "../agent-window"; import { SessionManager } from "../manager"; function fakeAgentWindow(): AgentWindowApi & { @@ -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, _opts?: AgentWindowCreateOptions) => { 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", {}); expect(aw.ensureActiveTabMock).toHaveBeenCalledOnce(); expect(aw.ensureActiveTabMock).toHaveBeenCalledWith(100, "about:blank"); expect(ctx.sessionId).toBe("aa11"); @@ -42,11 +42,22 @@ describe("SessionManager", () => { it("forwards an optional window size when starting a session", async () => { const aw = fakeAgentWindow(); const sm = new SessionManager({ agentWindow: aw }); - const ctx = await sm.start("aa11", { width: 1280, height: 800 }); - expect(aw.createMock).toHaveBeenCalledWith("about:blank", { width: 1280, height: 800 }); + const ctx = await sm.start("aa11", { size: { width: 1280, height: 800 } }); + expect(aw.createMock).toHaveBeenCalledWith("about:blank", { + size: { width: 1280, height: 800 }, + }); expect(ctx.agentWindowId).toBe(100); }); + it("forwards an explicit unfocused start to the Agent Window", async () => { + const aw = fakeAgentWindow(); + const sm = new SessionManager({ agentWindow: aw }); + + await sm.start("aa11", { focused: false }); + + expect(aw.createMock).toHaveBeenCalledWith("about:blank", { focused: 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 d880767..f345b32 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, size?: { width: number; height: number }): Promise; + create(url: string, opts?: AgentWindowCreateOptions): Promise; remove(windowId: number): Promise; /** * Guarantee the Agent Window has an active, CDP-navigable tab. @@ -18,16 +18,24 @@ export interface AgentWindowApi { ensureActiveTab(windowId: number, url: string): Promise; } +/** Creation hints for a new Agent Window. */ +export interface AgentWindowCreateOptions { + /** Optional outer size in CSS pixels. */ + size?: { width: number; height: number }; + /** Defaults to true so existing sessions keep visible Agent Windows. */ + focused?: boolean; +} + /** Initial tab URL for every new session's Agent Window. */ export const AGENT_WINDOW_HOME = "about:blank"; export const chromeAgentWindowApi: AgentWindowApi = { - async create(url: string, size?: { width: number; height: number }): Promise { + async create(url: string, opts: AgentWindowCreateOptions = {}): Promise { const win = await chrome.windows.create({ type: "normal", - focused: true, + focused: opts.focused ?? true, url, - ...(size ? { width: size.width, height: size.height } : {}), + ...(opts.size ? { width: opts.size.width, height: opts.size.height } : {}), }); if (typeof win?.id !== "number") { throw new Error("[bh] chrome.windows.create returned no window id"); diff --git a/apps/extension/src/session-manager/manager.ts b/apps/extension/src/session-manager/manager.ts index 6c65175..d2c1022 100644 --- a/apps/extension/src/session-manager/manager.ts +++ b/apps/extension/src/session-manager/manager.ts @@ -25,6 +25,14 @@ export interface SessionManagerOptions { now?: () => number; } +/** Options for starting a session's Agent Window. */ +export interface SessionStartOptions { + /** Optional Agent Window outer size in CSS pixels. */ + size?: { width: number; height: number }; + /** Defaults to true so existing clients keep visible Agent Windows. */ + focused?: boolean; +} + /** * Owner of all live agent sessions inside the extension. * @@ -128,18 +136,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, - size?: { width: number; height: number }, - ): Promise { + async start(sessionId: string, opts: SessionStartOptions = {}): Promise { if (this.sessions.has(sessionId)) { throw new Error(`[bh] session ${sessionId} already exists`); } - // Only pass `size` when given so the no-size call shape (and its - // chrome.windows.create payload) stays exactly as before. - const windowId = size - ? await this.agentWindow.create(AGENT_WINDOW_HOME, size) - : await this.agentWindow.create(AGENT_WINDOW_HOME); + const windowId = await this.agentWindow.create(AGENT_WINDOW_HOME, opts); 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 cb4e5ea..2280b26 100644 --- a/apps/extension/src/tools/__tests__/dispatcher.test.ts +++ b/apps/extension/src/tools/__tests__/dispatcher.test.ts @@ -69,6 +69,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", { focused: 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/__tests__/window.test.ts b/apps/extension/src/tools/__tests__/window.test.ts index 55fc0fd..3d86504 100755 --- a/apps/extension/src/tools/__tests__/window.test.ts +++ b/apps/extension/src/tools/__tests__/window.test.ts @@ -107,7 +107,7 @@ describe("handleSessionStart window size", () => { const sm = new SessionManager({ agentWindow: aw }); const result = await handleSessionStart(sm, { session_id: "aa11", width: 1280, height: 800 }); expect(result).toEqual({ agent_window_id: 100 }); - expect(aw.create).toHaveBeenCalledWith("about:blank", { width: 1280, height: 800 }); + expect(aw.create).toHaveBeenCalledWith("about:blank", { size: { width: 1280, height: 800 } }); }); it("creates the window without size when width/height are omitted", async () => { @@ -115,7 +115,15 @@ describe("handleSessionStart window size", () => { const sm = new SessionManager({ agentWindow: aw }); const result = await handleSessionStart(sm, { session_id: "aa11" }); expect(result).toEqual({ agent_window_id: 100 }); - expect(aw.create).toHaveBeenCalledWith("about:blank"); + expect(aw.create).toHaveBeenCalledWith("about:blank", {}); + }); + + it("forwards focused: false to the Agent Window", async () => { + const aw = fakeAgentWindow([100]); + const sm = new SessionManager({ agentWindow: aw }); + const result = await handleSessionStart(sm, { session_id: "aa11", focused: false }); + expect(result).toEqual({ agent_window_id: 100 }); + expect(aw.create).toHaveBeenCalledWith("about:blank", { focused: false }); }); it("rejects a lone width without height", async () => { diff --git a/apps/extension/src/tools/session.ts b/apps/extension/src/tools/session.ts index d3312af..edf564e 100644 --- a/apps/extension/src/tools/session.ts +++ b/apps/extension/src/tools/session.ts @@ -50,6 +50,8 @@ export interface SessionStartParams { width?: number; /** Optional Agent Window outer height in CSS pixels (100..=7680). */ height?: number; + /** Defaults to true so existing clients preserve visible Agent Windows. */ + focused?: boolean; } export interface SessionStartResult { @@ -99,7 +101,10 @@ export async function handleSessionStart( const sizeOrErr = validateWindowSize(params.width, params.height); if (isRpcError(sizeOrErr)) return sizeOrErr; try { - const ctx = await manager.start(params.session_id, sizeOrErr); + const ctx = await manager.start(params.session_id, { + size: sizeOrErr, + focused: params.focused, + }); 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 b88fffa..31a7a09 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**. @@ -129,6 +129,7 @@ Details and flags: **`bsk --help`** | Command | Summary | |---------|---------| | `bsk session start` | Open Agent Window (`--width`/`--height` for initial size); 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/record.rs b/crates/bsk-cli/src/cli/record.rs index 84d3daa..ec72d9a 100644 --- a/crates/bsk-cli/src/cli/record.rs +++ b/crates/bsk-cli/src/cli/record.rs @@ -17,7 +17,7 @@ use crate::cli::business_rpc; use crate::cli::ensure_daemon::ensure_daemon; use crate::cli::error::{CliError, Format}; use crate::cli::record_state; -use crate::cli::session::{start_session, stop_session}; +use crate::cli::session::{SessionStartOptions, start_session, stop_session}; /// Max wait for the user to click 结束 in the browser (24 hours). const RECORD_AWAIT_TIMEOUT_MS: u32 = 86_400_000; @@ -85,7 +85,13 @@ fn dispatch_start(args: RecordStartArgs, format: Format) -> Result<(), CliError> } let info = ensure_daemon().context("ensure daemon is running")?; - let session = start_session(info.sock_path.clone(), args.browser, None, None)?; + let session = start_session( + info.sock_path.clone(), + SessionStartOptions { + browser: args.browser, + ..SessionStartOptions::default() + }, + )?; let start_params = RecordStartParams { session_id: session.session_id.clone(), diff --git a/crates/bsk-cli/src/cli/session.rs b/crates/bsk-cli/src/cli/session.rs index 5d4a144..5365282 100644 --- a/crates/bsk-cli/src/cli/session.rs +++ b/crates/bsk-cli/src/cli/session.rs @@ -64,6 +64,10 @@ pub struct SessionStartArgs { /// `--width` and `--height` must be given to take effect. #[arg(long, value_parser = window_size)] pub height: Option, + + /// Open the Agent Window in the background without stealing focus. + #[arg(long)] + pub no_focus: bool, } /// Parse a `--width` / `--height` Agent Window dimension (CSS pixels). @@ -99,6 +103,8 @@ struct StartParams { width: Option, #[serde(skip_serializing_if = "Option::is_none")] height: Option, + #[serde(skip_serializing_if = "Option::is_none")] + focused: Option, } #[derive(Debug, Deserialize)] @@ -173,7 +179,15 @@ fn run_start(sock: PathBuf, args: SessionStartArgs, format: Format) -> Result<() } }); } - let result = start_session(sock, args.browser, args.width, args.height); + let result = start_session( + sock, + SessionStartOptions { + browser: args.browser, + width: args.width, + height: args.height, + focused: args.no_focus.then_some(false), + }, + ); waited.store(true, Ordering::SeqCst); match result { Ok(reply) => match format { @@ -197,20 +211,27 @@ fn run_start(sock: PathBuf, args: SessionStartArgs, format: Format) -> Result<() Ok(()) } +/// Options for [`start_session`]: browser selection plus Agent Window +/// creation hints. `None` fields keep the extension-side defaults +/// (focused window, browser-chosen size). +#[derive(Debug, Default, Clone)] +pub struct SessionStartOptions { + pub browser: Option, + pub width: Option, + pub height: Option, + pub focused: Option, +} + /// Start a session and open the Agent Window. Used by `session start` and `record start`. -pub fn start_session( - sock: PathBuf, - browser: Option, - width: Option, - height: Option, -) -> Result { +pub fn start_session(sock: PathBuf, opts: SessionStartOptions) -> Result { call( sock, Method::SessionStart, Some(StartParams { - browser_instance_id: browser, - width, - height, + browser_instance_id: opts.browser, + width: opts.width, + height: opts.height, + focused: opts.focused, }), SESSION_START_IPC_TIMEOUT, ) diff --git a/crates/bsk-cli/src/daemon/ipc.rs b/crates/bsk-cli/src/daemon/ipc.rs index 6d0c9f3..b464416 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, + AgentWindowOptions, SessionId, StartSessionError, StopSessionError, snapshot_status_entries, + start_session, stop_session, }; use super::state::{DAEMON_VERSION, DaemonState, PROTOCOL_VERSION}; @@ -545,6 +545,8 @@ struct CliSessionStartParams { pub width: Option, #[serde(default)] pub height: Option, + #[serde(default)] + pub focused: Option, } #[derive(Debug, Clone, Serialize, Deserialize)] @@ -638,6 +640,7 @@ async fn handle_session_start(state: &Arc, params: Value) -> Result browser_instance_id: None, width: None, height: None, + focused: None, } } else { serde_json::from_value(params).map_err(|err| RpcError { @@ -664,7 +667,10 @@ async fn handle_session_start(state: &Arc, params: Value) -> Result &state.sessions, &state.tool_queues, params.browser_instance_id.as_deref(), - window_size, + AgentWindowOptions { + size: window_size, + focused: 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 0e74e14..542f645 100644 --- a/crates/bsk-cli/src/daemon/sessions.rs +++ b/crates/bsk-cli/src/daemon/sessions.rs @@ -390,6 +390,17 @@ pub enum StopSessionError { /// of live sessions. const SESSION_ID_MAX_RESERVE_ATTEMPTS: u32 = 64; +/// Agent Window creation hints forwarded to the extension on +/// `tool.session_start`. `None` fields keep the extension-side defaults +/// (focused window, browser-chosen size). +#[derive(Debug, Default, Clone, Copy)] +pub struct AgentWindowOptions { + /// Optional outer size as `(width, height)` CSS pixels. + pub size: Option<(u32, u32)>, + /// Optional focus hint (`None` = extension default: focused). + pub focused: Option, +} + /// Ask the chosen browser to create a fresh Agent Window for a brand-new /// session id, registering the result on success. /// @@ -401,8 +412,7 @@ pub async fn start_session( sessions: &Arc, queues: &Arc, requested: Option<&str>, - // Optional Agent Window outer size as `(width, height)` CSS pixels. - window_size: Option<(u32, u32)>, + window: AgentWindowOptions, connect_wait: Duration, timeout_dur: Duration, ) -> Result { @@ -429,8 +439,9 @@ pub async fn start_session( let params = SessionStartParams { session_id: session_id.0.clone(), browser_instance_id: Some(client.id.0.clone()), - width: window_size.map(|(width, _)| width), - height: window_size.map(|(_, height)| height), + width: window.size.map(|(width, _)| width), + height: window.size.map(|(_, height)| height), + focused: window.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 174ae40..f8cb0f2 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; @@ -500,3 +501,15 @@ fn rejects_invalid_emulate_values() { .is_err() ); } + +#[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 9e6af21..69c87c9 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 b8a2ae8..0b8eb21 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" + ] + }, "height": { "description": "Optional Agent Window outer height in CSS pixels (100..=7680).", "type": [ diff --git a/crates/bsk-protocol/src/tools/session.rs b/crates/bsk-protocol/src/tools/session.rs index 7d4ffdd..ac935b2 100644 --- a/crates/bsk-protocol/src/tools/session.rs +++ b/crates/bsk-protocol/src/tools/session.rs @@ -16,6 +16,10 @@ pub struct SessionStartParams { /// Optional Agent Window outer height in CSS pixels (100..=7680). #[serde(default, skip_serializing_if = "Option::is_none")] pub height: 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)] @@ -49,6 +53,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 b88fffa..31a7a09 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**. @@ -129,6 +129,7 @@ Details and flags: **`bsk --help`** | Command | Summary | |---------|---------| | `bsk session start` | Open Agent Window (`--width`/`--height` for initial size); 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 |