main
Colby Knox 0.2.49 source (not yet released, needs signing): licence read on Altium Designer 26.x (a title with a version and no li… eadc6b3 5d ago
1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586878889909192939495969798991001011021031041051061071081091101111121131141151161171181191201211221231241251261271281291301311321331341351361371381391401411421431441451461471481491501511521531541551561571581591601611621631641651661671681691701711721731741751761771781791801811821831841851861871881891901911921931941951961971981992002012022032042052062072082092102112122132142152162172182192202212222232242252262272282292302312322332342352362372382392402412422432442452462472482492502512522532542552562572582592602612622632642652662672682692702712722732742752762772782792802812822832842852862872882892902912922932942952962972982993003013023033043053063073083093103113123133143153163173183193203213223233243253263273283293303313323333343353363373383393403413423433443453463473483493503513523533543553563573583593603613623633643653663673683693703713723733743753763773783793803813823833843853863873883893903913923933943953963973983994004014024034044054064074084094104114124134144154164174184194204214224234244254264274284294304314324334344354364374384394404414424434444454464474484494504514524534544554564574584594604614624634644654664674684694704714724734744754764774784794804814824834844854864874884894904914924934944954964974984995005015025035045055065075085095105115125135145155165175185195205215225235245255265275285295305315325335345355365375385395405415425435445455465475485495505515525535545555565575585595605615625635645655665675685695705715725735745755765775785795805815825835845855865875885895905915925935945955965975985996006016026036046056066076086096106116126136146156166176186196206216226236246256266276286296306316326336346356366376386396406416426436446456466476486496506516526536546556566576586596606616626636646656666676686696706716726736746756766776786796806816826836846856866876886896906916926936946956966976986997007017027037047057067077087097107117127137147157167177187197207217227237247257267277287297307317327337347357367377387397407417427437447457467477487497507517527537547557567577587597607617627637647657667677687697707717727737747757767777787797807817827837847857867877887897907917927937947957967977987998008018028038048058068078088098108118128138148158168178188198208218228238248258268278288298308318328338348358368378388398408418428438448458468478488498508518528538548558568578588598608618628638648658668678688698708718728738748758768778788798808818828838848858868878888898908918928938948958968978988999009019029039049059069079089099109119129139149159169179189199209219229239249259269279289299309319329339349359369379389399409419429439449459469479489499509519529539549559569579589599609619629639649659669679689699709719729739749759769779789799809819829839849859869879889899909919929939949959969979989991000100110021003100410051006100710081009101010111012101310141015101610171018101910201021102210231024102510261027102810291030103110321033103410351036103710381039104010411042104310441045104610471048104910501051105210531054105510561057105810591060106110621063106410651066106710681069107010711072107310741075107610771078
//! Agent + application lifecycle, ported from the Python bridge's `handlers/agent.py`.
//!
//! Everything desktop-shaped goes through ab (`desktop_find_window`, `desktop_find_control`,
//! `desktop_ui_click`, `desktop_get_foreground`, `desktop_raise_window`, `process_kill`). Process
//! SPAWNING is not desktop-shaped and ab's direct API refuses `shell_execute` (412 — CLI-only,
//! with pull_file/send_files/hd_send_files), so the bridge owns its child processes directly.
//! What is left here is the file protocol and the sequencing rules paid for in modals on a live box:
//!
//! * ONE script at a time. A `-R` lane is REFUSED while the agent is resident, otherwise Altium
//!   raises "Another script executing now" on the user's screen.
//! * A `-R` lane is a ONE-SHOT. Never retry it in a loop — each attempt that lands on a busy
//!   instance costs the user an "another instance is busy" notice.
//! * The rendered unit is named `AdomAgent_<digest>.pas`: Altium keeps the .PrjScr open in its
//!   Script editor and re-runs the IN-EDITOR copy, so overwriting in place runs stale code.
//! * The bootstrap activates Altium. Capture the user's foreground window first and hand it back.
use crate::ab;
use crate::agent;
use crate::instance;
use serde_json::{json, Value};
use std::path::PathBuf;
use std::time::{Duration, Instant};

const HB_STALE_SEC: u64 = 8;

/// The agent template ships beside the exe (`agent/AdomAgent.pas.tmpl`).
fn agent_dir() -> PathBuf {
    std::env::current_exe()
        .ok()
        .and_then(|p| p.parent().map(|d| d.join("agent")))
        .unwrap_or_else(|| PathBuf::from("agent"))
}

fn template_source() -> std::io::Result<String> {
    std::fs::read_to_string(agent_dir().join("AdomAgent.pas.tmpl"))
}

/// `AdomAgent_<sha256[..10]>` over the RAW template — a new filename whenever the source changes.
fn agent_base(raw: &str) -> String {
    format!("AdomAgent_{}", &sha256_hex(raw.as_bytes())[..10])
}

fn ws_str() -> String {
    let s = agent::workspace_dir().to_string_lossy().to_string();
    if s.ends_with('\\') { s } else { format!("{s}\\") }
}

fn rendered(raw: &str) -> String {
    raw.replace("__WORKSPACE__", &ws_str().replace('\'', "''"))
        .replace("__GEN__", &agent_base(raw))
}

/// Write the script project into the workspace. Idempotent per digest.
pub fn install() -> Value {
    let raw = match template_source() {
        Ok(r) => r,
        Err(e) => return json!({"success": false, "errorCode": "template_missing",
                                "error": format!("cannot read agent/AdomAgent.pas.tmpl beside the exe: {e}")}),
    };
    let ws = agent::workspace_dir();
    if let Err(e) = std::fs::create_dir_all(&ws) {
        return json!({"success": false, "error": format!("cannot create workspace: {e}")});
    }
    let base = agent_base(&raw);
    let pas = ws.join(format!("{base}.pas"));
    let prj = ws.join(format!("{base}.PrjScr"));
    let changed = !pas.exists();
    if changed {
        if let Err(e) = std::fs::write(&pas, rendered(&raw)) {
            return json!({"success": false, "error": format!("cannot write {}: {e}", pas.display())});
        }
        let prj_body = std::fs::read_to_string(agent_dir().join("AdomAgent.PrjScr"))
            .unwrap_or_else(|_| "[Design]\r\nVersion=1.0\r\n[Document1]\r\nDocumentPath=AdomAgent.pas\r\n".into())
            .replace("AdomAgent.pas", &format!("{base}.pas"));
        if let Err(e) = std::fs::write(&prj, prj_body) {
            return json!({"success": false, "error": format!("cannot write {}: {e}", prj.display())});
        }
        prune_generations(3);
    }
    json!({"installed": true, "changed": changed, "prjscr": prj.to_string_lossy(),
           "pas": pas.to_string_lossy(), "digest": &base[base.len() - 10..]})
}

/// Keep the newest `keep` generations so an open editor tab never dangles.
fn prune_generations(keep: usize) {
    let ws = agent::workspace_dir();
    let mut gens: Vec<(std::time::SystemTime, PathBuf)> = match std::fs::read_dir(&ws) {
        Ok(rd) => rd
            .filter_map(|e| e.ok())
            .filter(|e| {
                let n = e.file_name().to_string_lossy().to_string();
                n.starts_with("AdomAgent_") && n.ends_with(".pas")
            })
            .filter_map(|e| e.metadata().ok().and_then(|m| m.modified().ok()).map(|t| (t, e.path())))
            .collect(),
        Err(_) => return,
    };
    gens.sort_by_key(|(t, _)| *t);
    if gens.len() <= keep {
        return;
    }
    for (_, p) in &gens[..gens.len() - keep] {
        let _ = std::fs::remove_file(p);
        let _ = std::fs::remove_file(p.with_extension("PrjScr"));
    }
}

pub fn prjscr_path() -> Option<PathBuf> {
    let raw = template_source().ok()?;
    Some(agent::workspace_dir().join(format!("{}.PrjScr", agent_base(&raw))))
}

fn resident_generation() -> Option<String> {
    let hb = std::fs::read_to_string(agent::workspace_dir().join("hb.txt")).ok()?;
    hb.lines()
        .find_map(|l| l.strip_prefix("gen=").map(|s| s.trim().to_string()))
}

// ---- ab-backed helpers -------------------------------------------------------

fn x2_exe() -> Option<String> {
    // The SELECTED instance's own image first — Develop and Designer live in different install
    // folders, and the launcher hardcoding AD26 sent a Develop-targeted -R lane to Designer
    // (live bug, 2026-09-01).
    if let Some(exe) = crate::instance::selected().and_then(|i| i.get("exe").and_then(|e| e.as_str()).map(str::to_string)) {
        return Some(exe);
    }
    if let Some(exe) = crate::instance::instances().first().and_then(|i| i.get("exe").and_then(|e| e.as_str()).map(str::to_string)) {
        return Some(exe);
    }
    for root in ["C:\\Program Files\\Altium\\AD26", "C:\\Program Files\\Altium\\AD25", "C:\\Program Files\\Altium\\AD24"] {
        let p = format!("{root}\\X2.EXE");
        if std::path::Path::new(&p).exists() {
            return Some(p);
        }
    }
    let pl = ab::call("process_list", json!({"nameFilter": "X2"}), &[], 15);
    pl.get("processes")
        .and_then(|a| a.as_array())
        .and_then(|a| a.iter().find_map(|p| p.get("commandLine").and_then(|s| s.as_str()).and_then(crate::instance::exe_from_command_line)))
}

/// The first dialog on the main Altium window that a -R hand-off would bounce off: anything but the
/// script Error / X2 notice (a lone OK), which recover answers itself.
pub fn blocking_dialog() -> Option<Value> {
    crate::dialogs::scan_scoped(true).into_iter().find(|d| {
        let title = d.get("title").and_then(|t| t.as_str()).unwrap_or("").trim().to_string();
        let lone_ok = d.get("buttons").and_then(|b| b.as_array()).map(|a| a.len() == 1 && a[0].as_str().map(|x| x.eq_ignore_ascii_case("ok")).unwrap_or(false)).unwrap_or(false);
        !(lone_ok && (title.eq_ignore_ascii_case("error") || title.eq_ignore_ascii_case("x2")))
    })
}

/// Spawn the X2 helper. Process spawning is NOT desktop-shaped, and ab's direct API refuses
/// `shell_execute` anyway (412 — that verb is CLI-only, alongside pull_file/send_files), so the
/// bridge owns the child directly: no shell, no quoting layer, and a real handle to wait on.
fn spawn_helper(exe: &str, arg: &str) -> Result<std::process::Child, String> {
    // A -R hand-off cannot reach an Altium frozen behind a modal: the helper bounces off it with
    // "another instance is busy" on the user's screen. agent::call's auto-recover used to spawn one
    // per failing verb, so a single save prompt left mid-battery turned into fifteen minutes of those
    // notices (2026-10-16). The script Error box is the one dialog recover answers itself, so it does
    // not block; anything else (a save prompt, a reload confirm) does.
    if !arg.is_empty() {
        if let Some(d) = blocking_dialog() {
            return Err(format!("blocked_by_dialog: '{}' is open on Altium's main window; answer it first (altium_check_dialogs / altium_dismiss_dialogs)",
                               d.get("title").and_then(|t| t.as_str()).unwrap_or("?")));
        }
    }
    let mut cmd = std::process::Command::new(exe);
    #[cfg(windows)]
    {
        use std::os::windows::process::CommandExt;
        // `arg()` would RE-QUOTE: the workspace path contains a space ("Adom Bridge"), so the
        // helper received backslash-escaped quotes, which Altium cannot parse — it then neither
        // forwarded the request nor exited (measured on the Python bridge 2026-08-29, and again
        // here 2026-08-31 before switching to raw_arg). CreateProcess must see it verbatim.
        if !arg.is_empty() {
            cmd.raw_arg(arg);
        }
        cmd.creation_flags(0x0800_0000); // CREATE_NO_WINDOW — no console flash on the user's screen
    }
    #[cfg(not(windows))]
    if !arg.is_empty() {
        cmd.arg(arg);
    }
    cmd.stdin(std::process::Stdio::null())
        .stdout(std::process::Stdio::null())
        .stderr(std::process::Stdio::null())
        .spawn()
        .map_err(|e| format!("{e}"))
}

/// Wait for the helper to exit, capped. Returns true if it exited on its own.
fn wait_helper(child: &mut std::process::Child, cap: Duration) -> bool {
    let t0 = Instant::now();
    while t0.elapsed() < cap {
        match child.try_wait() {
            Ok(Some(_)) => return true,
            Ok(None) => std::thread::sleep(Duration::from_millis(150)),
            Err(_) => return false,
        }
    }
    false
}

/// Altium's single-instance notice ("another instance is busy") is modal on the USER's screen.
/// Watch briefly and press OK through UIA — never leave it for the human.
pub fn reap_busy_notice(wait: Duration) -> Value {
    let t0 = Instant::now();
    while t0.elapsed() < wait {
        let w = ab::call("desktop_find_window", json!({"titleContains": "Information"}), &[], 15);
        let hwnds: Vec<i64> = w
            .get("windows")
            .and_then(|a| a.as_array())
            .map(|a| a.iter().filter_map(|x| x.get("hwnd").and_then(|h| h.as_i64())).collect())
            .unwrap_or_default();
        for hw in hwnds {
            let t = ab::call("desktop_find_control", json!({"hwnd": hw, "contains": "another instance", "role": "text"}), &[], 15);
            if t.get("success").and_then(|b| b.as_bool()).unwrap_or(false) {
                ab::call("desktop_ui_click", json!({"hwnd": hw, "name": "OK", "role": "button"}), &[], 15);
                return json!({"reaped": true, "hwnd": hw, "afterMs": t0.elapsed().as_millis() as u64});
            }
        }
        std::thread::sleep(Duration::from_millis(250));
    }
    json!({"reaped": false})
}

fn foreground_hwnd() -> Option<i64> {
    ab::call("desktop_get_foreground", json!({}), &[], 10)
        .get("hwnd")
        .and_then(|h| h.as_i64())
}

fn restore_foreground(hwnd: i64) {
    ab::call("desktop_raise_window", json!({"hwnd": hwnd, "reason": "hand the user's window back after the agent bootstrap activated Altium"}), &[], 15);
}


/// Kill every X2.EXE that is NOT the one owning the main window: those are `-R` helpers left
/// waiting on a busy instance. A killed helper's request can still be DELIVERED later by Altium
/// and start a SECOND loop ("Another script executing now"), so sweep before every bootstrap.
fn kill_stray_helpers() -> Vec<u64> {
    // Identify the MAIN instance by the window it owns, not by guesswork: desktop_enum_windows is
    // the one ab call that reports a pid per window. The first cut asked licence_state() for a pid
    // (it has none) and fell back to a verb that refuses read-only calls, so main_pid was always
    // None and this function silently killed nothing — while hung -R helpers piled up (measured
    // 2026-09-01: two X2 processes, one a leftover helper). Silently doing nothing is the SAFE
    // failure here, but it is still a failure.
    // ab reports each process's COMMAND LINE, and a -R helper carries the request right there in
    // it. That is a definitive test — far better than "is it the main window's process", which on
    // 2026-09-01 mistook a genuine second Altium instance (spawned by the -R lane when the first
    // would not accept the request, and hosting the resident agent) for a helper and killed it.
    let pl = ab::call("process_list", json!({"nameFilter": "X2"}), &[], 15);
    let procs = pl.get("processes").and_then(|a| a.as_array()).cloned().unwrap_or_default();
    let pids: Vec<u64> = procs.iter().filter_map(|p| p.get("pid").and_then(|v| v.as_u64())).collect();
    let helper_pids: std::collections::BTreeSet<u64> = procs
        .iter()
        .filter(|p| {
            p.get("commandLine")
                .and_then(|c| c.as_str())
                .map(|c| c.contains("-R") && c.contains("ScriptingSystem"))
                .unwrap_or(false)
        })
        .filter_map(|p| p.get("pid").and_then(|v| v.as_u64()))
        .collect();
    if helper_pids.is_empty() {
        return Vec::new(); // nothing carrying a -R request: nothing to reap
    }
    // A process that owns a MAIN window is a real Altium instance — NEVER kill it. The previous
    // rule picked the first main window as "the" instance and killed every other X2 process; on
    // 2026-09-01 that killed a genuine SECOND instance which the -R lane had spawned (Altium starts
    // a fresh instance when the running one will not accept a forwarded request) and which was
    // hosting the resident agent. Only a process with NO main window is a helper.
    let wins = ab::call("desktop_enum_windows", json!({"pids": pids.clone()}), &[], 20);
    let instance_pids: std::collections::BTreeSet<u64> = wins
        .get("windows")
        .and_then(|a| a.as_array())
        .map(|a| {
            a.iter()
                .filter(|w| {
                    w.get("title")
                        .and_then(|s| s.as_str())
                        .map(|x| crate::health::parse_title(x).is_some())
                        .unwrap_or(false)
                })
                .filter_map(|w| w.get("pid").and_then(|p| p.as_u64()))
                .collect()
        })
        .unwrap_or_default();
    if instance_pids.is_empty() {
        return Vec::new(); // cannot identify any real instance — kill NOTHING
    }
    let mut killed = Vec::new();
    for pid in pids {
        if !helper_pids.contains(&pid) {
            continue; // no -R request in its command line: not a helper
        }
        if instance_pids.contains(&pid) {
            continue; // and belt-and-braces: owning a main window means a real Altium
        }
        let r = ab::call("process_kill", json!({"pid": pid, "reason": "stray X2 -R helper (owns no Altium main window) left over from a previous bootstrap"}), &[], 15);
        if r.get("success").and_then(|b| b.as_bool()).unwrap_or(false) {
            killed.push(pid);
        }
    }
    killed
}

// ---- the -R lane -------------------------------------------------------------

pub fn x2_exe_pub() -> Option<String> {
    x2_exe()
}

pub fn wait_helper_pub(child: &mut std::process::Child, cap: Duration) -> bool { wait_helper(child, cap) }

pub fn spawn_helper_pub(exe: &str, arg: &str) -> Result<std::process::Child, String> {
    spawn_helper(exe, arg)
}

/// The -R lane against a CALLER-SUPPLIED .PrjScr (altium_run_script). Same one-shot rules.
pub fn run_script_lane_in(proc_name: &str, prjscr: Option<&str>, timeout: Duration) -> Value {
    match prjscr {
        Some(p) => run_script_lane_impl(proc_name, p.to_string(), timeout),
        None => run_script_lane(proc_name, timeout),
    }
}

/// -R RunScriptText: STATEMENTS ONLY (Begin ... End;). Refused while any agent is resident.
pub fn run_script_text_lane(text: &str, timeout: Duration) -> Value {
    if let Some((ws, age)) = agent::any_agent_alive() {
        return json!({"success": false, "errorCode": "agent_resident",
            "liveWorkspace": ws.to_string_lossy(), "heartbeatAgeSec": age,
            "error": "a resident agent loop is already running - a second script would raise Altium's 'Another script executing now' modal on the user's screen",
            "_hint": "Use altium_eval (goes through the agent) while the agent is up; altium_agent_stop first if you truly need the text lane."});
    }
    let exe = match x2_exe() {
        Some(e) => e,
        None => return json!({"success": false, "errorCode": "host_app_not_installed", "hostApp": "Altium Designer"}),
    };
    if text.contains('"') {
        return json!({"success": false, "error": "RunScriptText text cannot contain double quotes (the -R arg is double-quoted); use single quotes for strings."});
    }
    let arg = format!("-RScriptingSystem:RunScriptText(Text=\"{text}\")");
    let mut child = match spawn_helper(&exe, &arg) {
        Ok(c) => c,
        Err(e) => return json!({"success": false, "error": format!("could not launch X2 helper: {e}")}),
    };
    let t0 = Instant::now();
    if wait_helper(&mut child, timeout) {
        return json!({"success": true, "helperSec": t0.elapsed().as_secs_f64()});
    }
    let _ = child.kill();
    let _ = child.wait();
    let reaped = reap_busy_notice(Duration::from_secs(4));
    json!({"success": false, "errorCode": "instance_busy", "busyNoticeReaped": reaped,
           "_hint": "altium_check_dialogs, then retry."})
}

/// Forward `RunScript` to the running instance. REFUSED while the agent is resident.
/// ONE SHOT: on timeout the helper is killed, the busy notice reaped, and the caller told —
/// never call this in a retry loop.
pub fn run_script_lane(proc_name: &str, timeout: Duration) -> Value {
    let prj = match prjscr_path() {
        Some(p) => p.to_string_lossy().to_string(),
        None => return json!({"success": false, "errorCode": "template_missing"}),
    };
    run_script_lane_impl(proc_name, prj, timeout)
}

fn run_script_lane_impl(proc_name: &str, prj: String, timeout: Duration) -> Value {
    // ANY live loop refuses the lane, not just this instance's: Altium raises "Another script
    // executing now" for the second script regardless of which workspace the first one polls
    // (an upgraded bridge leaves the OLD loop heartbeating under the legacy path).
    if let Some((ws, age)) = agent::any_agent_alive() {
        return json!({"success": false, "errorCode": "agent_resident",
            "error": "an Adom agent loop is resident; a second script would raise Altium's 'Another script executing now' modal",
            "liveWorkspace": ws.to_string_lossy(), "heartbeatAgeSec": age,
            "_hint": "send the work through the agent (eval / run_command), or stop the loop polling liveWorkspace first (altium_agent_stop, with instance: if it is another instance's)"});
    }
    let exe = match x2_exe() {
        Some(e) => e,
        None => return json!({"success": false, "errorCode": "host_app_not_installed", "hostApp": "Altium Designer"}),
    };
    let arg = format!("-RScriptingSystem:RunScript(ProjectName=\"{prj}\"|ProcName=\"{proc_name}\")");
    let mut child = match spawn_helper(&exe, &arg) {
        Ok(c) => c,
        Err(e) => return json!({"success": false, "error": format!("could not launch X2 helper: {e}")}),
    };
    // PRE-EMPT: watch for OUR OWN modals for as long as the helper is in flight and press OK the
    // instant one appears — the user should never have to find it. Anything not ours (a save
    // prompt, a licence question) is left strictly alone.
    let (watcher, cleared) = start_modal_watcher();
    let t0 = Instant::now();
    if wait_helper(&mut child, timeout) {
        watcher.store(true, std::sync::atomic::Ordering::Relaxed);
        let mut out = json!({"success": true, "helperSec": t0.elapsed().as_secs_f64(), "arg": arg});
        let c = cleared.lock().map(|g| g.clone()).unwrap_or_default();
        if !c.is_empty() {
            out["modalsPreempted"] = json!(c);
        }
        return out;
    }
    let _ = child.kill();
    let _ = child.wait();
    watcher.store(true, std::sync::atomic::Ordering::Relaxed);
    let reaped = reap_busy_notice(Duration::from_secs(4));
    json!({"success": false, "errorCode": "instance_busy",
           "error": "the -R helper never exited — the running instance did not accept the request",
           "busyNotice": reaped,
           "modalsPreempted": cleared.lock().map(|g| g.clone()).unwrap_or_default(),
           "_hint": "ONE SHOT: do not retry this lane. agent_recover frees a halted engine first."})
}

/// Modals this bridge CAUSES. Only these are auto-cleared while an operation is in flight;
/// anything else (a save prompt, a licence question) belongs to the user.
const OUR_MODALS: [&str; 4] = ["another script executing", "another instance of",
    "is busy at the moment", "scripting system is not able"];

fn start_modal_watcher() -> (std::sync::Arc<std::sync::atomic::AtomicBool>, std::sync::Arc<std::sync::Mutex<Vec<Value>>>) {
    let stop = std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false));
    let cleared = std::sync::Arc::new(std::sync::Mutex::new(Vec::new()));
    let (s2, c2) = (stop.clone(), cleared.clone());
    let _ = std::thread::Builder::new().name("altium-lane-modal-watcher".into()).spawn(move || {
        while !s2.load(std::sync::atomic::Ordering::Relaxed) {
            for d in crate::dialogs::scan() {
                let blob = format!("{} {}",
                    d.get("title").and_then(|t| t.as_str()).unwrap_or(""),
                    d.get("body").and_then(|b| b.as_str()).unwrap_or("")).to_lowercase();
                if !OUR_MODALS.iter().any(|k| blob.contains(k)) {
                    continue;
                }
                if let Some(hw) = d.get("hwnd").and_then(|h| h.as_i64()) {
                    ab::call("desktop_ui_click", json!({"hwnd": hw, "name": "OK", "role": "button"}), &[], 8);
                    if let Ok(mut g) = c2.lock() {
                        g.push(json!({"title": d.get("title"),
                            "body": d.get("body").and_then(|b| b.as_str()).map(|b| b.chars().take(120).collect::<String>()),
                            "clearedInFlight": true}));
                    }
                }
            }
            std::thread::sleep(Duration::from_millis(400));
        }
    });
    (stop, cleared)
}

