main
Colby Knox 0.2.45 source (not yet released): save-into-missing-folder fix + saved check, sheet grows on append, board_shape/pcb_re… 23ad0df 6d ago
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745
//! Odds and ends: readiness/status, instances, dialog_log, state, diagnostics, the process-
//! launcher catalog (list_commands), launch/close with the seat guard, uninstall.
use crate::support::{self, s, s_or};
use crate::{ab, agent, dialogs, instance, lifecycle, progress, taskbar};
use serde_json::{json, Value};
use std::time::Duration;

pub fn readiness(_args: &Value) -> Value {
    let inst = support::first_install();
    let lic = instance::licence_state();
    let alive = agent::is_alive();
    // The UNION view: is a loop resident in ANY workspace, not just the selected one? In #6 a loop
    // lived in the legacy workspace while the selected one was empty, and `agentAlive` (selected
    // only) reported false while the engine was occupied. `agentAlive` still answers "can I drive
    // the resolved instance now"; `agentResidentAnywhere` is the safety answer status must never
    // hide, so a resident loop can never again read as "not resident".
    let live_ws = agent::live_workspaces();
    let resident_anywhere = !live_ws.is_empty();
    let running = lic.get("running").and_then(|b| b.as_bool()).unwrap_or(false);
    let licensed = lic.get("licensed").and_then(|b| b.as_bool());
    let ready = inst.is_some() && running && licensed == Some(true) && alive;
    let hint = if inst.is_none() {
        "Not installed — see altium_launch's hint to install.".to_string()
    } else if !running {
        "Altium is installed but not running: altium_launch (cold start ~60s, measured).".to_string()
    } else if licensed == Some(false) {
        format!("Altium is running but the title says {:?} — scripting is refused without a licence. altium_license '{{}}' drives the trial/On-Demand flow.",
            lic.get("state").and_then(|st| st.as_str()).unwrap_or("?"))
    } else if !alive {
        "Licensed and running; the agent loop is not resident — altium_agent_start (≈8s), then drive.".to_string()
    } else {
        "Ready. altium_state for the picture, altium_eval / altium_run_command to act.".to_string()
    };
    let mut out = json!({"success": true, "hostApp": support::HOST_APP, "installed": inst.is_some(), "install": inst,
        "running": running, "licensed": licensed, "licenceState": lic.get("state"),
        "adVersion": lic.get("adVersion"), "agentAlive": alive, "agentResidentAnywhere": resident_anywhere,
        "ready": ready, "engine": "rust", "_hint": hint});
    // Surface a loop that is resident somewhere the current selection does not point at — the exact
    // blind spot behind #6. It is a live freeze-on-close hazard until torn down (altium_close does
    // that across all workspaces now).
    if resident_anywhere && !alive {
        out["agentLiveWorkspaces"] = json!(live_ws.iter().map(|(ws, age)|
            json!({"workspace": ws.to_string_lossy(), "heartbeatAgeSec": age})).collect::<Vec<_>>());
        out["_agentWarning"] = json!("An agent loop is resident in a workspace the current selection does not point at (agentAlive is the selected-instance view). Close via altium_close, which tears down every workspace's loop before the app closes; never let the user press the window X while a loop is resident (#6).");
    }
    out
}

pub fn status(args: &Value) -> Value {
    let mut r = readiness(args);
    r["windows"] = json!(instance::altium_windows());
    r["dialogs"] = json!(dialogs::scan());
    r["reapedDialogs"] = json!(dialogs::reaped_log(5));
    // The last bundle install, light: a client whose install outlived ab's budget polls this.
    r["lastInstall"] = crate::library::last_install_summary();
    r
}

pub fn diagnostics(_args: &Value) -> Value {
    let env: serde_json::Map<String, Value> = ["LOCALAPPDATA", "APPDATA", "USERPROFILE", "ProgramFiles", "ADOM_BIND_HOST", "ADOM_DIRECT_API_URL"]
        .iter()
        .map(|k| (k.to_string(), std::env::var(k).map(Value::from).unwrap_or(Value::Null)))
        .collect();
    json!({"success": true, "engine": "rust", "env": env,
        "installs": support::detect_installs(),
        "windows": instance::altium_windows(),
        "agent": agent_status(&json!({})),
        "cacheDir": agent::cache_dir().to_string_lossy()})
}

pub fn agent_status(_args: &Value) -> Value {
    let alive = agent::is_alive();
    let live_ws = agent::live_workspaces();
    let resident_anywhere = !live_ws.is_empty();
    let mut out = json!({"success": true, "alive": alive, "responsive": agent::is_responsive(),
        "residentAnywhere": resident_anywhere,
        "liveWorkspaces": live_ws.iter().map(|(ws, age)|
            json!({"workspace": ws.to_string_lossy(), "heartbeatAgeSec": age})).collect::<Vec<_>>(),
        "heartbeat": agent::heartbeat(), "workspace": agent::workspace_dir().to_string_lossy(),
        "engine": "rust"});
    if alive && !agent::is_responsive() {
        out["_hint"] = json!("Loop is alive but busy inside a long Altium process (no heartbeat while a RunProcess runs). Wait; do not start a second agent.");
    } else if resident_anywhere && !alive {
        // the #6 blind spot: a loop is resident in a workspace the selection does not point at
        out["_hint"] = json!("A loop is resident in another workspace (see liveWorkspaces) — the selected-instance view reads not-alive. This is a freeze-on-close hazard until torn down; altium_close stops every workspace's loop.");
    }
    out
}

