From 2b1a3c308af007c5bafcf00a7643c72ffe67bcfe Mon Sep 17 00:00:00 2001 From: leeguooooo Date: Sat, 9 May 2026 01:41:07 +0900 Subject: [PATCH] feat(daemon): preserve URL across version-mismatch restart MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Before: after `npm i -g` upgrade, the next agent-browser command would detect daemon version mismatch, kill the old daemon, spawn a fresh one, and connect to a brand-new about:blank tab. The user's previous navigation state was silently lost — `get url` returned about:blank even though the user's Chrome was still on the same page. Now: before killing the old daemon, the CLI synchronously asks it for its current URL via the existing socket. If non-empty and not about:blank, it's persisted to a `.restore-url` sidecar in the socket dir. After the new daemon spawns and auto-connects, it reads the sidecar (read-and-delete), navigates the fresh tab to the saved URL, and prints `⚠ Restored previous URL: `. Manual `agent-browser close` does NOT write the sidecar, so a clean shutdown won't trigger surprise navigation. The sidecar is consumed on read regardless of whether navigation succeeded, so a stale entry can't haunt later auto-launches. --- cli/src/connection.rs | 41 ++++++++++++++++++++++++++++++++++ cli/src/native/actions.rs | 46 ++++++++++++++++++++++++++++++++++++++- 2 files changed, 86 insertions(+), 1 deletion(-) diff --git a/cli/src/connection.rs b/cli/src/connection.rs index e07aa9f..4a9d00e 100644 --- a/cli/src/connection.rs +++ b/cli/src/connection.rs @@ -127,6 +127,15 @@ fn get_version_path(session: &str) -> PathBuf { get_socket_dir().join(format!("{}.version", session)) } +/// Path to the sidecar file that records the URL the previous daemon was on, +/// used to restore navigation after a version-mismatch restart. Only written +/// when the version-mismatch branch fires; cleared after the new daemon +/// reads it. Manual `close` does not write this file, so a clean shutdown +/// won't trigger surprise navigation. +pub fn get_restore_url_path(session: &str) -> PathBuf { + get_socket_dir().join(format!("{}.restore-url", session)) +} + /// Clean up stale socket and PID files for a session pub fn cleanup_stale_files(session: &str) { let pid_path = get_pid_path(session); @@ -135,6 +144,10 @@ pub fn cleanup_stale_files(session: &str) { let _ = fs::remove_file(&version_path); let stream_path = get_socket_dir().join(format!("{}.stream", session)); let _ = fs::remove_file(&stream_path); + // Note: the .restore-url sidecar is intentionally NOT removed here — + // it lives across the brief window between killing the old daemon + // and the new daemon reading it back. The new daemon deletes it after + // restoring (see actions::auto_launch). #[cfg(unix)] { @@ -527,6 +540,24 @@ fn daemon_version_matches(session: &str) -> bool { } } +/// One-shot socket query for the running daemon's current URL. +/// Returns None on any kind of failure — caller must treat as best-effort. +fn query_current_url(session: &str) -> Option { + let cmd = serde_json::json!({ + "id": format!("restore-url-probe-{}", std::process::id()), + "action": "url", + }); + let resp = send_command_once(&cmd, session).ok()?; + if !resp.success { + return None; + } + resp.data + .as_ref() + .and_then(|d| d.get("url")) + .and_then(|v| v.as_str()) + .map(|s| s.to_string()) +} + /// Kill a running daemon by reading its PID file and sending a kill signal. fn kill_stale_daemon(session: &str) { // Remove the socket first so no new connections reach the old daemon @@ -592,6 +623,16 @@ pub fn ensure_daemon(session: &str, opts: &DaemonOptions) -> Result Result<(), String> { try_auto_restore_state(state).await; try_load_storage_state(state, &storage_state_path).await; apply_stealth_to_browser(state).await; + try_restore_navigation(state).await; return Ok(()); } @@ -1573,6 +1574,7 @@ async fn auto_launch(state: &mut DaemonState) -> Result<(), String> { try_auto_restore_state(state).await; try_load_storage_state(state, &storage_state_path).await; apply_stealth_to_browser(state).await; + try_restore_navigation(state).await; return Ok(()); } Err(_e) => { @@ -1660,6 +1662,7 @@ async fn auto_launch(state: &mut DaemonState) -> Result<(), String> { try_load_storage_state(state, &storage_state_path).await; // Apply stealth anti-detection patches after browser is ready apply_stealth_to_browser(state).await; + try_restore_navigation(state).await; Ok(()) } @@ -1770,6 +1773,47 @@ async fn apply_stealth_to_browser(state: &DaemonState) { } } +/// If the previous daemon left a `.restore-url` sidecar (because it was killed +/// by a version-mismatch restart), navigate the freshly-connected browser to +/// that URL so `agent-browser get url` after `npm i -g` upgrade still reports +/// the page the user was on. Read-and-delete: the file is removed regardless +/// of whether navigation succeeds, so a stale sidecar can't haunt later +/// auto-launches. +async fn try_restore_navigation(state: &mut DaemonState) { + let path = get_restore_url_path(&state.session_id); + let url = match fs::read_to_string(&path) { + Ok(s) => s.trim().to_string(), + Err(_) => return, + }; + let _ = fs::remove_file(&path); + if url.is_empty() { + return; + } + let Some(mgr) = state.browser.as_mut() else { + return; + }; + state.ref_map.clear(); + state.iframe_sessions.clear(); + state.active_frame_id = None; + match mgr.navigate(&url, super::browser::WaitUntil::Load).await { + Ok(_) => { + eprintln!( + "{} Restored previous URL: {}", + crate::color::warning_indicator(), + url + ); + } + Err(e) => { + eprintln!( + "{} Could not restore previous URL ({}): {}", + crate::color::warning_indicator(), + url, + e + ); + } + } +} + fn launch_options_from_env() -> LaunchOptions { let headed = env::var("AGENT_BROWSER_HEADED") .map(|v| v == "1" || v == "true")