// ---- start / stop / recover --------------------------------------------------

pub fn start(mut wait: Duration) -> Value {
    // NEVER bootstrap into the legacy workspace. Every hard freeze in adom/altium-bridge#6
    // traces back to an agent that started with no instance resolved (auto-launch cold starts,
    // and once a transient resolve miss right after an Altium restart): the loop lands in the
    // legacy root, every per-instance status and teardown path goes blind to it, and the app
    // closes over a live loop. If no instance is selected, RETRY resolution briefly (the window
    // often becomes parseable within seconds of a launch), then refuse rather than fall back.
    // The legacy root remains a READ target everywhere (union scans), never a bootstrap target.
    if crate::instance::selected().and_then(|i| i.get("key").and_then(|k| k.as_str()).map(str::to_string)).is_none() {
        let t0 = Instant::now();
        let mut resolved = false;
        while t0.elapsed() < Duration::from_secs(12) {
            crate::instance::invalidate_snapshot();
            if let Ok(inst) = crate::instance::resolve(None) {
                crate::instance::select(Some(inst));
                resolved = true;
                break;
            }
            std::thread::sleep(Duration::from_secs(2));
        }
        if !resolved {
            return json!({"success": false, "errorCode": "instance_unresolved",
                "error": "no Altium instance could be resolved, so the agent has nowhere safe to live - bootstrapping into the shared legacy workspace is how loops become invisible to status and teardown (adom/altium-bridge#6)",
                "_hint": "is Altium running and its main window up? altium_status, then retry; pass instance:<pid|key> if several run"});
        }
    }
    let inst = install();
    if inst.get("success").and_then(|b| b.as_bool()) == Some(false) {
        return inst;
    }
    let want = template_source().map(|r| agent_base(&r)).unwrap_or_default();
    if agent::is_alive() {
        match resident_generation() {
            Some(g) if g == want => return json!({"success": true, "alreadyRunning": true, "generation": g}),
            _ => {
                // an OLDER generation is still polling — it would answer with stale code
                stop();
                std::thread::sleep(Duration::from_secs(1));
            }
        }
    }
    // a stale heartbeat / inbox / response from a previous life must not confuse this one
    let ws = agent::workspace_dir();
    for n in ["hb.txt", "stop", "request.txt", "request.payload", "request.txt.tmp", "request.payload.tmp"] {
        let _ = std::fs::remove_file(ws.join(n));
    }
    if let Ok(rd) = std::fs::read_dir(&ws) {
        for e in rd.filter_map(|e| e.ok()) {
            let n = e.file_name().to_string_lossy().to_string();
            if n.starts_with("response_") && n.ends_with(".json") {
                let _ = std::fs::remove_file(e.path());
            }
        }
    }
    let user_fg = foreground_hwnd();
    let t0 = Instant::now();
    let strays = kill_stray_helpers();
    let mut lane = run_script_lane("StartAgent", Duration::from_secs(30));
    if !strays.is_empty() {
        lane["strayHelpersKilled"] = json!(strays);
    }
    if lane.get("success").and_then(|b| b.as_bool()) != Some(true) {
        let mut out = lane;
        if let Some(o) = out.as_object_mut() {
            o.insert("install".into(), inst);
        }
        return out;
    }
    // the bootstrap ACTIVATES Altium (it opens the .PrjScr in the editor) — hand the window back
    if let Some(fg) = user_fg {
        restore_foreground(fg);
    }
    // The RunScript hand-off can be ACCEPTED and then quietly dropped while Altium is busy with
    // something of its own — its own startup, or unloading the script it was just told to stop.
    // Measured 2026-09-21 on AD 26.10.1: the first start after a launch, and the verifying start
    // inside `recover` right after a halt, each missed a 40-45 s window and reported no_heartbeat
    // on a perfectly healthy engine (the very next verb started it). A dropped hand-off is not a
    // failure worth surfacing — it is worth sending again. One retry, then the honest answer.
    let mut retried = false;
    loop {
        while t0.elapsed() < wait {
            if agent::is_alive() {
                if let Some(fg) = user_fg {
                    restore_foreground(fg);
                }
                let mut out = json!({"success": true, "startedInSec": t0.elapsed().as_secs_f64(),
                              "generation": resident_generation(), "install": inst});
                // earlier generations' script projects close (each start opens a new one in Altium's
                // Projects panel; ten were stacked there by 2026-10-15)
                let c = agent::call("close_old_agents", &[], None, Duration::from_secs(30));
                out["oldAgentProjectsClosed"] = c.get("data").and_then(|d| d.get("closed")).cloned().unwrap_or(Value::Null);
                if retried {
                    out["resentBootstrap"] = json!(true);
                    out["_hint"] = json!("The first RunScript hand-off was swallowed (Altium was busy); the bridge sent it again and the agent came up. Nothing for the caller to do.");
                }
                return out;
            }
            std::thread::sleep(Duration::from_millis(250));
        }
        let dialogs = crate::dialogs::scan();
        // A dialog is a real answer (a compile error behind a modal): never paper over it.
        if retried || !dialogs.is_empty() || !crate::health::is_running() {
            return json!({"success": false, "errorCode": "no_heartbeat",
                   "error": format!("agent did not heartbeat within {}s{}", t0.elapsed().as_secs(), if retried { ", including one re-sent bootstrap" } else { "" }),
                   "dialogs": dialogs, "resentBootstrap": retried,
                   "_hint": "a compile error halts the engine behind a script Error modal — check dialogs, then agent_recover"});
        }
        if let Some(orphan) = modal_orphan() {
            return json!({"success": false, "errorCode": "altium_modal_orphan", "orphan": orphan,
                   "_hint": "Altium is wedged inside a modal loop whose dialog is gone, so it can never run the agent script: its main window is DISABLED and nothing is on screen to answer. altium_close {discardChanges:true} ends it gracefully and RELEASES the licence seat."});
        }
        retried = true;
        let again = run_script_lane("StartAgent", Duration::from_secs(30));
        if again.get("success").and_then(|b| b.as_bool()) != Some(true) {
            return json!({"success": false, "errorCode": "no_heartbeat",
                   "error": "the agent did not heartbeat, and re-sending the bootstrap failed",
                   "resendLane": again, "resentBootstrap": true,
                   "_hint": "check dialogs, then agent_recover"});
        }
        if let Some(fg) = user_fg {
            restore_foreground(fg);
        }
        wait = t0.elapsed() + std::cmp::max(wait / 2, Duration::from_secs(25));
    }
}

