diff --git a/cli/src/connection.rs b/cli/src/connection.rs index 6decbb0..af4c247 100644 --- a/cli/src/connection.rs +++ b/cli/src/connection.rs @@ -642,6 +642,22 @@ fn kill_stale_daemon(session: &str) { cleanup_stale_files(session); } +/// Kill every per-session daemon worker (SIGTERM→SIGKILL + sidecar cleanup), +/// leaving the Chrome-launched `__nm-host` native-messaging bridge alone — it's +/// not a tracked session daemon, so the extension relay stays up. Returns the +/// session names that were stopped. Powers `chrome-use daemon restart`, which +/// clears corrupted/cross-leaked daemon state (e.g. after a version-mismatch +/// restart) without the user resorting to `pgrep`/`kill` (issue #20). +pub fn restart_all_daemons() -> Vec { + let inventory = walk_daemons(); + let mut stopped = Vec::new(); + for session in &inventory.sessions { + kill_stale_daemon(&session.name); + stopped.push(session.name.clone()); + } + stopped +} + pub fn ensure_daemon(session: &str, opts: &DaemonOptions) -> Result { // Socket connectivity is the sole liveness check — no PID check — so // callers in a different PID namespace (e.g. unshare) can still reuse @@ -1182,6 +1198,55 @@ mod tests { let _ = fs::remove_dir(&dir); } + #[test] + fn test_restart_all_daemons_empty_dir() { + let dir = std::env::temp_dir().join("ab-test-restart-empty"); + let _ = fs::create_dir_all(&dir); + let _guard = EnvGuard::new(&["AGENT_BROWSER_SOCKET_DIR", "XDG_RUNTIME_DIR"]); + _guard.set("AGENT_BROWSER_SOCKET_DIR", dir.to_str().unwrap()); + + // No daemons registered → nothing to stop, and it must not blow up. + assert!(restart_all_daemons().is_empty()); + + let _ = fs::remove_dir(&dir); + } + + #[cfg(unix)] + #[test] + fn test_restart_all_daemons_kills_live_session() { + let dir = std::env::temp_dir().join("ab-test-restart-live"); + let _ = fs::create_dir_all(&dir); + let _guard = EnvGuard::new(&["AGENT_BROWSER_SOCKET_DIR", "XDG_RUNTIME_DIR"]); + _guard.set("AGENT_BROWSER_SOCKET_DIR", dir.to_str().unwrap()); + + // Spawn a real, killable child and register it as a session daemon. + let mut child = Command::new("sleep") + .arg("30") + .spawn() + .expect("spawn sleep"); + let pid = child.id(); + let _ = fs::write(dir.join("rktest.pid"), pid.to_string()); + let _ = fs::write(get_socket_path("rktest"), b""); + + let stopped = restart_all_daemons(); + assert!( + stopped.contains(&"rktest".to_string()), + "stopped: {:?}", + stopped + ); + + // Reap the killed child first — until the parent waits, it lingers as a + // zombie that still answers `kill(pid, 0)`, so is_pid_alive would lie. + let _ = child.wait(); + assert!(!is_pid_alive(pid)); + + // Sidecars are cleaned up. + assert!(!dir.join("rktest.pid").exists()); + assert!(!get_socket_path("rktest").exists()); + + let _ = fs::remove_dir(&dir); + } + #[test] fn test_cleanup_stale_files_removes_version() { let dir = std::env::temp_dir().join("ab-test-cleanup-version"); diff --git a/cli/src/main.rs b/cli/src/main.rs index 501ec5e..97e530d 100644 --- a/cli/src/main.rs +++ b/cli/src/main.rs @@ -29,8 +29,8 @@ use windows_sys::Win32::System::Threading::OpenProcess; use commands::{gen_id, parse_command, ParseError}; use connection::{ - cleanup_stale_files, ensure_daemon, get_socket_dir, is_pid_alive, send_command, walk_daemons, - DaemonOptions, + cleanup_stale_files, ensure_daemon, get_socket_dir, is_pid_alive, restart_all_daemons, + send_command, walk_daemons, DaemonOptions, }; use flags::{clean_args, parse_flags, Flags}; use install::run_install; @@ -320,6 +320,94 @@ fn run_session(args: &[String], session: &str, json_mode: bool) { } } +/// `chrome-use daemon ` — manage the per-session daemon workers +/// without resorting to `pgrep`/`kill`. `restart` clears corrupted or +/// cross-leaked daemon state (e.g. after a mid-session `chrome-use upgrade` +/// where stale tab handles bleed across sessions, issue #20) by killing every +/// session worker. The Chrome-launched `__nm-host` native-messaging bridge is +/// NOT a tracked session daemon, so the extension relay survives a restart — +/// the next command spins up a fresh, clean daemon against the same live Chrome. +fn run_daemon(args: &[String], json_mode: bool) { + match args.get(1).map(|s| s.as_str()) { + Some("restart") => { + let stopped = restart_all_daemons(); + let relay_up = connect::relay_url().is_some(); + if json_mode { + print_json_value(json!({ + "success": true, + "data": { "stopped": stopped, "count": stopped.len(), "relay": relay_up }, + })); + } else if stopped.is_empty() { + println!("No session daemons running — nothing to restart."); + if relay_up { + println!( + "{}", + color::dim("Extension relay still up; next command starts a fresh daemon.") + ); + } + } else { + for s in &stopped { + println!("{} Stopped daemon: {}", color::green("✓"), s); + } + println!( + "{}", + color::dim(if relay_up { + "Extension relay (__nm-host) left running; next command starts a fresh daemon." + } else { + "Next command starts a fresh daemon." + }) + ); + } + } + Some("status") | Some("list") => { + let inventory = walk_daemons(); + let relay_up = connect::relay_url().is_some(); + if json_mode { + let sessions: Vec<_> = inventory + .sessions + .iter() + .map(|s| json!({ "name": s.name, "pid": s.pid, "version": s.version })) + .collect(); + print_json_value(json!({ + "success": true, + "data": { "sessions": sessions, "relay": relay_up }, + })); + } else if inventory.sessions.is_empty() { + println!("No session daemons running."); + if relay_up { + println!("{}", color::dim("Extension relay (__nm-host): up")); + } + } else { + println!("Session daemons:"); + for s in &inventory.sessions { + let ver = s + .version + .as_deref() + .map(|v| format!(" {}", color::dim(&format!("(v{})", v)))) + .unwrap_or_default(); + println!(" {} pid {}{}", s.name, s.pid, ver); + } + if relay_up { + println!("{}", color::dim("Extension relay (__nm-host): up")); + } + } + } + other => { + eprintln!( + "{} usage: chrome-use daemon ", + color::error_indicator() + ); + if let Some(unknown) = other { + eprintln!( + "{}", + color::dim(&format!(" unknown subcommand: {}", unknown)) + ); + } + exit(2); + } + } +} + fn get_dashboard_pid_path() -> std::path::PathBuf { get_socket_dir().join("dashboard.pid") } @@ -799,6 +887,12 @@ fn main() { return; } + // Handle daemon management (doesn't talk to a daemon — it manages them). + if clean.first().map(|s| s.as_str()) == Some("daemon") { + run_daemon(&clean, flags.json); + return; + } + // Handle close --all: close all active sessions if matches!( clean.first().map(|s| s.as_str()), diff --git a/cli/src/output.rs b/cli/src/output.rs index b274405..957b27b 100644 --- a/cli/src/output.rs +++ b/cli/src/output.rs @@ -3178,6 +3178,9 @@ Confirmation: Sessions: session Show current session name session list List active sessions + daemon status List running session daemons (+ relay state) + daemon restart Kill all session daemons; keeps the extension relay + up. Clears stale/cross-leaked state after an upgrade. Chat (AI): chat Send a natural language instruction (single-shot) diff --git a/cli/src/upgrade.rs b/cli/src/upgrade.rs index 37811a2..8f29466 100644 --- a/cli/src/upgrade.rs +++ b/cli/src/upgrade.rs @@ -100,7 +100,7 @@ pub fn maybe_notify_update() { if first.starts_with("__") || matches!( first.as_str(), - "upgrade" | "install" | "doctor" | "dashboard" + "upgrade" | "install" | "doctor" | "dashboard" | "daemon" ) { return; diff --git a/skill-data/core/SKILL.md b/skill-data/core/SKILL.md index b02bb19..e976224 100644 --- a/skill-data/core/SKILL.md +++ b/skill-data/core/SKILL.md @@ -501,6 +501,22 @@ the same browser's existing targets, so a second session's first `open` can navigate a sibling's tab. For concurrent agents on one real Chrome, use the extension (each with a distinct `--session`), not raw `--cdp`. +### Reset stuck daemon state + +Each session runs a background daemon worker that holds the page handles. If a +session starts misbehaving — commands hit the wrong tab, refs/handles look stale, +or you upgraded `chrome-use` mid-session and old workers linger — restart the +daemons instead of hunting PIDs with `pgrep`/`kill`: + +```bash +chrome-use daemon status # list running session daemons (+ relay state) +chrome-use daemon restart # kill every session daemon worker +``` + +`daemon restart` leaves the extension's native-messaging bridge (`__nm-host`) +alone, so the relay to your live Chrome stays up — the next command just spins up +a fresh, clean daemon against the same browser. It does **not** close any tabs. + ### Mock network requests ```bash