diff --git a/apps/extension/src/session-manager/__tests__/manager.test.ts b/apps/extension/src/session-manager/__tests__/manager.test.ts index cfeed29..9196126 100644 --- a/apps/extension/src/session-manager/__tests__/manager.test.ts +++ b/apps/extension/src/session-manager/__tests__/manager.test.ts @@ -39,6 +39,13 @@ describe("SessionManager", () => { expect(ctx.refStore.isEmpty()).toBe(true); expect(ctx.borrowedTabs.size).toBe(0); }); + 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 }); + expect(ctx.agentWindowId).toBe(100); + }); it("indexes the session by sessionId and agent window id", async () => { const aw = fakeAgentWindow(); diff --git a/apps/extension/src/session-manager/agent-window.ts b/apps/extension/src/session-manager/agent-window.ts index 108ede6..d880767 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, size?: { width: number; height: number }): Promise; remove(windowId: number): Promise; /** * Guarantee the Agent Window has an active, CDP-navigable tab. @@ -22,11 +22,12 @@ export interface AgentWindowApi { export const AGENT_WINDOW_HOME = "about:blank"; export const chromeAgentWindowApi: AgentWindowApi = { - async create(url: string): Promise { + async create(url: string, size?: { width: number; height: number }): Promise { const win = await chrome.windows.create({ type: "normal", focused: true, url, + ...(size ? { width: size.width, height: 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 0cbdb72..6c65175 100644 --- a/apps/extension/src/session-manager/manager.ts +++ b/apps/extension/src/session-manager/manager.ts @@ -128,11 +128,18 @@ 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, + size?: { width: number; height: number }, + ): Promise { if (this.sessions.has(sessionId)) { throw new Error(`[bh] session ${sessionId} already exists`); } - const windowId = await this.agentWindow.create(AGENT_WINDOW_HOME); + // 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); await this.agentWindow.ensureActiveTab(windowId, AGENT_WINDOW_HOME); const ctx: SessionContext = { sessionId, diff --git a/apps/extension/src/tools/__tests__/window.test.ts b/apps/extension/src/tools/__tests__/window.test.ts new file mode 100755 index 0000000..55fc0fd --- /dev/null +++ b/apps/extension/src/tools/__tests__/window.test.ts @@ -0,0 +1,138 @@ +import { describe, expect, it, vi } from "vitest"; +import { SessionManager } from "@/session-manager/manager"; +import { handleSessionStart } from "../session"; +import { handleWindowResize, type WindowResizeApi } from "../window"; + +function fakeAgentWindow(ids: number[]) { + let i = 0; + const create = vi.fn(async () => { + const id = ids[i++]; + if (id === undefined) throw new Error("ran out of fake ids"); + return id; + }); + const remove = vi.fn(async () => {}); + const ensureActiveTab = vi.fn(async () => {}); + return { create, remove, ensureActiveTab }; +} + +async function makeManager(): Promise { + const sm = new SessionManager({ agentWindow: fakeAgentWindow([100]) }); + await sm.start("aa11"); + return sm; +} + +function fakeUpdateApi(opts?: { throw?: boolean }) { + const calls: Array<{ windowId: number; width: number; height: number }> = []; + const api: WindowResizeApi = { + update: vi.fn(async (windowId: number, updateInfo: { width: number; height: number }) => { + if (opts?.throw) throw new Error("simulated chrome failure"); + calls.push({ windowId, width: updateInfo.width, height: updateInfo.height }); + return undefined; + }), + }; + return { api, calls }; +} + +describe("handleWindowResize", () => { + it("resizes the session's Agent Window", async () => { + const sm = await makeManager(); + const { api, calls } = fakeUpdateApi(); + const result = await handleWindowResize( + sm, + { session_id: "aa11", width: 1280, height: 800 }, + api, + ); + expect(result).toEqual({ window_id: 100, width: 1280, height: 800 }); + expect(calls).toEqual([{ windowId: 100, width: 1280, height: 800 }]); + }); + + it("rejects an unknown session", async () => { + const sm = await makeManager(); + const { api } = fakeUpdateApi(); + const result = await handleWindowResize( + sm, + { session_id: "zz99", width: 1280, height: 800 }, + api, + ); + expect(result).toMatchObject({ code: "not_found" }); + }); + + it("rejects missing session_id", async () => { + const sm = await makeManager(); + const { api } = fakeUpdateApi(); + const result = await handleWindowResize(sm, { session_id: "", width: 1280, height: 800 }, api); + expect(result).toMatchObject({ code: "invalid_params" }); + }); + + it("rejects missing dimensions", async () => { + const sm = await makeManager(); + const { api, calls } = fakeUpdateApi(); + const result = await handleWindowResize( + sm, + { session_id: "aa11" } as unknown as { session_id: string; width: number; height: number }, + api, + ); + expect(result).toMatchObject({ code: "invalid_params" }); + expect(calls).toEqual([]); + }); + + it.each([ + [99, 800], + [100, 7681], + [1280.5, 800], + [Number.NaN, 800], + ])("rejects out-of-range or non-integer dimensions (%s, %s)", async (width, height) => { + const sm = await makeManager(); + const { api, calls } = fakeUpdateApi(); + const result = await handleWindowResize(sm, { session_id: "aa11", width, height }, api); + expect(result).toMatchObject({ code: "invalid_params" }); + expect(calls).toEqual([]); + }); + + it("maps chrome API failures to protocol_error", async () => { + const sm = await makeManager(); + const { api } = fakeUpdateApi({ throw: true }); + const result = await handleWindowResize( + sm, + { session_id: "aa11", width: 1280, height: 800 }, + api, + ); + expect(result).toMatchObject({ code: "protocol_error", message: "simulated chrome failure" }); + }); +}); + +describe("handleSessionStart window size", () => { + it("passes width/height through to Agent Window creation", async () => { + const aw = fakeAgentWindow([100]); + 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 }); + }); + + it("creates the window without size when width/height are omitted", async () => { + const aw = fakeAgentWindow([100]); + 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"); + }); + + it("rejects a lone width without height", async () => { + const sm = new SessionManager({ agentWindow: fakeAgentWindow([100]) }); + const result = await handleSessionStart(sm, { session_id: "aa11", width: 1280 }); + expect(result).toMatchObject({ code: "invalid_params" }); + expect(sm.has("aa11")).toBe(false); + }); + + it.each([ + [99, 800], + [1280, 7681], + [1280.5, 800], + ])("rejects invalid dimensions (%s, %s)", async (width, height) => { + const sm = new SessionManager({ agentWindow: fakeAgentWindow([100]) }); + const result = await handleSessionStart(sm, { session_id: "aa11", width, height }); + expect(result).toMatchObject({ code: "invalid_params" }); + expect(sm.has("aa11")).toBe(false); + }); +}); diff --git a/apps/extension/src/tools/dispatcher.ts b/apps/extension/src/tools/dispatcher.ts index 8f079b3..0aca4fd 100644 --- a/apps/extension/src/tools/dispatcher.ts +++ b/apps/extension/src/tools/dispatcher.ts @@ -71,6 +71,7 @@ import { type TabSelectParams, } from "./tabs"; import { handleWaitForNavigation } from "./waits"; +import { handleWindowResize, type WindowResizeParams } from "./window"; type DispatcherCdpRunner = CdpRunner & NetworkCdpRunner & { @@ -286,6 +287,8 @@ export class ToolDispatcher { }); case "tool.tab_return": return handleTabReturn(this.sessions, req.params as TabReturnParams); + case "tool.window_resize": + return handleWindowResize(this.sessions, req.params as WindowResizeParams); case "tool.screenshot": return handleScreenshot( this.sessions, @@ -478,6 +481,7 @@ function sessionIdForBrowserControlMethod(req: RequestFrame): string | null { case "tool.tab_select": case "tool.tab_borrow": case "tool.tab_return": + case "tool.window_resize": case "tool.navigate": case "tool.navigate_back": case "tool.navigate_forward": diff --git a/apps/extension/src/tools/session.ts b/apps/extension/src/tools/session.ts index c83a2cd..d3312af 100644 --- a/apps/extension/src/tools/session.ts +++ b/apps/extension/src/tools/session.ts @@ -1,11 +1,55 @@ import type { SessionManager } from "@/session-manager/manager"; import type { RpcError } from "@/transport/types"; import { clearRecordingForSession } from "./record"; +import { isRpcError } from "./shared"; import { returnBorrowedTab, type TabManagementDeps } from "./tabs"; +/** Valid range for Agent Window dimensions in CSS pixels. */ +export const WINDOW_SIZE_MIN = 100; +export const WINDOW_SIZE_MAX = 7680; + +/** + * Validate an optional width/height pair. Both must be given (or + * neither), be integers, and fall within 100..=7680. Returns the size + * to pass down, or an `invalid_params` RpcError. + */ +export function validateWindowSize( + width: unknown, + height: unknown, +): { width: number; height: number } | undefined | RpcError { + if (width === undefined && height === undefined) return undefined; + if (width === undefined || height === undefined) { + return { + code: "invalid_params", + message: "width and height must be given together", + }; + } + for (const [name, value] of [ + ["width", width], + ["height", height], + ] as const) { + if ( + typeof value !== "number" || + !Number.isInteger(value) || + value < WINDOW_SIZE_MIN || + value > WINDOW_SIZE_MAX + ) { + return { + code: "invalid_params", + message: `${name} must be an integer in 100..=7680`, + }; + } + } + return { width: width as number, height: height as number }; +} + export interface SessionStartParams { session_id: string; browser_instance_id?: string; + /** Optional Agent Window outer width in CSS pixels (100..=7680). */ + width?: number; + /** Optional Agent Window outer height in CSS pixels (100..=7680). */ + height?: number; } export interface SessionStartResult { @@ -52,8 +96,10 @@ export async function handleSessionStart( message: "session.start requires session_id", }; } + const sizeOrErr = validateWindowSize(params.width, params.height); + if (isRpcError(sizeOrErr)) return sizeOrErr; try { - const ctx = await manager.start(params.session_id); + const ctx = await manager.start(params.session_id, sizeOrErr); return { agent_window_id: ctx.agentWindowId }; } catch (err) { // chrome.windows.create / SessionManager failures are not CDP diff --git a/apps/extension/src/tools/window.ts b/apps/extension/src/tools/window.ts new file mode 100755 index 0000000..ad11ad7 --- /dev/null +++ b/apps/extension/src/tools/window.ts @@ -0,0 +1,82 @@ +// Window-tool handlers: `tool.window_resize` resizes the session's +// Agent Window via `chrome.windows.update`. + +import type { SessionManager } from "@/session-manager/manager"; +import type { RpcError } from "@/transport/types"; +import { validateWindowSize } from "./session"; +import { isRpcError, lookupSession } from "./shared"; + +/** + * Mirror of bsk-protocol `WindowResizeParams` / + * `WindowResizeResult` (see crates/bsk-protocol/src/tools/window.rs). + */ +export interface WindowResizeParams { + session_id: string; + width: number; + height: number; +} + +export interface WindowResizeResult { + window_id: number; + width: number; + height: number; +} + +/** + * Subset of `chrome.windows` we depend on, injectable so unit tests + * can fake the browser without monkey-patching the global `chrome`. + */ +export interface WindowResizeApi { + update(windowId: number, updateInfo: { width: number; height: number }): Promise; +} + +export const chromeWindowResizeApi: WindowResizeApi = { + async update(windowId, updateInfo) { + return chrome.windows.update(windowId, updateInfo); + }, +}; + +/** + * Handler for `tool.window_resize` (called by the daemon over WS). + * + * Resizes the session's Agent Window to the given outer dimensions in + * CSS pixels. chrome API failures are surfaced as `protocol_error` + * (§4.5 reserves `cdp_failed` for raw CDP errors). + */ +export async function handleWindowResize( + manager: SessionManager, + params: WindowResizeParams, + api: WindowResizeApi = chromeWindowResizeApi, +): Promise { + const ctxOrErr = lookupSession(manager, params, "window_resize"); + if (isRpcError(ctxOrErr)) return ctxOrErr; + const ctx = ctxOrErr; + + const sizeOrErr = validateWindowSize(params.width, params.height); + if (isRpcError(sizeOrErr)) return sizeOrErr; + if (!sizeOrErr) { + // validateWindowSize only returns undefined when both are absent, + // which is valid for session_start but not for an explicit resize. + return { + code: "invalid_params", + message: "window_resize requires width and height", + }; + } + + try { + await api.update(ctx.agentWindowId, { + width: sizeOrErr.width, + height: sizeOrErr.height, + }); + } catch (err) { + return { + code: "protocol_error", + message: err instanceof Error ? err.message : String(err), + }; + } + return { + window_id: ctx.agentWindowId, + width: sizeOrErr.width, + height: sizeOrErr.height, + }; +} diff --git a/crates/bsk-cli/skill/SKILL.md b/crates/bsk-cli/skill/SKILL.md index 0eecabf..a5ddf77 100644 --- a/crates/bsk-cli/skill/SKILL.md +++ b/crates/bsk-cli/skill/SKILL.md @@ -123,11 +123,17 @@ Details and flags: **`bsk --help`** | Command | Summary | |---------|---------| -| `bsk session start` | Open Agent Window; prints **4-letter session id** | +| `bsk session start` | Open Agent Window (`--width`/`--height` for initial size); prints **4-letter session id** | | `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 | +### Window (require `--session `) + +| Command | Summary | +|---------|---------| +| `bsk window resize` | Resize the Agent Window (`--width`, `--height`; 100..=7680 CSS px) | + ### Tabs (require `--session `) | Command | Summary | diff --git a/crates/bsk-cli/src/cli/mod.rs b/crates/bsk-cli/src/cli/mod.rs index ff1e3a6..9a49134 100644 --- a/crates/bsk-cli/src/cli/mod.rs +++ b/crates/bsk-cli/src/cli/mod.rs @@ -30,6 +30,7 @@ pub mod status; pub mod tab; pub mod update; pub mod waits; +pub mod window; use clap::{Args, Parser, Subcommand}; @@ -50,6 +51,7 @@ use crate::cli::snapshot::SnapshotArgs; use crate::cli::tab::TabCmd; use crate::cli::update::UpdateArgs; use crate::cli::waits::{WaitForNavigationArgs, WaitMsArgs}; +use crate::cli::window::WindowCmd; /// Tool calls wait slightly longer than the daemon's 30s tool timeout so /// callers receive the structured daemon timeout instead of dropping the @@ -118,6 +120,9 @@ pub enum Command { /// Tab management commands. Tab(TabCmd), + /// Agent Window management commands. + Window(WindowCmd), + /// Capture a PNG of the active tab or a snapshot ref element. Screenshot(ScreenshotArgs), diff --git a/crates/bsk-cli/src/cli/record.rs b/crates/bsk-cli/src/cli/record.rs index dce12c1..84d3daa 100644 --- a/crates/bsk-cli/src/cli/record.rs +++ b/crates/bsk-cli/src/cli/record.rs @@ -85,7 +85,7 @@ 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)?; + let session = start_session(info.sock_path.clone(), args.browser, None, None)?; 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 198f568..5d4a144 100644 --- a/crates/bsk-cli/src/cli/session.rs +++ b/crates/bsk-cli/src/cli/session.rs @@ -54,6 +54,30 @@ pub struct SessionStartArgs { /// are connected). #[arg(long)] pub browser: Option, + + /// Agent Window outer width in CSS pixels (100..=7680). Both + /// `--width` and `--height` must be given to take effect. + #[arg(long, value_parser = window_size)] + pub width: Option, + + /// Agent Window outer height in CSS pixels (100..=7680). Both + /// `--width` and `--height` must be given to take effect. + #[arg(long, value_parser = window_size)] + pub height: Option, +} + +/// Parse a `--width` / `--height` Agent Window dimension (CSS pixels). +fn window_size(s: &str) -> Result { + let value: u32 = s + .parse() + .map_err(|_| format!("invalid window dimension {s:?}: expected a positive integer"))?; + if (100..=7680).contains(&value) { + Ok(value) + } else { + Err(format!( + "window dimension {value} out of range (100..=7680)" + )) + } } #[derive(Debug, Clone, Args)] @@ -71,6 +95,10 @@ pub struct SessionStopArgs { struct StartParams { #[serde(skip_serializing_if = "Option::is_none")] browser_instance_id: Option, + #[serde(skip_serializing_if = "Option::is_none")] + width: Option, + #[serde(skip_serializing_if = "Option::is_none")] + height: Option, } #[derive(Debug, Deserialize)] @@ -124,6 +152,11 @@ pub fn dispatch(cmd: SessionCmd, format: Format) -> Result<(), CliError> { } fn run_start(sock: PathBuf, args: SessionStartArgs, format: Format) -> Result<(), CliError> { + if args.width.is_some() != args.height.is_some() { + return Err(CliError::Local(anyhow::anyhow!( + "--width and --height must be given together" + ))); + } // The daemon may hold `session.start` open for up to // `EXTENSION_CONNECT_WAIT` while a just-woken service worker // reconnects. Let the user know we are waiting rather than hung — @@ -140,7 +173,7 @@ fn run_start(sock: PathBuf, args: SessionStartArgs, format: Format) -> Result<() } }); } - let result = start_session(sock, args.browser); + let result = start_session(sock, args.browser, args.width, args.height); waited.store(true, Ordering::SeqCst); match result { Ok(reply) => match format { @@ -165,12 +198,19 @@ 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 { +pub fn start_session( + sock: PathBuf, + browser: Option, + width: Option, + height: Option, +) -> Result { call( sock, Method::SessionStart, Some(StartParams { browser_instance_id: browser, + width, + height, }), SESSION_START_IPC_TIMEOUT, ) diff --git a/crates/bsk-cli/src/cli/window.rs b/crates/bsk-cli/src/cli/window.rs new file mode 100755 index 0000000..7c0b4db --- /dev/null +++ b/crates/bsk-cli/src/cli/window.rs @@ -0,0 +1,103 @@ +//! `bsk window …` subcommands — Agent Window management. + +use std::path::PathBuf; + +use anyhow::Context; +use bsk_protocol::Method; +use bsk_protocol::tools::{WindowResizeParams, WindowResizeResult}; +use clap::{Args, Subcommand}; + +use crate::cli::TOOL_IPC_TIMEOUT; +use crate::cli::ensure_daemon::ensure_daemon; +use crate::cli::error::{CliError, Format}; + +#[derive(Debug, Clone, Args)] +pub struct WindowCmd { + #[command(subcommand)] + pub sub: WindowSub, +} + +#[derive(Debug, Clone, Subcommand)] +pub enum WindowSub { + /// Resize the session's Agent Window. + Resize(WindowResizeArgs), +} + +#[derive(Debug, Clone, Args)] +pub struct WindowResizeArgs { + /// Session id (must be active). + #[arg(long)] + pub session: String, + + /// New Agent Window outer width in CSS pixels (100..=7680). + #[arg(long, value_parser = window_size)] + pub width: u32, + + /// New Agent Window outer height in CSS pixels (100..=7680). + #[arg(long, value_parser = window_size)] + pub height: u32, +} + +/// Parse a `--width` / `--height` Agent Window dimension (CSS pixels). +fn window_size(s: &str) -> Result { + let value: u32 = s + .parse() + .map_err(|_| format!("invalid window dimension {s:?}: expected a positive integer"))?; + if (100..=7680).contains(&value) { + Ok(value) + } else { + Err(format!( + "window dimension {value} out of range (100..=7680)" + )) + } +} + +pub fn dispatch(cmd: WindowCmd, format: Format) -> Result<(), CliError> { + let info = ensure_daemon().context("ensure daemon is running")?; + match cmd.sub { + WindowSub::Resize(args) => run_resize(info.sock_path, args, format), + } +} + +fn run_resize(sock: PathBuf, args: WindowResizeArgs, format: Format) -> Result<(), CliError> { + let params = WindowResizeParams { + session_id: args.session, + width: args.width, + height: args.height, + }; + let reply: WindowResizeResult = + ipc_call("window-resize-1", Method::ToolWindowResize, sock, params)?; + match format { + Format::Json => { + let json = serde_json::to_string_pretty(&reply) + .map_err(|e| CliError::Local(anyhow::anyhow!(e)))?; + println!("{json}"); + } + Format::Human => { + println!( + "resized window_id={} to {}x{}", + reply.window_id, reply.width, reply.height + ); + } + } + Ok(()) +} + +fn ipc_call( + rpc_id_prefix: &'static str, + method: Method, + sock: PathBuf, + params: P, +) -> Result +where + P: serde::Serialize + Send + 'static, + R: serde::de::DeserializeOwned + Send + 'static, +{ + crate::cli::business_rpc::call::( + sock, + rpc_id_prefix, + method, + Some(params), + TOOL_IPC_TIMEOUT, + ) +} diff --git a/crates/bsk-cli/src/daemon/ipc.rs b/crates/bsk-cli/src/daemon/ipc.rs index f76cc0a..cabcc6d 100644 --- a/crates/bsk-cli/src/daemon/ipc.rs +++ b/crates/bsk-cli/src/daemon/ipc.rs @@ -229,6 +229,7 @@ pub fn full_handler(status: DaemonStatus, state: Arc) -> RpcHandler | Method::ToolTabSelect | Method::ToolTabBorrow | Method::ToolTabReturn + | Method::ToolWindowResize | Method::ToolScreenshot | Method::ToolConsole | Method::ToolNetwork @@ -538,6 +539,10 @@ fn tool_dispatch_timeout(params: &Value) -> Result { struct CliSessionStartParams { #[serde(default)] pub browser_instance_id: Option, + #[serde(default)] + pub width: Option, + #[serde(default)] + pub height: Option, } #[derive(Debug, Clone, Serialize, Deserialize)] @@ -629,6 +634,8 @@ async fn handle_session_start(state: &Arc, params: Value) -> Result let params: CliSessionStartParams = if params.is_null() { CliSessionStartParams { browser_instance_id: None, + width: None, + height: None, } } else { serde_json::from_value(params).map_err(|err| RpcError { @@ -637,11 +644,25 @@ async fn handle_session_start(state: &Arc, params: Value) -> Result data: None, })? }; + // `--width` without `--height` (or vice versa) is rejected: the + // extension only accepts a complete size pair. + let window_size = match (params.width, params.height) { + (Some(width), Some(height)) => Some((width, height)), + (None, None) => None, + _ => { + return Err(RpcError { + code: ErrorCode::InvalidParams, + message: "width and height must be given together".into(), + data: None, + }); + } + }; match start_session( &state.browsers, &state.sessions, &state.tool_queues, params.browser_instance_id.as_deref(), + window_size, 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..53ba290 100644 --- a/crates/bsk-cli/src/daemon/sessions.rs +++ b/crates/bsk-cli/src/daemon/sessions.rs @@ -401,6 +401,8 @@ 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)>, connect_wait: Duration, timeout_dur: Duration, ) -> Result { @@ -427,6 +429,8 @@ 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), }; let rpc_id = next_rpc_id("sess-start"); let request = RequestFrame { diff --git a/crates/bsk-cli/src/main.rs b/crates/bsk-cli/src/main.rs index 75d912c..6237551 100644 --- a/crates/bsk-cli/src/main.rs +++ b/crates/bsk-cli/src/main.rs @@ -80,6 +80,7 @@ fn dispatch(cli: Cli, format: Format) -> Result<(), CliError> { Command::Session(cmd) => cli::session::dispatch(cmd, format), Command::Browsers => cli::browsers::dispatch(format), Command::Tab(cmd) => cli::tab::dispatch(cmd, format), + Command::Window(cmd) => cli::window::dispatch(cmd, format), Command::Screenshot(args) => cli::screenshot::dispatch(args, format), Command::Snapshot(args) => cli::snapshot::dispatch(args, format), Command::Observe(args) => cli::observe::dispatch(args, format), diff --git a/crates/bsk-cli/tests/cli_parse.rs b/crates/bsk-cli/tests/cli_parse.rs index 25c6739..28d856b 100644 --- a/crates/bsk-cli/tests/cli_parse.rs +++ b/crates/bsk-cli/tests/cli_parse.rs @@ -227,3 +227,99 @@ fn parses_record_start_without_url() { assert_eq!(args.browser.as_deref(), Some("022ca8ac")); assert!(args.url.is_none()); } + +#[test] +fn parses_session_start_with_window_size() { + use bsk::cli::session::{SessionCmd, SessionSub}; + let cli = parse(&[ + "bsk", "session", "start", "--width", "1280", "--height", "800", + ]); + let Command::Session(SessionCmd { + sub: SessionSub::Start(args), + }) = cli.command + else { + panic!("expected session start subcommand"); + }; + assert_eq!(args.width, Some(1280)); + assert_eq!(args.height, Some(800)); +} + +#[test] +fn session_start_window_size_defaults_to_none() { + use bsk::cli::session::{SessionCmd, SessionSub}; + let cli = parse(&["bsk", "session", "start"]); + let Command::Session(SessionCmd { + sub: SessionSub::Start(args), + }) = cli.command + else { + panic!("expected session start subcommand"); + }; + assert!(args.width.is_none()); + assert!(args.height.is_none()); +} + +#[test] +fn rejects_out_of_range_session_start_window_size() { + assert!(Cli::try_parse_from(["bsk", "session", "start", "--width", "99"]).is_err()); + assert!(Cli::try_parse_from(["bsk", "session", "start", "--height", "7681"]).is_err()); + assert!(Cli::try_parse_from(["bsk", "session", "start", "--width", "abc"]).is_err()); +} + +#[test] +fn parses_window_resize() { + use bsk::cli::window::{WindowCmd, WindowSub}; + let cli = parse(&[ + "bsk", + "window", + "resize", + "--session", + "s1", + "--width", + "1280", + "--height", + "800", + ]); + let Command::Window(WindowCmd { + sub: WindowSub::Resize(args), + }) = cli.command + else { + panic!("expected window resize subcommand"); + }; + assert_eq!(args.session, "s1"); + assert_eq!(args.width, 1280); + assert_eq!(args.height, 800); +} + +#[test] +fn rejects_invalid_window_resize_dimensions() { + assert!( + Cli::try_parse_from([ + "bsk", + "window", + "resize", + "--session", + "s1", + "--width", + "99", + "--height", + "800" + ]) + .is_err() + ); + assert!( + Cli::try_parse_from([ + "bsk", + "window", + "resize", + "--session", + "s1", + "--width", + "1280", + "--height", + "7681" + ]) + .is_err() + ); + // width/height are required for resize. + assert!(Cli::try_parse_from(["bsk", "window", "resize", "--session", "s1"]).is_err()); +} diff --git a/crates/bsk-protocol/schema/tool_session_start_params.json b/crates/bsk-protocol/schema/tool_session_start_params.json index 3c912dd..b8a2ae8 100644 --- a/crates/bsk-protocol/schema/tool_session_start_params.json +++ b/crates/bsk-protocol/schema/tool_session_start_params.json @@ -12,8 +12,26 @@ "null" ] }, + "height": { + "description": "Optional Agent Window outer height in CSS pixels (100..=7680).", + "type": [ + "integer", + "null" + ], + "format": "uint32", + "minimum": 0.0 + }, "session_id": { "type": "string" + }, + "width": { + "description": "Optional Agent Window outer width in CSS pixels (100..=7680).", + "type": [ + "integer", + "null" + ], + "format": "uint32", + "minimum": 0.0 } } } diff --git a/crates/bsk-protocol/schema/tool_window_resize_params.json b/crates/bsk-protocol/schema/tool_window_resize_params.json new file mode 100644 index 0000000..f6faffc --- /dev/null +++ b/crates/bsk-protocol/schema/tool_window_resize_params.json @@ -0,0 +1,27 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "title": "WindowResizeParams", + "type": "object", + "required": [ + "height", + "session_id", + "width" + ], + "properties": { + "height": { + "description": "New Agent Window outer height in CSS pixels (100..=7680).", + "type": "integer", + "format": "uint32", + "minimum": 0.0 + }, + "session_id": { + "type": "string" + }, + "width": { + "description": "New Agent Window outer width in CSS pixels (100..=7680).", + "type": "integer", + "format": "uint32", + "minimum": 0.0 + } + } +} diff --git a/crates/bsk-protocol/schema/tool_window_resize_result.json b/crates/bsk-protocol/schema/tool_window_resize_result.json new file mode 100644 index 0000000..f4cc646 --- /dev/null +++ b/crates/bsk-protocol/schema/tool_window_resize_result.json @@ -0,0 +1,26 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "title": "WindowResizeResult", + "type": "object", + "required": [ + "height", + "width", + "window_id" + ], + "properties": { + "height": { + "type": "integer", + "format": "uint32", + "minimum": 0.0 + }, + "width": { + "type": "integer", + "format": "uint32", + "minimum": 0.0 + }, + "window_id": { + "type": "integer", + "format": "int64" + } + } +} diff --git a/crates/bsk-protocol/src/bin/dump-schema.rs b/crates/bsk-protocol/src/bin/dump-schema.rs index 6b34449..92e18f8 100644 --- a/crates/bsk-protocol/src/bin/dump-schema.rs +++ b/crates/bsk-protocol/src/bin/dump-schema.rs @@ -45,6 +45,9 @@ fn main() { dump!(SessionStopParams, "tool_session_stop_params"); dump!(SessionStopResult, "tool_session_stop_result"); + dump!(WindowResizeParams, "tool_window_resize_params"); + dump!(WindowResizeResult, "tool_window_resize_result"); + dump!(TabListParams, "tool_tab_list_params"); dump!(TabListResult, "tool_tab_list_result"); dump!(TabCreateParams, "tool_tab_create_params"); diff --git a/crates/bsk-protocol/src/method.rs b/crates/bsk-protocol/src/method.rs index 6b712bf..71bf07f 100644 --- a/crates/bsk-protocol/src/method.rs +++ b/crates/bsk-protocol/src/method.rs @@ -44,6 +44,8 @@ pub enum Method { ToolSessionStart, #[serde(rename = "tool.session_stop")] ToolSessionStop, + #[serde(rename = "tool.window_resize")] + ToolWindowResize, #[serde(rename = "tool.tab_list")] ToolTabList, #[serde(rename = "tool.tab_create")] @@ -140,6 +142,7 @@ impl Method { | Method::ToolTabBorrow | Method::ToolTabReturn | Method::ToolTabSelect + | Method::ToolWindowResize | Method::ToolNavigate | Method::ToolNavigateBack | Method::ToolNavigateForward @@ -272,6 +275,7 @@ mod tests { assert!(Method::ToolSelect.is_mutating()); assert!(Method::ToolEvaluate.is_mutating()); assert!(Method::ToolRecordStart.is_mutating()); + assert!(Method::ToolWindowResize.is_mutating()); } #[test] diff --git a/crates/bsk-protocol/src/tools/mod.rs b/crates/bsk-protocol/src/tools/mod.rs index 8a0c319..b325f4d 100644 --- a/crates/bsk-protocol/src/tools/mod.rs +++ b/crates/bsk-protocol/src/tools/mod.rs @@ -12,6 +12,7 @@ pub mod script; pub mod session; pub mod tabs; pub mod waits; +pub mod window; pub use console::*; pub use dialog::*; @@ -25,3 +26,4 @@ pub use script::*; pub use session::*; pub use tabs::*; pub use waits::*; +pub use window::*; diff --git a/crates/bsk-protocol/src/tools/session.rs b/crates/bsk-protocol/src/tools/session.rs index e50e8df..7d4ffdd 100644 --- a/crates/bsk-protocol/src/tools/session.rs +++ b/crates/bsk-protocol/src/tools/session.rs @@ -10,6 +10,12 @@ pub struct SessionStartParams { pub session_id: String, #[serde(default, skip_serializing_if = "Option::is_none")] pub browser_instance_id: Option, + /// Optional Agent Window outer width in CSS pixels (100..=7680). + #[serde(default, skip_serializing_if = "Option::is_none")] + pub width: Option, + /// Optional Agent Window outer height in CSS pixels (100..=7680). + #[serde(default, skip_serializing_if = "Option::is_none")] + pub height: Option, } #[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)] diff --git a/crates/bsk-protocol/src/tools/window.rs b/crates/bsk-protocol/src/tools/window.rs new file mode 100755 index 0000000..c36547f --- /dev/null +++ b/crates/bsk-protocol/src/tools/window.rs @@ -0,0 +1,62 @@ +//! Agent Window management tools (`tool.window_*`). + +use schemars::JsonSchema; +use serde::{Deserialize, Serialize}; + +// ----- window_resize ----- + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)] +pub struct WindowResizeParams { + pub session_id: String, + /// New Agent Window outer width in CSS pixels (100..=7680). + pub width: u32, + /// New Agent Window outer height in CSS pixels (100..=7680). + pub height: u32, +} + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)] +pub struct WindowResizeResult { + pub window_id: i64, + pub width: u32, + pub height: u32, +} + +#[cfg(test)] +mod tests { + use super::*; + use serde_json::json; + + #[test] + fn window_resize_params_round_trip() { + let params: WindowResizeParams = serde_json::from_value(json!({ + "session_id": "ab12", + "width": 1280, + "height": 800, + })) + .unwrap(); + assert_eq!(params.session_id, "ab12"); + assert_eq!(params.width, 1280); + assert_eq!(params.height, 800); + let encoded = serde_json::to_value(params).unwrap(); + assert_eq!( + encoded, + json!({ "session_id": "ab12", "width": 1280, "height": 800 }) + ); + } + + #[test] + fn window_resize_result_round_trip() { + let result: WindowResizeResult = serde_json::from_value(json!({ + "window_id": 42, + "width": 1280, + "height": 800, + })) + .unwrap(); + assert_eq!(result.window_id, 42); + let encoded = serde_json::to_value(result).unwrap(); + assert_eq!( + encoded, + json!({ "window_id": 42, "width": 1280, "height": 800 }) + ); + } +} diff --git a/skill/SKILL.md b/skill/SKILL.md index 0eecabf..a5ddf77 100644 --- a/skill/SKILL.md +++ b/skill/SKILL.md @@ -123,11 +123,17 @@ Details and flags: **`bsk --help`** | Command | Summary | |---------|---------| -| `bsk session start` | Open Agent Window; prints **4-letter session id** | +| `bsk session start` | Open Agent Window (`--width`/`--height` for initial size); prints **4-letter session id** | | `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 | +### Window (require `--session `) + +| Command | Summary | +|---------|---------| +| `bsk window resize` | Resize the Agent Window (`--width`, `--height`; 100..=7680 CSS px) | + ### Tabs (require `--session `) | Command | Summary |