master
John Lauer v2.0.739 (insiders): Never a bare Chrome taskbar button during recovery (pup-bridge#143): an OS-level health-tick safet… f222eda 9d ago
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954
//! Recovery: reconnect the windows a previous bridge left running (session files + a live
//! DevTools port), the 15 s health loop, and pup_rescan. ONE implementation (Node had six paths).
//! A slow Chrome is never killed here (2.0.392 lesson): a probe miss leaves the process and the
//! session file alone for the next tick.

use crate::{events, BrowserHandle, Ctx};
use pup_browser::{Candidate, Launched};
use pup_cdp::Cdp;
use pup_core::ok;
use pup_host::Host;
use pup_session::{Session, Tab};
use serde_json::{json, Value};
use std::sync::Arc;
use std::time::Duration;

/// Connect to a profile's live browser (by its DevToolsActivePort) and register the handle.
fn reconnect_profile(ctx: &Ctx, profile: &str) -> Option<Arc<BrowserHandle>> { reconnect_profile_hint(ctx, profile, None) }
/// `hint`: the cdpPort a session file recorded. The Node build's Chrome answers on it while its
/// user-data-dir carries no DevToolsActivePort file (measured on winvm at the 2.0.401 install: Chrome
/// alive on 9420, no port file), so the file's port is the second place to look.
fn reconnect_profile_hint(ctx: &Ctx, profile: &str, hint: Option<u16>) -> Option<Arc<BrowserHandle>> {
    if let Some(b) = ctx.browsers.lock().unwrap().get(profile) { if b.cdp.is_alive() { return Some(b.clone()); } }
    let profile_dir = pup_browser::profiles_dir(&*ctx.host).join(profile);
    let port = pup_browser::existing_port(&profile_dir).or_else(|| hint.filter(|p| *p > 0 && Cdp::connect_port(*p, Duration::from_secs(3)).map(|c| { let ok = c.version().is_ok(); c.close(); ok }).unwrap_or(false)))?;
    let cdp = Cdp::connect_port(port, Duration::from_secs(8)).ok()?;
    let product = cdp.version().ok().and_then(|v| v["product"].as_str().map(|s| s.to_string())).unwrap_or_default();
    let cand = pup_browser::launch_candidates(&*ctx.host, ctx.prefer_cft()).into_iter().find(|c| (product.contains("Edg") && c.kind == "edge") || (!product.contains("Edg") && c.kind != "edge")).unwrap_or(Candidate { kind: "chrome".into(), source: "reconnect".into(), executable_path: Default::default(), build_id: None });
    // learn the browser pid from the browser itself (SystemInfo.getProcessInfo) so a later close can reap it
    let pid = cdp.send(None, "SystemInfo.getProcessInfo", json!({}), Duration::from_secs(5)).ok().and_then(|v| v["processInfo"].as_array().and_then(|a| a.iter().find(|p| p["type"] == json!("browser")).and_then(|p| p["id"].as_u64()))).unwrap_or(0) as u32;
    let h = Arc::new(BrowserHandle { cdp, launched: Launched { pid, cdp_port: port, candidate: cand, profile_dir, reused: true } });
    ctx.host.watch_process(pid);
    events::start_pump(profile.into(), h.cdp.clone(), ctx.sessions.clone(), ctx.tabstate.clone());
    ctx.browsers.lock().unwrap().insert(profile.into(), h.clone());
    pup_core::log(format!("Reconnected to Chrome on port {port} for profile \"{profile}\""));
    Some(h)
}

/// Node: sleepRecovery / the crash auto-recover. A session whose Chrome is gone comes back at its
/// last URL, in the background, signed in (same profile), at most twice per session id; the caller
/// hears about it through `_cleanupReport` on its next verb.
static RELAUNCHES: std::sync::Mutex<Vec<(String, u32)>> = std::sync::Mutex::new(Vec::new());
/// 2.0.468: session ids a relaunch is bringing back RIGHT NOW. Between "dropped from memory" and "open_window
/// inserted the new session" the id has a file and no session, which is exactly what the health tick's
/// file re-attach looks for: on arav-rog it re-attached "drill-b" to the tab "drill-a"'s relaunch had just
/// opened (same URL), and the restore thread then relaunched drill-b on top of it (4 tabs, one shared).
static RESTORING: std::sync::Mutex<Vec<String>> = std::sync::Mutex::new(Vec::new());
static IN_FLIGHT: std::sync::Mutex<Vec<String>> = std::sync::Mutex::new(Vec::new());
pub fn restoring(id: &str) -> bool { RESTORING.lock().unwrap().iter().any(|s| s == id) }
/// Reserve ids a caller is about to relaunch one by one (pup_restart_browser): the health tick leaves their
/// files alone until each relaunch has run. Released by `release`, or by the relaunch itself.
pub fn reserve(ids: &[String]) { let mut m = RESTORING.lock().unwrap(); for id in ids { if !m.contains(id) { m.push(id.clone()); } } }
pub fn release(id: &str) { RESTORING.lock().unwrap().retain(|s| s != id); }
struct RelaunchGuard(String);
static RELAUNCHED_AT: std::sync::Mutex<Vec<(String, u64)>> = std::sync::Mutex::new(Vec::new());
/// 2.0.473: a window is "settling" for a grace period after a relaunch. During it the event pump must not adopt a
/// tab into the window as "opened outside pup": a killed Chrome auto-restores the old session's page as the new
/// window comes up, and pup has already reopened that page itself, so the restore lands as a phantom extra tab
/// (triple-restart drill on ConfRoomROG: "back with 4 of 3", 0 live).
pub fn note_relaunched(id: &str) { let mut m = RELAUNCHED_AT.lock().unwrap(); let now = pup_core::now_ms(); m.retain(|(s, t)| s != id && now.saturating_sub(*t) < 120_000); m.push((id.to_string(), now)); }
pub fn recently_relaunched(id: &str, within_ms: u64) -> bool { let now = pup_core::now_ms(); RELAUNCHED_AT.lock().unwrap().iter().any(|(s, t)| s == id && now.saturating_sub(*t) < within_ms) }
static CANCELLED_RESTORE: std::sync::Mutex<Vec<(String, u64)>> = std::sync::Mutex::new(Vec::new());
/// 2.0.682: windows whose browser pup KNOWS is gone for this pass (pup ended it for an upgrade or restart, it crashed,
/// or it was already dead when this pup booted). Only these are relaunched when their window is missing; a missing
/// window that is not marked here was closed and is forgotten. 2.0.681 decided by "is a browser running NOW", and the
/// first relaunch of an upgrade restarts the browser, so every later window of that upgrade was dropped as "closed".
static LOST: std::sync::Mutex<Vec<String>> = std::sync::Mutex::new(Vec::new());
pub fn mark_lost(ids: &[String]) { let mut m = LOST.lock().unwrap(); for id in ids { if !m.contains(id) { m.push(id.clone()); } } }
fn is_lost(id: &str) -> bool { LOST.lock().unwrap().iter().any(|s| s == id) }
/// 2.0.476: a bulk close CANCELS any in-flight restore of these windows instead of waiting for it. The restore
/// thread checks this after it brings a window back and, if the window was cancelled, closes it again and drops
/// the session — so "Close all" is instant and no window finishes relaunching behind the close and lingers
/// (close-during-restore cycle). The mark expires in 45 s, and a deliberate reopen of the id clears it at once.
pub fn cancel_restore(id: &str) { let mut m = CANCELLED_RESTORE.lock().unwrap(); let now = pup_core::now_ms(); m.retain(|(s, t)| s != id && now.saturating_sub(*t) < 300_000); m.push((id.to_string(), now)); }
pub fn restore_cancelled(id: &str) -> bool { let now = pup_core::now_ms(); CANCELLED_RESTORE.lock().unwrap().iter().any(|(s, t)| s == id && now.saturating_sub(*t) < 300_000) }   // 2.0.682: 45 s -> 5 min; an upgrade queue took 70 s
pub fn clear_cancel(id: &str) { CANCELLED_RESTORE.lock().unwrap().retain(|(s, _)| s != id); }
impl Drop for RelaunchGuard { fn drop(&mut self) { note_relaunched(&self.0); IN_FLIGHT.lock().unwrap().retain(|s| *s != self.0); LOST.lock().unwrap().retain(|s| *s != self.0); release(&self.0); } }

/// 2.0.533: an EXT session is re-adopted, never relaunched, while its Chrome window is still in the lane
/// browser. The liveness check here is CDP (reconnect_profile), which an ext window never satisfies, so a
/// pup restart declared both ext sessions' "Chrome is gone" and relaunched them as NEW windows next to the
/// originals (AdomLapper, 2026-09-13: the 17-tab proof window orphaned, a duplicate opened beside it). The
/// session file carries windowId; the live `tabs` listing says whether that window still exists and which
/// tabs it holds, so the session is rebuilt from what Chrome actually has.
/// Runs the body on the lane of the file's OWN profile. 2.0.670: recovery threads sit on the adom-you lane, so a
/// svc-test window was looked for among adom-you's tabs, never found, and stayed forgotten (CRR, 2026-09-21).
struct ProfileScope;
impl ProfileScope { fn enter(profile: &str) -> ProfileScope { crate::extdrive::enter_profile(profile); ProfileScope } }
impl Drop for ProfileScope { fn drop(&mut self) { crate::extdrive::leave_profile(); } }

/// 2.0.717: profiles whose browser pup restarted on purpose in the last 2 min. Chrome can bring such windows back
/// itself (its own session restore); pup then RE-ADOPTS them instead of relaunching, and the relaunch path was the only
/// one that paused their autoplaying media. A plain pup restart (the browser kept running) is not in this set, so a
/// video someone is watching is never paused by it.
static RESTARTED: std::sync::Mutex<Vec<(String, u64)>> = std::sync::Mutex::new(Vec::new());
pub fn mark_browser_restarted(profile: &str) { let now = pup_core::now_ms(); let mut m = RESTARTED.lock().unwrap(); m.retain(|(p, t)| p != profile && now.saturating_sub(*t) < 120_000); m.push((profile.to_string(), now)); }
fn browser_restarted_recently(profile: &str) -> bool { let now = pup_core::now_ms(); RESTARTED.lock().unwrap().iter().any(|(p, t)| p == profile && now.saturating_sub(*t) < 120_000) }
/// Pause autoplaying media in a restored window's tabs, in the background (see quietMedia in worker.js).
fn quiet_restored(id: &str, profile: &str, wid: i64) {
    let (id2, prof2) = (id.to_string(), profile.to_string());
    std::thread::spawn(move || {
        crate::extdrive::enter_profile(&prof2);
        match crate::extdrive::run(json!({ "action": "quietMedia", "windowId": wid, "ms": 30000, "timeoutMs": 20000 }), Duration::from_secs(24)) {
            Ok(q) => pup_core::log(format!("[recovery] \"{id2}\": restored tabs keep their media paused for 30 s ({} tab(s) guarded, {} paused at once)", q["guarded"], q["paused"])),
            Err(e) => pup_core::log(format!("[recovery] \"{id2}\": could not quiet the restored tabs: {}", e["error"].as_str().unwrap_or("?"))),
        }
    });
}

fn readopt_ext(ctx: &Ctx, f: &Value) -> bool {
    if f["transport"].as_str() != Some("ext") { return false; }
    let _scope = ProfileScope::enter(f["profile"].as_str().unwrap_or("adom-you"));
    let id = f["sessionId"].as_str().unwrap_or("").to_string();
    let Some(wid) = f["windowId"].as_i64() else { return false };
    if !crate::extdrive::alive(ctx) {
        // 2.0.536: at boot restore_extdrive has already re-attached the lane's live pids, but the extension's
        // first poll lands ~1-2 s later. Declaring the window gone in that gap KILLED the live browser and
        // rebuilt the 20-tab window from its file, twice (AdomLapper 20:34, 2026-09-13). Wait for the heartbeat.
        let t0 = std::time::Instant::now();
        while !crate::extdrive::alive(ctx) && !crate::extdrive_pids().is_empty() && t0.elapsed() < Duration::from_secs(8) { std::thread::sleep(Duration::from_millis(250)); }
        if !crate::extdrive::alive(ctx) { return false; }
    }
    let Ok(r) = crate::extdrive::run(json!({ "action": "tabs" }), Duration::from_secs(20)) else { return false };
    let live: Vec<Value> = r["tabs"].as_array().cloned().unwrap_or_default().into_iter().filter(|t| t["windowId"].as_i64() == Some(wid)).collect();
    if live.is_empty() { return false; }
    let mut s = Session::new(&id, f["profile"].as_str().unwrap_or("adom-you"), f["owner"].as_str(), None, 0, None);
    s.transport = "ext".into(); s.window_id = Some(wid);
    // 2.0.694: keep the tab ids the file saved (matched by the browser's own tab id). Renumbering from tab-1 on
    // every bridge restart sent an AI's eval to a different tab under the id it already held (2026-10-04).
    let saved: std::collections::HashMap<String, String> = f["tabs"].as_array().cloned().unwrap_or_default().iter()
        .filter_map(|t| Some((t["browserTabId"].as_str()?.to_string(), t["tabId"].as_str()?.to_string()))).collect();
    let top = saved.values().filter_map(|v| v.strip_prefix("tab-")?.parse::<u32>().ok()).max().unwrap_or(0);
    s.next_tab_seq = s.next_tab_seq.max(top + 1);
    for t in &live {
        let mut tab = crate::transport::ext_tab(&mut s, t["tabId"].as_i64().unwrap_or(0), t["url"].as_str().unwrap_or(""), t["title"].as_str().unwrap_or(""));
        // 2.0.724: a tab still on the birth page keeps the address the file recorded, so the rescue can send it on
        if let Some(saved_url) = f["tabs"].as_array().and_then(|a| a.iter().find(|x| x["browserTabId"].as_str() == Some(tab.target_id.as_str()))).and_then(|x| x["url"].as_str()) { let mut u = saved_url.to_string(); crate::extdrive::keep_url(&mut u, &tab.url); tab.url = u; }
        if let Some(keep) = saved.get(&tab.target_id) { if !s.tabs.iter().any(|x| &x.tab_id == keep) { tab.tab_id = keep.clone(); } }
        s.tabs.push(tab);
    }
    if let Some(i) = live.iter().position(|t| t["active"] == json!(true)) { s.active_tab = i; }
    s.hwnd = f["_hwnd"].as_i64().filter(|&h| h > 0 && ctx.host.is_alive(h));
    if s.hwnd.is_none() {
        let taken: Vec<i64> = ctx.sessions.all().iter().filter_map(|x| x.hwnd).collect();
        s.hwnd = ctx.host.windows_for_pids(&crate::extdrive_pids(), Some("Chrome_WidgetWin_1")).iter().find(|w| w.visible && w.rect.2 - w.rect.0 > 200 && !taken.contains(&w.hwnd)).map(|w| w.hwnd);
    }
    s.app_id = f["appId"].as_str().map(|x| x.to_string());
    ctx.sessions.insert(s.clone()); ctx.sessions.save_file(&s);
    // 2.0.690: a window re-adopted across an upgrade keeps the tile of the build that drew it. When that tile comes
    // from an older drawing generation, stamp the window once so it wears the current art (2.0.689 redrew tiles
    // smoother and John's re-adopted windows kept the grainy ones).
    if s.app_id.as_deref().map(|a| a.contains(".thread.") && !a.contains(&format!(".{}", pup_identity::TILE_GEN))).unwrap_or(false) {
        if let Some(a) = ctx.arc() { crate::identity::schedule(a, s.id.clone(), crate::identity::internal_caller(), true); pup_core::log(format!("[recovery] \"{id}\": its tile is from an older drawing; re-stamping it")); }
    }
    pup_core::log(format!("[recovery] \"{id}\": re-adopted its extension-lane window (Chrome window {wid}, {} tab(s), hwnd {:?}); nothing relaunched", s.tabs.len(), s.hwnd));
    if browser_restarted_recently(&s.profile) { quiet_restored(&id, &s.profile, wid); }
    crate::extdrive::rebind_unbound(ctx);   // stamp the re-adopted window NOW; the health tick is only a backstop
    true
}

pub fn relaunch_from_file(ctx: &Ctx, f: &Value) {
    let id = f["sessionId"].as_str().unwrap_or("").to_string();
    forget_user_closed(ctx);   // 2.0.682: a close reported during the previous relaunch cancels this one before it opens
    { let mut m = IN_FLIGHT.lock().unwrap(); if m.contains(&id) { pup_core::log(format!("[recovery] \"{id}\": a relaunch is already in flight; not starting a second")); return; } m.push(id.clone()); }
    reserve(std::slice::from_ref(&id));
    let _guard = RelaunchGuard(id.clone());
    let url = f["url"].as_str().unwrap_or("about:blank").to_string();
    let profile = f["profile"].as_str().unwrap_or("adom-you").to_string();
    let owner = f["owner"].as_str().unwrap_or("").to_string();
    // 2.0.669: a window still on glass is NOT a failed relaunch. The "alive, will re-adopt" path below used to
    // run after this counter, so three quiet 15 s ticks read as "failed to come back twice" and the file of a
    // LIVE window was deleted: after the 2.0.668 upgrade on AdomLapper (2026-09-20 23:57) three windows stayed on
    // screen with no session, no badge and no owner, and the adom/insiders thread opened a duplicate the next
    // morning. Re-adopt first, then decline while the window lives, and count only real relaunches.
    if restore_cancelled(&id) { pup_core::log(format!("[recovery] \"{id}\": its restore was cancelled by a close; not relaunching")); ctx.sessions.delete_file(&id); return; }
    if readopt_ext(ctx, f) { RELAUNCHES.lock().unwrap().retain(|(s, _)| *s != id); return; }
    if let Some(h) = f["_hwnd"].as_i64().filter(|&h| h > 0) {
        if ctx.host.is_alive(h) {
            // 2.0.670: alive is not enough; it must be alive in ITS profile's browser. A window left inside another
            // profile's browser (adopted as the wrong lane before 2.0.670) would otherwise be "kept" forever, never
            // re-adopted, running in the wrong cookie jar. That one is closed and relaunched in its own profile.
            let prof = f["profile"].as_str().unwrap_or("adom-you").to_string();
            let by = crate::pup_browser_pids_by_profile(ctx);
            let owner_prof = by.iter().find(|(_, pids)| !ctx.host.windows_for_pids(pids, None).iter().all(|w| w.hwnd != h)).map(|(p, _)| p.clone());
            match owner_prof {
                Some(op) if op != prof => {
                    pup_core::log(format!("[recovery] \"{id}\": its window {h} is alive but inside the {op} profile's browser, not {prof}; closing it there and relaunching it in {prof}"));
                    let _ = ctx.ab.call("desktop_close_window", json!({ "hwnd": h, "reason": format!("pup: the \"{id}\" window was running in the wrong profile ({op}); reopening it in {prof}") }), &crate::identity::internal_caller(), Duration::from_secs(10));
                }
                _ => { pup_core::log(format!("[recovery] \"{id}\": its window {h} is alive on glass; NOT relaunching (worker quiet, will re-adopt on the next tick; its file is kept)")); return; }
            }
        }
    }
    // 2.0.681 (John, 2026-09-23: "i fucking closed those windows on purpose cuz i don't fucking need them anymore").
    // A window is only RESTORED when its browser is GONE (a crash, a sleep, an upgrade). If the lane browser is alive
    // and simply does not have this window, the window was CLOSED — by the user, or by the page — and pup must forget
    // it. Before, any missing window was treated as a crash to repair, so windows the user closed while pup was
    // restarting (an install landed in that exact second on his laptop) came back at the next boot, twice.
    if f["transport"].as_str() == Some("ext") && !is_lost(&id) {
        let _scope = ProfileScope::enter(&profile);
        if !crate::extdrive_pids().is_empty() {
            pup_core::log(format!("[recovery] \"{id}\": its browser is running and does not have this window, so it was CLOSED, not lost; forgetting it (never restored)"));
            ctx.sessions.delete_file(&id);
            RELAUNCHES.lock().unwrap().retain(|(s, _)| *s != id);
            return;
        }
    }
    let attempts = { let mut m = RELAUNCHES.lock().unwrap(); match m.iter_mut().find(|(s, _)| *s == id) { Some(e) => { e.1 += 1; e.1 } None => { m.push((id.clone(), 1)); 1 } } };
    if attempts > 2 { pup_core::log(format!("[recovery] \"{id}\" failed to come back twice — giving up on it (the page itself may be crashing Chrome); removing its file")); ctx.sessions.delete_file(&id); crate::push_cleanup_report(format!("pup could NOT auto-recover the \"{id}\" window: it crashed again on relaunch ({url}). Open it again yourself with pup_open_window if you still need it.")); return; }
    // (2.0.588: a window still on glass is never relaunched; that check now runs before the attempt counter.)
    pup_core::log(format!("[recovery] \"{id}\": its Chrome is gone (profile {profile}) — relaunching at {url} in the background (attempt {attempts})"));
    let caller = pup_core::Caller { thread: owner.clone(), reason: "auto-recovery: the window's Chrome was gone".into(), ..Default::default() };
    let open_args = json!({ "window": id, "url": url, "profile": profile, "takeover": true, "lane": f["transport"].as_str().map(|x| x.to_string()).unwrap_or_else(|| ctx.settings.get("defaultLane", "ext")), "owner": if owner.is_empty() { Value::Null } else { json!(owner) } });
    let mut r = crate::open_window(ctx, &caller, &open_args);
    // 2.0.580: a cold extension lane answers `starting:true` (no session yet) when its worker is not up within
    // the open's budget. Treating that as done left an orphan window and "0 session(s) active" (2026-09-14
    // 20:57). Wait for the worker, then open once more.
    if r["starting"] == json!(true) {
        let t0 = std::time::Instant::now();
        while t0.elapsed() < Duration::from_secs(30) && !crate::extdrive::alive(ctx) { std::thread::sleep(Duration::from_millis(500)); }
        pup_core::log(format!("[recovery] \"{id}\": the lane was still starting; opening again now that the worker {}", if crate::extdrive::alive(ctx) { "is up" } else { "is STILL silent (best effort)" }));
        r = crate::open_window(ctx, &caller, &open_args);
    }
    // 2.0.476: a close that landed WHILE this relaunch was opening the window cancels it; undo the window we just
    // brought back so it does not linger behind the close.
    if r["ok"] == json!(true) && restore_cancelled(&id) {
        pup_core::log(format!("[recovery] \"{id}\": a close cancelled its restore mid-flight; closing the window it had just reopened"));
        let c = pup_core::Caller { thread: owner.clone(), reason: "undo a cancelled restore".into(), ..Default::default() };
        let _ = crate::close_window(ctx, &c, &json!({ "window": id, "takeover": true }));
        ctx.sessions.delete_file(&id);
        return;
    }
    // 2.0.461: reopen the window's OTHER tabs too, in their saved order.
    if r["ok"] == json!(true) {
        // 2.0.478: dedup the reopened tabs by base URL (and drop the active url) so a file that accumulated a
        // duplicate never reopens the same page twice.
        // 2.0.675: exact URLs (only a trailing slash ignored). Stripping the query collapsed different pages on one
        // path (two YouTube videos, two search results, example.org/?a2 and ?a3): a recovered window came back with
        // 2 of its 4 tabs on CRR, 2026-09-22.
        let base = |u: &str| u.trim_end_matches('/').to_string();
        let mut seen: Vec<String> = vec![base(&url)];
        let rest: Vec<String> = f["tabs"].as_array().map(|a| a.iter().filter_map(|t| t["url"].as_str()).map(|u| u.to_string()).filter(|u| {
            let b = base(u);
            if u.is_empty() || u == "about:blank" || seen.contains(&b) { false } else { seen.push(b); true }
        }).collect()).unwrap_or_default();
        if !rest.is_empty() {
            // 2.0.537: reopen ONLY into the window this relaunch itself just made. If a caller opened this id
            // meanwhile (its session is newer than the relaunch's own window), the saved list would land on top
            // of the caller's tabs: that produced ti.com twice and 33 tabs for 20 sites (John, 2026-09-13).
            let mine = r["hwnd"].as_i64().or_else(|| ctx.sessions.get(&id).and_then(|x| x.hwnd));
            let cur = ctx.sessions.get(&id);
            let Some(cur) = cur else {
                // 2.0.580: no session at all is a FAILED relaunch, not another caller's window. Keep the file so the
                // next tick tries again instead of dropping the user's tabs on the floor.
                pup_core::log(format!("[recovery] \"{id}\": the relaunch registered no session (open answered ok={} error={:?}); keeping its file for the next attempt", r["ok"], r["error"].as_str()));
                return;
            };
            // Chrome's own windowId is exact; the hwnd can legitimately change between the open and this check
            // (a late rebind), which is what mis-fired this guard on the extension lane.
            let still_mine = match (cur.window_id, r["windowId"].as_i64()) { (Some(a), Some(b)) => a == b, _ => cur.hwnd == mine || mine.is_none() };
            if !still_mine {
                pup_core::log(format!("[recovery] \"{id}\": another caller opened this window while the relaunch ran; not reopening the saved {} tab(s) on top of theirs", rest.len()));
            } else {
                pup_core::log(format!("[recovery] \"{id}\": reopening its other {} tab(s)", rest.len()));
                for u in &rest { let _ = crate::tabs::open_tab(ctx, &caller, &json!({ "window": id, "url": u, "takeover": true })); }
            }
        }
    }
    // 2.0.698: a restored window reloads every page at once, and every video in them autoplayed together ("i get like
    // 10 videos playing all at the same time", John 2026-10-04). Pause media that starts on its own in the restored
    // tabs for 30 s; anything the user starts by clicking plays normally.
    if r["ok"] == json!(true) {
        if let Some(wid) = ctx.sessions.get(&id).filter(|s| crate::transport::is_ext(s)).and_then(|s| s.window_id) {
            quiet_restored(&id, &profile, wid);   // 2.0.715: in the background; the next window's restore never waits on it
        }
    }
    // 2.0.471: a relaunch that WORKED resets the counter. It counted every relaunch in the bridge's life, so a window
    // brought back twice (a wake, a crash) was refused the third time as "failed to come back twice" (winvm drill,
    // 2026-09-11: "drill-b" back with 0 of 3 after pup_restart_browser). Only consecutive failures count now.
    if r["ok"] == json!(true) { RELAUNCHES.lock().unwrap().retain(|(s, _)| *s != id); }
    if r["ok"] == json!(true) { crate::push_cleanup_report(format!("pup auto-recovered your \"{id}\" window after its Chrome was gone (a bridge update, a Chrome crash, or a sleep): it is back at {url}, in the background, on the same profile. Verify with pup_screenshot.")); }
    if r["ok"] == json!(true) { ctx.sessions.with_mut(&id, |x| x.disruption_notice = Some(format!("This window was auto-recovered after its Chrome was gone; it is back at {url} in the background. If something you set on the old page (an emulated viewport, an in-page draft) is missing, that is why."))); }
    else { pup_core::log(format!("[recovery] \"{id}\" relaunch failed: {}", r["error"].as_str().unwrap_or("?"))); }
}

fn page_targets(cdp: &Cdp) -> Vec<Value> { cdp.targets().unwrap_or_default().into_iter().filter(|t| t["type"] == json!("page")).collect() }

/// Rebuild one session from its file against a connected browser. Match by the recorded
/// targetId first (exact), then by URL prefix (the Node rule), never adopting about:blank.
fn recover_one(ctx: &Ctx, file: &Value, h: &BrowserHandle, taken: &mut Vec<String>) -> Option<Session> {
    let id = file["sessionId"].as_str()?.to_string();
    let profile = file["profile"].as_str().unwrap_or("adom-you").to_string();
    let targets = page_targets(&h.cdp);
    let want_tid = file["targetId"].as_str().map(|s| s.to_string());
    let want_url = file["url"].as_str().unwrap_or("").split('?').next().unwrap_or("").to_string();
    let pick = targets.iter().find(|t| want_tid.as_deref().is_some_and(|w| t["targetId"] == json!(w)))
        .or_else(|| targets.iter().find(|t| { let u = t["url"].as_str().unwrap_or(""); !want_url.is_empty() && u != "about:blank" && u.starts_with(&want_url) && !taken.contains(&t["targetId"].as_str().unwrap_or("").to_string()) }))?;
    // 2.0.454: a target another session already re-attached is not picked twice; an "adopted-*" leftover whose
    // page now belongs to its window's session is finished with, and its file goes.
    if let Some(t) = pick["targetId"].as_str() { if taken.contains(&t.to_string()) {
        if id.starts_with("adopted-") { pup_core::log(format!("Session \"{id}\": its page already belongs to a window session; dropping the leftover file")); ctx.sessions.delete_file(&id); }
        else { pup_core::log(format!("Session \"{id}\": its target {t} was already re-attached by another session; leaving the file")); }
        return None;
    } }
    let Some(tid) = pick["targetId"].as_str().map(|t| t.to_string()) else { pup_core::log(format!("Session \"{id}\": no live page target matches its file (wanted {:?} or url {:?}); leaving the file", want_tid, want_url)); return None; };
    taken.push(tid.clone());
    let sid = match h.cdp.attach(&tid) { Ok(x) => x, Err(e) => { pup_core::log(format!("Session \"{id}\": could not attach its target {tid}: {e}")); return None; } };
    // 2.0.456: Page.enable on a helper thread (a discarded tab answers only when its renderer comes back;
    // inline it cost the first recovery 40 s on AdomLapper).
    { let (c2, s2) = (h.cdp.clone(), sid.clone()); std::thread::Builder::new().name("recover-setup".into()).spawn(move || { let _ = c2.page_enable(&s2); }).ok(); }
    let mut s = Session::new(&id, &profile, file["owner"].as_str(), None, h.launched.cdp_port, file["pid"].as_u64().map(|p| p as u32));
    s.created_iso = file["createdAt"].as_str().unwrap_or(&s.created_iso).to_string();
    s.foreground = file["foreground"].as_bool().unwrap_or(false);
    s.app_id = file["appId"].as_str().map(|a| a.to_string());
    s.window_id = h.cdp.window_for_target(&tid).ok().map(|(w, _)| w).filter(|w| *w > 0).or_else(|| file["windowId"].as_i64());
    let tab_id = s.next_tab_id();
    s.tabs.push(Tab { tab_id, target_id: tid, cdp_session: sid, url: pick["url"].as_str().unwrap_or("").into(), title: pick["title"].as_str().unwrap_or("").into(), error_count: 0, fav: None });
    // the OS window: the persisted handle if it is still alive, else the one carrying our tag
    let tag = s.tag_string();
    let hwnd = file["_hwnd"].as_i64().filter(|&h| h > 0 && ctx.host.is_alive(h))
        .or_else(|| ctx.host.windows_for_pids(&[], Some("Chrome_WidgetWin_1")).into_iter().find(|w| ctx.host.has_tag(w.hwnd, &tag)).map(|w| w.hwnd));
    if let Some(hh) = hwnd { s.hwnd = Some(hh); ctx.host.tag(hh, &tag); if !s.foreground { ctx.host.send_to_bottom_no_activate(hh); } }
    // A recovered tab is a fresh CDP session: give it what attach_tab gives a new one (the login
    // capture + floaty bindings, the owner chip, the floaty) or the window silently loses them
    // (measured 2026-09-05: no owner chip on a window recovered across the 2.0.407 update).
    if let Some(t) = s.tabs.last() {
        for name in ["__pupCredCapture", "__pupFloatyPush"] { let _ = h.cdp.send(Some(&t.cdp_session), "Runtime.addBinding", json!({ "name": name }), Duration::from_secs(5)); }
        let capture = crate::vault::capture_login_script();
        let _ = h.cdp.send(Some(&t.cdp_session), "Page.addScriptToEvaluateOnNewDocument", json!({ "source": capture }), Duration::from_secs(5));
        let _ = h.cdp.evaluate(&t.cdp_session, &capture, Duration::from_secs(5));
        crate::inject_page_scripts(ctx, &s, &h.cdp, &t.cdp_session);
    }
    Some(s)
}

/// Boot: every session file names a browser we may still own.
/// 2.0.456: after sessions re-attach (boot, reconnect), every page target in a recovered window that no
/// session tracks joins that window's session, keyed by the CDP Browser window id. Event-driven (runs at
/// the recovery moment, never on a timer). Skips about:blank and browser-internal pages.
pub fn adopt_window_tabs(ctx: &Ctx, h: &Arc<BrowserHandle>) -> usize {
    let mut adopted = 0;
    let tracked: Vec<String> = ctx.sessions.all().iter().flat_map(|s| s.tabs.iter().map(|t| t.target_id.clone())).collect();
    for t in page_targets(&h.cdp) {
        let tid = t["targetId"].as_str().unwrap_or("").to_string(); let url = t["url"].as_str().unwrap_or("").to_string();
        if tid.is_empty() || tracked.contains(&tid) || url == "about:blank" || url.starts_with("chrome://") || url.starts_with("devtools://") || url.starts_with("chrome-extension://") { continue; }
        let Ok((wid, _)) = h.cdp.window_for_target(&tid) else { continue };
        let Some(owner) = ctx.sessions.all().into_iter().find(|s| s.window_id == Some(wid) && s.hwnd.is_some()) else { continue };
        let mut s = owner.clone();
        if crate::attach_tab(&mut s, &h.cdp, &tid).is_ok() { adopted += 1; pup_core::log(format!("[recover] \"{}\": adopted the window's untracked tab {}", s.id, &url[..url.len().min(80)])); ctx.sessions.insert(s.clone()); ctx.sessions.save_file(&s); }
    }
    adopted
}

pub fn recover_at_boot(ctx: &Ctx) {
    // 2.0.718: the owner chip went default-OFF in 2.0.713 (John: it covers the page's own controls), but a desktop where
    // the Settings dialog had ever saved its values kept the old default stored as "on" (ConfRoomROG, measured). Before
    // 2.0.713 "on" was the default, so a stored "on" is almost never a choice: turn it off once. Anyone who turns the
    // chip on after this keeps it (the marker means the migration never runs again).
    if ctx.settings.get("ownerChipMigrated", "") != "713" {
        if ctx.settings.get("ownerChip", "off") == "on" { let _ = ctx.settings.set("ownerChip", json!("off")); pup_core::log("[settings] ownerChip was stored as the old default \"on\"; turned off once (2.0.713 made it default-off)"); }
        let _ = ctx.settings.set("ownerChipMigrated", json!("713"));
    }
    // 2.0.659 (pup-bridge#113): throwaway lanes do not survive a pup restart. End any browser still on an
    // iso-* profile and remove the profile; their windows were fleeting by definition.
    if let Ok(rd) = std::fs::read_dir(pup_browser::profiles_dir(&*ctx.host)) {
        for e in rd.flatten() {
            let name = e.file_name().to_string_lossy().to_string();
            if !name.starts_with("iso-") { continue; }
            let pids = crate::orphan_browser_pids(ctx, &e.path());
            for p in &pids { ctx.host.kill(*p); }
            std::thread::sleep(Duration::from_millis(if pids.is_empty() { 0 } else { 800 }));
            let removed = std::fs::remove_dir_all(e.path()).is_ok();
            pup_core::log(format!("[profile] boot: throwaway profile {name}: ended {} browser process(es), removed={removed}", pids.len()));
            for id in ctx.sessions.for_profile(&name).iter().map(|s| s.id.clone()).collect::<Vec<_>>() { ctx.sessions.remove(&id); ctx.sessions.delete_file(&id); }
        }
    }
    let files = ctx.sessions.read_files();
    if files.is_empty() { pup_core::log("Recovering 0 session(s) from disk..."); return; }
    pup_core::log(format!("Recovering {} session(s) from disk...", files.len()));
    let mut taken: Vec<String> = Vec::new();
    // 2.0.454: named window sessions first; hwnd-less "adopted-*" leftovers last, so they can never claim a
    // window's targets ahead of the session that owns the window.
    let mut files = files; files.sort_by_key(|f| f["sessionId"].as_str().unwrap_or("").starts_with("adopted-"));
    // 2.0.682: decide ONCE, before anything relaunches, which profiles' browsers were already gone at boot. Their
    // windows are lost (restore them); a missing window whose browser is still running was closed while pup was down.
    {
        let mut dead_profiles: std::collections::HashMap<String, bool> = std::collections::HashMap::new();
        let mut lost = Vec::new();
        for f in files.iter().filter(|f| f["transport"].as_str() == Some("ext")) {
            let prof = f["profile"].as_str().unwrap_or("adom-you").to_string();
            let dead = *dead_profiles.entry(prof.clone()).or_insert_with(|| { let _s = ProfileScope::enter(&prof); crate::extdrive_pids().is_empty() });
            if dead { if let Some(i) = f["sessionId"].as_str() { lost.push(i.to_string()); } }
        }
        mark_lost(&lost);
    }
    for f in &files {
        let id = f["sessionId"].as_str().unwrap_or("").to_string();
        if id.is_empty() { continue; }
        let pid = f["pid"].as_u64().unwrap_or(0) as u32;
        let profile = f["profile"].as_str().unwrap_or("adom-you").to_string();
        if readopt_ext(ctx, f) { continue; }
        if f["transport"].as_str() == Some("ext") { relaunch_from_file(ctx, f); continue; }
        let hint = f["cdpPort"].as_u64().map(|p| p as u16);
        let Some(h) = reconnect_profile_hint(ctx, &profile, hint) else {
            if pid == 0 || !ctx.host.alive(pid) { relaunch_from_file(ctx, f); } else { pup_core::log(format!("Session \"{id}\": profile \"{profile}\" has no live DevTools port but PID {pid} is alive (Chrome asleep or starting) — leaving the file for the health loop")); }
            continue
        };
        match recover_one(ctx, f, &h, &mut taken) {
            Some(s) => { pup_core::log(format!("Recovered session \"{id}\" (profile: {profile}, URL: {}, hwnd: {:?})", s.active().map(|t| t.url.clone()).unwrap_or_default(), s.hwnd)); ctx.sessions.insert(s.clone()); ctx.sessions.save_file(&s); }
            None => { pup_core::log(format!("Session \"{id}\" — no matching page in Chrome, removing")); ctx.sessions.delete_file(&id); }
        }
    }
    let handles: Vec<Arc<BrowserHandle>> = ctx.browsers.lock().unwrap().values().cloned().collect();
    let adopted: usize = handles.iter().map(|h| adopt_window_tabs(ctx, h)).sum();
    crate::focus_emulate_all(ctx);   // 2.0.458: re-apply the focus-emulation policy to the recovered sessions
    pup_core::log(format!("Recovery complete. {} session(s) active{}.", ctx.sessions.count(), if adopted > 0 { format!(", {adopted} untracked tab(s) adopted into their windows") } else { String::new() }));
}

/// One health tick: dead browser connections are reconnected (never killed), sessions whose
/// target vanished are dropped, stale files are swept.
/// 2.0.453. (a) A session whose last tab target was destroyed keeps its OS window for 15 s: if Chrome
/// recreated a page in that Browser window (sleep, Memory Saver, renderer restart) it is re-attached;
/// once the OS window is gone, or nothing replaced the tab, the session is removed. (b) A hwnd-less
/// session whose tab lives in another session's window (an "adopted-*" leftover) merges into it.
fn tabless_and_orphan_pass(ctx: &Ctx) {
    let now = pup_core::now_ms();
    for s in ctx.sessions.all() {
        // 2.0.536: both halves of this pass reason in CDP terms (window_for_target, browser_for). An ext session's
        // liveness is the lane and its Chrome window id, judged by readopt_ext; this pass removed a live ext
        // session 17 s after recovery rebuilt it, which the file pass then relaunched again.
        if crate::transport::is_ext(&s) { continue; }
        if s.tabs.is_empty() && s.tabless_since_ms > 0 {
            let Some(h) = crate::browser_for(ctx, &s.profile) else { continue };
            let taken: Vec<String> = ctx.sessions.all().iter().flat_map(|x| x.tabs.iter().map(|t| t.target_id.clone())).collect();
            let replacement = page_targets(&h.cdp).into_iter().map(|t| t["targetId"].as_str().unwrap_or("").to_string())
                .filter(|tid| !tid.is_empty() && !taken.contains(tid))
                .find(|tid| h.cdp.window_for_target(tid).ok().map(|(w, _)| Some(w) == s.window_id).unwrap_or(false));
            if let Some(tid) = replacement {
                let mut ns = s.clone();
                if crate::attach_tab(&mut ns, &h.cdp, &tid).is_ok() { ns.tabless_since_ms = 0; pup_core::log(format!("[health] \"{}\": Chrome replaced its tab; re-attached the new page target", ns.id)); ctx.sessions.insert(ns.clone()); ctx.sessions.save_file(&ns); if let Some(a) = ctx.arc() { crate::identity::schedule(a, ns.id.clone(), crate::identity::internal_caller(), false); } continue; }
            }
            let window_alive = s.hwnd.map(|h| ctx.host.is_alive(h)).unwrap_or(false);
            if !window_alive || now.saturating_sub(s.tabless_since_ms) > 15_000 {
                pup_core::log(format!("[health] session \"{}\" dead (its last tab was closed outside pup and {}) — removing", s.id, if window_alive { "nothing replaced it in 15 s" } else { "its window is gone" }));
                ctx.sessions.remove(&s.id); ctx.sessions.delete_file(&s.id);
            }
            continue;
        }
        if s.hwnd.is_none() && !s.tabs.is_empty() && s.id.starts_with("adopted-") {
            let Some(h) = crate::browser_for(ctx, &s.profile) else { continue };
            let Ok((wid, _)) = h.cdp.window_for_target(&s.tabs[0].target_id) else { continue };
            if let Some(owner) = ctx.sessions.all().into_iter().find(|x| x.id != s.id && x.window_id == Some(wid) && x.hwnd.is_some()) {
                let mut o = owner.clone();
                for t in &s.tabs { let mut t2 = t.clone(); t2.tab_id = o.next_tab_id(); o.tabs.push(t2); }
                pup_core::log(format!("[health] merged the hwnd-less session \"{}\" into \"{}\" (same Browser window)", s.id, o.id));
                ctx.sessions.insert(o.clone()); ctx.sessions.save_file(&o); ctx.sessions.remove(&s.id); ctx.sessions.delete_file(&s.id);
            }
        }
    }
}

/// A Chrome whose BROWSER process stops answering is invisible to a bridge that only ever times out: on
/// 2026-09-10 John's Chrome browser main thread spun at 98 % of a core for hours, so its taskbar tab strip and
/// his touchpad wheel stopped working, pup's calls timed out one by one, and pup said nothing. Every health
/// tick now pings the browser endpoint itself (Browser.getVersion, no page involved). Three misses in a row
/// (about 45 s) is not a slow page, it is a browser that is not coming back: pup says so in the log, toasts the
/// user with what to do, and carries it in pup_status / pup_readiness until it answers again.
fn browser_liveness(ctx: &Ctx) {
    let handles: Vec<(String, Arc<BrowserHandle>)> = ctx.browsers.lock().unwrap().iter().map(|(p, h)| (p.clone(), h.clone())).collect();
    for (profile, h) in handles {
        let alive = h.cdp.send(None, "Browser.getVersion", json!({}), Duration::from_secs(5)).is_ok();
        let mut m = ctx.browser_stall.lock().unwrap();
        let misses = m.entry(profile.clone()).or_insert(0u32);
        if alive { if *misses >= 3 { pup_core::log(format!("[health] the browser for profile \"{profile}\" is answering again")); } *misses = 0; continue; }
        *misses += 1;
        let n = *misses;
        drop(m);
        if n == 3 {
            let tabs: usize = ctx.sessions.for_profile(&profile).iter().map(|s| s.tabs.len()).sum();
            pup_core::log(format!("[health] the browser for profile \"{profile}\" has not answered 3 checks in a row (~45 s). Its browser process is wedged, not busy: your windows will not scroll with the wheel and their tab strips will not respond, though dragging a scrollbar still works. pup cannot fix this from inside; that Chrome has to be restarted. {tabs} tab(s) are saved in the session files and come back with it."));
            let _ = ctx.ab.call("notify_user", json!({
                "title": "pup: this browser has stopped responding",
                "body": format!("The Chrome behind your pup windows (profile {profile}) is wedged: the wheel and the tab strip will not respond, though the scrollbar still drags. Restarting it fixes it, and your tabs are saved. Ask your AI thread to run pup_restart_browser."),
                "level": "warning", "focus": false }), &crate::identity::internal_caller(), Duration::from_secs(8));
        }
    }
}

/// A browser with no pup windows is holding the user's RAM for nothing. Measured on John's laptop,
/// 2026-09-11, with zero tabs open: 657 MB to 1.2 GB across 8 processes, most of it a GPU process still
/// sitting on allocations from 3D pages that had been closed. pup has always had pup_quit_idle_browsers and
/// never called it, so an empty Chrome lived until the machine rebooted. Now: a browser that has had no
/// windows for `idleBrowserQuitMin` minutes (default 10, 0 disables) is closed. The grace means a thread that
/// closes one window and opens another never pays a cold start, and nothing on disk is lost: the profile,
/// its cookies and its logins are files, so the next open comes back signed in.
fn idle_browser_reap(ctx: &Ctx) {
    let mins: u64 = ctx.settings.get("idleBrowserQuitMin", "10").parse().unwrap_or(10);
    if mins == 0 { return; }
    let now = pup_core::now_ms();
    let profiles: Vec<String> = ctx.browsers.lock().unwrap().keys().cloned().collect();
    for p in profiles {
        if !ctx.sessions.for_profile(&p).is_empty() { ctx.browser_idle_since.lock().unwrap().remove(&p); continue; }
        let since = *ctx.browser_idle_since.lock().unwrap().entry(p.clone()).or_insert(now);
        if now.saturating_sub(since) < mins * 60_000 { continue; }
        let Some(b) = ctx.browsers.lock().unwrap().remove(&p) else { continue };
        let mb = ctx.host.process_tree_rss_mb(b.launched.pid).unwrap_or(0);
        let _ = b.cdp.send(None, "Browser.close", json!({}), Duration::from_secs(5));
        b.cdp.close();
        if b.launched.pid > 0 { std::thread::sleep(Duration::from_millis(800)); if ctx.host.alive(b.launched.pid) { ctx.host.kill(b.launched.pid); } }
        ctx.browser_idle_since.lock().unwrap().remove(&p);
        pup_core::log(format!("[idle] closed the browser for profile \"{p}\": no pup windows for {mins} min, and it was holding {mb} MB. The next open launches a fresh one, still signed in."));
    }
}

/// 2.0.595: forget ONE session whose window the user closed. Nothing is restored: "pup windows are fleeting,
/// a user uses them for some AI it's doing, then closes the window and doesn't care about them anymore"
/// (John, 2026-09-15, after a cold open brought back six windows he had closed by hand).
fn forget_session(ctx: &Ctx, s: &Session, why: &str) {
    ctx.sessions.remove(&s.id); ctx.sessions.delete_file(&s.id);
    cancel_restore(&s.id);
    // 2.0.682 (John, 2026-09-23): closing a thread's taskbar icon while an upgrade was relaunching its windows one by
    // one brought the NEXT window of that thread back 13-28 s later, twice. A user close now cancels every queued
    // restore of the same thread, so the icon they closed stays closed.
    if let Some(owner) = s.owner.as_deref() {
        for f in ctx.sessions.read_files() {
            let Some(sid) = f["sessionId"].as_str() else { continue };
            if sid == s.id || f["owner"].as_str() != Some(owner) || ctx.sessions.exists(sid) || !restoring(sid) { continue; }
            cancel_restore(sid); ctx.sessions.delete_file(sid);
            pup_core::log(format!("[lane] \"{sid}\": the user closed \"{}\" of the same thread ({owner}) during a restore; its queued restore is cancelled", s.id));
        }
    }
    pup_core::log(format!("[lane] \"{}\": {why}; forgotten, nothing restored", s.id));
    crate::push_cleanup_report(format!("the user closed the \"{}\" pup window ({why}). pup forgot it and will not bring it back; open it again with pup_open_window if you still need it.", s.id));
}

/// The user's close is final. Two signals, both ground truth: the worker reports every chrome.windows.onRemoved
/// on its next poll, and a session whose OS window is dead while its lane is alive has no window to come back
/// to. A dead hwnd whose Chrome window still exists is a stale handle, not a close: it is unbound for rebind.
fn forget_user_closed(ctx: &Ctx) {
    // 2.0.707: once per browser. Each profile's browser reports its own closes on its own lane, and this only ever read
    // the default lane, so a closed ISOLATED window was never forgotten: its session stayed, sweep_idle_isolated (rightly)
    // kept its browser, and the windowless browser sat in the system tray (John, 2026-10-11).
    let mut profiles = vec!["adom-you".to_string()];
    for l in crate::extdrive::secondary_lanes() { if !profiles.contains(&l.profile) { profiles.push(l.profile.clone()); } }
    for p in profiles { let _scope = ProfileScope::enter(&p); forget_user_closed_on(ctx, &p); }
}
fn forget_user_closed_on(ctx: &Ctx, profile: &str) {
    let removed = crate::extdrive::take_removed();
    let ext: Vec<Session> = ctx.sessions.all().into_iter().filter(|s| crate::transport::is_ext(s) && s.profile == profile).collect();
    if ext.is_empty() { return; }
    for s in &ext {
        if let Some(w) = s.window_id { if removed.contains(&w) { forget_session(ctx, s, "its window was closed by the user (reported by the extension)"); } }
    }
    if !crate::extdrive::alive(ctx) || crate::extdrive::connected_ms_ago() > 8000 { return; }
    let dead: Vec<Session> = ctx.sessions.all().into_iter().filter(|s| crate::transport::is_ext(s) && s.profile == profile && s.hwnd.map(|h| !ctx.host.is_alive(h)).unwrap_or(false)).collect();
    if dead.is_empty() { return; }
    let Ok(r) = crate::extdrive::run(json!({ "action": "tabs" }), Duration::from_secs(10)) else { return };
    let live: Vec<i64> = r["tabs"].as_array().cloned().unwrap_or_default().iter().filter_map(|t| t["windowId"].as_i64()).collect();
    for s in dead {
        match s.window_id {
            Some(w) if live.contains(&w) => { let mut u = s.clone(); u.hwnd = None; ctx.sessions.insert(u.clone()); ctx.sessions.save_file(&u); pup_core::log(format!("[lane] \"{}\": its OS handle is dead but Chrome window {w} lives; unbound for rebind", s.id)); }
            _ => forget_session(ctx, &s, "its OS window is gone while the lane is alive (closed by the user)"),
        }
    }
}

/// What ended the lane browser, from the exit code of the pids pup launched (host.watch_process at launch).
/// Some(code) with code != 0 is a crash; 0 is the user closing the last window (or pup's own kill); None when
/// still running or never watched (after a pup restart), which is NOT proof of a crash.
fn lane_crash_code(ctx: &Ctx) -> Option<u32> {
    // pup's own kill counts as "not the user": restore, do not forget (2.0.600).
    if crate::extdrive_pids().iter().any(|p| ctx.host.ended_by_pup(*p)) { return Some(1); }
    crate::extdrive_pids().iter().filter_map(|p| ctx.host.exit_code(*p)).find(|c| *c != 0)
}

/// 2.0.580 restored every extension session from disk whenever the lane went quiet with no window on glass,
/// on the theory that a silent lane is a dead lane. 2.0.595: a lane that is gone is, by default, a lane whose
/// windows the USER closed (Chrome exits with its last window, exit code 0). Restore ONLY a proven crash
/// (nonzero exit code, sleepRecovery on); everything else is forgotten. A pup restart still restores what
/// was live when it died (recover_at_boot), which is the one restore a user expects.
fn lane_gone_pass(ctx: &Ctx) {
    static LAST: std::sync::Mutex<u64> = std::sync::Mutex::new(0);
    let ext: Vec<Session> = ctx.sessions.all().into_iter().filter(|s| crate::transport::is_ext(s)).collect();
    if ext.is_empty() { return; }
    let pids = crate::extdrive_pids();
    let process_alive = !pids.is_empty() && pids.iter().any(|p| ctx.host.alive(*p));
    let alive_sessions = ext.iter().filter(|s| s.hwnd.map(|h| ctx.host.is_alive(h)).unwrap_or(false)).count();
    // a window on glass is a live lane whatever the worker or the pids say (2.0.588, the kicad window John raised)
    if alive_sessions > 0 { return; }
    let ago = if crate::extdrive::last_poll_ms() == 0 { u64::MAX } else { crate::extdrive::connected_ms_ago() };
    let gone = if !pids.is_empty() && !process_alive { true }   // the browser process ended: known now, not in 3 min
        else if ago == u64::MAX { false }
        else { ago >= 180_000 && !process_alive || ago >= 600_000 };
    if !gone { return; }
    { let mut g = LAST.lock().unwrap(); if pup_core::now_ms().saturating_sub(*g) < 60_000 { return; } *g = pup_core::now_ms(); }
    let crash = lane_crash_code(ctx);
    let restore = crash.is_some() && ctx.settings.get("sleepRecovery", "on") == "on";
    if restore {
        pup_core::log(format!("[health] the lane browser CRASHED (exit code 0x{:x}); restoring {} session(s) from disk", crash.unwrap_or(0), ext.len()));
        let ids: Vec<String> = ext.iter().map(|s| s.id.clone()).collect();
        for s in &ext { ctx.sessions.save_file(s); ctx.sessions.remove(&s.id); }
        crate::kill_extdrive(ctx);
        mark_lost(&ids);
        for f in ctx.sessions.read_files() {
            let id = f["sessionId"].as_str().unwrap_or("");
            if ids.iter().any(|x| x == id) { relaunch_from_file(ctx, &f); }
        }
        return;
    }
    let why = if !pids.is_empty() && !process_alive { "the lane browser exited cleanly (exit code 0: its last window was closed by the user)".to_string() }
        else if process_alive { format!("the lane browser has been silent {} s with no window on glass (a hung process, not a crash)", ago / 1000) }
        else { format!("the lane has been silent {} s with no window on glass and no process to ask", ago / 1000) };
    pup_core::log(format!("[health] {why}: forgetting {} session(s), nothing restored (pup windows are fleeting)", ext.len()));
    for s in &ext { forget_session(ctx, s, &why); }
    crate::kill_extdrive(ctx);
}

/// 2.0.706: end isolated (iso-*) browsers whose windows are gone, and delete their throwaway profiles. This only
/// happened at boot, so every isolated window a thread opened and closed left its browser running for the rest of
/// the session, each one a Chrome for Testing icon in the system tray (John's laptop, 2026-10-07: three icons,
/// two of them windowless isolated browsers from 15:13 and 17:52). A profile no session uses on two health ticks in
/// a row goes; never while a window is being born.
pub fn sweep_idle_isolated(ctx: &Ctx) -> usize {
    static IDLE: std::sync::Mutex<Option<std::collections::HashSet<String>>> = std::sync::Mutex::new(None);
    let Ok(_birth) = crate::BIRTH_LOCK.try_lock() else { return 0 };
    let Ok(rd) = std::fs::read_dir(pup_browser::profiles_dir(&*ctx.host)) else { return 0 };
    let mut idle = IDLE.lock().unwrap();
    let prev = idle.take().unwrap_or_default();
    let mut now_idle = std::collections::HashSet::new();
    let mut n = 0;
    for e in rd.flatten() {
        let name = e.file_name().to_string_lossy().to_string();
        if !name.starts_with("iso-") || !ctx.sessions.for_profile(&name).is_empty() { continue; }
        if !prev.contains(&name) { now_idle.insert(name); continue; }   // first sighting: wait one more tick
        let pids = crate::orphan_browser_pids(ctx, &e.path());
        for p in &pids { ctx.host.kill(*p); }
        std::thread::sleep(Duration::from_millis(if pids.is_empty() { 0 } else { 800 }));
        let removed = std::fs::remove_dir_all(e.path()).is_ok();
        crate::extdrive::drop_lane_for_profile(&name);
        pup_core::log(format!("[profile] isolated profile {name} has no window: ended {} browser process(es), removed={removed}", pids.len()));
        n += 1;
    }
    *idle = Some(now_idle);
    n
}

/// 2.0.728 (John, 2026-10-20: a wiki 3D viewer failed in pup with "Cannot read properties of null (reading 'alpha')"
/// while it worked in his own Chrome). After its GPU process crashes a few times, Chrome stops using the GPU for the
/// rest of that browser session, and current Chrome has no software WebGL fallback, so every 3D page breaks silently
/// until the browser restarts. Probe WebGL in a pup window every 5 min (every minute once a probe fails); after two
/// failures in a row, restart pup's browser when no AI has used a pup window for a minute (at most every 30 min), so
/// every window comes back with 3D working, and tell the user.
/// The Chrome for Testing build the adom-you lane browser was launched from (set in launch_extdrive).
pub static LANE_BUILD: std::sync::Mutex<Option<String>> = std::sync::Mutex::new(None);

/// 2.0.734 (John, 2026-10-25: "it sure seems like u should just be upgrading silently and always"). pup pinned one
/// Chrome for Testing build, which has no updater of its own, so once it aged Chrome showed "New Chrome available"
/// in pup windows. Now: a few minutes after start and every 6 h, check Google's Stable channel; download and unpack
/// a newer build in the background (detect_cft then prefers it). When the main lane still runs an older build, wait
/// until no AI has used pup for 10 minutes and no pup window is the one the user is looking at, then restart pup's
/// browser once; every window comes back at its page on the new build.
pub fn cft_auto_update(ctx: &Ctx) {
    static STATE: std::sync::Mutex<(u64, bool, Option<String>)> = std::sync::Mutex::new((0, false, None));   // last check, check running, restarted for build
    static BOOT: std::sync::OnceLock<u64> = std::sync::OnceLock::new();
    let now = pup_core::now_ms();
    let boot = *BOOT.get_or_init(|| now);
    if ctx.settings.get("browserAutoUpdate", "on") == "off" { return; }
    let (last, running, restarted_for) = STATE.lock().unwrap().clone();
    // 1. check + stage
    if !running && now.saturating_sub(boot) > 3 * 60_000 && (last == 0 || now.saturating_sub(last) > 6 * 3600_000) {
        STATE.lock().unwrap().0 = now; STATE.lock().unwrap().1 = true;
        let host = ctx.host.clone();
        std::thread::Builder::new().name("cft-update".into()).spawn(move || {
            let current = pup_browser::detect_cft(&*host).and_then(|c| c.build_id).unwrap_or_else(|| pup_browser::CFT_BUILD_ID.to_string());
            match pup_browser::latest_stable_build() {
                Some(latest) if pup_browser::build_newer(&latest, &current) => {
                    pup_core::log(format!("[chrome-update] Stable is {latest}, pup has {current}: downloading it in the background"));
                    let st = std::sync::Arc::new(std::sync::Mutex::new(pup_browser::InstallState::default()));
                    match pup_browser::install_cft_build(&*host, &st, &latest) {
                        Ok(_) => pup_core::log(format!("[chrome-update] {latest} is ready; pup's browser moves to it at its next start")),
                        Err((code, e)) => pup_core::log(format!("[chrome-update] could not install {latest} ({code}): {e}; pup keeps {current}")),
                    }
                }
                Some(latest) => pup_core::log(format!("[chrome-update] up to date ({current}; Stable is {latest})")),
                None => pup_core::log("[chrome-update] could not read the Chrome for Testing Stable version; will try again later"),
            }
            STATE.lock().unwrap().1 = false;
        }).ok();
        return;
    }
    // 2. switch the running lane to a newer staged build at a quiet moment
    let Some(newest) = pup_browser::detect_cft(&*ctx.host).and_then(|c| c.build_id) else { return };
    let Some(lane) = LANE_BUILD.lock().unwrap().clone() else { return };
    if !pup_browser::build_newer(&newest, &lane) || restarted_for.as_deref() == Some(newest.as_str()) { return; }
    let sessions = ctx.sessions.for_profile("adom-you");
    if sessions.is_empty() { return; }   // nothing open: the next open launches the new build anyway
    let busy = sessions.iter().map(|s| s.last_agent_update_ms.max(s.user_active_at_ms)).max().unwrap_or(0);
    if now.saturating_sub(busy) < 10 * 60_000 { return; }
    let fg = ctx.host.foreground();
    if fg != 0 && sessions.iter().any(|s| s.hwnd == Some(fg)) { return; }   // the user is looking at a pup window
    STATE.lock().unwrap().2 = Some(newest.clone());
    let Some(a) = ctx.arc() else { return };
    pup_core::log(format!("[chrome-update] moving pup's browser from {lane} to {newest} while it is quiet; every window comes back at its page"));
    std::thread::Builder::new().name("cft-switch".into()).spawn(move || {
        let ctx2: &Ctx = &a;
        let _ = crate::extras::restart_browser(ctx2, &crate::identity::internal_caller(), &json!({ "profile": "adom-you", "reason": format!("move pup's browser to Chrome {newest}") }));
    }).ok();
}

pub fn webgl_watch(ctx: &Ctx) {
    static STATE: std::sync::Mutex<(u64, u32, u64, u64)> = std::sync::Mutex::new((0, 0, 0, 0));   // last probe, failures, last restart, last wait log
    let now = pup_core::now_ms();
    let (last, fails, last_restart, _) = *STATE.lock().unwrap();
    let every = if fails > 0 { 60_000 } else { 300_000 };
    if now.saturating_sub(last) < every { return; }
    STATE.lock().unwrap().0 = now;
    let sessions: Vec<Session> = ctx.sessions.for_profile("adom-you").into_iter().filter(|s| crate::transport::is_ext(s)).collect();
    let probe = sessions.iter().find_map(|s| {
        let t = s.active().filter(|t| t.url.starts_with("http") || t.url.starts_with("file:"))?.clone();
        let lane = crate::transport::lane_for(ctx, s).ok()?;
        let expr = "(()=>{try{const c=document.createElement('canvas');const g=c.getContext('webgl2')||c.getContext('webgl');if(!g)return false;const l=g.getExtension('WEBGL_lose_context');if(l)l.loseContext();return true}catch(e){return null}})()";
        let r = crate::transport::cdp_raw(&lane, &t, "Runtime.evaluate", json!({ "expression": expr, "returnByValue": true }), 10).ok()?;
        r["result"]["value"].as_bool().map(|v| (v, s.id.clone()))
    });
    let Some((has_webgl, sid)) = probe else { return };
    if has_webgl { if fails > 0 { pup_core::log(format!("[webgl] back in \"{sid}\"")); } STATE.lock().unwrap().1 = 0; return; }
    let fails = { let mut g = STATE.lock().unwrap(); g.1 += 1; g.1 };
    pup_core::log(format!("[webgl] OFF in \"{sid}\" (probe {fails}): the browser has stopped giving pages 3D graphics, most likely after GPU-process crashes"));
    if fails < 2 { return; }
    if now.saturating_sub(last_restart) < 30 * 60_000 { return; }
    let busy_ms = sessions.iter().map(|s| s.last_agent_update_ms).max().unwrap_or(0);
    if now.saturating_sub(busy_ms) < 60_000 {
        let mut g = STATE.lock().unwrap();
        if now.saturating_sub(g.3) > 300_000 { g.3 = now; pup_core::log("[webgl] waiting for a quiet minute (an AI used a pup window in the last 60 s) before restarting the browser"); }
        return;
    }
    { let mut g = STATE.lock().unwrap(); g.1 = 0; g.2 = now; }
    let Some(a) = ctx.arc() else { return };
    std::thread::Builder::new().name("webgl-restart".into()).spawn(move || {
        let ctx2: &Ctx = &a;
        let caller = crate::identity::internal_caller();
        pup_core::log("[webgl] restarting pup's browser so 3D pages work again; every window comes back at its page");
        let r = crate::extras::restart_browser(ctx2, &caller, &json!({ "profile": "adom-you", "reason": "WebGL was off after GPU crashes; a fresh browser restores 3D" }));
        let mut ok = r["ok"] != json!(false) && r["success"] != json!(false);
        // The restore after a restart can stall on a busy box (ConfRoomROG, 2026-10-20: 3 of 8 windows back, the rest
        // left on disk while the extension went quiet; a second restart brought all of them). Check after 3 minutes and
        // restart once more if any saved window is still waiting.
        std::thread::sleep(Duration::from_secs(180));
        let live: std::collections::HashSet<String> = ctx2.sessions.all().iter().map(|s| s.id.clone()).collect();
        let waiting: Vec<String> = ctx2.sessions.read_files().iter().filter(|f| f["profile"].as_str().unwrap_or("adom-you") == "adom-you").filter_map(|f| f["sessionId"].as_str().map(|s| s.to_string())).filter(|id| !live.contains(id)).collect();
        if !waiting.is_empty() {
            pup_core::log(format!("[webgl] {} window(s) still waiting after the restart ({}); restarting once more to bring them back", waiting.len(), waiting.join(", ")));
            let r2 = crate::extras::restart_browser(ctx2, &caller, &json!({ "profile": "adom-you", "reason": "finish restoring windows after the WebGL restart" }));
            ok = r2["ok"] != json!(false) && r2["success"] != json!(false);
        }
        let _ = ctx2.ab.call("notify_user", json!({ "title": "pup restarted its browser", "body": if ok { "3D graphics (WebGL) had stopped working in pup's browser, so pages like the wiki's 3D viewer were failing. pup restarted it; your pup windows are back at the same pages." } else { "3D graphics (WebGL) stopped working in pup's browser and the automatic restart failed. Ask your AI to run pup_restart_browser." }, "focus": false }), &caller, Duration::from_secs(8));
    }).ok();
}

pub fn health_tick(ctx: &Ctx) {
    sweep_idle_isolated(ctx);   // 2.0.706: an isolated window's browser ends with its window
    crate::extdrive::adopt_tokenless(ctx);   // 2.0.580: a token-less extension browser after a restart
    forget_user_closed(ctx);                 // 2.0.595: the user's close is final
    lane_gone_pass(ctx);                     // 2.0.595: a gone lane is forgotten unless it provably crashed
    cft_auto_update(ctx);   // 2.0.734: keep pup's browser on the current Chrome Stable, silently
    webgl_watch(ctx);   // 2.0.728: Chrome turned WebGL off after GPU crashes; restart when quiet
    crate::extdrive::rescue_birth_tabs(ctx);   // 2.0.724: a window stuck on its birth page (PUPBIRTH-... on the taskbar)
    crate::extdrive::adopt_stray_windows(ctx);   // 2.0.694: windows Chrome made outside pup (a dragged-out tab, a page's popup)
    crate::extdrive::rebind_unbound(ctx);   // ext sessions whose birth outran the hwnd wait: bind + stamp now
    crate::identity::stamp_stray_cft_windows(ctx);   // 2.0.739 (#143): no pup window wears a bare Chrome button while recovery catches up
    crate::extdrive::reload_stale_worker(ctx);   // a bridge_install must not wait for every thread's window to close
    crate::extdrive::close_orphan_blank_windows(ctx, None);   // and any about:blank window no session owns
    // 2.0.542: a lane browser that outlived a pup update keeps running the OLD extension until it cycles
    // (measured: worker.2-0-539 still serving under pup 2.0.541, so a brand rewrite shipped in .540 never
    // ran). When no extension-lane window is open, end it; the next open stages the current extension.
    if let Some((w, want)) = crate::extdrive::lane_worker_stale() {
        // 2.0.692: "idle" means no extension-lane window in memory AND none saved on disk waiting to be re-adopted.
        // Right after a restart both of John's windows were still waiting, the in-memory list was empty, and this
        // ended the browser with them on it; one did not come back (AdomLapper, 2026-10-03 13:02).
        let saved_ext = ctx.sessions.read_files().iter().any(|f| f["transport"].as_str() == Some("ext"));
        if crate::extdrive_browser_alive(ctx) && !saved_ext && !ctx.sessions.all().iter().any(|s| crate::transport::is_ext(s)) {
            pup_core::log(format!("[lane] cycling the idle lane browser: it runs extension {} while pup is {}", w.replace('-', "."), want.replace('-', ".")));
            crate::kill_extdrive(ctx);
            crate::extdrive::reset_worker_version();
        }
    }
    browser_liveness(ctx);
    idle_browser_reap(ctx);
    tabless_and_orphan_pass(ctx);
    let profiles: Vec<(String, u32)> = ctx.browsers.lock().unwrap().iter().filter(|(_, b)| !b.cdp.is_alive()).map(|(p, b)| (p.clone(), b.launched.pid)).collect();
    for (p, pid) in profiles {
        pup_core::log(format!("[health] browser for profile \"{p}\" disconnected — attempting reconnect"));
        ctx.browsers.lock().unwrap().remove(&p);
        match reconnect_profile(ctx, &p) {
            Some(h) => {
                let mut taken = Vec::new();
                for s in ctx.sessions.for_profile(&p) {
                    let f = s.file_json();
                    match recover_one(ctx, &f, &h, &mut taken) { Some(ns) => { ctx.sessions.insert(ns.clone()); ctx.sessions.save_file(&ns); if let Some(a) = ctx.arc() { crate::identity::schedule(a, ns.id.clone(), crate::identity::internal_caller(), true); } } None => { pup_core::log(format!("[health] session \"{}\" not found after reconnect — removing", s.id)); ctx.sessions.remove(&s.id); ctx.sessions.delete_file(&s.id); } }
                }
                let n = adopt_window_tabs(ctx, &h); if n > 0 { pup_core::log(format!("[health] {n} untracked tab(s) adopted into their windows after the reconnect")); }
                crate::focus_emulate_all(ctx);
            }
            None => {
                // Chrome is GONE (its pid is dead): every window on that profile comes back from its file
                // (Node: the crash auto-recover, "pup auto-recovered your window after a Chrome crash").
                // A live-but-silent Chrome (asleep, hung, restarting) is left alone for the next tick.
                if pid == 0 || !ctx.host.alive(pid) {
                    // 2.0.595: Chrome exits with its last window (code 0) when the USER closes it; only a nonzero
                    // exit code is a crash worth restoring. Never watched (pid 0, or a pup restart) is not proof.
                    let code = if pid == 0 { None } else { ctx.host.exit_code(pid) };
                    // 2.0.600: PUP's OWN kill is not a user close. pup ends browsers for its own reasons (a
                    // profile handover, a lane relaunch, a restart), and reading that as "the user closed the
                    // last window" deleted live windows: the ab-smoke window died this way three runs running
                    // (ConfRoomROG, 2026-09-16, "is gone with exit code Some(0): not a crash. Forgetting").
                    // Only a browser that ended on ITS OWN with code 0 means the user closed its last window.
                    let ours = pid != 0 && ctx.host.ended_by_pup(pid);
                    let crashed = (ours || matches!(code, Some(c) if c != 0)) && ctx.settings.get("sleepRecovery", "on") == "on";
                    if !crashed {
                        let gone: Vec<Session> = ctx.sessions.for_profile(&p);
                        pup_core::log(format!("[health] Chrome for profile \"{p}\" (pid {pid}) is gone with exit code {code:?}: not a crash. Forgetting its {} window(s), nothing restored (pup windows are fleeting)", gone.len()));
                        for s in &gone { forget_session(ctx, s, "its browser exited cleanly (the user closed its last window)"); }
                        continue;
                    }
                    let files: Vec<Value> = ctx.sessions.for_profile(&p).iter().map(|s| s.file_json()).collect();
                    pup_core::log(format!("[health] Chrome for profile \"{p}\" (pid {pid}) {} — relaunching its {} window(s) from their files", if ours { "was ended by pup itself (a handover or relaunch), not by the user".to_string() } else { format!("CRASHED (exit code 0x{:x})", code.unwrap_or(0)) }, files.len()));
                    // 2.0.469: drop and reserve EVERY session first, then relaunch. Dropping each only at its turn left
                    // the second window listed with its three dead tabs for the ~30 s the first window's relaunch took
                    // (CRR crash drill: "restored 2x3" but 3 of 6 live); a listed window must be a live one.
                    let ids: Vec<String> = files.iter().filter_map(|f| f["sessionId"].as_str().map(|s| s.to_string())).collect();
                    reserve(&ids); mark_lost(&ids);
                    for id in &ids { ctx.sessions.remove(id); }
                    for f in files { relaunch_from_file(ctx, &f); }
                } else {
                    pup_core::log(format!("[health] could not reconnect profile \"{p}\" but its Chrome (pid {pid}) is alive — its sessions wait for the next tick (Chrome may be asleep or restarting)"));
                }
            }
        }
    }
    for s in ctx.sessions.all() {
        if let Some(h) = s.hwnd { if !ctx.host.is_alive(h) { ctx.sessions.with_mut(&s.id, |x| x.hwnd = None); } }
    }
    // No-fault fg watch (Node: the fgWatch interval): if a BACKGROUND window fronts itself within 20s of
    // opening and notMineCaption is on, caption that pup did NOT raise it — then let it stay up.
    if ctx.settings.get("notMineCaption", "off") == "on" {
        let now = pup_core::now_ms(); let fg = ctx.host.foreground();
        for s in ctx.sessions.all() {
            if s.fg_watch_until_ms > now && !s.foreground && s.hwnd == Some(fg) && fg != 0 {
                ctx.sessions.with_mut(&s.id, |x| { x.fg_watch_until_ms = 0; x.foreground = true; x.user_active_at_ms = pup_core::now_ms(); });
                let _ = ctx.ab.call("desktop_caption", json!({ "id": "pup-notmine", "text": format!("Adom did NOT raise this \"{}\" window — the page or Chrome brought it forward.", s.id), "duration": 3000, "expiresInMs": 3000, "position": "bottom", "size": "small" }), &crate::identity::internal_caller(), Duration::from_secs(5));
            }
        }
    }
    for f in ctx.sessions.read_files() {
        let id = f["sessionId"].as_str().unwrap_or("").to_string(); let pid = f["pid"].as_u64().unwrap_or(0) as u32;
        if id.is_empty() || ctx.sessions.exists(&id) || restoring(&id) { continue; }
        // 2.0.536: an ext file is re-adopted from the live lane first; it never enters the CDP reconnect, and it
        // is relaunched only when the lane really is dead (pid is 0 on ext sessions by design, which used to
        // read as "gone" every tick and tore the live window down).
        if f["transport"].as_str() == Some("ext") {
            if readopt_ext(ctx, &f) { continue; }
            let dead = { let _scope = ProfileScope::enter(f["profile"].as_str().unwrap_or("adom-you")); !crate::extdrive::alive(ctx) && crate::extdrive_pids().is_empty() };
            if dead { if let Some(i) = f["sessionId"].as_str() { mark_lost(&[i.to_string()]); } relaunch_from_file(ctx, &f); }
            continue;
        }
        let profile = f["profile"].as_str().unwrap_or("adom-you").to_string();
        if let Some(h) = reconnect_profile_hint(ctx, &profile, f["cdpPort"].as_u64().map(|p| p as u16)) {
            // 2.0.468: a tab a LIVE session already tracks is never re-attached from a file (the URL-prefix rule
            // matched another window's tab at the same page on arav-rog).
            let mut taken: Vec<String> = ctx.sessions.all().iter().flat_map(|s| s.tabs.iter().map(|t| t.target_id.clone())).collect();
            match recover_one(ctx, &f, &h, &mut taken) { Some(ns) => { pup_core::log(format!("[health] re-attached session \"{id}\" to its live Chrome")); ctx.sessions.insert(ns.clone()); ctx.sessions.save_file(&ns); if let Some(a) = ctx.arc() { crate::identity::schedule(a, ns.id.clone(), crate::identity::internal_caller(), true); } } None => relaunch_from_file(ctx, &f) }
            let n = adopt_window_tabs(ctx, &h); if n > 0 { pup_core::log(format!("[health] {n} untracked tab(s) adopted into their windows after the re-attach")); }
            crate::focus_emulate_all(ctx);
        } else if pid == 0 || !ctx.host.alive(pid) { relaunch_from_file(ctx, &f); }
    }
    crate::focus_emulate_all(ctx);   // 2.0.459: converge the focus-emulation policy (no CDP calls when it already matches)
}

/// 2.0.470: the event pump kicks the health loop the moment a browser connection closes, so a crashed Chrome's
/// windows come back within a second or two instead of up to 15 s later (arav-rog crash drill: pup_list_windows
/// listed the dead windows as normal for the rest of the tick).
static HEALTH_KICK: std::sync::atomic::AtomicBool = std::sync::atomic::AtomicBool::new(false);
pub fn kick_health() { HEALTH_KICK.store(true, std::sync::atomic::Ordering::SeqCst); }
/// 2.0.472: how many relaunches are still running. pup_restart_browser waits for this to reach zero before it
/// saves + kills again, so a second restart (or the wedge watchdog firing while a restore is mid-flight) can no
/// longer tear a window down while its tabs are still being reopened into it (ConfRoomROG/arav-rog triple-restart
/// drill: round 2 killed tri-b's window mid-restore — "HWND is not a valid window", "back with 6 of 3").
pub fn relaunches_in_flight() -> usize { IN_FLIGHT.lock().unwrap().len() }
/// 2.0.475: drain on the RESERVATION set, not IN_FLIGHT. A restart/crash batch reserves every window id up front
/// and releases each only as its relaunch runs, so RESTORING stays non-empty for the WHOLE batch; IN_FLIGHT goes
/// momentarily empty BETWEEN two sequential relaunches, and a close that drained on it fired in that gap and
/// missed the second window (close-during-restore cycle: a "cyc-b" lingered, then poisoned the next restart).
pub fn restores_pending() -> usize { RESTORING.lock().unwrap().len() }
pub fn wait_for_relaunches_to_drain(max: Duration) {
    let t0 = std::time::Instant::now();
    while (restores_pending() > 0 || relaunches_in_flight() > 0) && t0.elapsed() < max { std::thread::sleep(Duration::from_millis(100)); }
}
pub fn start_health_loop(ctx: Arc<Ctx>) {
    std::thread::Builder::new().name("health".into()).spawn(move || loop {
        let slept = std::time::Instant::now();
        while slept.elapsed() < Duration::from_secs(15) { if HEALTH_KICK.swap(false, std::sync::atomic::Ordering::SeqCst) { pup_core::log("[health] kicked: a browser connection closed; running the tick now".to_string()); break; } std::thread::sleep(Duration::from_millis(500)); }
        let t0 = std::time::Instant::now(); health_tick(&ctx); pup_core::loops::tick("health+user-activity", 15_000, t0.elapsed().as_millis() as u64);
    }).ok();
}

/// pup_rescan: reconnect dropped connections and rebuild sessions from files; adoptOrphans:true
/// also adopts pages no session owns (as sessions named after their target).
pub fn rescan(ctx: &Ctx, args: &Value) -> Value {
    let before = ctx.sessions.count();
    health_tick(ctx);
    let mut reattached = 0; let mut adopted = 0; let mut reconnected = Vec::new(); let mut unreachable = Vec::new();
    let mut taken: Vec<String> = ctx.sessions.all().iter().flat_map(|s| s.tabs.iter().map(|t| t.target_id.clone())).collect();
    for f in ctx.sessions.read_files() {
        let id = f["sessionId"].as_str().unwrap_or("").to_string();
        if id.is_empty() || ctx.sessions.exists(&id) { continue; }
        let profile = f["profile"].as_str().unwrap_or("adom-you").to_string();
        match reconnect_profile(ctx, &profile) {
            Some(h) => { if !reconnected.contains(&profile) { reconnected.push(profile.clone()); } if let Some(s) = recover_one(ctx, &f, &h, &mut taken) { ctx.sessions.insert(s.clone()); ctx.sessions.save_file(&s); reattached += 1; } }
            None => { if !unreachable.contains(&profile) { unreachable.push(profile.clone()); } }
        }
    }
    if args.get("adoptOrphans").and_then(|b| b.as_bool()) == Some(true) {
        let handles: Vec<(String, Arc<BrowserHandle>)> = ctx.browsers.lock().unwrap().iter().map(|(p, h)| (p.clone(), h.clone())).collect();
        for (profile, h) in handles {
            for t in page_targets(&h.cdp) {
                let tid = t["targetId"].as_str().unwrap_or("").to_string(); let url = t["url"].as_str().unwrap_or("");
                if taken.contains(&tid) || url == "about:blank" || url.starts_with("chrome://") || url.starts_with("devtools://") { continue; }
                // 2.0.453: a page in a window pup already tracks joins THAT session as a tab (keyed by the CDP
                // Browser window id), instead of becoming a hwnd-less standalone "adopted-*" session.
                if let Ok((wid, _)) = h.cdp.window_for_target(&tid) {
                    if let Some(owner) = ctx.sessions.all().into_iter().find(|s| s.window_id == Some(wid) && s.hwnd.is_some()) {
                        let mut s = owner.clone();
                        if crate::attach_tab(&mut s, &h.cdp, &tid).is_ok() { s.tabless_since_ms = 0; taken.push(tid.clone()); ctx.sessions.insert(s.clone()); ctx.sessions.save_file(&s); adopted += 1; pup_core::log(format!("[rescan] \"{}\": adopted the orphan tab {} into its own window", s.id, &url[..url.len().min(80)])); continue; }
                    }
                }
                if let Ok(sid) = h.cdp.attach(&tid) {
                    let _ = h.cdp.page_enable(&sid);
                    let id = format!("adopted-{}", &tid[..tid.len().min(8)].to_lowercase());
                    let mut s = Session::new(&id, &profile, None, None, h.launched.cdp_port, None);
                    let tab_id = s.next_tab_id();
                    s.tabs.push(Tab { tab_id, target_id: tid.clone(), cdp_session: sid, url: url.into(), title: t["title"].as_str().unwrap_or("").into(), error_count: 0, fav: None });
                    taken.push(tid); ctx.sessions.insert(s.clone()); ctx.sessions.save_file(&s); adopted += 1;
                }
            }
        }
    }
    let live: Vec<String> = ctx.sessions.all().iter().filter(|s| crate::browser_for(ctx, &s.profile).is_some()).map(|s| s.id.clone()).collect();
    let rebound = crate::extdrive::rebind_unbound(ctx);
    let disconnected: Vec<String> = ctx.sessions.all().iter().filter(|s| if crate::transport::is_ext(s) { !crate::extdrive_browser_alive(ctx) } else { crate::browser_for(ctx, &s.profile).is_none() }).map(|s| s.id.clone()).collect();
    let hint = if disconnected.is_empty() { format!("All known sessions are live: [{}].", live.join(", ")) } else { format!("{} session(s) still disconnected: [{}] — their Chrome is gone; pup_open_window recreates them.", disconnected.len(), disconnected.join(", ")) };
    ok(json!({ "rescanned": reconnected.len() + unreachable.len(), "liveSessions": live, "disconnectedSessions": disconnected, "before": before, "reattached": reattached, "orphansAdopted": adopted, "profilesReconnected": reconnected.len(), "extWindowsRebound": rebound, "profilesUnreachable": unreachable.len(), "_hint": hint }))
}

#[allow(dead_code)]
fn _host(_: &dyn Host) {}