pub fn stop() -> Value {
    let ws = agent::workspace_dir();
    if !agent::is_alive() {
        return json!({"success": true, "wasRunning": false});
    }
    if let Err(e) = std::fs::write(ws.join("stop"), "1") {
        return json!({"success": false, "error": format!("cannot drop the stop file: {e}")});
    }
    let t0 = Instant::now();
    while t0.elapsed() < Duration::from_secs(HB_STALE_SEC + 4) {
        if !agent::is_alive() {
            return json!({"success": true, "stoppedInSec": t0.elapsed().as_secs_f64()});
        }
        std::thread::sleep(Duration::from_millis(200));
    }
    json!({"success": false, "error": "the loop did not exit", "_hint": "agent_recover"})
}

/// Stop EVERY resident loop, in every workspace (legacy root + per-instance subdirs), not just the
/// selected one. This is what a close path must call: in #6 a loop lived in the legacy workspace
/// while the selected one was empty, so the selected-only `stop()` was a no-op and the app closed
/// over a live loop. Drops each stop file, then waits for all heartbeats to go stale. Returns the
/// workspaces it acted on and whether every one exited within the wait.
pub fn stop_all_live() -> Value {
    let live = agent::live_workspaces();
    if live.is_empty() {
        return json!({"success": true, "wasRunning": false, "stopped": []});
    }
    let mut targeted: Vec<String> = Vec::new();
    for (ws, _age) in &live {
        let _ = std::fs::write(ws.join("stop"), "1");
        targeted.push(ws.to_string_lossy().to_string());
    }
    let t0 = Instant::now();
    while t0.elapsed() < Duration::from_secs(HB_STALE_SEC + 4) {
        if agent::live_workspaces().is_empty() {
            return json!({"success": true, "stoppedInSec": t0.elapsed().as_secs_f64(), "stopped": targeted});
        }
        std::thread::sleep(Duration::from_millis(200));
    }
    let stragglers: Vec<String> = agent::live_workspaces().into_iter()
        .map(|(ws, _)| ws.to_string_lossy().to_string()).collect();
    json!({"success": false, "error": "one or more loops did not exit", "stopped": targeted,
           "stillAlive": stragglers, "_hint": "agent_recover"})
}