/// Every running Altium, and how to address it. Each instance gets its own agent workspace
/// (`bridges-cache/altium/agent/<key>`), so two resident loops never share an inbox.
pub fn instances_verb(_args: &Value) -> Value {
    let root = agent::cache_dir().join("agent");
    let found: Vec<Value> = instance::instances().into_iter().map(|mut i| {
        let key = i.get("key").and_then(|k| k.as_str()).unwrap_or("").to_string();
        let ws = root.join(&key);
        let alive = 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);
        i["agentAlive"] = json!(alive);
        i["agentWorkspace"] = json!(ws.to_string_lossy());
        i
    }).collect();
    let n = found.len();
    let out = json!({"success": true, "count": n, "instances": found,
        "_hint": if n > 1 {
            "Pass instance:<pid|key|word> to any verb to target one. With several running, verbs without it fail with errorCode ambiguous_instance."
        } else {
            "One Altium running — `instance` is optional."
        }});
    support::seat_hints(out, None, n)
}

/// Activate a docked panel TAB in the background (the "ganged-up view": symbol on canvas, 3D chip
/// in the Editor preview, footprint in the SCH Library panel). No server process activates a tab:
/// `Client:CustomizeResources Action=Show|ObjectKind=Panel` only makes sure the panel EXISTS.
///
/// Bounded by construction. A UIA search by name walks Altium's whole window tree and can take
/// longer than the per-call timeout on a licensed box, so the first cut (read, select, read,
/// click, read at 10+15+10+15+10 s) spent exactly ab's 60 s budget and answered ab's generic
/// timeout, and the searches it abandoned kept ab busy for the next verb. Now: ONE discovery
/// call that reads existence and selection together, at most ONE select, ONE verify, all inside
/// PANEL_BUDGET, and every exit is a named answer. No click fallback: a click does not switch a
/// panel tab. If ab's select falls back to its raising SendInput click, the reply says so.
const PANEL_BUDGET_SEC: u64 = 24;

fn find_tab(hwnd: i64, name: &str, secs: u64) -> Result<Option<Value>, ()> {
    let r = ab::call("desktop_find_controls", json!({"hwnd": hwnd, "name": name, "maxResults": 5}), &[], secs.max(1));
    if r.get("success").and_then(|b| b.as_bool()) == Some(false) && r.get("matches").is_none() {
        return Err(());
    }
    let matches = r.get("matches").and_then(|m| m.as_array()).cloned().unwrap_or_default();
    // prefer the element UIA calls a tab; else the first exact-name match
    let tab = matches.iter().find(|m| m.get("role").and_then(|r| r.as_str())
            .map(|r| r.to_ascii_lowercase().contains("tab")).unwrap_or(false))
        .or_else(|| matches.first()).cloned();
    Ok(tab)
}

fn tab_selected(m: &Value) -> Option<bool> {
    m.get("selected").or_else(|| m.get("isSelected")).and_then(|b| b.as_bool())
}

