← Commit history

0.2.9: the dedicated freeze-fix release (#6, #7, #8) - workspace-union teardown/status, bootstrap refuses legacy fallback, native hung-signature watchdog + scoped zombie-breaker, install lane shows the library with honest shown and stops the agent, close_document verb, close via Client:QuitFromEDAClient (WM_CLOSE/SC_CLOSE are ignored by the skinned frame), template Terminated check + 5min idle stop, dialog catalog additions, board.rs reconstruction (SchematicData + cross_check), zip wrapper flattening

Colby Knox ·9f4e1af4b5 ·1mo ago ·parent 195ea60
14 files changed +786−78
CHANGELOG.md+11
@@ -1,5 +1,16 @@ # Changelog +## 0.2.9 — 2026-09-02+The dedicated freeze-fix release (adom/altium-bridge#6, #7, #8). Every mechanism here traces to the evidence gathered on Colby-ROG2 on 2026-09-02: three hard desktop freezes, all enabled by a resident agent loop the per-instance views could not see.++- **The workspace split-brain is closed.** The agent REFUSES to bootstrap without a resolved instance (with a 12s resolve retry for just-launched windows) instead of silently landing in the legacy root where status and teardown were blind to it. `altium_close` now stops EVERY workspace's live loop (legacy included) before its WM_CLOSE; readiness/agent_status report `agentResidentAnywhere` + `liveWorkspaces`; `altium_state` (#8) answers from the union too, naming the workspace its `logical` read came from. `install_library_bundle` re-resolves the instance after its own auto-launch.+- **Close watchdog v2 detects natively and can end a wedged shutdown.** 0.2.8's watchdog missed both freezes it watched because its detection ran through ab, whose enumeration hangs or returns empty exactly mid-freeze. v2 asks Win32 directly (non-messaging calls only; "cannot answer" is distinguished from "none"), drops stop files into every live workspace the instant the main window vanishes, and after 45s of "window gone, X2 alive, our loop was involved" it warns by toast and terminates the wedged process, recording the seat consequence. A ten-second blip instead of a bricked desktop.+- **The one-click install lands the user on their library (#7).** After registering, the bundle opens the installed SchLib, selects the SCH Library panel (UIA SelectionItemPattern - a plain click does not switch panel tabs), captures the proof frame, and reports an honest `shown: true|false` plus real component counts (`symbolCount`; the old `symbols`/`footprints` file counts remain one release). The agent is STOPPED when the install lane finishes, so the user's later X-press finds an idle engine. The wrapper folder inside export zips is flattened (no more `UI\UI\`).+- **`altium_close_document {path}`** (new verb): closes one open document through its document handle, never saving. The menu processes are broken for this on ADDevelop: RunProcess `Client:CloseDocument` no-ops and `Client:CloseAllOpenDocuments` unregisters documents while their views stay open (both measured).+- **Agent template hardening**: `Application.Terminated` joins the loop's exit checks (`Client.IsQuitting` demonstrably never fired in time), idle auto-stop cut from 30 to 5 minutes (residency IS the freeze window), and the `close_document` command. Per the probe campaign on #6: no interception design is possible in this engine (timers die at script return, the VCL context is a sandbox, runtime event assignment is illegal in every spelling, TApplicationEvents is unknown to the dfm loader), and `Client.StopServer` rejects both plausible signatures, so server hygiene is deliberately NOT attempted.+- **Dialog catalog**: "Error Reading Form" (dfm class-not-found; Cancel is the clean dismissal) and a body-text fallback that reads Text-role control names, so the script debugger's "Continue execution?" prompt (title "X2", previously empty body) classifies instead of evading the reaper.++ ## 0.2.8 — 2026-09-02 - **User-initiated-close watchdog, ported from Colby Knox's PR 74 (adom/altium-bridge#4) to the Rust runtime.** A standing service polls every 2 s; for any per-instance workspace with a live heartbeat whose main window has vanished while an X2 process still lives, it drops the agent's stop file that instant, so the loop exits during shutdown instead of wedging under the engine teardown (the freeze that hard-locked a desktop). Cold starts never trigger it (a workspace must have been SEEN with a window). Each firing is logged in `altium_dialog_log`. Honest limit, as in the PR: a 2 s race, not a guarantee — the complete fix is the agent noticing shutdown itself. - **`altium_install_library_bundle` auto-launches a closed Altium** (Colby, #5): one-click parity with the KiCad/Fusion lanes; cold start runs with the measured taskbar bar, the agent bootstraps on the first call, and the reply carries `launched` with the cold-start time.
bridge-rs/src/agent.rs+22−3
@@ -79,6 +79,16 @@ pub fn heartbeat() -> Option<Value> { /// second script REGARDLESS of which workspace the first one polls. Returns the workspace path /// of the live loop so the refusal can name it. pub fn any_agent_alive() -> Option<(PathBuf, f64)> {+    live_workspaces().into_iter().next()+}++/// EVERY workspace with a live heartbeat — the legacy root AND every per-instance subdir —+/// each with its heartbeat age. The freeze in #6 happened because a loop bootstrapped into the+/// legacy root (no instance was resolved at the time) while `is_alive()` — which reads only the+/// SELECTED workspace — reported "not resident", so `altium_close` skipped its agent teardown and+/// the polite WM_CLOSE went out over a live loop. Teardown and status must scan the UNION, never+/// one workspace: a loop anywhere is a loop that can wedge the shutdown.+pub fn live_workspaces() -> Vec<(PathBuf, f64)> {     let root = agent_root();     let mut candidates = vec![root.clone()];     if let Ok(rd) = std::fs::read_dir(&root) {@@ -88,14 +98,15 @@ pub fn any_agent_alive() -> Option<(PathBuf, f64)> {             }         }     }+    let mut live = Vec::new();     for ws in candidates {         if let Some(age) = hb_age(&ws) {             if age < Duration::from_secs(ALIVE_SEC) {-                return Some((ws, (age.as_millis() as f64) / 1000.0));+                live.push((ws, (age.as_millis() as f64) / 1000.0));             }         }     }-    None+    live }  fn rid() -> String {@@ -116,7 +127,15 @@ pub fn call(command: &str, params: &[(&str, String)], payload: Option<&str>, tim             return out;         }     }-    let ws = workspace_dir();+    call_in(&workspace_dir(), command, params, payload, timeout)+}++/// The file-drop round trip against an EXPLICIT workspace. Instance-exempt verbs (state,+/// diagnostics) have no selection, so `workspace_dir()` points at the legacy root and goes blind+/// to a per-instance loop (adom/altium-bridge#8); they resolve a live workspace via+/// `live_workspaces()` and call here directly.+pub fn call_in(ws: &std::path::Path, command: &str, params: &[(&str, String)], payload: Option<&str>, timeout: Duration) -> Value {+    let ws = ws.to_path_buf();     let id = rid();     let mut lines = vec![format!("id={id}"), format!("command={command}")];     for (k, v) in params {
bridge-rs/src/board.rs+131
@@ -160,6 +160,90 @@ impl BoardData {     } } +/// A recorded or live `read_schematic` payload (the agent's `data` object): the CURRENT sheet's+/// components with pins and params. Same envelope rules as `BoardData` — the agent nests the+/// section reader's own `{ok, count, components}` under the section name.+#[derive(Clone, Debug)]+pub struct SchematicData(pub Value);++impl SchematicData {+    pub fn new(data: Value) -> Self {+        SchematicData(data)+    }++    pub fn document(&self) -> Option<&str> {+        self.0.get("document").and_then(|v| v.as_str())+    }++    pub fn components(&self) -> Vec<Value> {+        self.0+            .get("components")+            .filter(|s| s.get("ok").and_then(|b| b.as_bool()).unwrap_or(false))+            .and_then(|s| s.get("components"))+            .and_then(|a| a.as_array())+            .cloned()+            .unwrap_or_default()+    }++    /// Designators, naturally sorted and de-duplicated (a multi-part symbol appears once per part).+    pub fn designators(&self) -> Vec<String> {+        let mut d: Vec<String> = self+            .components()+            .iter()+            .filter_map(|c| c.get("designator").and_then(|v| v.as_str()).map(str::to_string))+            .collect();+        d.sort_by(|a, b| natural_cmp(a, b));+        d.dedup();+        d+    }+}++/// Windows folder of a document path, lowercased — `sameProject` compares folders because the+/// SchServer and PCBServer each hold their own "current document" and the two can genuinely+/// belong to different projects.+fn folder_of(p: Option<&str>) -> Option<String> {+    let q = p?.replace('/', "\\");+    q.rsplit_once('\\').map(|(dir, _)| dir.to_lowercase())+}++/// Schematic sheet vs board: what is drawn but not placed, and placed but not drawn — which+/// neither read answers alone. Pure function over the two reads (the verb does the activation+/// dance, because Altium cannot hold both documents current at once). Two caveats travel WITH+/// the answer because both make it wrong to act on silently: the schematic side is ONE sheet,+/// and the two currents can belong to different projects (`sameProject: false`).+pub fn cross_check(sheet: &SchematicData, board: &BoardData) -> Value {+    let sch: std::collections::BTreeSet<String> = sheet.designators().into_iter().collect();+    let pcb: std::collections::BTreeSet<String> = board.designators().into_iter().collect();+    let sort = |s: Vec<String>| {+        let mut v = s;+        v.sort_by(|a, b| natural_cmp(a, b));+        v+    };+    let on_sheet_only = sort(sch.difference(&pcb).cloned().collect());+    let on_board_only = sort(pcb.difference(&sch).cloned().collect());+    let same = match (folder_of(sheet.document()), folder_of(board.board_path())) {+        (Some(a), Some(b)) => json!(a == b),+        _ => Value::Null,+    };+    let mut out = json!({+        "sheet": sheet.document(),+        "board": board.board_path(),+        "sameProject": same,+        "matched": sch.intersection(&pcb).count(),+        "onSheetNotOnBoard": on_sheet_only,+        "onBoardNotOnSheet": on_board_only,+        "schematicComponents": sheet.components().len(),+        "boardComponents": board.components().len(),+        "_scope": "The schematic side is ONE sheet. On a hierarchical design, run this per leaf sheet before calling anything 'missing from the schematic'.",+    });+    if out.get("sameProject") == Some(&json!(false)) {+        out["_warning"] = json!(+            "The sheet and the board are in DIFFERENT folders, so they are probably not the same project — which makes every difference above meaningless."+        );+    }+    out+}+ /// Altium `TRuleKind` ordinals, read off a live AD26 default rule set (2026-09-01): every default /// rule is named after its own kind, which makes the mapping observable rather than guessed. /// Unknown ordinals are rendered as `kind <n>` — never invented.@@ -306,4 +390,51 @@ mod tests {         assert!(d.designators().is_empty());         assert_eq!(d.summary().get("components").and_then(|v| v.as_u64()), Some(0));     }++    fn sheet(des: &[&str], doc: &str) -> SchematicData {+        SchematicData::new(json!({+            "ok": true, "document": doc,+            "components": {"ok": true,+                "components": des.iter().map(|d| json!({"designator": d})).collect::<Vec<_>>()}+        }))+    }++    fn small_board(des: &[&str], path: &str) -> BoardData {+        BoardData::new(json!({+            "board": path,+            "components": {"ok": true,+                "components": des.iter().map(|d| json!({"designator": d})).collect::<Vec<_>>()}+        }))+    }++    #[test]+    fn cross_check_reports_both_directions_naturally_sorted() {+        let s = sheet(&["U1", "U10", "U2", "R1"], "C:\\proj\\main.SchDoc");+        let b = small_board(&["U1", "U2", "C5"], "C:\\proj\\main.PcbDoc");+        let out = cross_check(&s, &b);+        assert_eq!(out["matched"], json!(2));+        assert_eq!(out["onSheetNotOnBoard"], json!(["R1", "U10"]));+        assert_eq!(out["onBoardNotOnSheet"], json!(["C5"]));+        assert_eq!(out["sameProject"], json!(true));+        assert!(out.get("_warning").is_none());+    }++    #[test]+    fn cross_check_warns_on_different_folders() {+        let s = sheet(&["U1"], "C:\\projA\\main.SchDoc");+        let b = small_board(&["U1"], "C:\\projB\\main.PcbDoc");+        let out = cross_check(&s, &b);+        assert_eq!(out["sameProject"], json!(false));+        assert!(out.get("_warning").is_some(), "different folders must warn loudly");+    }++    #[test]+    fn schematic_failed_section_is_empty_not_a_panic() {+        let s = SchematicData::new(json!({"components": {"ok": false, "error": "no current schematic"}}));+        assert!(s.components().is_empty());+        assert!(s.designators().is_empty());+        // and sameProject stays null when either path is missing+        let out = cross_check(&s, &small_board(&["U1"], "C:\\p\\b.PcbDoc"));+        assert_eq!(out["sameProject"], Value::Null);+    } }
bridge-rs/src/dialogs.rs+16−1
@@ -32,6 +32,8 @@ const CATALOG: &[(&str, &str, &str, &str)] = &[      "The script failed to compile; the engine is not halted after OK."),     ("unknown identifier", "script_compile_error", "OK",      "Compile error in RunScriptText text (statements only, no Var)."),+    ("error reading form", "script_form_load_error", "Cancel",+     "A script form's .dfm references a class or property this engine does not know (e.g. 'Class TApplicationEvents not found'). Cancel aborts the form load cleanly; Ignore would half-load the form. Fires on open and again on each editor touch until the project is closed."),     ("select item to run", "run_script_picker", "Cancel",      "Altium could not resolve the -R RunScript target (unit did not compile, or wrong ProcName) and opened its Run Script picker. Cancel it, fix the script."),     ("another instance of", "second_instance_busy", "OK",@@ -71,11 +73,24 @@ pub fn classify(title: &str, body: &str) -> (String, Option<String>, String) { /// only button labels, which is why classification must go through ab's desktop_ui_text). fn dialog_text(hwnd: i64) -> String {     let r = ab::call("desktop_ui_text", json!({"hwnd": hwnd}), &[], 10);-    match r.get("text").or_else(|| r.get("texts")) {+    let s = match r.get("text").or_else(|| r.get("texts")) {         Some(Value::String(s)) => s.clone(),         Some(Value::Array(a)) => a.iter().filter_map(|x| x.as_str()).collect::<Vec<_>>().join(" "),         _ => String::new(),+    };+    if !s.trim().is_empty() {+        return s;     }+    // Fallback: some dialogs (the script debugger's "Continue execution?" prompt, title "X2")+    // paint their message only as Text-role control NAMES; ui_text sees nothing and the empty+    // body defeated classification (measured 2026-09-02 during the StopServer probes, #7).+    let fc = ab::call("desktop_find_controls", json!({"hwnd": hwnd, "role": "text"}), &[], 10);+    fc.get("matches")+        .and_then(|a| a.as_array())+        .map(|a| a.iter()+            .filter_map(|m| m.get("name").and_then(|n| n.as_str()))+            .collect::<Vec<_>>().join(" "))+        .unwrap_or_default() }  fn buttons(hwnd: i64) -> Vec<String> {
bridge-rs/src/library.rs+61−14
@@ -3,7 +3,7 @@ //! params, save) and `install_library_bundle` (the no-AI web-control lane: staged zip → //! Installed Libraries, hardened for untrusted zips). use crate::support::{self, s, s_or, timeout_of};-use crate::{agent, shot};+use crate::{ab, agent, shot}; use serde_json::{json, Value}; use std::time::Duration; @@ -481,7 +481,16 @@ pub fn install_library_bundle(args: &Value) -> Value {             return out;         }         launched = json!({"coldStartSec": lr.get("startedInSec"), "note": "Altium was closed; launched it for the install"});+        // RE-RESOLVE the instance we just launched. Dispatch resolved the instance ONCE at the top+        // of the verb, when Altium was not running, so no instance was selected — and the agent+        // would then bootstrap into the LEGACY workspace instead of the per-instance one. That+        // split-brain is the #6 blind spot: a loop in the legacy workspace is invisible to the+        // per-instance status and teardown, and the app closes over a live loop. Select the running+        // instance now so the agent workspace is keyed to it and every status/teardown path sees it.         crate::instance::invalidate_snapshot();+        if let Ok(inst) = crate::instance::resolve(None) {+            crate::instance::select(Some(inst));+        }     }     if let Some(g) = support::need_licensed() {         return g;@@ -549,6 +558,21 @@ pub fn install_library_bundle(args: &Value) -> Value {             Err(e) => return json!({"success": false, "failedAt": "unpack", "error": format!("cannot write {raw}: {e}")}),         }     }+    // Flatten a lone wrapper folder: the wiki's export zip wraps everything in `<Lib>/` (so the+    // plain download unpacks tidily), and preserving it under our own `<Lib>/` made the confusing+    // `UI\UI\` double nesting Colby hit. One top-level dir and nothing else = hoist its contents.+    if let Ok(rd) = std::fs::read_dir(&dest) {+        let entries: Vec<_> = rd.flatten().collect();+        if entries.len() == 1 && entries[0].path().is_dir() {+            let wrapper = entries[0].path();+            if let Ok(inner) = std::fs::read_dir(&wrapper) {+                for e in inner.flatten() {+                    let _ = std::fs::rename(e.path(), dest.join(e.file_name()));+                }+            }+            let _ = std::fs::remove_dir(&wrapper);+        }+    }     let mut schlibs = Vec::new();     let mut pcblibs = Vec::new();     let mut intlibs = Vec::new();@@ -583,29 +607,52 @@ pub fn install_library_bundle(args: &Value) -> Value {         installed.push(json!({"lib": lib.file_name().map(|n| n.to_string_lossy().to_string()),                               "ok": ir.get("success").and_then(|b| b.as_bool()).unwrap_or(false)}));     }-    let st = agent::call("state", &[], None, Duration::from_secs(30));-    let running = st.get("windows").map(|w| !w.is_null()).unwrap_or(false)-        || st.get("data").and_then(|d| d.get("windows")).map(|w| !w.is_null()).unwrap_or(false);     let mut out = json!({"success": true, "launched": launched,         "library": if name.is_empty() { stem } else { name },-        "symbols": schlibs.len(), "footprints": pcblibs.len(), "intlibs": intlibs.len(),-        "installed": installed, "altiumRunning": running, "restartNeeded": running,-        "_hint": "Registered in Installed Libraries. A running Altium predates the install and needs a restart to see the new library — the page/AI tells the user; the verb never closes their Altium."});-    if args.get("capture").and_then(|b| b.as_bool()) == Some(true) {-        if !schlibs.is_empty() && !running {-            agent::call("show_library", &[("path", schlibs[0].to_string_lossy().replace('/', "\\"))], None, Duration::from_secs(90));+        "symbolFiles": schlibs.len(), "footprintFiles": pcblibs.len(), "intlibs": intlibs.len(),+        // legacy names kept one release for the wiki page's current parser; they are FILE counts+        "symbols": schlibs.len(), "footprints": pcblibs.len(),+        "installed": installed, "shown": false});+    // ---- the #7 show-and-prove: the user who clicked "Export into Altium" must LAND on their+    // library, not the Home Page, with the receipt carrying the proof frame and an honest+    // `shown` — on the auto-launch path too, where 0.2.8 always skipped it. Registration via+    // IntegratedLibraryManager takes effect live (measured: the Components panel served the+    // installed library in the same session), so no restart story.+    if !schlibs.is_empty() || !pcblibs.is_empty() {+        let show_path = schlibs.first().or_else(|| pcblibs.first()).unwrap();+        let kind = if schlibs.is_empty() { "pcb" } else { "sch" };+        let o = support::agent_open(&show_path.to_string_lossy().replace('/', "\\"), 60);+        let opened = o.get("success").and_then(|b| b.as_bool()) == Some(true);+        if opened {+            let lst = agent::call("lib_list", &[("kind", kind.into()), ("limit", "200".into())], None, Duration::from_secs(30));+            if let Some(n) = lst.get("data").and_then(|d| d.get("count")) {+                out[if kind == "sch" { "symbolCount" } else { "footprintCount" }] = n.clone();+                out["components"] = lst.get("data").and_then(|d| d.get("names")).cloned().unwrap_or(Value::Null);+            }+            // land the user on the library LIST, not just the first symbol's canvas: the panel+            // tab wants the UIA select action, not a click (a click does not switch, measured)+            if let Some(hw) = crate::instance::licence_state().get("hwnd").and_then(|h| h.as_i64()) {+                let panel = if kind == "sch" { "SCH Library" } else { "PCB Library" };+                let sel = ab::call("desktop_ui_select", json!({"hwnd": hw, "name": panel,+                    "reason": "land the user on their freshly installed library list (adom/altium-bridge#7)"}), &[], 10);+                out["panelSelected"] = json!(sel.get("success").and_then(|b| b.as_bool()).unwrap_or(false));+            }             let frame = shot::shot_of_main();             if let Some(img) = frame.get("image").and_then(|i| i.as_str()) {                 out["thumbnailDataUrl"] = json!(format!("data:image/png;base64,{img}"));                 out["shot"] = frame.clone();-            }-            if args.get("closeEditor").and_then(|b| b.as_bool()) == Some(true) {-                agent::call("run_process", &[("process", "Client:CloseDocument".into()), ("params", "".into())], None, Duration::from_secs(30));+                out["shown"] = json!(true);             }         } else {-            out["proofSkipped"] = json!("Altium was already running; its library tables predate this install.");+            out["shownError"] = o.get("error").cloned().unwrap_or(Value::Null);         }     }+    // ---- the #6 discipline: the one-click lane must never leave a resident script for a later+    // close to wedge on. Every live loop is stopped before the reply goes out; the next verb+    // re-bootstraps in ~8s. The library stays open for the user; the ENGINE goes idle.+    let teardown = crate::lifecycle::stop_all_live();+    out["agentStopped"] = json!(teardown.get("success").and_then(|b| b.as_bool()).unwrap_or(false));+    out["_hint"] = json!("Registered in Installed Libraries (live, no restart needed) and opened in Altium when shown:true - the SCH/PCB Library panel lists every part. The resident agent was stopped afterward so a user-initiated Altium close finds an idle engine (adom/altium-bridge#6).");     out } 
bridge-rs/src/lifecycle.rs+195−37
@@ -423,6 +423,31 @@ fn start_modal_watcher() -> (std::sync::Arc<std::sync::atomic::AtomicBool>, std: // ---- start / stop / recover --------------------------------------------------  pub fn start(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;@@ -504,6 +529,34 @@ pub fn stop() -> Value {     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"})+}+ /// 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 {@@ -570,20 +623,67 @@ pub fn launch(wait: Duration) -> Value { // 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 (Colby Knox, adom/altium-bridge#4 + PR 74, ported) -------+// ---- user-initiated-close watchdog v2 (adom/altium-bridge#6; v1 was PR 74 / 0.2.8) --------- //-// altium_close tears the agent down BEFORE its WM_CLOSE, but a user pressing the window X goes-// straight to Altium: shutdown then unloads the scripting engine under a LIVE agent loop, and the-// close can wedge — X2 lingered headless and hard-froze the desktop until it was killed-// (2026-09-02). Standing service: every 2 s, for every workspace with a live heartbeat, detect-// the CLOSING state (that instance's main window gone while an X2 process still lives) and drop-// the stop file THAT INSTANT so the loop's next tick exits it during the shutdown instead of-// wedging under it. Read-only detection plus a file drop: no Altium call, no focus. Honest limit:-// a 2 s poll race, not a guarantee — the complete fix is the agent noticing shutdown itself.+// 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); -fn close_watch_once(seen: &mut std::collections::BTreeSet<String>) -> Vec<String> {+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) {@@ -593,34 +693,95 @@ fn close_watch_once(seen: &mut std::collections::BTreeSet<String>) -> Vec<String             }         }     }-    let live: Vec<(String, PathBuf)> = workspaces.into_iter().filter(|(_, ws)| {+    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();-    if live.is_empty() {-        return Vec::new();-    }-    crate::instance::invalidate_snapshot();-    let keys_with_windows: std::collections::BTreeSet<String> = crate::instance::instances().iter()-        .filter_map(|i| i.get("key").and_then(|k| k.as_str()).map(str::to_string)).collect();-    let any_x2 = !crate::instance::x2_pids().is_empty();-    let mut dropped = Vec::new();-    for (key, ws) in live {-        let has_window = if key == "legacy" { !keys_with_windows.is_empty() } else { keys_with_windows.contains(&key) };-        if has_window {-            seen.insert(key);-            continue;-        }-        // window gone. Only a workspace we have SEEN with a window counts as closing — a cold-        // start (pid up, window not yet created) must never fire this.-        if seen.contains(&key) && any_x2 {+    }).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");-            seen.remove(&key);-            dropped.push(key);+            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"}));         }     }-    dropped }  pub fn start_close_watchdog() -> bool {@@ -628,14 +789,11 @@ pub fn start_close_watchdog() -> bool {         return false;     }     std::thread::Builder::new().name("altium-agent-close-watchdog".into()).spawn(|| {-        let mut seen = std::collections::BTreeSet::new();+        let mut st = WatchState::default();         loop {             std::thread::sleep(Duration::from_secs(2));             let _ = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {-                for key in close_watch_once(&mut seen) {-                    crate::dialogs::note_event(json!({"closeWatchdog": key,-                        "note": "main window gone while X2 still alive — stop file dropped so the resident agent exits during the shutdown instead of wedging under it (adom/altium-bridge#4)"}));-                }+                close_watch_once(&mut st);             }));         }     }).is_ok()
bridge-rs/src/main.rs+2−1
@@ -20,11 +20,12 @@ mod support; mod surfaces; mod taskbar; mod verbs;+mod win32;  use std::time::Instant; use tiny_http::{Header, Method, Response, Server}; -pub const BRIDGE_VERSION: &str = "0.2.8";+pub const BRIDGE_VERSION: &str = "0.2.9";  fn json_response(status: u16, body: &serde_json::Value) -> Response<std::io::Cursor<Vec<u8>>> {     let data = serde_json::to_vec(body).unwrap_or_else(|_| b"{}".to_vec());
bridge-rs/src/misc.rs+82−15
@@ -9,6 +9,13 @@ 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;@@ -24,10 +31,19 @@ pub fn readiness(_args: &Value) -> Value {     } else {         "Ready. altium_state for the picture, altium_eval / altium_run_command to act.".to_string()     };-    json!({"success": true, "hostApp": support::HOST_APP, "installed": inst.is_some(), "install": inst,+    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, "ready": ready,-        "engine": "rust", "_hint": hint})+        "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 {@@ -52,11 +68,19 @@ pub fn diagnostics(_args: &Value) -> Value {  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 }@@ -112,16 +136,29 @@ pub fn state(args: &Value) -> Value {             }         }     }-    let logical = if agent::is_alive() {-        agent::call_no_autostart("state", &[], Duration::from_secs(15))-    } else {-        Value::Null+    // 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::is_alive(),+        "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)."})+        "_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 {@@ -218,18 +255,48 @@ pub fn close(args: &Value) -> Value {     if force && !accept && lic.get("licensed").and_then(|b| b.as_bool()) != Some(false) {         return strand_refusal(json!({}));     }-    if agent::is_alive() {-        lifecycle::stop();-    }-    if let Some(h) = lic.get("hwnd").and_then(|h| h.as_i64()) {-        ab::call("desktop_set_window_state", json!({"hwnd": h, "state": "close", "reason": "polite close of Altium Designer (releases the licence seat)"}), &[], 20);+    // 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.+    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.)+    lifecycle::note_close_initiated();+    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)});+            return json!({"success": true, "closedInSec": support::elapsed_round(t0), "agentTeardown": agent_teardown});         }         let mut dl = dialogs::scan_scoped(true);         if !dl.is_empty() {
bridge-rs/src/verbs.rs+11−1
@@ -14,7 +14,7 @@ pub const KNOWN: &[&str] = &[     "dismiss_dialogs", "launch", "close", "instances", "dialog_log", "uninstall", "describe",     // agent / REPL     "agent_start", "agent_stop", "agent_status", "agent_recover",-    "eval", "run_command", "run_script", "list_commands", "open_document", "open_project",+    "eval", "run_command", "run_script", "list_commands", "open_document", "open_project", "close_document",     "activate_document", "progress",     // board / schematic reads     "read_board", "board_summary", "rules_by_kind", "layers_in_use", "read_schematic",@@ -296,6 +296,16 @@ pub fn dispatch(command: &str, args: &Value, _forwarded: &[(String, String)]) ->         "run_script" => outputs::run_script(args),         "list_commands" => misc::list_commands(args),         "open_document" | "open_project" => surfaces::open_document(args),+        "close_document" => {+            // through the agent's document-handle close: the menu processes are broken for this+            // on ADDevelop (RunProcess Client:CloseDocument no-ops; CloseAllOpenDocuments+            // unregisters documents while their views stay open - measured 2026-09-02, #7)+            if let Some(g) = need_licensed() { return g; }+            match s(args, "path") {+                None => json!({"success": false, "error": "path is required (absolute path of the open document to close; never saves - altium_save first if anything is worth keeping)"}),+                Some(p) => agent::call("close_document", &[("path", p)], None, Duration::from_secs(30)),+            }+        }         "activate_document" => surfaces::activate_document(args),         "progress" => misc::progress_verb(args),         // ---- board-read primitive: ONE agent round trip, views shaped here ----
bridge-rs/src/win32.rsadded+214
@@ -0,0 +1,214 @@+//! Freeze-safe native window/process introspection (adom/altium-bridge#6, adom/adom-bridge#122).+//!+//! The close watchdog's whole job is to act DURING a wedged Altium shutdown, and that is exactly+//! when ab's `desktop_enum_windows` / `process_list` hang or return empty: an empty answer read as+//! "no window" is how the 0.2.8 watchdog missed both freezes on 2026-09-02. This module asks+//! Win32 directly, using ONLY non-messaging calls:+//!+//! * `EnumWindows` walks the shell's window list without touching any window's thread.+//! * `GetClassNameW`, `GetWindowThreadProcessId`, `IsWindowVisible` read cached/kernel state.+//! * NEVER `GetWindowTextW`: it SENDS WM_GETTEXT into the window's (possibly hung) thread and+//!   inherits the hang. Titles are deliberately not available here; match by class + pid.+//!+//! Everything else in the bridge keeps going through ab; this module exists solely so the+//! safety path cannot be blinded by the very freeze it guards against.++#[cfg(windows)]+mod imp {+    use std::sync::Mutex;++    type Hwnd = isize;++    #[link(name = "user32")]+    extern "system" {+        fn EnumWindows(cb: extern "system" fn(Hwnd, isize) -> i32, lparam: isize) -> i32;+        fn GetClassNameW(hwnd: Hwnd, buf: *mut u16, len: i32) -> i32;+        fn GetWindowThreadProcessId(hwnd: Hwnd, pid: *mut u32) -> u32;+        fn IsWindowVisible(hwnd: Hwnd) -> i32;+        fn IsHungAppWindow(hwnd: Hwnd) -> i32;+    }++    /// One visible top-level window: pid, class, hwnd, and whether its UI thread is HUNG. The+    /// freeze in #6 leaves the main window PRESENT but non-responding (measured 2026-09-02: X2+    /// alive, TDocumentForm still visible, the desktop wedged) — so "gone" is not the signal;+    /// "hung" is. `IsHungAppWindow` reports the thread's ghost state WITHOUT sending a message,+    /// so it stays truthful during the freeze it detects.+    #[derive(Clone, Debug)]+    pub struct WinRow {+        pub pid: u32,+        pub class: String,+        pub hwnd: i64,+        pub hung: bool,+    }++    // EnumWindows wants a plain function pointer; rows are collected through a mutex the+    // callback can reach. Enumeration is short (hundreds of windows) and single-threaded per+    // call, guarded by CALL_LOCK so two watchdog ticks can never interleave collections.+    static CALL_LOCK: Mutex<()> = Mutex::new(());+    static ROWS: Mutex<Vec<WinRow>> = Mutex::new(Vec::new());++    extern "system" fn on_window(hwnd: Hwnd, _lparam: isize) -> i32 {+        unsafe {+            if IsWindowVisible(hwnd) == 0 {+                return 1; // keep enumerating+            }+            let mut pid: u32 = 0;+            GetWindowThreadProcessId(hwnd, &mut pid);+            if pid == 0 {+                return 1;+            }+            let mut buf = [0u16; 128];+            let n = GetClassNameW(hwnd, buf.as_mut_ptr(), buf.len() as i32);+            let class = String::from_utf16_lossy(&buf[..n.max(0) as usize]);+            let hung = IsHungAppWindow(hwnd) != 0;+            if let Ok(mut rows) = ROWS.lock() {+                rows.push(WinRow { pid, class, hwnd: hwnd as i64, hung });+            }+        }+        1+    }++    /// Every VISIBLE top-level window as (pid, class). `None` only if the enumeration itself+    /// failed — an empty Vec is a real "no windows", which is the distinction ab's lane loses.+    pub fn visible_windows() -> Option<Vec<WinRow>> {+        let _guard = CALL_LOCK.lock().ok()?;+        if let Ok(mut rows) = ROWS.lock() {+            rows.clear();+        }+        let ok = unsafe { EnumWindows(on_window, 0) };+        if ok == 0 {+            return None;+        }+        ROWS.lock().ok().map(|r| r.clone())+    }+}++#[cfg(windows)]+mod proc_imp {+    //! Native process enumeration + termination (kernel32 toolhelp), so the zombie-breaker can+    //! see and end a wedged X2 even when ab's process_list is empty or hung mid-freeze.+    #[repr(C)]+    struct ProcessEntry32W {+        dw_size: u32,+        cnt_usage: u32,+        th32_process_id: u32,+        th32_default_heap_id: usize,+        th32_module_id: u32,+        cnt_threads: u32,+        th32_parent_process_id: u32,+        pc_pri_class_base: i32,+        dw_flags: u32,+        sz_exe_file: [u16; 260],+    }++    #[link(name = "kernel32")]+    extern "system" {+        fn CreateToolhelp32Snapshot(flags: u32, pid: u32) -> isize;+        fn Process32FirstW(snap: isize, entry: *mut ProcessEntry32W) -> i32;+        fn Process32NextW(snap: isize, entry: *mut ProcessEntry32W) -> i32;+        fn CloseHandle(h: isize) -> i32;+        fn OpenProcess(access: u32, inherit: i32, pid: u32) -> isize;+        fn TerminateProcess(h: isize, code: u32) -> i32;+    }++    const TH32CS_SNAPPROCESS: u32 = 0x2;+    const PROCESS_TERMINATE: u32 = 0x0001;++    /// Pids of every process whose image name matches `image` case-insensitively (e.g. "X2.EXE").+    /// `None` only if the snapshot itself failed.+    pub fn pids_of(image: &str) -> Option<Vec<u32>> {+        let want = image.to_ascii_lowercase();+        unsafe {+            let snap = CreateToolhelp32Snapshot(TH32CS_SNAPPROCESS, 0);+            if snap == -1 || snap == 0 {+                return None;+            }+            let mut out = Vec::new();+            let mut entry: ProcessEntry32W = std::mem::zeroed();+            entry.dw_size = std::mem::size_of::<ProcessEntry32W>() as u32;+            let mut ok = Process32FirstW(snap, &mut entry);+            while ok != 0 {+                let name_len = entry.sz_exe_file.iter().position(|&c| c == 0).unwrap_or(260);+                let name = String::from_utf16_lossy(&entry.sz_exe_file[..name_len]).to_ascii_lowercase();+                if name == want {+                    out.push(entry.th32_process_id);+                }+                ok = Process32NextW(snap, &mut entry);+            }+            CloseHandle(snap);+            Some(out)+        }+    }++    /// Terminate `pid`. Returns whether the terminate call succeeded.+    pub fn kill(pid: u32) -> bool {+        unsafe {+            let h = OpenProcess(PROCESS_TERMINATE, 0, pid);+            if h == 0 {+                return false;+            }+            let ok = TerminateProcess(h, 1);+            CloseHandle(h);+            ok != 0+        }+    }+}++#[cfg(windows)]+pub use imp::{visible_windows, WinRow};+#[cfg(windows)]+pub use proc_imp::{kill, pids_of};++#[cfg(not(windows))]+pub fn pids_of(_image: &str) -> Option<Vec<u32>> {+    None+}++#[cfg(not(windows))]+pub fn kill(_pid: u32) -> bool {+    false+}++#[cfg(not(windows))]+#[derive(Clone, Debug)]+pub struct WinRow {+    pub pid: u32,+    pub class: String,+    pub hwnd: i64,+    pub hung: bool,+}++#[cfg(not(windows))]+pub fn visible_windows() -> Option<Vec<WinRow>> {+    None // non-Windows builds (tests on the Linux build container) have no window session+}++/// Does `pid` own a visible top-level window of Altium's main-window class? The main document+/// frame is `TDocumentForm` (measured on AD26 Designer and ADDevelop alike); during shutdown it+/// is destroyed well before X2.EXE exits, which is the closing signature the watchdog keys on.+pub fn pid_has_main_window(pid: u32, rows: &[WinRow]) -> bool {+    rows.iter().any(|w| w.pid == pid && w.class == "TDocumentForm")+}++/// Is `pid`'s main window present but NON-RESPONDING? This is the actual freeze signature on+/// this build (2026-09-02, live): the frame stays visible while its thread hangs and the+/// desktop wedges around it. The 0.2.8-era "window gone" heuristic never fires in this state.+pub fn pid_main_window_hung(pid: u32, rows: &[WinRow]) -> bool {+    rows.iter().any(|w| w.pid == pid && w.class == "TDocumentForm" && w.hung)+}++#[cfg(test)]+mod tests {+    use super::*;++    #[test]+    fn pid_matching_is_class_scoped() {+        let rows = vec![+            WinRow { pid: 10, class: "TDocumentForm".into(), hwnd: 1, hung: false },+            WinRow { pid: 11, class: "#32770".into(), hwnd: 2, hung: false },+        ];+        assert!(pid_has_main_window(10, &rows));+        assert!(!pid_has_main_window(11, &rows), "a dialog is not a main window");+        assert!(!pid_has_main_window(12, &rows));+    }+}
package.json+2−2
@@ -1,6 +1,6 @@ {   "slug": "altium-bridge",-  "version": "0.2.8",+  "version": "0.2.9",   "type": "app",   "description": "Skills for your container so your AI knows how to drive the Altium Designer bridge (altium_* verbs). The bridge runtime itself is the release zip; Adom Bridge loads that.",   "tags": [@@ -115,4 +115,4 @@       "prompt": "generate gerbers and a BOM from my altium project"     }   ]-}\ No newline at end of file+}
src/BRIDGE_VERSION+1−1
@@ -1 +1 @@-0.2.8+0.2.9
src/agent/AdomAgent.pas.tmpl+35−1
@@ -28,7 +28,9 @@ Const     POLL_IDLE_MS   = 100;     IDLE_AFTER_TICKS = 300;           { ~3 s of no requests -> idle cadence }     HEARTBEAT_MS   = 500;-    AUTO_STOP_MS   = 1800000;         { 30 min without a request -> exit loop }+    AUTO_STOP_MS   = 300000;          { 5 min idle -> exit. Residency is the freeze exposure+                                        window - a user X-press over a live loop can wedge+                                        the shutdown, so the loop does not linger. }  Var     gRunning    : Boolean;@@ -405,6 +407,34 @@ Begin         Result := '{' + JRaw('ok', 'true') + ',' + JStr('path', Path) + ',' + JStr('objectKind', Kind) + ',' + JRaw('shown', 'false') + ',' + JStr('note', 'OpenObject ran; document handle not resolved (projects have no document view)') + '}'; End; +{ Close ONE open document by path, never saving (a caller who wants a save saves first). Goes+  through the document HANDLE: the menu processes are broken for this on ADDevelop - RunProcess+  Client:CloseDocument no-ops, and Client:CloseAllOpenDocuments unregisters documents while+  their views stay open (both measured 2026-09-02, adom/altium-bridge issue 7). }+Function CmdCloseDocument(Lines : TStringList) : String;+Var+    Path : String;+    Doc  : IServerDocument;+Begin+    Path := Param(Lines, 'path');+    Doc := Nil;+    Try+        Doc := Client.GetDocumentByPath(Path);+    Except+    End;+    If Doc = Nil Then+    Begin+        Result := '{' + JRaw('ok', 'true') + ',' + JRaw('closed', 'false') + ',' + JStr('note', 'not open: ' + Path) + '}';+        Exit;+    End;+    Try+        Client.CloseDocument(Doc);+        Result := '{' + JRaw('ok', 'true') + ',' + JRaw('closed', 'true') + ',' + JStr('path', Path) + '}';+    Except+        Result := '{' + JRaw('ok', 'false') + ',' + JStr('error', 'CloseDocument raised for ' + Path) + '}';+    End;+End;+ { Inline statements: RunScriptText from INSIDE the agent (measured separately;   if the engine refuses nested scripts this returns the error in-band). } Function CmdEval(Lines : TStringList; Payload : String) : String;@@ -3803,6 +3833,7 @@ Begin     Else If Cmd = 'state' Then Result := CmdState(Lines)     Else If Cmd = 'run_process' Then Result := CmdRunProcess(Lines)     Else If Cmd = 'open' Then Result := CmdOpen(Lines)+    Else If Cmd = 'close_document' Then Result := CmdCloseDocument(Lines)     Else If Cmd = 'eval' Then Result := CmdEval(Lines, Payload)     Else If Cmd = 'io_bench' Then Result := CmdIoBench(Lines)     Else If Cmd = 'read_board' Then Result := CmdReadBoard(Lines)@@ -3951,7 +3982,10 @@ Begin     While gRunning Do     Begin         Try+            { two shutdown signals, both cheap reads: IsQuitting demonstrably never fired+              in time during the 2026-09-02 freezes, so Terminated rides beside it }             If Client.IsQuitting Then gRunning := False;+            If Application.Terminated Then gRunning := False;         Except         End;         If FileExists(WORKSPACE + 'stop') Then
src/bridge.json+3−2
@@ -2,7 +2,7 @@   "manifest_version": 1,   "name": "altium",   "displayName": "Altium Designer",-  "version": "0.2.8",+  "version": "0.2.9",   "description": "Drive Altium Designer from Adom: a resident DelphiScript agent (eval + the 1,100-command process launcher), the X2 -R command line, UIA over the VCL shell, dialogs by PID, screenshots of every Altium window, and Altium 365.",   "homepage": "https://wiki.adom.inc/adom/altium-bridge",   "docs": "https://wiki.adom.inc/adom/altium-bridge",@@ -90,6 +90,7 @@     "altium_net_connections",     "altium_normalize_bodies",     "altium_open_document",+    "altium_close_document",     "altium_open_project",     "altium_pcb_report",     "altium_place_on_pcb",@@ -183,4 +184,4 @@       ]     }   }-}\ No newline at end of file+}