/// Is Altium stuck inside a modal loop whose DIALOG IS GONE? Windows disables a process's other
/// top-level windows for the duration of a modal dialog, so "main window disabled + nothing on
/// screen to answer" means the app is waiting on a box that no longer exists. In that state
/// Altium accepts no input at all: no menu, no Alt+F4, no WM_CLOSE, and every `-R` hand-off comes
/// back as "There is another instance ... but it is busy" — which is exactly how arav-rog held the
/// single licence seat hostage for a whole night on 2026-09-21 after a close-time save prompt.
/// Returns `{pid, tid, hwnd}` for the stuck instance.
pub fn modal_orphan() -> Option<Value> {
    let rows = crate::win32::visible_windows()?;
    let pids = crate::win32::pids_of("X2.EXE").unwrap_or_default();
    let pid = pids.into_iter().find(|p| crate::win32::pid_main_window_disabled(*p, &rows))?;
    // A dialog we can SEE is not an orphan: it is a question, and answering it is the right fix.
    if !crate::dialogs::scan().is_empty() {
        return None;
    }
    let tid = crate::win32::pid_ui_thread(pid, &rows)?;
    let hwnd = rows.iter().find(|w| w.pid == pid && w.class == "TDocumentForm").map(|w| w.hwnd);
    Some(json!({"pid": pid, "tid": tid, "hwnd": hwnd}))
}