pub fn panel(args: &Value) -> Value {
    let name = match s(args, "name") {
        Some(n) if !n.is_empty() => n,
        _ => return json!({"success": false, "errorCode": "name_required", "error": "name is required - the dock tab's caption exactly as painted, e.g. 'SCH Library', 'PCB Library', 'Components', 'Projects', 'Properties'"}),
    };
    let lic = instance::licence_state();
    let hwnd = match lic.get("hwnd").and_then(|h| h.as_i64()) {
        Some(h) if lic.get("running").and_then(|b| b.as_bool()) == Some(true) => h,
        _ => return json!({"success": false, "errorCode": "altium_not_running"}),
    };
    let t0 = std::time::Instant::now();
    let left = || PANEL_BUDGET_SEC.saturating_sub(t0.elapsed().as_secs());
    let mut ensured = Value::Null;
    if let Some(id) = s(args, "ensureId") {
        let r = agent::call("run_process",
            &[("command", "Client:CustomizeResources".into()),
              ("params", format!("Action=Show|ObjectKind=Panel|ID={id}"))],
            None, Duration::from_secs(10));
        ensured = json!({"id": id, "ok": r.get("success").and_then(|b| b.as_bool()).unwrap_or(false)});
    }
    let timed_out = |stage: &str, t0: std::time::Instant| json!({"success": false, "errorCode": "panel_search_timeout",
        "panel": name, "stage": stage, "elapsedSec": t0.elapsed().as_secs_f64(), "ensured": ensured,
        "_hint": "A UI Automation search of Altium's main window did not finish inside the panel budget, so the tab could not be reached; nothing was clicked. On Altium Designer Develop this is the normal case (one search measured over 20 s on 2026-10-15), and no Altium process switches a docked tab either (Client:CustomizeResources Show/Toggle only ensures the panel exists). What does work: opening a library document brings its own panel to the front, so altium_show_library / show_symbol lands on SCH Library and show_footprint on PCB Library."});
    // 1. discovery: existence + selection in one search
    let tab = match find_tab(hwnd, &name, left().min(10)) {
        Err(_) => return timed_out("find", t0),
        Ok(None) => return json!({"success": false, "errorCode": "panel_not_found", "panel": name, "ensured": ensured,
            "elapsedSec": t0.elapsed().as_secs_f64(),
            "_hint": "No control with that caption on Altium's main window. Check the spelling against the painted tab, and pass ensureId if the panel was never opened in this session."}),
        Ok(Some(m)) => m,
    };
    if tab_selected(&tab) == Some(true) {
        return json!({"success": true, "panel": name, "selected": true, "lane": "already",
            "ensured": ensured, "elapsedSec": t0.elapsed().as_secs_f64()});
    }
    if left() < 4 {
        return timed_out("select", t0);
    }
    // 2. one select
    let sel = ab::call("desktop_ui_select", json!({"hwnd": hwnd, "name": name,
        "reason": format!("activate the '{name}' panel tab in the background (altium_panel)")}), &[], left().min(10));
    let err = sel.get("error").and_then(|e| e.as_str()).unwrap_or("").to_string();
    if err.contains("approval") || err.contains("SHELL") {
        return json!({"success": false, "errorCode": "ui_select_gated", "panel": name, "ensured": ensured,
            "elapsedSec": t0.elapsed().as_secs_f64(),
            "_hint": "ab gates desktop_ui_select behind the shell approval grant (Adom Bridge footer 'Auto' button / Settings > Shell Commands > Auto mode). Turn it on, or select the tab by hand."});
    }
    if sel.get("raised").and_then(|b| b.as_bool()) == Some(true) {
        // ab fell back to a SendInput click, which foregrounds Altium: report it, never hide it
        return json!({"success": sel.get("success").and_then(|b| b.as_bool()).unwrap_or(false),
            "panel": name, "lane": "ui_select_raised", "windowEtiquette": "foreground",
            "_hint": "The tab exposes no SelectionItemPattern, so ab clicked it with synthetic input, which raised Altium. The dispatch wrapper hands the user's window back, but this tab cannot be switched silently."});
    }
    // 3. verify
    if left() < 3 {
        return json!({"success": sel.get("success").and_then(|b| b.as_bool()).unwrap_or(false),
            "panel": name, "lane": "ui_select", "verified": false, "elapsedSec": t0.elapsed().as_secs_f64()});
    }
    match find_tab(hwnd, &name, left().min(8)) {
        Ok(Some(m)) if tab_selected(&m) == Some(true) =>
            json!({"success": true, "panel": name, "selected": true, "lane": "ui_select", "ensured": ensured,
                "elapsedSec": t0.elapsed().as_secs_f64()}),
        Ok(Some(m)) if tab_selected(&m).is_none() =>
            // the tree exposes no selection state for this tab: trust the pattern call, say so
            json!({"success": sel.get("success").and_then(|b| b.as_bool()).unwrap_or(false), "panel": name,
                "lane": "ui_select", "verified": false, "elapsedSec": t0.elapsed().as_secs_f64(),
                "_hint": "The tab reports no selection state, so the select could not be verified; check the returned frame of the next show_* verb."}),
        Err(_) => timed_out("verify", t0),
        _ => json!({"success": false, "errorCode": "panel_not_selected", "panel": name, "ensured": ensured,
            "selectReply": {"success": sel.get("success"), "error": err.chars().take(160).collect::<String>()},
            "elapsedSec": t0.elapsed().as_secs_f64(),
            "_hint": "The tab exists but did not become selected. It may be a floating panel's title rather than a dock tab."}),
    }
}

/// What the standing dialog reaper has pre-empted.
pub fn dialog_log(args: &Value) -> Value {
    let limit = args.get("limit").and_then(|l| l.as_u64()).unwrap_or(25) as usize;
    let shots = if args.get("shots").and_then(|b| b.as_bool()) == Some(true) { json!(dialogs::reaped_shots()) } else { Value::Null };
    json!({"success": true, "reaped": dialogs::reaped_log(limit), "running": true, "shots": shots,
        "_shotsHint": "shots:true returns PNGs (base64) of the last 8 boxes the reaper pressed. Altium paints its message text, so the picture is the only copy of what a box SAID: read it whenever a verb is refused for no visible reason.",
        "_hint": "Anything NOT listed here that reached the user is either a save prompt (never auto-answered, by design) or a dialog missing from the catalog — report it so it can be added with a safe action."})
}

/// Screenshot ARRAY of every Altium window + logical state from the agent.
pub fn state(args: &Value) -> Value {
    let wins = instance::altium_windows();
    let mut shots: Vec<Value> = Vec::new();
    if args.get("screenshots").and_then(|b| b.as_bool()).unwrap_or(true) {
        for w in &wins {
            let kind = w.get("kind").and_then(|k| k.as_str()).unwrap_or("");
            if !matches!(kind, "main" | "dialog" | "script_error_modal") {
                continue;
            }
            if let Some(hwnd) = w.get("hwnd").and_then(|h| h.as_i64()) {
                let r = ab::call("desktop_screenshot_base64", json!({"hwnd": hwnd}), &[], 15);
                if let Some(img) = r.get("image").or_else(|| r.get("base64")).and_then(|i| i.as_str()) {
                    shots.push(json!({"title": w.get("title"), "hwnd": hwnd, "kind": kind,
                        "image": img, "width": r.get("width"), "height": r.get("height")}));
                }
            }
        }
    }
    // state is instance-exempt (it must answer with several Altiums running), so the selection
    // is None and `is_alive()` would read only the legacy root — blind to a per-instance loop
    // (adom/altium-bridge#8: agentAlive:false / logical:null beside a healthy agent). Use the
    // UNION: any live workspace counts, and the logical read goes to the live one. With several
    // live loops the selected instance's wins, else the first (and the reply names which).
    let live_ws = agent::live_workspaces();
    let agent_alive = !live_ws.is_empty();
    let selected_ws = agent::workspace_dir();
    let logical_ws = live_ws.iter().map(|(w, _)| w.clone())
        .find(|w| *w == selected_ws)
        .or_else(|| live_ws.first().map(|(w, _)| w.clone()));
    let logical = match &logical_ws {
        Some(ws) => agent::call_in(ws, "state", &[], None, Duration::from_secs(15)),
        None => Value::Null,
    };
    json!({"success": true, "licence": instance::licence_state(), "windows": wins,
        "dialogs": dialogs::scan(), "shots": shots, "logical": logical, "focusEvents": [],
        "agentAlive": agent_alive,
        "agentWorkspace": logical_ws.map(|w| w.to_string_lossy().to_string()),
        "liveWorkspaces": live_ws.iter().map(|(w, age)|
            json!({"workspace": w.to_string_lossy(), "heartbeatAgeSec": age})).collect::<Vec<_>>(),
        "drivingBadge": {"active": taskbar::badge_active()},
        "_hint": "shots[] are base64 PNGs of every Altium window (render inline). logical.data.documents lists open docs (agent required). agentAlive is the union over every workspace; agentWorkspace names the one logical came from."})
}

