From 5970579d7c00dfe5d6c7e24a57d9035de0b6effa Mon Sep 17 00:00:00 2001 From: leeguooooo Date: Thu, 5 Mar 2026 13:40:34 +0900 Subject: [PATCH] feat: add parallel mode and idle daemon shutdown --- README.md | 29 ++ cli/Cargo.lock | 2 +- cli/Cargo.toml | 2 +- cli/src/commands.rs | 4 + cli/src/connection.rs | 12 + cli/src/flags.rs | 119 ++++- cli/src/main.rs | 20 + cli/src/native/daemon.rs | 73 ++- cli/src/output.rs | 22 +- docs/src/app/commands/page.mdx | 16 +- docs/src/app/configuration/page.mdx | 41 +- docs/src/app/sessions/page.mdx | 36 +- package.json | 2 +- skills/agent-browser/SKILL.md | 24 +- src/daemon.test.ts | 44 +- src/daemon.ts | 659 +++++++++++++++------------- 16 files changed, 775 insertions(+), 330 deletions(-) diff --git a/README.md b/README.md index 452df5f..4024885 100644 --- a/README.md +++ b/README.md @@ -52,6 +52,33 @@ agent-browser snapshot -i agent-browser click @e2 ``` +### Parallel AI Runs (Isolated Runtime Channel) + +Use `--parallel ` to run multiple AI flows concurrently without fighting over the same runtime channel. + +```bash +agent-browser --parallel worker-a open https://example.com +agent-browser --parallel worker-b open https://example.org +``` + +`--parallel` is designed for stateless throughput tasks (navigation, extraction, checks). For authenticated flows, keep using one stable `--session-name`. + +| Option | Purpose | Typical Usage | +| --- | --- | --- | +| `--parallel ` | Isolate runtime channel for concurrent AI tasks | Stateless/no-login parallel jobs | +| `--session-name ` | Persist cookies/localStorage across restarts | Login/auth continuity | + +### Daemon Lifecycle + +- Daemons auto-shutdown after 10 minutes of inactivity by default. +- Use `--resident` to keep a daemon alive until an explicit `close`. + +```bash +agent-browser --resident open https://example.com +# ... long-lived background workflow ... +agent-browser close +``` + ### Default: Auto Group Agent Tabs (CDP + Plugin) ```bash @@ -233,6 +260,8 @@ flowchart TD - Prefer `--headed` for high-friction targets. - Reuse session state with one stable `--session-name` for continuity (when omitted, it defaults to `default`). +- Use `--parallel ` only for stateless parallel workloads where higher throughput matters. +- Use `--resident` only for deliberate long-running workflows, and close when done. - Keep locale/timezone consistent with target market. - For challenge-heavy pages, prefer `--wait-until domcontentloaded` on `open`/`navigate` to avoid `load` stalls. - Use `--risk-mode block` in strict pipelines that require explicit operator intervention on verification pages. diff --git a/cli/Cargo.lock b/cli/Cargo.lock index fefd183..f1c1570 100644 --- a/cli/Cargo.lock +++ b/cli/Cargo.lock @@ -45,7 +45,7 @@ dependencies = [ [[package]] name = "agent-browser-stealth" -version = "0.16.3-fork.2" +version = "0.16.3-fork.3" dependencies = [ "aes-gcm", "async-trait", diff --git a/cli/Cargo.toml b/cli/Cargo.toml index d635ef8..8d69253 100644 --- a/cli/Cargo.toml +++ b/cli/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "agent-browser-stealth" -version = "0.16.3-fork.2" +version = "0.16.3-fork.3" edition = "2021" description = "Stealth browser automation CLI for AI agents with anti-bot evasions" license = "Apache-2.0" diff --git a/cli/src/commands.rs b/cli/src/commands.rs index 8cd5d69..ad9d388 100644 --- a/cli/src/commands.rs +++ b/cli/src/commands.rs @@ -2060,6 +2060,7 @@ mod tests { full: false, headed: false, debug: false, + resident: false, headers: None, executable_path: None, extensions: Vec::new(), @@ -2075,6 +2076,7 @@ mod tests { device: None, auto_connect: false, session_name: None, + parallel: None, cli_executable_path: false, cli_extensions: false, cli_state: false, @@ -2094,6 +2096,8 @@ mod tests { wait_until: None, cli_tab_group: false, cli_tab_group_plugin_id: false, + cli_session_name: false, + cli_resident: false, } } diff --git a/cli/src/connection.rs b/cli/src/connection.rs index de76e69..5142275 100644 --- a/cli/src/connection.rs +++ b/cli/src/connection.rs @@ -183,6 +183,8 @@ pub struct DaemonResult { pub fn ensure_daemon( session: &str, headed: bool, + // Keep daemon resident and disable idle auto-shutdown. + resident: bool, executable_path: Option<&str>, extensions: &[String], args: Option<&str>, @@ -289,6 +291,11 @@ pub fn ensure_daemon( let mut cmd = Command::new("node"); cmd.arg(daemon_path) + .arg(if resident { + "--resident" + } else { + "--idle-auto-shutdown" + }) .env("AGENT_BROWSER_DAEMON", "1") .env("AGENT_BROWSER_SESSION", session); @@ -384,6 +391,11 @@ pub fn ensure_daemon( // and automatically quotes arguments containing spaces. let mut cmd = Command::new("node"); cmd.arg(daemon_path) + .arg(if resident { + "--resident" + } else { + "--idle-auto-shutdown" + }) .env("AGENT_BROWSER_DAEMON", "1") .env("AGENT_BROWSER_SESSION", session); diff --git a/cli/src/flags.rs b/cli/src/flags.rs index 14725b8..c387f6a 100644 --- a/cli/src/flags.rs +++ b/cli/src/flags.rs @@ -1,4 +1,5 @@ use crate::color; +use crate::validation::is_valid_session_name; use serde::Deserialize; use std::env; use std::fs; @@ -40,6 +41,7 @@ pub struct Config { pub tab_group_plugin_id: Option, pub risk_mode: Option, pub wait_until: Option, + pub parallel: Option, } impl Config { @@ -78,6 +80,7 @@ impl Config { tab_group_plugin_id: other.tab_group_plugin_id.or(self.tab_group_plugin_id), risk_mode: other.risk_mode.or(self.risk_mode), wait_until: other.wait_until.or(self.wait_until), + parallel: other.parallel.or(self.parallel), } } } @@ -148,6 +151,7 @@ fn extract_config_path(args: &[String]) -> Option> { "--tab-group-plugin-id", "--risk-mode", "--wait-until", + "--parallel", ]; let mut i = 0; while i < args.len() { @@ -199,6 +203,10 @@ pub struct Flags { pub full: bool, pub headed: bool, pub debug: bool, + /// Keep daemon resident and disable idle auto-shutdown. + pub resident: bool, + /// Runtime daemon session channel. + /// Defaults to `default`; when `--parallel ` is provided it becomes `parallel-`. pub session: String, pub headers: Option, pub executable_path: Option, @@ -214,7 +222,9 @@ pub struct Flags { pub allow_file_access: bool, pub device: Option, pub auto_connect: bool, - pub session_name: Option, // Defaults to "default" when unset + // Defaults to "default" when unset in default runtime mode. + // In --parallel mode, defaults to None unless explicitly provided on CLI. + pub session_name: Option, pub annotate: bool, pub color_scheme: Option, pub download_path: Option, @@ -226,6 +236,8 @@ pub struct Flags { /// Navigation wait strategy passed to navigate/open commands: /// `load`, `domcontentloaded`, or `networkidle`. pub wait_until: Option, + /// Parallel execution channel name. When set, commands run in an isolated runtime session. + pub parallel: Option, // Track which launch-time options were explicitly passed via CLI // (as opposed to being set only via environment variables) @@ -241,6 +253,8 @@ pub struct Flags { pub cli_download_path: bool, pub cli_tab_group: bool, pub cli_tab_group_plugin_id: bool, + pub cli_session_name: bool, + pub cli_resident: bool, } pub fn parse_flags(args: &[String]) -> Flags { @@ -273,7 +287,9 @@ pub fn parse_flags(args: &[String]) -> Flags { Err(_) => config.headed.unwrap_or(true), }, debug: env_var_is_truthy("AGENT_BROWSER_DEBUG") || config.debug.unwrap_or(false), - // --session is disabled: user-facing CLI always uses one default session. + resident: false, + // --session is disabled for users. + // Runtime session defaults to `default`, and can be isolated with `--parallel`. session: "default".to_string(), headers: config.headers, executable_path: env::var("AGENT_BROWSER_EXECUTABLE_PATH") @@ -321,6 +337,7 @@ pub fn parse_flags(args: &[String]) -> Flags { .or(config.risk_mode) .map(|s| s.to_ascii_lowercase()), wait_until: config.wait_until.map(|s| s.to_ascii_lowercase()), + parallel: env::var("AGENT_BROWSER_PARALLEL").ok().or(config.parallel), cli_executable_path: false, cli_extensions: false, cli_state: false, @@ -333,6 +350,8 @@ pub fn parse_flags(args: &[String]) -> Flags { cli_download_path: false, cli_tab_group: false, cli_tab_group_plugin_id: false, + cli_session_name: false, + cli_resident: false, }; let mut i = 0; @@ -366,6 +385,14 @@ pub fn parse_flags(args: &[String]) -> Flags { i += 1; } } + "--resident" => { + let (val, consumed) = parse_bool_arg(args, i); + flags.resident = val; + flags.cli_resident = true; + if consumed { + i += 1; + } + } "--headers" => { if let Some(h) = args.get(i + 1) { flags.headers = Some(h.clone()); @@ -464,6 +491,13 @@ pub fn parse_flags(args: &[String]) -> Flags { "--session-name" => { if let Some(s) = args.get(i + 1) { flags.session_name = Some(s.clone()); + flags.cli_session_name = true; + i += 1; + } + } + "--parallel" => { + if let Some(s) = args.get(i + 1) { + flags.parallel = Some(s.clone()); i += 1; } } @@ -523,9 +557,24 @@ pub fn parse_flags(args: &[String]) -> Flags { i += 1; } - // Keep auth/state continuity stable by default: if no explicit --session-name - // is provided, derive it from the default session id. - if flags.session_name.is_none() { + if let Some(parallel_name) = &flags.parallel { + // Validate early so session id derivation cannot introduce unsafe paths. + if !is_valid_session_name(parallel_name) { + // Keep default session and let main.rs surface a user-facing validation error. + } else { + flags.session = format!("parallel-{}", parallel_name); + } + } + + // Parallel mode is for isolated/stateless runs. + // Unless --session-name is explicitly provided on this invocation, disable + // auto save/restore persistence to avoid cross-flow auth leakage. + if flags.parallel.is_some() && !flags.cli_session_name { + flags.session_name = None; + } + + // Keep auth/state continuity stable by default for the default runtime session. + if flags.session_name.is_none() && flags.parallel.is_none() { flags.session_name = Some("default".to_string()); } @@ -542,6 +591,7 @@ pub fn clean_args(args: &[String]) -> Vec { "--full", "--headed", "--debug", + "--resident", "--ignore-https-errors", "--allow-file-access", "--auto-connect", @@ -569,6 +619,7 @@ pub fn clean_args(args: &[String]) -> Vec { "--tab-group-plugin-id", "--risk-mode", "--wait-until", + "--parallel", "--config", ]; @@ -770,6 +821,34 @@ mod tests { assert_eq!(flags.session_name.as_deref(), Some("default")); } + #[test] + fn test_parallel_sets_isolated_runtime_session() { + let flags = parse_flags(&args("--parallel worker_a snapshot")); + assert_eq!(flags.parallel.as_deref(), Some("worker_a")); + assert_eq!(flags.session, "parallel-worker_a"); + assert_eq!(flags.session_name, None); + } + + #[test] + fn test_parallel_keeps_explicit_session_name() { + let flags = parse_flags(&args( + "--parallel worker_b --session-name keep-state snapshot", + )); + assert_eq!(flags.session, "parallel-worker_b"); + assert_eq!(flags.session_name.as_deref(), Some("keep-state")); + assert!(flags.cli_session_name); + } + + #[test] + fn test_parallel_from_env_sets_runtime_session() { + let _guard = EnvGuard::new(&["AGENT_BROWSER_PARALLEL", "AGENT_BROWSER_SESSION_NAME"]); + env::set_var("AGENT_BROWSER_PARALLEL", "envworker"); + env::set_var("AGENT_BROWSER_SESSION_NAME", "persisted"); + let flags = parse_flags(&args("snapshot")); + assert_eq!(flags.session, "parallel-envworker"); + assert_eq!(flags.session_name, None); + } + #[test] fn test_cli_executable_path_tracking() { // When --executable-path is passed via CLI, cli_executable_path should be true @@ -805,6 +884,20 @@ mod tests { assert!(!flags.cli_annotate); } + #[test] + fn test_parse_resident_flag() { + let flags = parse_flags(&args("--resident open example.com")); + assert!(flags.resident); + assert!(flags.cli_resident); + } + + #[test] + fn test_parse_resident_false() { + let flags = parse_flags(&args("--resident false open example.com")); + assert!(!flags.resident); + assert!(flags.cli_resident); + } + #[test] fn test_cli_download_path_tracking() { let flags = parse_flags(&args("--download-path /tmp/dl snapshot")); @@ -940,6 +1033,18 @@ mod tests { assert_eq!(cleaned, vec!["open", "example.com"]); } + #[test] + fn test_clean_args_removes_parallel() { + let cleaned = clean_args(&args("--parallel worker_x open example.com")); + assert_eq!(cleaned, vec!["open", "example.com"]); + } + + #[test] + fn test_clean_args_removes_resident_flag() { + let cleaned = clean_args(&args("--resident open example.com")); + assert_eq!(cleaned, vec!["open", "example.com"]); + } + #[test] fn test_cli_multiple_flags_tracking() { let flags = parse_flags(&args( @@ -978,7 +1083,8 @@ mod tests { "headers": "{\"Auth\":\"token\"}", "tabGroup": "Agent Browser Stealth", "tabGroupPluginId": "tab-group-plugin-id", - "riskMode": "block" + "riskMode": "block", + "parallel": "worker-c" }"#; let config: Config = serde_json::from_str(json).unwrap(); assert_eq!(config.headed, Some(true)); @@ -1010,6 +1116,7 @@ mod tests { Some("tab-group-plugin-id") ); assert_eq!(config.risk_mode.as_deref(), Some("block")); + assert_eq!(config.parallel.as_deref(), Some("worker-c")); } #[test] diff --git a/cli/src/main.rs b/cli/src/main.rs index 12b3a75..0087bed 100644 --- a/cli/src/main.rs +++ b/cli/src/main.rs @@ -174,6 +174,24 @@ fn main() { } } + if let Some(ref parallel) = flags.parallel { + if !validation::is_valid_session_name(parallel) { + let msg = format!( + "Invalid --parallel value '{}'. Only alphanumeric characters, hyphens, and underscores are allowed.", + parallel + ); + if flags.json { + println!( + r#"{{"success":false,"error":"{}","type":"invalid_parallel_name"}}"#, + msg.replace('"', "\\\"") + ); + } else { + eprintln!("{} {}", color::error_indicator(), msg); + } + exit(1); + } + } + if args.iter().any(|a| a == "--profile") { let msg = "Project policy: --profile is forbidden. Use your existing browser and --session-name for state persistence."; @@ -275,6 +293,7 @@ fn main() { let daemon_result = match ensure_daemon( &flags.session, flags.headed, + flags.resident, flags.executable_path.as_deref(), &flags.extensions, flags.args.as_deref(), @@ -346,6 +365,7 @@ fn main() { flags .cli_tab_group_plugin_id .then_some("--tab-group-plugin-id"), + flags.cli_resident.then_some("--resident"), ] .into_iter() .flatten() diff --git a/cli/src/native/daemon.rs b/cli/src/native/daemon.rs index 464be34..87b2c40 100644 --- a/cli/src/native/daemon.rs +++ b/cli/src/native/daemon.rs @@ -3,14 +3,20 @@ use std::env; use std::fs; use std::path::PathBuf; use std::process; +use std::sync::atomic::{AtomicUsize, Ordering}; use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader}; use tokio::signal; +use tokio::sync::mpsc::{unbounded_channel, UnboundedSender}; +use tokio::time::{Duration, Instant}; use super::actions::{execute_command, DaemonState}; use super::state; +const IDLE_SHUTDOWN_SECS: u64 = 600; + pub async fn run_daemon(session: &str) { + let resident_mode = env::args().any(|arg| arg == "--resident"); let socket_dir = get_daemon_socket_dir(); if !socket_dir.exists() { let _ = fs::create_dir_all(&socket_dir); @@ -33,7 +39,7 @@ pub async fn run_daemon(session: &str) { } } - let result = run_socket_server(&socket_path, session).await; + let result = run_socket_server(&socket_path, session, resident_mode).await; let _ = fs::remove_file(&socket_path); let _ = fs::remove_file(&pid_path); @@ -47,7 +53,11 @@ pub async fn run_daemon(session: &str) { } #[cfg(unix)] -async fn run_socket_server(socket_path: &PathBuf, _session: &str) -> Result<(), String> { +async fn run_socket_server( + socket_path: &PathBuf, + _session: &str, + resident_mode: bool, +) -> Result<(), String> { use tokio::net::UnixListener; let listener = @@ -55,6 +65,9 @@ async fn run_socket_server(socket_path: &PathBuf, _session: &str) -> Result<(), let state: std::sync::Arc> = std::sync::Arc::new(tokio::sync::Mutex::new(DaemonState::new())); + let active_commands = std::sync::Arc::new(AtomicUsize::new(0)); + let (activity_tx, mut activity_rx) = unbounded_channel::<()>(); + let mut idle_deadline = Instant::now() + Duration::from_secs(IDLE_SHUTDOWN_SECS); loop { tokio::select! { @@ -62,8 +75,10 @@ async fn run_socket_server(socket_path: &PathBuf, _session: &str) -> Result<(), match accept_result { Ok((stream, _)) => { let state = state.clone(); + let activity_tx = activity_tx.clone(); + let active_commands = active_commands.clone(); tokio::spawn(async move { - handle_connection(stream, state).await; + handle_connection(stream, state, activity_tx, active_commands).await; }); } Err(e) => { @@ -71,6 +86,19 @@ async fn run_socket_server(socket_path: &PathBuf, _session: &str) -> Result<(), } } } + Some(_) = activity_rx.recv() => { + idle_deadline = Instant::now() + Duration::from_secs(IDLE_SHUTDOWN_SECS); + } + _ = tokio::time::sleep_until(idle_deadline), if !resident_mode => { + if active_commands.load(Ordering::SeqCst) == 0 { + let mut s = state.lock().await; + if let Some(ref mut mgr) = s.browser { + let _ = mgr.close().await; + } + break; + } + idle_deadline = Instant::now() + Duration::from_secs(IDLE_SHUTDOWN_SECS); + } _ = shutdown_signal() => { let mut s = state.lock().await; if let Some(ref mut mgr) = s.browser { @@ -85,7 +113,11 @@ async fn run_socket_server(socket_path: &PathBuf, _session: &str) -> Result<(), } #[cfg(windows)] -async fn run_socket_server(socket_path: &PathBuf, session: &str) -> Result<(), String> { +async fn run_socket_server( + socket_path: &PathBuf, + session: &str, + resident_mode: bool, +) -> Result<(), String> { use tokio::net::TcpListener; let port = get_port_for_session(session); @@ -99,6 +131,9 @@ async fn run_socket_server(socket_path: &PathBuf, session: &str) -> Result<(), S let state: std::sync::Arc> = std::sync::Arc::new(tokio::sync::Mutex::new(DaemonState::new())); + let active_commands = std::sync::Arc::new(AtomicUsize::new(0)); + let (activity_tx, mut activity_rx) = unbounded_channel::<()>(); + let mut idle_deadline = Instant::now() + Duration::from_secs(IDLE_SHUTDOWN_SECS); loop { tokio::select! { @@ -106,8 +141,10 @@ async fn run_socket_server(socket_path: &PathBuf, session: &str) -> Result<(), S match accept_result { Ok((stream, _)) => { let state = state.clone(); + let activity_tx = activity_tx.clone(); + let active_commands = active_commands.clone(); tokio::spawn(async move { - handle_connection(stream, state).await; + handle_connection(stream, state, activity_tx, active_commands).await; }); } Err(e) => { @@ -115,6 +152,20 @@ async fn run_socket_server(socket_path: &PathBuf, session: &str) -> Result<(), S } } } + Some(_) = activity_rx.recv() => { + idle_deadline = Instant::now() + Duration::from_secs(IDLE_SHUTDOWN_SECS); + } + _ = tokio::time::sleep_until(idle_deadline), if !resident_mode => { + if active_commands.load(Ordering::SeqCst) == 0 { + let mut s = state.lock().await; + if let Some(ref mut mgr) = s.browser { + let _ = mgr.close().await; + } + let _ = fs::remove_file(&port_path); + break; + } + idle_deadline = Instant::now() + Duration::from_secs(IDLE_SHUTDOWN_SECS); + } _ = shutdown_signal() => { let mut s = state.lock().await; if let Some(ref mut mgr) = s.browser { @@ -129,7 +180,12 @@ async fn run_socket_server(socket_path: &PathBuf, session: &str) -> Result<(), S Ok(()) } -async fn handle_connection(stream: S, state: std::sync::Arc>) +async fn handle_connection( + stream: S, + state: std::sync::Arc>, + activity_tx: UnboundedSender<()>, + active_commands: std::sync::Arc, +) where S: tokio::io::AsyncRead + tokio::io::AsyncWrite + Unpin, { @@ -166,6 +222,8 @@ where }; let is_close = cmd.get("action").and_then(|v| v.as_str()) == Some("close"); + let _ = activity_tx.send(()); + active_commands.fetch_add(1, Ordering::SeqCst); let response = { let mut s = state.lock().await; @@ -175,8 +233,11 @@ where let mut resp = serde_json::to_string(&response).unwrap_or_default(); resp.push('\n'); if writer.write_all(resp.as_bytes()).await.is_err() { + active_commands.fetch_sub(1, Ordering::SeqCst); break; } + active_commands.fetch_sub(1, Ordering::SeqCst); + let _ = activity_tx.send(()); if is_close { tokio::time::sleep(tokio::time::Duration::from_millis(100)).await; diff --git a/cli/src/output.rs b/cli/src/output.rs index ae924f0..448f32f 100644 --- a/cli/src/output.rs +++ b/cli/src/output.rs @@ -2076,9 +2076,11 @@ Operations: Automatic State Persistence: Use --session-name to auto-save/restore state across restarts. - If omitted, it defaults to "default": + If omitted in default runtime mode, it defaults to "default": agent-browser --session-name myapp open https://example.com Or set AGENT_BROWSER_SESSION_NAME environment variable. + Note: with --parallel , persistence is disabled by default unless + --session-name is explicitly passed on the same command. State Encryption: Set AGENT_BROWSER_ENCRYPTION_KEY (64-char hex) for AES-256-GCM encryption. @@ -2105,7 +2107,7 @@ agent-browser session - Manage sessions Usage: agent-browser session [operation] -Show the current fixed session and active daemon state. +Show the current runtime session and active daemon state. Operations: (none) Show current session name @@ -2437,7 +2439,7 @@ Snapshot Options: -s, --selector Scope to CSS selector Options: - --session Ignored (single default session only) + --session Ignored (runtime uses default session unless --parallel is set) --state Load storage state from JSON file (or AGENT_BROWSER_STATE env) --headers HTTP headers scoped to URL's origin (for auth) --executable-path Custom browser executable (or AGENT_BROWSER_EXECUTABLE_PATH) @@ -2467,7 +2469,10 @@ Options: Extension side panel supports browser controls + console/network/DOM + workflow scheduling --risk-mode Verify/captcha handling: off, warn, block (or AGENT_BROWSER_RISK_MODE) --wait-until Navigation wait strategy for open/navigate: load, domcontentloaded, networkidle - --session-name Auto-save/restore session state (defaults to "default") + --parallel Isolated runtime channel for parallel AI runs (maps to parallel-) + Default behavior in this mode is stateless (no auto session persistence unless --session-name is explicitly passed) + --resident Keep daemon running; disable 10-minute idle auto-shutdown + --session-name Auto-save/restore session state (defaults to "default" in non-parallel mode) --content-boundaries Wrap page output in boundary markers (or AGENT_BROWSER_CONTENT_BOUNDARIES) --max-output Truncate page output to N chars (or AGENT_BROWSER_MAX_OUTPUT) --allowed-domains Restrict navigation domains (or AGENT_BROWSER_ALLOWED_DOMAINS) @@ -2482,6 +2487,7 @@ Options: Policy: --profile / AGENT_BROWSER_PROFILE are forbidden --channel / AGENT_BROWSER_CHANNEL are forbidden + Daemon auto-shuts down after 10 minutes of inactivity unless --resident is set Auto-attach existing browser (prefer CDP localhost:9333, then auto-discovery), or pass --cdp explicitly Configuration: @@ -2497,6 +2503,7 @@ Configuration: Boolean flags accept an optional true/false value to override config: --headed (same as --headed true) --headed false (disables "headed": true from config) + --resident false (disable resident mode for this invocation) Extensions from user and project configs are merged (not replaced). @@ -2505,7 +2512,8 @@ Configuration: Environment: AGENT_BROWSER_CONFIG Path to config file (or use --config) - AGENT_BROWSER_SESSION_NAME Auto-save/restore state persistence name (default: "default") + AGENT_BROWSER_PARALLEL Isolated runtime channel for parallel AI runs (maps to parallel-) + Best for stateless/no-login tasks where throughput matters AGENT_BROWSER_ENCRYPTION_KEY 64-char hex key for AES-256-GCM state encryption AGENT_BROWSER_STATE_EXPIRE_DAYS Auto-delete states older than N days (default: 30) AGENT_BROWSER_EXECUTABLE_PATH Custom browser executable path @@ -2528,7 +2536,7 @@ Environment: AGENT_BROWSER_TAB_GROUP_PLUGIN_ID Expected Chrome extension ID for tab-group handshake (default: "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa") AGENT_BROWSER_RISK_MODE Verify/captcha handling mode (off, warn, block) AGENT_BROWSER_DEFAULT_TIMEOUT Default Playwright timeout in ms (default: 25000) - AGENT_BROWSER_SESSION_NAME Auto-save/load state persistence name + AGENT_BROWSER_SESSION_NAME Auto-save/load state persistence name (default: "default" when --parallel is not set) AGENT_BROWSER_STATE_EXPIRE_DAYS Auto-delete saved states older than N days (default: 30) AGENT_BROWSER_ENCRYPTION_KEY 64-char hex key for AES-256-GCM session encryption AGENT_BROWSER_STREAM_PORT Enable WebSocket streaming on port (e.g., 9223) @@ -2564,6 +2572,8 @@ Examples: agent-browser --color-scheme dark open example.com # Dark mode agent-browser --risk-mode block open example.com # Block on verification/captcha pages agent-browser --session-name myapp open example.com # Auto-save/restore state + agent-browser --parallel worker-a open example.com # Isolated runtime for parallel AI task + agent-browser --resident open example.com # Keep daemon resident until explicit close Command Chaining: Chain commands with && in a single shell call (browser persists via daemon): diff --git a/docs/src/app/commands/page.mdx b/docs/src/app/commands/page.mdx index d804f19..11091a9 100644 --- a/docs/src/app/commands/page.mdx +++ b/docs/src/app/commands/page.mdx @@ -280,6 +280,18 @@ agent-browser state clean --older-than # Delete old states ```bash agent-browser session # Show current session name agent-browser session list # List active sessions +agent-browser --parallel worker-a open https://example.com # Isolated runtime for parallel AI tasks +``` + +## Daemon lifetime + +By default, daemon processes auto-shutdown after 10 minutes of inactivity. + +Use `--resident` when you need a long-running daemon: + +```bash +agent-browser --resident open https://example.com +agent-browser close ``` ## Navigation @@ -293,7 +305,7 @@ agent-browser reload # Reload page ## Global options ```bash ---session-name # Auto-save/restore session state (defaults to "default" when omitted) +--session-name # Auto-save/restore session state (defaults to "default" in non-parallel mode) --state # Load storage state from JSON file --headers # HTTP headers scoped to URL's origin --executable-path # Custom browser executable @@ -315,6 +327,8 @@ agent-browser reload # Reload page --auto-connect # Auto-discover and connect to running Chrome --tab-group # Base title for agent tab groups (CDP plugin mode) --tab-group-plugin-id # Expected extension ID for tab-group handshake +--parallel # Isolated runtime channel for parallel AI runs (maps to parallel-) +--resident # Keep daemon running; disable 10-minute idle auto-shutdown --wait-until # Navigation wait strategy for open/navigate (load, domcontentloaded, networkidle) --debug # Debug output (includes stealth connection type + capabilities) ``` diff --git a/docs/src/app/configuration/page.mdx b/docs/src/app/configuration/page.mdx index 725adef..07a65b3 100644 --- a/docs/src/app/configuration/page.mdx +++ b/docs/src/app/configuration/page.mdx @@ -72,7 +72,7 @@ AGENT_BROWSER_CONFIG=./ci-config.json agent-browser open example.com ## All Options -Every CLI flag can be set in the config file using its camelCase equivalent: +Most CLI flags can be set in the config file using their camelCase equivalents (`--resident` is CLI-only): @@ -128,6 +128,15 @@ Every CLI flag can be set in the config file using its camelCase equivalent: + + + + + + + + + + + + + +
string
+ parallel + + --parallel + string (isolated runtime channel name)
executablePath @@ -353,6 +362,25 @@ session window isolation controls, activation guard toggles, empty-group cleanup } ``` +### Parallel Stateless Worker + +```json +{ + "parallel": "worker-a" +} +``` + +Use this for stateless throughput tasks. For authenticated flows, prefer a stable `sessionName`. + +## CLI-only daemon lifecycle flag + +`--resident` is a CLI-only flag (not a config/env key). It keeps the daemon alive and disables the default 10-minute idle auto-shutdown. + +```bash +agent-browser --resident open example.com +agent-browser close +``` + ## Overriding Boolean Options Boolean flags accept an optional `true`/`false` value to override config settings: @@ -368,7 +396,7 @@ agent-browser --headed open example.com # same as --headed true agent-browser --headed true open example.com # explicit ``` -This applies to all boolean flags: `--headed`, `--debug`, `--json`, `--ignore-https-errors`, `--allow-file-access`, `--auto-connect`. +This applies to all boolean flags: `--headed`, `--debug`, `--json`, `--ignore-https-errors`, `--allow-file-access`, `--auto-connect`, `--resident`. ## Extensions Merging @@ -471,6 +499,15 @@ These environment variables configure additional daemon and runtime behavior: default
+ AGENT_BROWSER_PARALLEL + + Isolated runtime channel name for parallel AI runs (maps to parallel-<name>). + (none)
AGENT_BROWSER_STATE_EXPIRE_DAYS diff --git a/docs/src/app/sessions/page.mdx b/docs/src/app/sessions/page.mdx index f04b202..bd3f166 100644 --- a/docs/src/app/sessions/page.mdx +++ b/docs/src/app/sessions/page.mdx @@ -4,25 +4,49 @@ export const metadata = pageMetadata('sessions'); # Sessions -Use one default runtime session and optional named persistence: +Use the default runtime session or an isolated parallel runtime channel, plus optional named persistence: ```bash # Show current runtime session agent-browser session # Output: default +# Isolated runtime channel for parallel AI flow +agent-browser --parallel worker-a session +# Output: parallel-worker-a + # Show active daemon sessions agent-browser session list ``` ## Session isolation -The runtime session is fixed to `default`. Use `--session-name` to isolate persisted state files per workflow. +Runtime session defaults to `default`. Use `--parallel ` when you need isolated concurrent runtime channels, and `--session-name` to isolate persisted state files per workflow. - Cookies and storage snapshots - Authentication state - Saved state lifecycle +Daemons auto-shutdown after 10 minutes of inactivity by default. Use `--resident` to keep a daemon alive until explicit `close`. + +## Parallel runtime channels + +Use `--parallel ` to isolate runtime channels for concurrent AI execution: + +```bash +agent-browser --parallel worker-a open https://example.com +agent-browser --parallel worker-b open https://example.org +``` + +`--parallel` is intended for stateless throughput tasks (navigation/extraction/checks). For authenticated flows, use a stable `--session-name`. + +For long-running workers, add `--resident` to disable idle auto-shutdown: + +```bash +agent-browser --parallel worker-a --resident open https://example.com +agent-browser --parallel worker-a close +``` + ## Session persistence Use `--session-name` to automatically save and restore cookies and localStorage across browser restarts: @@ -41,6 +65,8 @@ agent-browser open twitter.com If `--session-name` is omitted, it defaults to `default`. +When `--parallel` is enabled, auto persistence is disabled by default unless `--session-name` is explicitly passed on that command. + State files are stored in `~/.agent-browser/sessions/` and automatically loaded on daemon start. ### Session name rules @@ -165,6 +191,12 @@ agent-browser set headers '{"X-Custom-Header": "value"}' Auto-save/load state persistence name
+ AGENT_BROWSER_PARALLEL + Isolated runtime channel name for parallel AI runs (maps to parallel-<name>)
AGENT_BROWSER_ENCRYPTION_KEY diff --git a/package.json b/package.json index efa9990..d9abdfd 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "agent-browser-stealth", - "version": "0.16.3-fork.2", + "version": "0.16.3-fork.3", "description": "Stealth browser automation CLI for AI agents with anti-bot evasions", "type": "module", "main": "dist/daemon.js", diff --git a/skills/agent-browser/SKILL.md b/skills/agent-browser/SKILL.md index daa3093..4851ee7 100644 --- a/skills/agent-browser/SKILL.md +++ b/skills/agent-browser/SKILL.md @@ -208,13 +208,15 @@ agent-browser get text @e1 --json ### Parallel Workflows ```bash -agent-browser --session-name site1 open https://site-a.com -agent-browser --session-name site2 open https://site-b.com +agent-browser --parallel site1 open https://site-a.com +agent-browser --parallel site2 open https://site-b.com -agent-browser --session-name site1 snapshot -i -agent-browser --session-name site2 snapshot -i +agent-browser --parallel site1 snapshot -i +agent-browser --parallel site2 snapshot -i ``` +Use `--parallel ` for stateless throughput tasks (navigation, extraction, checks). For login/auth continuity, use `--session-name` instead. + ### Connect to Existing Chrome By default in this fork, commands without `--cdp` auto-attach to your existing browser with this order: @@ -244,6 +246,18 @@ agent-browser --wait-until domcontentloaded open https://example.com pnpm run check:turnstile-testkey ``` +### Daemon Lifecycle + +Daemons auto-shutdown after 10 minutes of inactivity. + +Use `--resident` for long-running workflows that should not auto-close: + +```bash +agent-browser --resident open https://example.com +# keep running until explicit close +agent-browser close +``` + ### Color Scheme (Dark Mode) ```bash @@ -489,7 +503,7 @@ These behaviors are always active. For sensitive sites, combine with `--headed` ## Session Management and Cleanup -`--session` is ignored in this fork. The runtime always uses one default session. Use `--session-name` to isolate persistence when needed. +`--session` is ignored in this fork. Runtime defaults to `default`; use `--parallel ` for isolated concurrent channels, and `--session-name` for persistence isolation. Always close your browser session when done to avoid leaked processes: diff --git a/src/daemon.test.ts b/src/daemon.test.ts index bdafc0b..29d0b21 100644 --- a/src/daemon.test.ts +++ b/src/daemon.test.ts @@ -3,7 +3,7 @@ import * as os from 'os'; import * as path from 'path'; import * as net from 'net'; import { EventEmitter } from 'events'; -import { getSocketDir, safeWrite } from './daemon.js'; +import { createSerializedExecutor, getSocketDir, safeWrite } from './daemon.js'; /** * HTTP request detection pattern used in daemon.ts to prevent cross-origin attacks. @@ -159,3 +159,45 @@ describe('safeWrite', () => { expect(socket.listenerCount('close')).toBe(0); }); }); + +describe('createSerializedExecutor', () => { + it('should execute tasks one-by-one even when started concurrently', async () => { + const runSerialized = createSerializedExecutor(); + const order: string[] = []; + + const slow = runSerialized(async () => { + order.push('slow-start'); + await new Promise((resolve) => setTimeout(resolve, 30)); + order.push('slow-end'); + return 'slow'; + }); + + const fast = runSerialized(async () => { + order.push('fast-start'); + order.push('fast-end'); + return 'fast'; + }); + + await expect(Promise.all([slow, fast])).resolves.toEqual(['slow', 'fast']); + expect(order).toEqual(['slow-start', 'slow-end', 'fast-start', 'fast-end']); + }); + + it('should continue running queued tasks after a task fails', async () => { + const runSerialized = createSerializedExecutor(); + const order: string[] = []; + + const first = runSerialized(async () => { + order.push('first'); + throw new Error('boom'); + }); + + const second = runSerialized(async () => { + order.push('second'); + return 'ok'; + }); + + await expect(first).rejects.toThrow('boom'); + await expect(second).resolves.toBe('ok'); + expect(order).toEqual(['first', 'second']); + }); +}); diff --git a/src/daemon.ts b/src/daemon.ts index 0de0217..e24a2f5 100644 --- a/src/daemon.ts +++ b/src/daemon.ts @@ -62,6 +62,23 @@ export function safeWrite(socket: net.Socket, payload: string): Promise { }); } +/** + * Create an async executor that runs tasks strictly one-by-one. + * Used to serialize daemon commands across all client connections. + */ +export function createSerializedExecutor(): (task: () => Promise) => Promise { + let tail: Promise = Promise.resolve(); + + return async function runSerialized(task: () => Promise): Promise { + const run = tail.then(task, task); + tail = run.then( + () => undefined, + () => undefined + ); + return run; + }; +} + // Platform detection const isWindows = process.platform === 'win32'; @@ -73,6 +90,8 @@ let streamServer: StreamServer | null = null; // Default stream port (can be overridden with AGENT_BROWSER_STREAM_PORT) const DEFAULT_STREAM_PORT = 9223; +// Default idle auto-shutdown timeout: 10 minutes +const DEFAULT_IDLE_SHUTDOWN_MS = 10 * 60 * 1000; /** * Save state to file with optional encryption. @@ -325,6 +344,7 @@ export function getStreamPortFile(session?: string): string { export async function startDaemon(options?: { streamPort?: number; provider?: string; + resident?: boolean; }): Promise { // Ensure socket directory exists with restricted permissions (owner-only access) const socketDir = getSocketDir(); @@ -345,6 +365,10 @@ export async function startDaemon(options?: { // Create appropriate manager const manager: Manager = isIOS ? new IOSManager() : new BrowserManager(); let shuttingDown = false; + const runSerialized = createSerializedExecutor(); + const residentMode = options?.resident ?? process.argv.includes('--resident'); + let idleTimer: NodeJS.Timeout | null = null; + let pendingCommands = 0; // Start stream server if port is specified (or use default if env var is set) // Note: Stream server only works with BrowserManager (desktop), not iOS @@ -363,6 +387,21 @@ export async function startDaemon(options?: { fs.writeFileSync(streamPortFile, streamPort.toString()); } + const cancelIdleTimer = (): void => { + if (idleTimer) { + clearTimeout(idleTimer); + idleTimer = null; + } + }; + + const scheduleIdleShutdown = (): void => { + if (residentMode || shuttingDown || pendingCommands > 0) return; + cancelIdleTimer(); + idleTimer = setTimeout(() => { + void shutdown('idle timeout'); + }, DEFAULT_IDLE_SHUTDOWN_MS); + }; + const server = net.createServer((socket) => { let buffer = ''; let httpChecked = false; @@ -379,311 +418,326 @@ export async function startDaemon(options?: { while (commandQueue.length > 0) { const line = commandQueue.shift()!; + pendingCommands += 1; + cancelIdleTimer(); try { - const parseResult = parseCommand(line); - - if (!parseResult.success) { - const resp = errorResponse(parseResult.id ?? 'unknown', parseResult.error); - await safeWrite(socket, serializeResponse(resp) + '\n'); - continue; - } - - // Handle device_list specially - it works without a session and always uses IOSManager - if (parseResult.command.action === 'device_list') { - const iosManager = new IOSManager(); + await runSerialized(async () => { try { - const devices = await iosManager.listAllDevices(); - const response = { - id: parseResult.command.id, - success: true as const, - data: { devices }, - }; + const parseResult = parseCommand(line); + + if (!parseResult.success) { + const resp = errorResponse(parseResult.id ?? 'unknown', parseResult.error); + await safeWrite(socket, serializeResponse(resp) + '\n'); + return; + } + + // Handle device_list specially - it works without a session and always uses IOSManager + if (parseResult.command.action === 'device_list') { + const iosManager = new IOSManager(); + try { + const devices = await iosManager.listAllDevices(); + const response = { + id: parseResult.command.id, + success: true as const, + data: { devices }, + }; + await safeWrite(socket, serializeResponse(response) + '\n'); + } catch (err) { + const message = err instanceof Error ? err.message : String(err); + await safeWrite( + socket, + serializeResponse(errorResponse(parseResult.command.id, message)) + '\n' + ); + } + return; + } + + // Auto-launch if not already launched and this isn't a launch/close/state_load command. + // Default behavior for this fork: attach to an existing browser only. + const isDoctor = parseResult.command.action === 'doctor'; + if ( + !manager.isLaunched() && + parseResult.command.action !== 'launch' && + parseResult.command.action !== 'close' && + parseResult.command.action !== 'state_load' && + parseResult.command.action !== 'doctor' + ) { + if (isIOS && manager instanceof IOSManager) { + // Auto-launch iOS Safari + // Check for device in command first (for reused daemons), then fall back to env vars + const cmd = parseResult.command as { iosDevice?: string }; + const iosDevice = cmd.iosDevice || process.env.AGENT_BROWSER_IOS_DEVICE; + await manager.launch({ + device: iosDevice, + udid: process.env.AGENT_BROWSER_IOS_UDID, + }); + } else if (manager instanceof BrowserManager) { + // Auto-launch desktop browser + const extensions = process.env.AGENT_BROWSER_EXTENSIONS + ? process.env.AGENT_BROWSER_EXTENSIONS.split(/[,\n]/) + .map((p) => p.trim()) + .filter(Boolean) + : undefined; + + // Parse args from env (comma or newline separated) + const argsEnv = process.env.AGENT_BROWSER_ARGS; + const args = argsEnv + ? argsEnv + .split(/[,\n]/) + .map((a) => a.trim()) + .filter((a) => a.length > 0) + : undefined; + + // Parse proxy from env + const proxyServer = process.env.AGENT_BROWSER_PROXY; + const proxyBypass = process.env.AGENT_BROWSER_PROXY_BYPASS; + const proxy = proxyServer + ? { + server: proxyServer, + ...(proxyBypass && { bypass: proxyBypass }), + } + : undefined; + + const ignoreHTTPSErrors = process.env.AGENT_BROWSER_IGNORE_HTTPS_ERRORS === '1'; + const allowFileAccess = process.env.AGENT_BROWSER_ALLOW_FILE_ACCESS === '1'; + // Stealth is always enabled in agent-browser-stealth + const colorSchemeEnv = process.env.AGENT_BROWSER_COLOR_SCHEME; + const colorScheme: 'dark' | 'light' | 'no-preference' | undefined = + colorSchemeEnv === 'dark' || + colorSchemeEnv === 'light' || + colorSchemeEnv === 'no-preference' + ? colorSchemeEnv + : undefined; + const tabGroup = process.env.AGENT_BROWSER_TAB_GROUP?.trim(); + const tabGroupPluginId = process.env.AGENT_BROWSER_TAB_GROUP_PLUGIN_ID?.trim(); + const launchOptions = { + id: 'auto', + action: 'launch' as const, + headless: + process.env.AGENT_BROWSER_HEADED !== '1' && + process.env.AGENT_BROWSER_HEADED !== 'true', + executablePath: process.env.AGENT_BROWSER_EXECUTABLE_PATH, + extensions: extensions, + storageState: process.env.AGENT_BROWSER_STATE, + args, + userAgent: process.env.AGENT_BROWSER_USER_AGENT, + proxy, + ignoreHTTPSErrors: ignoreHTTPSErrors, + allowFileAccess: allowFileAccess, + + colorScheme, + tabGroup: tabGroup && tabGroup.length > 0 ? tabGroup : undefined, + tabGroupPluginId: + tabGroupPluginId && tabGroupPluginId.length > 0 + ? tabGroupPluginId + : undefined, + autoStateFilePath: getSessionAutoStatePath(), + }; + + let attachedToExistingBrowser = false; + try { + // Keep default CDP attempt minimal. Launch-only options like extensions + // are incompatible with CDP and can cause false-negative attach failures. + const cdpLaunchOptions = { + id: launchOptions.id, + action: launchOptions.action, + cdpPort: 9333, + ignoreHTTPSErrors: launchOptions.ignoreHTTPSErrors, + colorScheme: launchOptions.colorScheme, + userAgent: launchOptions.userAgent, + tabGroup: launchOptions.tabGroup, + tabGroupPluginId: launchOptions.tabGroupPluginId, + }; + await manager.launch({ + ...cdpLaunchOptions, + }); + attachedToExistingBrowser = true; + if (process.env.AGENT_BROWSER_DEBUG === '1') { + console.error('[DEBUG] Auto-launch connected via default CDP port 9333'); + } + } catch (error) { + if (process.env.AGENT_BROWSER_DEBUG === '1') { + const message = error instanceof Error ? error.message : String(error); + console.error( + `[DEBUG] Default CDP port 9333 unavailable, trying auto-connect discovery: ${message}` + ); + } + } + + if (!attachedToExistingBrowser) { + try { + await manager.launch({ + id: launchOptions.id, + action: launchOptions.action, + autoConnect: true, + ignoreHTTPSErrors: launchOptions.ignoreHTTPSErrors, + colorScheme: launchOptions.colorScheme, + userAgent: launchOptions.userAgent, + tabGroup: launchOptions.tabGroup, + tabGroupPluginId: launchOptions.tabGroupPluginId, + }); + attachedToExistingBrowser = true; + if (process.env.AGENT_BROWSER_DEBUG === '1') { + console.error('[DEBUG] Auto-launch connected via auto-connect discovery'); + } + } catch (error) { + if (process.env.AGENT_BROWSER_DEBUG === '1') { + const message = error instanceof Error ? error.message : String(error); + console.error(`[DEBUG] Auto-connect discovery failed: ${message}`); + } + } + } + + if (!attachedToExistingBrowser) { + throw new Error( + 'Project policy requires using your existing browser. Could not connect to CDP at localhost:9333 and auto-discovery also failed.' + ); + } + } + } + + // For doctor, attempt the same default attach flow but do not fail hard if attach is unavailable. + // This keeps diagnostics actionable even when CDP is down. + if (!manager.isLaunched() && isDoctor && manager instanceof BrowserManager) { + try { + await manager.launch({ + id: 'doctor-cdp', + action: 'launch', + cdpPort: 9333, + ignoreHTTPSErrors: process.env.AGENT_BROWSER_IGNORE_HTTPS_ERRORS === '1', + userAgent: process.env.AGENT_BROWSER_USER_AGENT, + colorScheme: + process.env.AGENT_BROWSER_COLOR_SCHEME === 'dark' || + process.env.AGENT_BROWSER_COLOR_SCHEME === 'light' || + process.env.AGENT_BROWSER_COLOR_SCHEME === 'no-preference' + ? (process.env.AGENT_BROWSER_COLOR_SCHEME as + | 'dark' + | 'light' + | 'no-preference') + : undefined, + tabGroup: process.env.AGENT_BROWSER_TAB_GROUP?.trim() || undefined, + tabGroupPluginId: + process.env.AGENT_BROWSER_TAB_GROUP_PLUGIN_ID?.trim() || undefined, + }); + } catch { + try { + await manager.launch({ + id: 'doctor-auto-connect', + action: 'launch', + autoConnect: true, + ignoreHTTPSErrors: process.env.AGENT_BROWSER_IGNORE_HTTPS_ERRORS === '1', + userAgent: process.env.AGENT_BROWSER_USER_AGENT, + colorScheme: + process.env.AGENT_BROWSER_COLOR_SCHEME === 'dark' || + process.env.AGENT_BROWSER_COLOR_SCHEME === 'light' || + process.env.AGENT_BROWSER_COLOR_SCHEME === 'no-preference' + ? (process.env.AGENT_BROWSER_COLOR_SCHEME as + | 'dark' + | 'light' + | 'no-preference') + : undefined, + tabGroup: process.env.AGENT_BROWSER_TAB_GROUP?.trim() || undefined, + tabGroupPluginId: + process.env.AGENT_BROWSER_TAB_GROUP_PLUGIN_ID?.trim() || undefined, + }); + } catch { + // Keep running: doctor should report failures instead of exiting early. + } + } + } + + // Recover from stale state: browser is launched but all pages were closed + if ( + manager instanceof BrowserManager && + manager.isLaunched() && + !manager.hasPages() && + parseResult.command.action !== 'launch' && + parseResult.command.action !== 'close' + ) { + await manager.ensurePage(); + } + + // Handle explicit launch with auto-load state + if ( + parseResult.command.action === 'launch' && + manager instanceof BrowserManager && + !parseResult.command.autoStateFilePath + ) { + const autoStatePath = getSessionAutoStatePath(); + if (autoStatePath) { + parseResult.command.autoStateFilePath = autoStatePath; + } + } + + // Handle close command specially - shuts down daemon + if (parseResult.command.action === 'close') { + // Auto-save state before closing + if (manager instanceof BrowserManager && manager.isLaunched()) { + const savePath = getSessionSaveStatePath(); + if (savePath) { + try { + const { encrypted } = await saveStateToFile(manager, savePath); + fs.chmodSync(savePath, 0o600); + if (process.env.AGENT_BROWSER_DEBUG === '1') { + console.error( + `Auto-saved session state: ${savePath}${encrypted ? ' (encrypted)' : ''}` + ); + } + } catch (err) { + if (process.env.AGENT_BROWSER_DEBUG === '1') { + console.error(`Failed to auto-save session state:`, err); + } + } + } + } + + const response = + isIOS && manager instanceof IOSManager + ? await executeIOSCommand(parseResult.command, manager) + : await executeCommand(parseResult.command, manager as BrowserManager); + await safeWrite(socket, serializeResponse(response) + '\n'); + + if (!shuttingDown) { + shuttingDown = true; + setTimeout(() => { + server.close(); + cleanupSocket(); + process.exit(0); + }, 100); + } + + commandQueue.length = 0; + processing = false; + return; + } + + // Execute command with appropriate handler + const response = + isIOS && manager instanceof IOSManager + ? await executeIOSCommand(parseResult.command, manager) + : await executeCommand(parseResult.command, manager as BrowserManager); + + // Add any launch warnings to the response + if (manager instanceof BrowserManager) { + const warnings = manager.getAndClearWarnings(); + if (warnings.length > 0 && response.success && response.data) { + (response.data as Record).warnings = warnings; + } + } + await safeWrite(socket, serializeResponse(response) + '\n'); } catch (err) { const message = err instanceof Error ? err.message : String(err); await safeWrite( socket, - serializeResponse(errorResponse(parseResult.command.id, message)) + '\n' - ); + serializeResponse(errorResponse('error', message)) + '\n' + ).catch(() => {}); // Socket may already be destroyed } - continue; - } - - // Auto-launch if not already launched and this isn't a launch/close/state_load command. - // Default behavior for this fork: attach to an existing browser only. - const isDoctor = parseResult.command.action === 'doctor'; - if ( - !manager.isLaunched() && - parseResult.command.action !== 'launch' && - parseResult.command.action !== 'close' && - parseResult.command.action !== 'state_load' && - parseResult.command.action !== 'doctor' - ) { - if (isIOS && manager instanceof IOSManager) { - // Auto-launch iOS Safari - // Check for device in command first (for reused daemons), then fall back to env vars - const cmd = parseResult.command as { iosDevice?: string }; - const iosDevice = cmd.iosDevice || process.env.AGENT_BROWSER_IOS_DEVICE; - await manager.launch({ - device: iosDevice, - udid: process.env.AGENT_BROWSER_IOS_UDID, - }); - } else if (manager instanceof BrowserManager) { - // Auto-launch desktop browser - const extensions = process.env.AGENT_BROWSER_EXTENSIONS - ? process.env.AGENT_BROWSER_EXTENSIONS.split(/[,\n]/) - .map((p) => p.trim()) - .filter(Boolean) - : undefined; - - // Parse args from env (comma or newline separated) - const argsEnv = process.env.AGENT_BROWSER_ARGS; - const args = argsEnv - ? argsEnv - .split(/[,\n]/) - .map((a) => a.trim()) - .filter((a) => a.length > 0) - : undefined; - - // Parse proxy from env - const proxyServer = process.env.AGENT_BROWSER_PROXY; - const proxyBypass = process.env.AGENT_BROWSER_PROXY_BYPASS; - const proxy = proxyServer - ? { - server: proxyServer, - ...(proxyBypass && { bypass: proxyBypass }), - } - : undefined; - - const ignoreHTTPSErrors = process.env.AGENT_BROWSER_IGNORE_HTTPS_ERRORS === '1'; - const allowFileAccess = process.env.AGENT_BROWSER_ALLOW_FILE_ACCESS === '1'; - // Stealth is always enabled in agent-browser-stealth - const colorSchemeEnv = process.env.AGENT_BROWSER_COLOR_SCHEME; - const colorScheme: 'dark' | 'light' | 'no-preference' | undefined = - colorSchemeEnv === 'dark' || - colorSchemeEnv === 'light' || - colorSchemeEnv === 'no-preference' - ? colorSchemeEnv - : undefined; - const tabGroup = process.env.AGENT_BROWSER_TAB_GROUP?.trim(); - const tabGroupPluginId = process.env.AGENT_BROWSER_TAB_GROUP_PLUGIN_ID?.trim(); - const launchOptions = { - id: 'auto', - action: 'launch' as const, - headless: - process.env.AGENT_BROWSER_HEADED !== '1' && - process.env.AGENT_BROWSER_HEADED !== 'true', - executablePath: process.env.AGENT_BROWSER_EXECUTABLE_PATH, - extensions: extensions, - storageState: process.env.AGENT_BROWSER_STATE, - args, - userAgent: process.env.AGENT_BROWSER_USER_AGENT, - proxy, - ignoreHTTPSErrors: ignoreHTTPSErrors, - allowFileAccess: allowFileAccess, - - colorScheme, - tabGroup: tabGroup && tabGroup.length > 0 ? tabGroup : undefined, - tabGroupPluginId: - tabGroupPluginId && tabGroupPluginId.length > 0 ? tabGroupPluginId : undefined, - autoStateFilePath: getSessionAutoStatePath(), - }; - - let attachedToExistingBrowser = false; - try { - // Keep default CDP attempt minimal. Launch-only options like extensions - // are incompatible with CDP and can cause false-negative attach failures. - const cdpLaunchOptions = { - id: launchOptions.id, - action: launchOptions.action, - cdpPort: 9333, - ignoreHTTPSErrors: launchOptions.ignoreHTTPSErrors, - colorScheme: launchOptions.colorScheme, - userAgent: launchOptions.userAgent, - tabGroup: launchOptions.tabGroup, - tabGroupPluginId: launchOptions.tabGroupPluginId, - }; - await manager.launch({ - ...cdpLaunchOptions, - }); - attachedToExistingBrowser = true; - if (process.env.AGENT_BROWSER_DEBUG === '1') { - console.error('[DEBUG] Auto-launch connected via default CDP port 9333'); - } - } catch (error) { - if (process.env.AGENT_BROWSER_DEBUG === '1') { - const message = error instanceof Error ? error.message : String(error); - console.error( - `[DEBUG] Default CDP port 9333 unavailable, trying auto-connect discovery: ${message}` - ); - } - } - - if (!attachedToExistingBrowser) { - try { - await manager.launch({ - id: launchOptions.id, - action: launchOptions.action, - autoConnect: true, - ignoreHTTPSErrors: launchOptions.ignoreHTTPSErrors, - colorScheme: launchOptions.colorScheme, - userAgent: launchOptions.userAgent, - tabGroup: launchOptions.tabGroup, - tabGroupPluginId: launchOptions.tabGroupPluginId, - }); - attachedToExistingBrowser = true; - if (process.env.AGENT_BROWSER_DEBUG === '1') { - console.error('[DEBUG] Auto-launch connected via auto-connect discovery'); - } - } catch (error) { - if (process.env.AGENT_BROWSER_DEBUG === '1') { - const message = error instanceof Error ? error.message : String(error); - console.error(`[DEBUG] Auto-connect discovery failed: ${message}`); - } - } - } - - if (!attachedToExistingBrowser) { - throw new Error( - 'Project policy requires using your existing browser. Could not connect to CDP at localhost:9333 and auto-discovery also failed.' - ); - } - } - } - - // For doctor, attempt the same default attach flow but do not fail hard if attach is unavailable. - // This keeps diagnostics actionable even when CDP is down. - if (!manager.isLaunched() && isDoctor && manager instanceof BrowserManager) { - try { - await manager.launch({ - id: 'doctor-cdp', - action: 'launch', - cdpPort: 9333, - ignoreHTTPSErrors: process.env.AGENT_BROWSER_IGNORE_HTTPS_ERRORS === '1', - userAgent: process.env.AGENT_BROWSER_USER_AGENT, - colorScheme: - process.env.AGENT_BROWSER_COLOR_SCHEME === 'dark' || - process.env.AGENT_BROWSER_COLOR_SCHEME === 'light' || - process.env.AGENT_BROWSER_COLOR_SCHEME === 'no-preference' - ? (process.env.AGENT_BROWSER_COLOR_SCHEME as 'dark' | 'light' | 'no-preference') - : undefined, - tabGroup: process.env.AGENT_BROWSER_TAB_GROUP?.trim() || undefined, - tabGroupPluginId: - process.env.AGENT_BROWSER_TAB_GROUP_PLUGIN_ID?.trim() || undefined, - }); - } catch { - try { - await manager.launch({ - id: 'doctor-auto-connect', - action: 'launch', - autoConnect: true, - ignoreHTTPSErrors: process.env.AGENT_BROWSER_IGNORE_HTTPS_ERRORS === '1', - userAgent: process.env.AGENT_BROWSER_USER_AGENT, - colorScheme: - process.env.AGENT_BROWSER_COLOR_SCHEME === 'dark' || - process.env.AGENT_BROWSER_COLOR_SCHEME === 'light' || - process.env.AGENT_BROWSER_COLOR_SCHEME === 'no-preference' - ? (process.env.AGENT_BROWSER_COLOR_SCHEME as - | 'dark' - | 'light' - | 'no-preference') - : undefined, - tabGroup: process.env.AGENT_BROWSER_TAB_GROUP?.trim() || undefined, - tabGroupPluginId: - process.env.AGENT_BROWSER_TAB_GROUP_PLUGIN_ID?.trim() || undefined, - }); - } catch { - // Keep running: doctor should report failures instead of exiting early. - } - } - } - - // Recover from stale state: browser is launched but all pages were closed - if ( - manager instanceof BrowserManager && - manager.isLaunched() && - !manager.hasPages() && - parseResult.command.action !== 'launch' && - parseResult.command.action !== 'close' - ) { - await manager.ensurePage(); - } - - // Handle explicit launch with auto-load state - if ( - parseResult.command.action === 'launch' && - manager instanceof BrowserManager && - !parseResult.command.autoStateFilePath - ) { - const autoStatePath = getSessionAutoStatePath(); - if (autoStatePath) { - parseResult.command.autoStateFilePath = autoStatePath; - } - } - - // Handle close command specially - shuts down daemon - if (parseResult.command.action === 'close') { - // Auto-save state before closing - if (manager instanceof BrowserManager && manager.isLaunched()) { - const savePath = getSessionSaveStatePath(); - if (savePath) { - try { - const { encrypted } = await saveStateToFile(manager, savePath); - fs.chmodSync(savePath, 0o600); - if (process.env.AGENT_BROWSER_DEBUG === '1') { - console.error( - `Auto-saved session state: ${savePath}${encrypted ? ' (encrypted)' : ''}` - ); - } - } catch (err) { - if (process.env.AGENT_BROWSER_DEBUG === '1') { - console.error(`Failed to auto-save session state:`, err); - } - } - } - } - - const response = - isIOS && manager instanceof IOSManager - ? await executeIOSCommand(parseResult.command, manager) - : await executeCommand(parseResult.command, manager as BrowserManager); - await safeWrite(socket, serializeResponse(response) + '\n'); - - if (!shuttingDown) { - shuttingDown = true; - setTimeout(() => { - server.close(); - cleanupSocket(); - process.exit(0); - }, 100); - } - - commandQueue.length = 0; - processing = false; - return; - } - - // Execute command with appropriate handler - const response = - isIOS && manager instanceof IOSManager - ? await executeIOSCommand(parseResult.command, manager) - : await executeCommand(parseResult.command, manager as BrowserManager); - - // Add any launch warnings to the response - if (manager instanceof BrowserManager) { - const warnings = manager.getAndClearWarnings(); - if (warnings.length > 0 && response.success && response.data) { - (response.data as Record).warnings = warnings; - } - } - - await safeWrite(socket, serializeResponse(response) + '\n'); - } catch (err) { - const message = err instanceof Error ? err.message : String(err); - await safeWrite(socket, serializeResponse(errorResponse('error', message)) + '\n').catch( - () => {} - ); // Socket may already be destroyed + }); + } finally { + pendingCommands = Math.max(0, pendingCommands - 1); + scheduleIdleShutdown(); } } @@ -757,14 +811,16 @@ export async function startDaemon(options?: { server.on('error', (err) => { console.error('Server error:', err); + cancelIdleTimer(); cleanupSocket(); process.exit(1); }); // Handle shutdown signals - const shutdown = async () => { + const shutdown = async (_reason?: string) => { if (shuttingDown) return; shuttingDown = true; + cancelIdleTimer(); // Stop stream server if running if (streamServer) { @@ -792,28 +848,35 @@ export async function startDaemon(options?: { // Handle unexpected errors - always cleanup process.on('uncaughtException', (err) => { console.error('Uncaught exception:', err); + cancelIdleTimer(); cleanupSocket(); process.exit(1); }); process.on('unhandledRejection', (reason) => { console.error('Unhandled rejection:', reason); + cancelIdleTimer(); cleanupSocket(); process.exit(1); }); // Cleanup on normal exit process.on('exit', () => { + cancelIdleTimer(); cleanupSocket(); }); + scheduleIdleShutdown(); + // Keep process alive process.stdin.resume(); } // Run daemon if this is the entry point if (process.argv[1]?.endsWith('daemon.js') || process.env.AGENT_BROWSER_DAEMON === '1') { - startDaemon().catch((err) => { + startDaemon({ + resident: process.argv.includes('--resident'), + }).catch((err) => { console.error('Daemon error:', err); cleanupSocket(); process.exit(1);