/// Leave the message loop the way Altium's own Exit does (posted WM_QUIT = Application.Terminate),
/// NOT a process kill: the app runs its shutdown and RELEASES the licence seat. Measured on
/// AD 26.10.1, 2026-09-21: the orphaned instance exited in under 8 s and the next launch was
/// licensed. Never prompts, so callers gate it on the user having declared the documents theirs.
pub fn graceful_terminate(orphan: &Value, wait: Duration) -> Value {
    let tid = orphan.get("tid").and_then(|t| t.as_u64()).unwrap_or(0) as u32;
    let pid = orphan.get("pid").and_then(|p| p.as_u64()).unwrap_or(0) as u32;
    if tid == 0 {
        return json!({"success": false, "error": "no UI thread for the stuck instance"});
    }
    let posted = crate::win32::post_quit_to_thread(tid);
    let t0 = Instant::now();
    while t0.elapsed() < wait {
        if !crate::win32::pids_of("X2.EXE").unwrap_or_default().contains(&pid) {
            instance::invalidate_snapshot();
            return json!({"success": true, "route": "wm_quit", "posted": posted,
                          "exitedInSec": t0.elapsed().as_secs_f64(), "seatReleased": true});
        }
        std::thread::sleep(Duration::from_millis(400));
    }
    json!({"success": false, "route": "wm_quit", "posted": posted,
           "error": "the stuck instance did not exit after a posted WM_QUIT"})
}

/// Un-halt the script engine: press OK on the script Error modal, stop the editor's script,
/// clear stale files. Costs the user a modal, so only on a real halt.
pub fn recover(reason: &str) -> Value {
    recover_impl(reason, true)
}

/// For altium_close: free the engine but do NOT start a new agent loop (a close must never go out
/// over a live loop, adom/altium-bridge#6).
pub fn recover_without_restart(reason: &str) -> Value {
    recover_impl(reason, false)
}