pub fn progress_verb(_args: &Value) -> Value {
    json!({"success": true, "active": progress::live(false, ""), "history": progress::history(),
        "verbTimes": progress::verb_report(40)})
}

pub fn uninstall_verb(args: &Value) -> Value {
    let mut removed: Vec<String> = Vec::new();
    let dry = args.get("dryRun").and_then(|b| b.as_bool()) == Some(true);
    // A DRY run changes nothing: it used to stop the resident agent anyway (found 2026-09-21).
    if agent::is_alive() && !dry {
        lifecycle::stop();
    }
    let d = agent::cache_dir();
    if d.exists() && args.get("dryRun").and_then(|b| b.as_bool()) != Some(true) {
        let _ = std::fs::remove_dir_all(&d);
        removed.push(d.to_string_lossy().to_string());
    }
    json!({"success": true, "removed": removed, "dryRun": args.get("dryRun").and_then(|b| b.as_bool()).unwrap_or(false),
        "note": "Altium Designer itself and the user's designs are never touched."})
}

// ---- launch / close (seat-aware) ---------------------------------------------

pub fn launch(args: &Value) -> Value {
    let inst = match support::first_install() {
        Some(i) => i,
        None => return support::not_installed(),
    };
    let lic = instance::licence_state();
    if lic.get("running").and_then(|b| b.as_bool()) == Some(true) {
        return json!({"success": true, "alreadyRunning": true, "licence": lic,
            "windowEtiquette": "background", "_hint": "Already running — nothing launched."});
    }
    let t0 = support::now();
    progress::begin("cold_start", "cold", "launching Altium Designer", "bridge");
    // John, 2026-09-20/21: "dude u keep forcing altium in front of me [while] i'm typing".
    // Altium's own launch foregrounds itself and there is no switch for it, so remember the
    // window the user was actually in and hand it straight back once Altium is up.
    // `background:false` opts out when the launch IS the thing the user asked to see.
    let hand_back = args.get("background").and_then(|b| b.as_bool()).unwrap_or(true);
    let user_fg = if hand_back { lifecycle::foreground_hwnd_pub() } else { None };
    let exe = inst.get("exe").and_then(|e| e.as_str()).unwrap_or("").to_string();
    if let Err(e) = lifecycle::spawn_helper_pub(&exe, "") {
        progress::end("cold_start", "bridge");
        return json!({"success": false, "error": format!("launch failed: {e}")});
    }
    let wait = args.get("waitSec").and_then(|v| v.as_f64()).unwrap_or(180.0);
    while support::now() - t0 < wait {
        instance::invalidate_snapshot();
        let lic = instance::licence_state();
        if lic.get("running").and_then(|b| b.as_bool()) == Some(true) {
            progress::record("cold_start", support::now() - t0, "cold");
            progress::end("cold_start", "bridge");
            // Altium keeps painting for a moment after its main window exists; hand the focus
            // back once it has settled, or it simply takes it again.
            let mut returned = Value::Null;
            if let Some(fg) = user_fg {
                std::thread::sleep(Duration::from_secs(3));
                lifecycle::restore_foreground_pub(fg);
                returned = json!(fg);
            }
            return json!({"success": true, "startedInSec": support::elapsed_round(t0), "licence": lic,
                "windowEtiquette": if returned.is_null() { "foreground" } else { "background" },
                "foregroundReturnedTo": returned,
                "_hint": if returned.is_null() { "Altium's own launch foregrounds itself. Next: altium_readiness; if unlicensed, altium_license." }
                         else { "Altium is up and the user's own window was handed back (it launches in front of everything and cannot be told not to). Pass background:false if the user asked to SEE Altium come up. Next: altium_readiness; if unlicensed, altium_license." }});
        }
        std::thread::sleep(Duration::from_secs(1));
    }
    progress::end("cold_start", "bridge");
    json!({"success": false, "errorCode": "launch_timeout", "error": format!("no Altium main window after {wait}s")})
}

