feat(session): support unfocused agent windows (--no-focus) (#87)

`bsk session start --no-focus` opens the Agent Window in the background
without stealing focus. The flag threads focused=false through
CLI -> daemon IPC -> tool.session_start -> chrome.windows.create; an
omitted `focused` keeps the extension default (true) so older clients
are unaffected.

Reworked onto current main so it composes with #52 window sizing:
AgentWindowApi.create / SessionManager.start (extension), start_session
(CLI) and the daemon's session start now take options objects/structs
({ size, focused }) instead of colliding positional params.

Co-authored-by: alectimison-maker <alec.timison@gmail.com>
This commit is contained in:
BB-fat
2026-08-12 19:58:53 +08:00
committed by GitHub
co-authored by alectimison-maker
parent 0ce497f017
commit f443cfae74
17 changed files with 235 additions and 43 deletions
+2 -1
View File
@@ -42,7 +42,7 @@ Every automation task **must** follow this lifecycle. Do **not** rely on idle ti
3. bsk session stop <id> → REQUIRED when done (even on error paths)
```
Optional: `bsk session start --browser <instance-id-or-label>` when multiple browsers are connected (`bsk browsers` / error output lists them).
Optional: `bsk session start --browser <instance-id-or-label>` 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 <cmd> --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 <id>` | End session, close Agent Window, auto-return borrowed tabs |
| `bsk session stop --all` | Stop every active session |
| `bsk session list` | List active sessions |
+8 -2
View File
@@ -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(),
+31 -10
View File
@@ -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<u32>,
/// 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<u32>,
#[serde(skip_serializing_if = "Option::is_none")]
height: Option<u32>,
#[serde(skip_serializing_if = "Option::is_none")]
focused: Option<bool>,
}
#[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<String>,
pub width: Option<u32>,
pub height: Option<u32>,
pub focused: Option<bool>,
}
/// Start a session and open the Agent Window. Used by `session start` and `record start`.
pub fn start_session(
sock: PathBuf,
browser: Option<String>,
width: Option<u32>,
height: Option<u32>,
) -> Result<StartReply, CliError> {
pub fn start_session(sock: PathBuf, opts: SessionStartOptions) -> Result<StartReply, CliError> {
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,
)
+9 -3
View File
@@ -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<u32>,
#[serde(default)]
pub height: Option<u32>,
#[serde(default)]
pub focused: Option<bool>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
@@ -638,6 +640,7 @@ async fn handle_session_start(state: &Arc<DaemonState>, 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<DaemonState>, 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,
)
+15 -4
View File
@@ -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<bool>,
}
/// 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<SessionRegistry>,
queues: &Arc<ToolQueueRegistry>,
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<Session, StartSessionError> {
@@ -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 {
+13
View File
@@ -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);
}
+4 -1
View File
@@ -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<String>,
focused: Option<bool>,
}
#[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),
)