fn recover_impl(reason: &str, verify_by_restart: bool) -> Value {
    let mut steps: Vec<Value> = vec![json!({"reason": reason})];
    // A modal orphan answers NOTHING: every -R below would bounce off "another instance is busy"
    // and each bounce spawns a helper X2. Say so instead of thrashing for 10 s.
    if let Some(orphan) = modal_orphan() {
        return json!({"success": false, "errorCode": "altium_modal_orphan", "orphan": orphan, "steps": steps,
            "_hint": "Altium is stuck inside a modal loop whose dialog is gone: its main window is DISABLED and nothing is on screen to answer. No menu, keystroke or -R hand-off can reach it, and it is still holding the licence seat. altium_close {discardChanges:true} now ends it gracefully (a posted WM_QUIT — Altium's own shutdown, which RELEASES the seat; not a kill). Only pass discardChanges when the open documents are yours."});
    }
    // 1. the script Error modal, if any
    for d in crate::dialogs::scan() {
        if let Some(hw) = d.get("hwnd").and_then(|h| h.as_i64()) {
            let title = d.get("title").and_then(|t| t.as_str()).unwrap_or("");
            // ONLY the script error box: a lone OK titled "Error" (or the X2 notice). This loop used
            // to press OK on EVERY dialog, and agent::call runs recover automatically: with the scan
            // now seeing save prompts that would have answered them (found 2026-09-21).
            let lone_ok = d.get("buttons").and_then(|b| b.as_array()).map(|a| a.len() == 1 && a[0].as_str().map(|x| x.eq_ignore_ascii_case("ok")).unwrap_or(false)).unwrap_or(false);
            let ours = lone_ok && (title.trim().eq_ignore_ascii_case("error") || title.trim().eq_ignore_ascii_case("x2"));
            if !ours {
                steps.push(json!({"dialog": title, "left": "not a script error box: recover never answers other dialogs"}));
                continue;
            }
            ab::call("desktop_ui_click", json!({"hwnd": hw, "name": "OK", "role": "button"}), &[], 15);
            steps.push(json!({"dialog": title, "pressed": "OK"}));
        }
    }
    if let Some(d) = blocking_dialog() {
        return json!({"success": false, "errorCode": "blocked_by_dialog", "dialog": d, "steps": steps,
            "_hint": "A dialog that is not the script Error box is open on Altium's main window (a save prompt, a reload confirm). recover never answers those, and every -R hand-off would bounce off it with an 'another instance is busy' notice on the user's screen. Read it (altium_check_dialogs), answer it with altium_dismiss_dialogs, then retry."});
    }
    // 2. tell the editor to stop whatever unit it is holding (raises the busy notice → reap it).
    //    EditScript:Stop ONLY acts when a SCRIPT DOCUMENT is the active document. With a board or a
    //    library in front it is a silent no-op: recover answered success while the script stayed on
    //    its breakpoint, the self-test reported halted_and_recovered for an engine that was never
    //    freed, and Altium then refused to close (measured on AD 26.10.1, 2026-09-21; it had only
    //    ever worked because the script happened to be in front). So: bring every agent script this
    //    workspace holds to the front, one at a time, and stop it there.
    let scripts: Vec<std::path::PathBuf> = std::fs::read_dir(agent::workspace_dir()).map(|rd| rd.filter_map(|e| e.ok()).map(|e| e.path())
        .filter(|p| p.file_name().map(|n| { let n = n.to_string_lossy().to_string(); n.starts_with("AdomAgent_") && n.ends_with(".pas") }).unwrap_or(false))
        .collect()).unwrap_or_default();
    let instances_before = instance::instances().len();
    if let Some(exe) = x2_exe() {
        for sc in &scripts {
            let arg = format!("-RWorkspaceManager:OpenObject(ObjectKind=Document|FileName=\"{}\")", sc.to_string_lossy());
            if let Ok(mut child) = spawn_helper(&exe, &arg) {
                let _ = wait_helper(&mut child, Duration::from_secs(8));
                let _ = child.kill();
                let _ = child.wait();
            }
            std::thread::sleep(Duration::from_millis(600));
            if let Ok(mut child) = spawn_helper(&exe, "-REditScript:Stop") {
                let _ = wait_helper(&mut child, Duration::from_secs(8));
                let _ = child.kill();
                let _ = child.wait();
            }
            steps.push(json!({"scriptActivatedAndStopped": sc.file_name().map(|n| n.to_string_lossy().to_string())}));
        }
    }
    instance::invalidate_snapshot();
    let instances_after = instance::instances().len();
    if instances_after > instances_before {
        // A -R hand-off that does not reach the running instance makes X2 start a NEW Altium.
        steps.push(json!({"warning": "a helper started a second Altium instance instead of forwarding", "instancesBefore": instances_before, "instancesAfter": instances_after}));
    }
    if let Some(exe) = x2_exe() {
        match spawn_helper(&exe, "-REditScript:Stop") {
            Ok(mut child) => {
                let t0 = Instant::now();
                let exited = wait_helper(&mut child, Duration::from_secs(8));
                if !exited {
                    let _ = child.kill();
                    let _ = child.wait();
                }
                steps.push(json!({"editScriptStop": "sent", "exited": exited, "sec": t0.elapsed().as_secs_f64()}));
            }
            Err(e) => steps.push(json!({"editScriptStop": "failed", "error": e})),
        }
        steps.push(reap_busy_notice(Duration::from_secs(4)));
    }
    // 3. stale files
    let ws = agent::workspace_dir();
    for n in ["hb.txt", "stop", "request.txt", "request.payload", "request.txt.tmp"] {
        let _ = std::fs::remove_file(ws.join(n));
    }
    // VERIFY, never assume: the engine is free only if a fresh agent loop can actually start.
    instance::invalidate_snapshot();
    let mut verified = Value::Null;
    if verify_by_restart && crate::health::is_running() && instances_after <= 1 {
        let started = start(Duration::from_secs(45));
        let ok = started.get("success").and_then(|b| b.as_bool()) == Some(true) && agent::is_alive();
        verified = json!(ok);
        steps.push(json!({"verifiedByRestartingTheAgent": ok}));
    }
    let freed = verified.as_bool().unwrap_or(true);
    json!({"success": freed, "engineFree": verified, "steps": steps,
           "errorCode": if freed { Value::Null } else { json!("engine_still_parked") },
           "_hint": if !verify_by_restart { "Sent the stop to every agent script; NOT verified (no agent loop was started, because the caller is about to close Altium)." }
                    else if freed { "The script engine is free and a fresh agent loop is running (verified by starting it)." }
                    else { "The engine is STILL parked: a new agent loop could not start. Look at the screen (desktop_screenshot_screen): a script document must be the active document for Altium to accept the stop. Click the agent script's tab in Altium, then altium_agent_recover again. Do not altium_close yet: Altium refuses to close over a parked script." }})
}

// ---- application lifecycle ---------------------------------------------------

/// The window the user is actually working in, for verbs that must hand it back (`misc::launch`).
pub fn foreground_hwnd_pub() -> Option<i64> { foreground_hwnd() }
/// Hand a remembered foreground window back to the user.
pub fn restore_foreground_pub(hwnd: i64) { restore_foreground(hwnd) }

/// (hwnd, pid) of the OS foreground window — the per-verb etiquette wrapper needs the pid to
/// tell "Altium stole the focus" from "the user switched windows themselves" (#12).
pub fn foreground_info() -> Option<(i64, u64)> {
    let r = ab::call("desktop_get_foreground", json!({}), &[], 10);
    match (r.get("hwnd").and_then(|h| h.as_i64()), r.get("pid").and_then(|p| p.as_u64())) {
        (Some(h), Some(p)) if h != 0 => Some((h, p)),
        _ => None,
    }
}