fn strand_refusal(extra: Value) -> Value {
    let lic = instance::licence_state();
    let mut out = json!({"success": false, "errorCode": "would_strand_licence_seat",
        "licenceState": lic.get("state"),
        "error": "force:true kills Altium and STRANDS its licence seat — no other Altium will be able to claim it afterwards, and the licence page will report it as free while refusing every claim.",
        "_hint": "Try altium_close with no force first: a polite WM_CLOSE releases the seat. If it reports blocked_by_dialog, READ the dialog (an Unsaved Changes prompt names the documents — on a shared machine one may belong to another AI thread) and answer it. Only if you truly must kill: repeat with acceptSeatLoss:true."});
    if let (Some(o), Some(e)) = (out.as_object_mut(), extra.as_object()) {
        for (k, v) in e {
            o.insert(k.clone(), v.clone());
        }
    }
    support::seat_hints(out, lic.get("state").and_then(|st| st.as_str()), instance::instances().len())
}

fn kill_instance_pids() -> Vec<u64> {
    let mut killed = Vec::new();
    for i in instance::instances() {
        if let Some(pid) = i.get("pid").and_then(|p| p.as_u64()) {
            let r = ab::call("process_kill", json!({"pid": pid, "reason": "forced altium_close with acceptSeatLoss"}), &[], 20);
            if r.get("success").and_then(|b| b.as_bool()).unwrap_or(false) {
                killed.push(pid);
            }
        }
    }
    killed
}

/// Altium's own Exit process through the -R lane (the only close its skinned title bar honours).
fn send_quit() {
    if let Some(exe) = lifecycle::x2_exe_pub() {
        if let Ok(mut child) = lifecycle::spawn_helper_pub(&exe, "-RClient:QuitFromEDAClient") {
            let t0 = std::time::Instant::now();
            while t0.elapsed() < Duration::from_secs(10) {
                match child.try_wait() {
                    Ok(Some(_)) => break,
                    Ok(None) => std::thread::sleep(Duration::from_millis(200)),
                    Err(_) => break,
                }
            }
            let _ = child.kill();
        }
    }
}

