diff --git a/cli/Cargo.lock b/cli/Cargo.lock index cc589de..ba8231f 100644 --- a/cli/Cargo.lock +++ b/cli/Cargo.lock @@ -290,7 +290,7 @@ checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724" [[package]] name = "chrome-use" -version = "1.5.24" +version = "1.5.25" dependencies = [ "aes", "aes-gcm", diff --git a/cli/Cargo.toml b/cli/Cargo.toml index c63c8cf..deb65d6 100644 --- a/cli/Cargo.toml +++ b/cli/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "chrome-use" -version = "1.5.24" +version = "1.5.25" edition = "2021" description = "Fast browser automation CLI for AI agents" license = "Apache-2.0" diff --git a/cli/src/commands.rs b/cli/src/commands.rs index 4353df9..0ffcebe 100644 --- a/cli/src/commands.rs +++ b/cli/src/commands.rs @@ -84,6 +84,7 @@ const KNOWN_COMMANDS: &[&str] = &[ "canvas", "viewport", "resize", + "keep", ]; /// Levenshtein distance, capped — small inputs only (command names). @@ -1375,6 +1376,10 @@ fn parse_command_inner(args: &[String], flags: &Flags) -> Result Ok(json!({ "id": id, "action": "url" })), + // `keep`: leave the active tab for the user — exempt it from the daemon's + // auto-close/idle cleanup and remove it from the session's tab group. + "keep" => Ok(json!({ "id": id, "action": "keep" })), + // === Stealth self-check === "stealth" => { // `stealth [status]` — local stealth self-check: mode, live probes @@ -3180,7 +3185,7 @@ fn parse_viewport(rest: &[&str], id: &str) -> Result { let (w, h, scale_tok): (i32, i32, Option<&str>) = match positionals.first() { Some(first) if first.contains('x') || first.contains('X') => { - let mut parts = first.split(|c| c == 'x' || c == 'X'); + let mut parts = first.split(['x', 'X']); let w = parts.next().and_then(|s| s.parse::().ok()); let h = parts.next().and_then(|s| s.parse::().ok()); match (w, h) { @@ -3238,7 +3243,7 @@ fn parse_viewport(rest: &[&str], id: &str) -> Result { if let Some(s) = scale { cmd["deviceScaleFactor"] = json!(s); } - if rest.iter().any(|a| *a == "--mobile") { + if rest.contains(&"--mobile") { cmd["mobile"] = json!(true); } Ok(cmd) diff --git a/cli/src/native/actions.rs b/cli/src/native/actions.rs index e08a1b4..c63977d 100644 --- a/cli/src/native/actions.rs +++ b/cli/src/native/actions.rs @@ -1317,6 +1317,7 @@ pub async fn execute_command(cmd: &Value, state: &mut DaemonState) -> Value { "evaluate" => handle_evaluate(cmd, state).await, "site" => handle_site(cmd, state).await, "close" => handle_close(state).await, + "keep" => handle_keep(state).await, "stealth_status" => handle_stealth_status(state).await, "snapshot" => handle_snapshot(cmd, state).await, "screenshot" => handle_screenshot(cmd, state).await, @@ -2855,6 +2856,34 @@ async fn handle_stealth_status(state: &DaemonState) -> Result { })) } +/// `keep` — leave the ACTIVE tab for the user: stop owning it (so the daemon's +/// `close()`/idle-shutdown won't close it) and best-effort remove it from this +/// session's tab group so it looks like a normal user tab. The "leave for the +/// user" half of the auto-close-on-idle cleanup: scratch tabs get closed, tabs +/// the agent explicitly `keep`s stay. (Adopted user tabs are never owned, so +/// they're already safe.) +async fn handle_keep(state: &mut DaemonState) -> Result { + let mgr = state.browser.as_mut().ok_or("Browser not launched")?; + let target_id = mgr.active_target_id()?.to_string(); + let session_id = mgr.active_session_id()?.to_string(); + let was_owned = mgr.unown_target(&target_id); + // Best-effort: ask the extension to ungroup the tab (relay only; no-ops on a + // launched browser or an older extension that doesn't know ABExt.ungroupTab). + let _ = mgr + .client + .send_command_typed::<_, Value>( + "ABExt.ungroupTab", + &json!({ "sessionId": session_id, "targetId": target_id }), + None, + ) + .await; + Ok(json!({ + "kept": target_id, + "wasOwned": was_owned, + "note": "tab left for the user — exempt from auto-close, removed from the session tab group", + })) +} + async fn handle_close(state: &mut DaemonState) -> Result { if let Some(ref mgr) = state.browser { if let Some(ref session_name) = state.session_name { diff --git a/cli/src/native/browser.rs b/cli/src/native/browser.rs index 3b08e98..17ad945 100644 --- a/cli/src/native/browser.rs +++ b/cli/src/native/browser.rs @@ -1546,6 +1546,13 @@ impl BrowserManager { .ok_or_else(|| "No active page".to_string()) } + /// Stop owning a tab — drop it from `created_targets` so it survives `close()` + /// and idle-shutdown (the agent is leaving it for the user). Returns true if it + /// was owned. Used by `keep`. + pub fn unown_target(&mut self, target_id: &str) -> bool { + self.created_targets.remove(target_id) + } + /// Returns true if this manager was connected via CDP (as opposed to local launch). pub fn is_cdp_connection(&self) -> bool { self.browser_process.is_none() diff --git a/cli/src/native/daemon.rs b/cli/src/native/daemon.rs index 9bb0d5a..ed26697 100644 --- a/cli/src/native/daemon.rs +++ b/cli/src/native/daemon.rs @@ -130,12 +130,21 @@ pub async fn run_daemon(session: &str) { } } - // Auto-shutdown the daemon after this many ms of inactivity (no commands received). - // Disabled when unset or 0. - let idle_timeout_ms = env::var("AGENT_BROWSER_IDLE_TIMEOUT_MS") - .ok() - .and_then(|s| s.parse::().ok()) - .filter(|&ms| ms > 0); + // Auto-shutdown the daemon after this many ms of inactivity (no commands + // received). On shutdown the daemon closes the tabs IT created (its per-session + // tab group), so an agent that finishes a task and just stops — without ever + // calling `close` — no longer leaves a pile of scratch tabs and a lingering + // tab group in the user's Chrome. The timer resets on every command, so active + // sessions are never interrupted; only genuinely-idle ones clean up. + // + // Defaults to 10 minutes. Set AGENT_BROWSER_IDLE_TIMEOUT_MS to override, or 0 + // to disable (keep the daemon alive forever — the old behaviour). Adopted + // tabs (the user's own, via `adopt`) are never closed: only `created_targets`. + const DEFAULT_IDLE_TIMEOUT_MS: u64 = 600_000; + let idle_timeout_ms = match env::var("AGENT_BROWSER_IDLE_TIMEOUT_MS") { + Ok(s) => s.trim().parse::().ok().filter(|&ms| ms > 0), + Err(_) => Some(DEFAULT_IDLE_TIMEOUT_MS), + }; let result = run_socket_server( &socket_path, diff --git a/cli/src/output.rs b/cli/src/output.rs index 671d4a3..e2132c0 100644 --- a/cli/src/output.rs +++ b/cli/src/output.rs @@ -3326,6 +3326,9 @@ Core Commands: snapshot Accessibility tree with refs (for AI) eval Run JavaScript connect Connect to browser via CDP + keep Leave the active tab for the user — exempt it from + auto-close/idle cleanup + remove it from the session + tab group (so scratch tabs get cleaned, this one stays) close [--all] Close browser (--all closes every session) Navigation: diff --git a/extensions/ab-connect/background.js b/extensions/ab-connect/background.js index 82edb7f..054e749 100644 --- a/extensions/ab-connect/background.js +++ b/extensions/ab-connect/background.js @@ -288,6 +288,19 @@ async function handleForwardCdpCommand(msg) { const params = msg?.params?.params || undefined const sessionId = typeof msg?.params?.sessionId === 'string' ? msg.params.sessionId : undefined + // Non-CDP extension commands (ABExt.*) the daemon sends. `ungroupTab` removes a + // tab from its per-session tab group so a `keep`-marked tab is left for the user + // as a normal, ungrouped tab (the group can then be cleaned up). Best-effort. + if (method === 'ABExt.ungroupTab') { + const tabId = tabIdFromSession(sessionId) ?? tabForSession(sessionId) + if (tabId != null && chrome.tabs.ungroup) { + try { + await chrome.tabs.ungroup(tabId) + } catch {} + } + return { ungrouped: tabId ?? null } + } + // Browser-level Target methods that map onto chrome.tabs. if (method === 'Target.createTarget') { const url = typeof params?.url === 'string' && params.url ? params.url : 'about:blank' diff --git a/extensions/ab-connect/manifest.json b/extensions/ab-connect/manifest.json index 81d1283..3d9e20f 100644 --- a/extensions/ab-connect/manifest.json +++ b/extensions/ab-connect/manifest.json @@ -1,8 +1,8 @@ { "manifest_version": 3, "name": "chrome-use", - "version": "0.4.11", - "description": "Let chrome-use drive your logged-in Chrome \u2014 install once, no token, no per-use confirmation.", + "version": "0.4.12", + "description": "Let chrome-use drive your logged-in Chrome — install once, no token, no per-use confirmation.", "key": "MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEA6vQIyscGIPYPZdSpPwPL0+0gxUROyRgCpmvCSDoc8XUm4qm97VbKnD9Ijc1lV22lNWZtE78gaRjt6BeSfuMgnBymnhLKjN1gU6AI5QUU0mrJyeHdWKvrKQR5FmsM2A7Xr1ykE2SiiS8zNUS3Y/6O5l+Nva7wrVy6E4a2dkBVQkOsu+DV+nEZvhIyuDY5D5SPXqNwUTWTaglwj5mjvHz36xSwCWlPmrtJ+ED0AUyrb2z4GIOmvk4kqtBVrh/UD058klLo4CkYOnIybB5aV6WYuwarfPY4bF/dLggPem+ewLNTUNBuwrxj/A4nUv0LJTuRO8rR7f8WR9qnRCY0Ic5saQIDAQAB", "icons": { "16": "icons/icon16.png", diff --git a/package.json b/package.json index 4b0f800..811fc7b 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "chrome-use", - "version": "1.5.24", + "version": "1.5.25", "description": "chrome-use — drive your real, logged-in Chrome from any AI agent, stealth by default", "type": "module", "packageManager": "pnpm@11.1.3",