pub fn launch(wait: Duration) -> Value {
    if crate::health::is_running() {
        return json!({"success": true, "alreadyRunning": true, "windowEtiquette": "background"});
    }
    let exe = match x2_exe() {
        Some(e) => e,
        None => return json!({"success": false, "errorCode": "host_app_not_installed", "hostApp": "Altium Designer"}),
    };
    let t0 = Instant::now();
    if let Err(e) = spawn_helper(&exe, "") {
        return json!({"success": false, "error": format!("launch failed: {e}")});
    } // the child is Altium itself — let it outlive this call
    while t0.elapsed() < wait {
        if crate::health::is_running() {
            return json!({"success": true, "startedInSec": t0.elapsed().as_secs_f64(),
                          "windowEtiquette": "foreground",
                          "_hint": "Altium's own launch foregrounds itself (the one legitimate foreground)."});
        }
        std::thread::sleep(Duration::from_secs(1));
    }
    json!({"success": false, "errorCode": "launch_timeout",
           "error": format!("no Altium main window after {}s", wait.as_secs())})
}

// NOTE: application close lives in misc::close (the seat-guarded verb); the old lifecycle-level
// close was removed when it landed — one close path, one seat rule.

// ---- user-initiated-close watchdog v2 (adom/altium-bridge#6; v1 was PR 74 / 0.2.8) ---------
//
// altium_close tears every resident loop down BEFORE its WM_CLOSE, but a user pressing the
// window X goes straight to Altium, and a shutdown over a live agent loop wedges — X2 lingers
// headless and the desktop hard-freezes (three times on 2026-09-02). The 0.2.8 watchdog missed
// both freezes it watched because its detection ran through ab, whose window/process enumeration
// hangs or returns empty EXACTLY during a freeze; an empty answer read as "no X2" and it dropped
// nothing. v2 detects natively (win32.rs: non-messaging calls only, and "cannot answer" is
// distinguished from "none") and acts in two stages:
//
// 1. The instant no Altium main window is visible while a previously-seen resident loop still
//    heartbeats: drop the stop file into EVERY live workspace (legacy included — the split-brain
//    workspaces are exactly the ones nothing else reaches). A pumping loop consumes it within
//    ~100 ms and X2 finishes its shutdown cleanly (measured live, 2026-09-02).
// 2. The zombie-breaker: if an X2 that was SEEN owning a main window is still alive
//    KILL_AFTER_SEC after every main window vanished, AND the bridge's own machinery was
//    involved (a resident loop existed during this close), the shutdown is wedged beyond
//    recovery and each further minute is desktop-freeze exposure: warn the user by toast, then
//    terminate the pid natively, recording the seat consequence. Scoped deliberately: an Altium
//    the bridge never touched is the user's own to manage, and we never kill it.

static CLOSE_WATCHDOG: std::sync::atomic::AtomicBool = std::sync::atomic::AtomicBool::new(false);

const KILL_AFTER_SEC: u64 = 45;

/// Unix seconds of the last bridge-initiated close (misc::close). The zombie-breaker kills a
/// HUNG Altium only when the bridge was involved in the close (its own close verb ran recently,
/// or a resident loop was freed during this hang). A hung window with NO bridge involvement can
/// be a legitimate long computation (a big DRC trips IsHungAppWindow too) and is never killed,
/// only reported.
static LAST_CLOSE_AT: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0);

pub fn note_close_initiated() {
    let now = std::time::SystemTime::now().duration_since(std::time::SystemTime::UNIX_EPOCH)
        .map(|d| d.as_secs()).unwrap_or(0);
    LAST_CLOSE_AT.store(now, std::sync::atomic::Ordering::Relaxed);
}

fn close_was_recent() -> bool {
    let at = LAST_CLOSE_AT.load(std::sync::atomic::Ordering::Relaxed);
    if at == 0 { return false; }
    let now = std::time::SystemTime::now().duration_since(std::time::SystemTime::UNIX_EPOCH)
        .map(|d| d.as_secs()).unwrap_or(0);
    now.saturating_sub(at) < 300
}

#[derive(Default)]
struct WatchState {
    /// workspace keys observed live while a main window existed
    seen_ws: std::collections::BTreeSet<String>,
    /// pids observed owning a TDocumentForm main window
    seen_pids: std::collections::BTreeSet<u32>,
    /// when every main window vanished (None while one is visible)
    windows_gone_since: Option<Instant>,
    /// whether a resident loop was live at (or after) the moment the windows vanished
    close_involved_agent: bool,
    /// pids already escalated, never re-killed
    escalated: std::collections::BTreeSet<u32>,
}

fn live_agent_workspaces() -> Vec<(String, PathBuf)> {
    let root = agent::cache_dir().join("agent");
    let mut workspaces: Vec<(String, PathBuf)> = vec![("legacy".into(), root.clone())];
    if let Ok(rd) = std::fs::read_dir(&root) {
        for e in rd.flatten() {
            if e.path().is_dir() {
                workspaces.push((e.file_name().to_string_lossy().to_string(), e.path()));
            }
        }
    }
    workspaces.into_iter().filter(|(_, ws)| {
        std::fs::metadata(ws.join("hb.txt")).and_then(|m| m.modified()).ok()
            .and_then(|t| std::time::SystemTime::now().duration_since(t).ok())
            .map(|d| d < Duration::from_secs(agent::ALIVE_SEC)).unwrap_or(false)
    }).collect()
}

fn close_watch_once(st: &mut WatchState) {
    // Native, non-messaging reads. `None` means the enumeration itself failed: act on NOTHING
    // this tick rather than mistake blindness for absence (the 0.2.8 failure mode).
    let windows = match crate::win32::visible_windows() {
        Some(w) => w,
        None => return,
    };
    let x2_pids = crate::win32::pids_of("X2.EXE").unwrap_or_default();
    let healthy_pids: Vec<u32> = x2_pids.iter().copied()
        .filter(|p| crate::win32::pid_has_main_window(*p, &windows)
                 && !crate::win32::pid_main_window_hung(*p, &windows)).collect();
    let live = live_agent_workspaces();

    if !healthy_pids.is_empty() && healthy_pids.len() == x2_pids.len() {
        // every X2 has a responsive main window: remember what we saw, clear closing state
        for p in &healthy_pids {
            st.seen_pids.insert(*p);
        }
        for (key, _) in &live {
            st.seen_ws.insert(key.clone());
        }
        st.windows_gone_since = None;
        st.close_involved_agent = false;
        return;
    }

    // Some X2 is in the freeze signature: its main window is GONE or HUNG (the live freezes of
    // 2026-09-02 showed BOTH shapes; the fatal one keeps the frame visible and non-responding).
    // Stage 1: free every previously-seen resident loop NOW - harmless if the close is healthy,
    // decisive if the shutdown is about to wedge on the script engine.
    for (key, ws) in &live {
        if st.seen_ws.contains(key) {
            let _ = std::fs::write(ws.join("stop"), "1");
            st.seen_ws.remove(key);
            st.close_involved_agent = true;
            crate::dialogs::note_event(json!({"closeWatchdog": key,
                "note": "Altium main window gone or hung while a resident loop heartbeats - stop file dropped so the loop exits instead of wedging the shutdown (adom/altium-bridge#6)"}));
        }
    }

    // Stage 2: the zombie-breaker. Killing is scoped to closes the BRIDGE was involved in
    // (its close verb ran in the last 5 minutes, or a resident loop was freed during this
    // episode): a hung window with no bridge involvement can be a legitimate long computation
    // (a big DRC trips IsHungAppWindow too) and is never killed, only reported once.
    let wedged: Vec<u32> = x2_pids.iter().copied()
        .filter(|p| st.seen_pids.contains(p) && !st.escalated.contains(p)
                 && (!crate::win32::pid_has_main_window(*p, &windows)
                     || crate::win32::pid_main_window_hung(*p, &windows)))
        .collect();
    if wedged.is_empty() {
        st.windows_gone_since = None;
        return;
    }
    let since = *st.windows_gone_since.get_or_insert_with(Instant::now);
    if since.elapsed() < Duration::from_secs(KILL_AFTER_SEC) {
        return;
    }
    let bridge_involved = st.close_involved_agent || close_was_recent();
    for pid in wedged {
        st.escalated.insert(pid);
        if bridge_involved {
            ab::call("notify_user", json!({
                "title": "Altium shutdown wedged",
                "message": format!("Altium (pid {pid}) has been frozen or window-less for {KILL_AFTER_SEC}s after a bridge-involved close. Terminating it to protect the desktop; the On-Demand seat may need a sign-out/sign-in to free."),
                "level": "warning",
                "reason": "zombie-breaker: a wedged Altium shutdown hard-froze this desktop four times on 2026-09-02"
            }), &[], 10);
            let killed = crate::win32::kill(pid);
            crate::dialogs::note_event(json!({"zombieBreaker": pid, "killed": killed,
                "note": format!("X2 frozen/window-less {KILL_AFTER_SEC}s after a bridge-involved close - terminated to prevent the desktop freeze (adom/altium-bridge#6). Seat may be stranded until sign-out/sign-in."),
                "_seatWarning": "seat possibly stranded by this kill"}));
        } else {
            ab::call("notify_user", json!({
                "title": "Altium appears frozen",
                "message": format!("Altium (pid {pid}) has been non-responding for {KILL_AFTER_SEC}s. The bridge was not involved in a close, so it will NOT auto-kill (this can be a long computation). If it is truly stuck: altium_close force:true acceptSeatLoss:true."),
                "level": "warning",
                "reason": "watchdog observation, no action taken"
            }), &[], 10);
            crate::dialogs::note_event(json!({"frozenObserved": pid,
                "note": "hung main window with no bridge-involved close - reported, deliberately not killed"}));
        }
    }
}