pub fn close(args: &Value) -> Value {
    let lic = instance::licence_state();
    if lic.get("running").and_then(|b| b.as_bool()) != Some(true) {
        return json!({"success": true, "wasRunning": false});
    }
    let force = args.get("force").and_then(|b| b.as_bool()).unwrap_or(false);
    let accept = args.get("acceptSeatLoss").and_then(|b| b.as_bool()).unwrap_or(false);
    // The INTENT to force is the risk, not whether a dialog happens to block: force:true KILLS
    // the process, which strands the On-Demand seat. Refuse up front.
    if force && !accept && lic.get("licensed").and_then(|b| b.as_bool()) != Some(false) {
        return strand_refusal(json!({}));
    }
    // Tear down EVERY resident loop, in every workspace, BEFORE the polite WM_CLOSE — not just the
    // one the current selection points at. In #6 a loop lived in the legacy workspace while the
    // selected one was empty; the old selected-only `is_alive()`/`stop()` was a silent no-op and
    // the app closed over a live loop, wedging the shutdown and hard-freezing the desktop. The
    // teardown MUST precede the close so the scripting engine is unoccupied when Altium unloads it.
    //
    // `unsafeSkipTeardown: true` is the #6 ACCEPTANCE LEVER, nothing else: it reproduces a user
    // X-press (a close dispatched over whatever loops are resident) so the freeze fix stays
    // re-testable on every release. Altium's themed title bar exposes no UIA close button and
    // synthetic input is approval-gated, so the gesture cannot be reproduced from outside.
    // Parked = no loop was alive BEFORE the teardown below. Asked after it, every close looked
    // parked (the teardown itself makes is_alive false) and paid ~10 s of pointless -R hand-offs.
    let loop_was_alive = agent::is_alive();
    let agent_teardown = if args.get("unsafeSkipTeardown").and_then(|b| b.as_bool()) == Some(true) {
        json!({"skipped": "unsafeSkipTeardown — the issue-6 acceptance lever: the close goes out over any resident loop, exactly like a user X-press. Never use outside a freeze acceptance run."})
    } else {
        lifecycle::stop_all_live()
    };
    // The user's close gesture, faithfully: Altium's skinned title bar ignores WM_CLOSE (it
    // closes the ACTIVE DOCUMENT instead) and WM_SYSCOMMAND/SC_CLOSE outright (both measured
    // 2026-09-02); even Alt+F4 maps to the internal Exit process. So the polite close IS that
    // process, forwarded through the -R lane — which, being a server process and not a script,
    // executes even while an agent loop is resident (exactly the incident gesture of #6).
    // (ab's desktop_set_window_state dropped its "close" state the same day; nothing here
    // depends on another surface's verb set.)
    // A script parked on a breakpoint makes Altium REFUSE to close ("cannot close the current
    // document when a script is stopped on a breakpoint", measured on two builds 2026-09-20/21), and
    // Altium Develop parks silently. Free the engine first; it is a no-op when nothing is parked.
    let mut engine_freed = Value::Null;
    if !loop_was_alive && agent::is_agent_script_title(&instance::main_window().and_then(|w| w.get("title").and_then(|t| t.as_str()).map(str::to_string)).unwrap_or_default()) {
        engine_freed = lifecycle::recover_without_restart("altium_close: the script engine was parked on a breakpoint");
    }
    lifecycle::note_close_initiated();
    send_quit();
    let mut breakpoint_retries = 0;
    if false {
    if let Some(exe) = lifecycle::x2_exe_pub() {
        if let Ok(mut child) = lifecycle::spawn_helper_pub(&exe, "-RClient:QuitFromEDAClient") {
            let t0 = std::time::Instant::now();
            while t0.elapsed() < Duration::from_secs(10) {
                match child.try_wait() {
                    Ok(Some(_)) => break,
                    Ok(None) => std::thread::sleep(Duration::from_millis(200)),
                    Err(_) => break,
                }
            }
            let _ = child.kill();
        }
    }
    }
    let t0 = support::now();
    let wait = args.get("waitSec").and_then(|v| v.as_f64()).unwrap_or(20.0);
    while support::now() - t0 < wait {
        instance::invalidate_snapshot();
        if instance::instances().is_empty() {
            return json!({"success": true, "closedInSec": support::elapsed_round(t0), "agentTeardown": agent_teardown, "engineFreed": engine_freed});
        }
        let mut dl = dialogs::scan_scoped(true);
        if !dl.is_empty() {
            if force {
                if !accept {
                    return strand_refusal(json!({"dialogs": dl}));
                }
                let killed = kill_instance_pids();
                return json!({"success": true, "forced": true, "killedPids": killed,
                    "dismissedDialogs": dl.iter().map(|d| d.get("title").cloned().unwrap_or(Value::Null)).collect::<Vec<_>>(),
                    "_seatWarning": format!("The licence seat is now STRANDED. {}", support::SEAT_STRANDED_FIX),
                    "_hint": "force:true kills even when a dialog blocks the polite close (e.g. the 'script stopped on a breakpoint' Error that refuses WM_CLOSE)."});
            }
            // Altium's save prompts. discardChanges:true = "every modified document is mine": press
            // Save None, then OK on the confirmation, and keep waiting for the exit. Without it, say
            // exactly what is unsaved instead of timing out.
            let lone_error = dl.iter().find(|d| d.get("title").and_then(|t| t.as_str()).map(|t| t.trim().eq_ignore_ascii_case("error")).unwrap_or(false)
                && d.get("buttons").and_then(|b| b.as_array()).map(|a| a.len() == 1).unwrap_or(false)).cloned();
            if let Some(d) = lone_error {
                if breakpoint_retries < 2 {
                    breakpoint_retries += 1;
                    if let Some(hw) = d.get("hwnd").and_then(|h| h.as_i64()) { let _ = dialogs::press(hw, "OK"); }
                    engine_freed = lifecycle::recover_without_restart("altium_close: Altium refused to close over a parked script; freed it and asked again");
                    std::thread::sleep(Duration::from_millis(1200));
                    send_quit();
                    continue;
                }
            }
            let is_save = |d: &Value| d.get("category").and_then(|c| c.as_str()) == Some("save_prompt");
            if dl.iter().any(is_save) {
                if args.get("discardChanges").and_then(|b| b.as_bool()) == Some(true) {
                    for d in dl.iter().filter(|d| is_save(d)) {
                        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("").to_lowercase();
                            // "Save None" does NOT dismiss this dialog: it unticks every document
                            // and RENAMES the same window to "Confirm Not Saving (N) Modified
                            // Documents", which OK then commits (measured 2026-09-21, AD 26.10.1).
                            // Leaving the second press to the next poll leaves Altium sitting
                            // modal — and a modal Altium disables its own main window, which is
                            // the orphan state above. So finish the exchange here.
                            let button = if title.contains("not saving") { "OK" } else { "Save None" };
                            let r = dialogs::press(hw, button);
                            dialogs::note_event(json!({"title": d.get("title"), "category": "save_prompt_discarded", "pressed": button,
                                "ok": r.get("success"), "note": "altium_close {discardChanges:true}: the caller declared the modified documents its own"}));
                            if button == "Save None" {
                                let t1 = std::time::Instant::now();
                                while t1.elapsed() < Duration::from_secs(5) {
                                    std::thread::sleep(Duration::from_millis(300));
                                    let still = dialogs::scan_scoped(true);
                                    let confirm = still.iter().find(|x| x.get("hwnd").and_then(|h| h.as_i64()) == Some(hw)
                                        && x.get("title").and_then(|t| t.as_str()).map(|t| t.to_lowercase().contains("not saving")).unwrap_or(false));
                                    match confirm {
                                        Some(_) => {
                                            let r2 = dialogs::press(hw, "OK");
                                            dialogs::note_event(json!({"title": "Confirm Not Saving", "category": "save_prompt_discarded",
                                                "pressed": "OK", "ok": r2.get("success"),
                                                "note": "the second half of the Save None exchange, pressed in the same pass"}));
                                            break;
                                        }
                                        None => { if still.iter().all(|x| x.get("hwnd").and_then(|h| h.as_i64()) != Some(hw)) { break; } }
                                    }
                                }
                            }
                        }
                    }
                    std::thread::sleep(Duration::from_millis(1500));
                    continue;
                }
                crate::shot::attach_dialog_shots(&mut dl);
                return json!({"success": false, "errorCode": "unsaved_changes", "dialogs": dl,
                    "_hint": "Altium will not close: documents are modified. dialogs[].shot NAMES them: read it. If EVERY one is yours (a scratch board, a test library, the agent script), call altium_close {discardChanges:true}: the bridge presses Save None and confirms. If ANY belongs to the user or another thread, altium_save it first or ask. Never force-kill over unsaved work."});
            }
            crate::shot::attach_dialog_shots(&mut dl);
            return json!({"success": false, "errorCode": "close_blocked_by_dialog", "dialogs": dl,
                "_hint": "Altium asked something on close (save?). Read dialogs[] and answer via altium_dismiss_dialogs {hwnd,button}, or pass force:true (with acceptSeatLoss:true — a kill strands the seat)."});
        }
        // Nothing to answer, yet Altium is disabled: it is inside a modal loop whose dialog is
        // gone. It will never honour the quit, so stop waiting out the clock.
        if dl.is_empty() {
            if let Some(orphan) = lifecycle::modal_orphan() {
                if args.get("discardChanges").and_then(|b| b.as_bool()) == Some(true) || force {
                    let r = lifecycle::graceful_terminate(&orphan, Duration::from_secs(20));
                    if r.get("success").and_then(|b| b.as_bool()) == Some(true) {
                        return json!({"success": true, "closedInSec": support::elapsed_round(t0),
                            "agentTeardown": agent_teardown, "engineFreed": engine_freed,
                            "stuckModalOrphan": orphan, "endedBy": r,
                            "_hint": "Altium was stuck in a modal loop with no dialog left to answer (main window DISABLED). The bridge ended it with a posted WM_QUIT — Altium's own shutdown path, so the licence seat was RELEASED, not stranded."});
                    }
                    return json!({"success": false, "errorCode": "modal_orphan_survived_quit",
                        "stuckModalOrphan": orphan, "endedBy": r,
                        "_hint": "Altium is wedged in a modal loop and did not leave it even on a posted WM_QUIT. Screenshot the desktop, then force:true + acceptSeatLoss:true is the only route left — it kills and STRANDS the seat, so say so to the user first."});
                }
                return json!({"success": false, "errorCode": "altium_modal_orphan", "stuckModalOrphan": orphan,
                    "_hint": "Altium is stuck inside a modal loop whose dialog is gone (main window DISABLED, nothing on screen to answer). It cannot be reached by menu, keystroke or -R, and it is holding the licence seat. If every open document is yours, altium_close {discardChanges:true} ends it gracefully with a posted WM_QUIT and RELEASES the seat. If a document may be the user's, its unsaved content cannot be recovered from this state — ask first."});
            }
        }
        std::thread::sleep(Duration::from_millis(500));
    }
    if force && accept {
        let killed = kill_instance_pids();
        return json!({"success": true, "forced": true, "killedPids": killed,
            "_seatWarning": format!("The licence seat is now STRANDED. {}", support::SEAT_STRANDED_FIX)});
    }
    json!({"success": false, "errorCode": "close_timeout",
        "_hint": "still running — a modal may be blocking WM_CLOSE (altium_check_dialogs); force:true + acceptSeatLoss:true taskkills and strands the seat"})
}

// ---- the process-launcher catalog (list_commands) ----------------------------
//
// Every menu/toolbar action in Altium is a "process launcher" line in a System/*.rcs table:
//   PL PLPCB:Undo   Command='PCB:Undo'  Caption='&Undo'  Shortcut1='Ctrl+Z' ... End
// AD 26.9.1 ships 47 .rcs files -> 3,205 launchers -> 1,109 distinct commands.

static RCS_CACHE: std::sync::Mutex<Option<(std::time::Instant, String, Value)>> = std::sync::Mutex::new(None);

/// Parse `Key='value'` attributes ('' = escaped quote) off a launcher line.
fn parse_attrs(rest: &str) -> Vec<(String, String)> {
    let bytes = rest.as_bytes();
    let mut out = Vec::new();
    let mut i = 0;
    while i < bytes.len() {
        // find identifier start
        while i < bytes.len() && !bytes[i].is_ascii_alphanumeric() && bytes[i] != b'_' {
            i += 1;
        }
        let ks = i;
        while i < bytes.len() && (bytes[i].is_ascii_alphanumeric() || bytes[i] == b'_') {
            i += 1;
        }
        if i >= bytes.len() || ks == i {
            break;
        }
        if bytes[i] != b'=' || i + 1 >= bytes.len() || bytes[i + 1] != b'\'' {
            continue;
        }
        let key = rest[ks..i].to_string();
        i += 2; // past ='
        let vs = i;
        loop {
            if i >= bytes.len() {
                return out; // unterminated — drop the tail
            }
            if bytes[i] == b'\'' {
                if i + 1 < bytes.len() && bytes[i + 1] == b'\'' {
                    i += 2; // escaped ''
                    continue;
                }
                break;
            }
            i += 1;
        }
        out.push((key, rest[vs..i].replace("''", "'")));
        i += 1;
    }
    out
}