pub fn start_close_watchdog() -> bool {
    if CLOSE_WATCHDOG.swap(true, std::sync::atomic::Ordering::SeqCst) {
        return false;
    }
    std::thread::Builder::new().name("altium-agent-close-watchdog".into()).spawn(|| {
        let mut st = WatchState::default();
        loop {
            std::thread::sleep(Duration::from_secs(2));
            let _ = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
                close_watch_once(&mut st);
            }));
        }
    }).is_ok()
}

// ---- sha256 (no external crate) ----------------------------------------------

pub fn sha256_hex(data: &[u8]) -> String {
    const K: [u32; 64] = [
        0x428a2f98, 0x71374491, 0xb5c0fbcf, 0xe9b5dba5, 0x3956c25b, 0x59f111f1, 0x923f82a4, 0xab1c5ed5,
        0xd807aa98, 0x12835b01, 0x243185be, 0x550c7dc3, 0x72be5d74, 0x80deb1fe, 0x9bdc06a7, 0xc19bf174,
        0xe49b69c1, 0xefbe4786, 0x0fc19dc6, 0x240ca1cc, 0x2de92c6f, 0x4a7484aa, 0x5cb0a9dc, 0x76f988da,
        0x983e5152, 0xa831c66d, 0xb00327c8, 0xbf597fc7, 0xc6e00bf3, 0xd5a79147, 0x06ca6351, 0x14292967,
        0x27b70a85, 0x2e1b2138, 0x4d2c6dfc, 0x53380d13, 0x650a7354, 0x766a0abb, 0x81c2c92e, 0x92722c85,
        0xa2bfe8a1, 0xa81a664b, 0xc24b8b70, 0xc76c51a3, 0xd192e819, 0xd6990624, 0xf40e3585, 0x106aa070,
        0x19a4c116, 0x1e376c08, 0x2748774c, 0x34b0bcb5, 0x391c0cb3, 0x4ed8aa4a, 0x5b9cca4f, 0x682e6ff3,
        0x748f82ee, 0x78a5636f, 0x84c87814, 0x8cc70208, 0x90befffa, 0xa4506ceb, 0xbef9a3f7, 0xc67178f2,
    ];
    let mut h: [u32; 8] = [0x6a09e667, 0xbb67ae85, 0x3c6ef372, 0xa54ff53a, 0x510e527f, 0x9b05688c, 0x1f83d9ab, 0x5be0cd19];
    let mut msg = data.to_vec();
    let bit_len = (data.len() as u64).wrapping_mul(8);
    msg.push(0x80);
    while msg.len() % 64 != 56 {
        msg.push(0);
    }
    msg.extend_from_slice(&bit_len.to_be_bytes());
    for chunk in msg.chunks(64) {
        let mut w = [0u32; 64];
        for i in 0..16 {
            w[i] = u32::from_be_bytes([chunk[i * 4], chunk[i * 4 + 1], chunk[i * 4 + 2], chunk[i * 4 + 3]]);
        }
        for i in 16..64 {
            let s0 = w[i - 15].rotate_right(7) ^ w[i - 15].rotate_right(18) ^ (w[i - 15] >> 3);
            let s1 = w[i - 2].rotate_right(17) ^ w[i - 2].rotate_right(19) ^ (w[i - 2] >> 10);
            w[i] = w[i - 16].wrapping_add(s0).wrapping_add(w[i - 7]).wrapping_add(s1);
        }
        let (mut a, mut b, mut c, mut d, mut e, mut f, mut g, mut hh) = (h[0], h[1], h[2], h[3], h[4], h[5], h[6], h[7]);
        for i in 0..64 {
            let s1 = e.rotate_right(6) ^ e.rotate_right(11) ^ e.rotate_right(25);
            let ch = (e & f) ^ ((!e) & g);
            let t1 = hh.wrapping_add(s1).wrapping_add(ch).wrapping_add(K[i]).wrapping_add(w[i]);
            let s0 = a.rotate_right(2) ^ a.rotate_right(13) ^ a.rotate_right(22);
            let maj = (a & b) ^ (a & c) ^ (b & c);
            let t2 = s0.wrapping_add(maj);
            hh = g; g = f; f = e; e = d.wrapping_add(t1);
            d = c; c = b; b = a; a = t1.wrapping_add(t2);
        }
        for (i, v) in [a, b, c, d, e, f, g, hh].iter().enumerate() {
            h[i] = h[i].wrapping_add(*v);
        }
    }
    h.iter().map(|x| format!("{x:08x}")).collect()
}

#[cfg(test)]
mod tests {
    use super::sha256_hex;

    #[test]
    fn sha256_matches_reference_vectors() {
        assert_eq!(sha256_hex(b""), "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855");
        assert_eq!(sha256_hex(b"abc"), "ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad");
        // > 55 bytes: forces a second padding block
        assert_eq!(
            sha256_hex(b"abcdbcdecdefdefgefghfghighijhijkijkljklmklmnlmnomnopnopq"),
            "248d6a61d20638b8e5c026930c3e6039a33ce45964ff2167f6ecedd419db06c1"
        );
    }
}

#[cfg(test)]
mod interop_tests {
    use super::{agent_base, sha256_hex};

    /// The Python bridge names the generation `AdomAgent_<sha256(template)[..10]>`. Both bridges
    /// MUST agree or they would each install their own generation and fight over the engine.
    #[test]
    fn generation_digest_matches_python() {
        let raw = match std::fs::read_to_string("../src/agent/AdomAgent.pas.tmpl") {
            Ok(r) => r,
            Err(_) => return, // template not present in this checkout
        };
        let full = sha256_hex(raw.as_bytes());
        assert_eq!(agent_base(&raw), format!("AdomAgent_{}", &full[..10]));
        println!("digest = {}", &full[..10]);
    }
}