fn parse_rcs_dir(system_dir: &str) -> Value {
    if let Ok(g) = RCS_CACHE.lock() {
        if let Some((t, dir, v)) = g.as_ref() {
            if dir == system_dir && t.elapsed() < Duration::from_secs(3600) {
                return v.clone();
            }
        }
    }
    let mut commands: std::collections::BTreeMap<String, Value> = std::collections::BTreeMap::new();
    let mut files = 0;
    let mut launchers = 0;
    if let Ok(rd) = std::fs::read_dir(system_dir) {
        let mut paths: Vec<_> = rd.flatten().map(|e| e.path())
            .filter(|p| p.extension().map(|e| e.to_string_lossy().to_lowercase() == "rcs").unwrap_or(false))
            .collect();
        paths.sort();
        for rcs in paths {
            files += 1;
            let Ok(bytes) = std::fs::read(&rcs) else { continue };
            let text: String = bytes.iter().map(|b| *b as char).collect(); // latin-1
            let fname = rcs.file_name().map(|n| n.to_string_lossy().to_string()).unwrap_or_default();
            for line in text.lines() {
                let t = line.trim_start();
                if t.starts_with("//") || !t.starts_with("PL") {
                    continue;
                }
                let Some(rest) = t.trim_end().strip_suffix("End") else { continue };
                // PL<word> <id> <attrs>
                let mut it = rest.splitn(3, char::is_whitespace).filter(|q| !q.is_empty());
                let (Some(_pl), Some(_id), Some(attrs_str)) = (it.next(), it.next(), it.next()) else { continue };
                launchers += 1;
                let attrs = parse_attrs(attrs_str);
                let cmd = attrs.iter().find(|(k, _)| k == "Command").map(|(_, v)| v.clone()).unwrap_or_default();
                if cmd.is_empty() {
                    continue;
                }
                let server = cmd.split(':').next().unwrap_or("").to_string();
                let e = commands.entry(cmd.clone()).or_insert_with(|| json!({
                    "command": cmd, "server": server, "captions": [], "shortcuts": [],
                    "params": [], "descriptions": [], "sources": []}));
                let push_unique = |e: &mut Value, list: &str, v: String| {
                    if v.is_empty() {
                        return;
                    }
                    let arr = e[list].as_array_mut().unwrap();
                    if !arr.iter().any(|x| x.as_str() == Some(v.as_str())) {
                        arr.push(json!(v));
                    }
                };
                for (k, list) in [("Caption", "captions"), ("Description", "descriptions"), ("Params", "params")] {
                    let mut v = attrs.iter().find(|(a, _)| a == k).map(|(_, v)| v.clone()).unwrap_or_default();
                    if k == "Caption" {
                        v = v.replace('&', "");
                    }
                    push_unique(e, list, v);
                }
                for k in ["Shortcut1", "Shortcut2"] {
                    let v = attrs.iter().find(|(a, _)| a == k).map(|(_, v)| v.clone()).unwrap_or_default();
                    push_unique(e, "shortcuts", v);
                }
                push_unique(e, "sources", fname.clone());
            }
        }
    }
    let mut servers: std::collections::BTreeMap<String, u64> = std::collections::BTreeMap::new();
    for c in commands.values() {
        *servers.entry(c.get("server").and_then(|st| st.as_str()).unwrap_or("").to_string()).or_default() += 1;
    }
    let data = json!({"systemDir": system_dir, "files": files, "launchers": launchers,
        "distinctCommands": commands.len(), "servers": servers,
        "commands": commands.into_iter().collect::<serde_json::Map<_, _>>()});
    if let Ok(mut g) = RCS_CACHE.lock() {
        *g = Some((std::time::Instant::now(), system_dir.to_string(), data.clone()));
    }
    data
}

pub fn list_commands(args: &Value) -> Value {
    let inst = match support::first_install() {
        Some(i) => i,
        None => return support::not_installed(),
    };
    let system_dir = inst.get("system_dir").and_then(|d| d.as_str()).unwrap_or("").to_string();
    let data = parse_rcs_dir(&system_dir);
    let q = s_or(args, "query", "").to_lowercase();
    let server = s(args, "server");
    let limit = args.get("limit").and_then(|l| l.as_u64()).unwrap_or(50) as usize;
    let mut hits = Vec::new();
    if let Some(cmds) = data.get("commands").and_then(|c| c.as_object()) {
        for (cmd, e) in cmds {
            if let Some(srv) = &server {
                if e.get("server").and_then(|st| st.as_str()).map(|x| !x.eq_ignore_ascii_case(srv)).unwrap_or(true) {
                    continue;
                }
            }
            if !q.is_empty() {
                let hay = format!("{cmd} {} {} {}",
                    e["captions"].to_string(), e["descriptions"].to_string(), e["params"].to_string()).to_lowercase();
                if !hay.contains(&q) {
                    continue;
                }
            }
            let mut hit = serde_json::Map::new();
            for k in ["command", "server", "captions", "shortcuts", "params", "descriptions"] {
                hit.insert(k.into(), e.get(k).cloned().unwrap_or(Value::Null));
            }
            hits.push(Value::Object(hit));
            if hits.len() >= limit {
                break;
            }
        }
    }
    json!({"success": true, "query": q, "server": server, "count": hits.len(),
        "total": data.get("distinctCommands"), "servers": data.get("servers"), "hits": hits,
        "_hint": "Run one with altium_run_command {command:'PCB:Zoom', params:'Action=All'} (goes through the agent). Params are Name=Value|Name=Value."})
}

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

    #[test]
    fn rcs_attr_parser_handles_escapes() {
        let attrs = parse_attrs("Command='PCB:Undo'  Caption='&Undo'  Shortcut1='Ctrl+Z' Description='It''s undo' Params=''");
        let get = |k: &str| attrs.iter().find(|(a, _)| a == k).map(|(_, v)| v.clone());
        assert_eq!(get("Command").as_deref(), Some("PCB:Undo"));
        assert_eq!(get("Caption").as_deref(), Some("&Undo"));
        assert_eq!(get("Description").as_deref(), Some("It's undo"));
        assert_eq!(get("Params").as_deref(), Some(""));
    }
}