app
Altium - the Altium Bridge
Public Made by Adomby adom
Adom Bridge for Altium Designer: a resident DelphiScript agent gives the AI eval, the 1,100-command process launcher, all 8 EDA surfaces with screenshots, compile/ERC, DRC, OutJob outputs, native SchLib/PcbLib authoring with STEP 3D bodies, and dialog handling by PID.
main
Colby Knox
0.2.49 source (not yet released, needs signing): licence read on Altium Designer 26.x (a title with a version and no li…
eadc6b3
5d ago
1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586878889909192939495969798991001011021031041051061071081091101111121131141151161171181191201211221231241251261271281291301311321331341351361371381391401411421431441451461471481491501511521531541551561571581591601611621631641651661671681691701711721731741751761771781791801811821831841851861871881891901911921931941951961971981992002012022032042052062072082092102112122132142152162172182192202212222232242252262272282292302312322332342352362372382392402412422432442452462472482492502512522532542552562572582592602612622632642652662672682692702712722732742752762772782792802812822832842852862872882892902912922932942952962972982993003013023033043053063073083093103113123133143153163173183193203213223233243253263273283293303313323333343353363373383393403413423433443453463473483493503513523533543553563573583593603613623633643653663673683693703713723733743753763773783793803813823833843853863873883893903913923933943953963973983994004014024034044054064074084094104114124134144154164174184194204214224234244254264274284294304314324334344354364374384394404414424434444454464474484494504514524534544554564574584594604614624634644654664674684694704714724734744754764774784794804814824834844854864874884894904914924934944954964974984995005015025035045055065075085095105115125135145155165175185195205215225235245255265275285295305315325335345355365375385395405415425435445455465475485495505515525535545555565575585595605615625635645655665675685695705715725735745755765775785795805815825835845855865875885895905915925935945955965975985996006016026036046056066076086096106116126136146156166176186196206216226236246256266276286296306316326336346356366376386396406416426436446456466476486496506516526536546556566576586596606616626636646656666676686696706716726736746756766776786796806816826836846856866876886896906916926936946956966976986997007017027037047057067077087097107117127137147157167177187197207217227237247257267277287297307317327337347357367377387397407417427437447457467477487497507517527537547557567577587597607617627637647657667677687697707717727737747757767777787797807817827837847857867877887897907917927937947957967977987998008018028038048058068078088098108118128138148158168178188198208218228238248258268278288298308318328338348358368378388398408418428438448458468478488498508518528538548558568578588598608618628638648658668678688698708718728738748758768778788798808818828838848858868878888898908918928938948958968978988999009019029039049059069079089099109119129139149159169179189199209219229239249259269279289299309319329339349359369379389399409419429439449459469479489499509519529539549559569579589599609619629639649659669679689699709719729739749759769779789799809819829839849859869879889899909919929939949959969979989991000100110021003100410051006100710081009101010111012101310141015101610171018101910201021102210231024102510261027102810291030103110321033103410351036103710381039104010411042104310441045104610471048104910501051105210531054105510561057105810591060106110621063106410651066106710681069107010711072107310741075107610771078
//! Agent + application lifecycle, ported from the Python bridge's `handlers/agent.py`.
//!
//! Everything desktop-shaped goes through ab (`desktop_find_window`, `desktop_find_control`,
//! `desktop_ui_click`, `desktop_get_foreground`, `desktop_raise_window`, `process_kill`). Process
//! SPAWNING is not desktop-shaped and ab's direct API refuses `shell_execute` (412 — CLI-only,
//! with pull_file/send_files/hd_send_files), so the bridge owns its child processes directly.
//! What is left here is the file protocol and the sequencing rules paid for in modals on a live box:
//!
//! * ONE script at a time. A `-R` lane is REFUSED while the agent is resident, otherwise Altium
//! raises "Another script executing now" on the user's screen.
//! * A `-R` lane is a ONE-SHOT. Never retry it in a loop — each attempt that lands on a busy
//! instance costs the user an "another instance is busy" notice.
//! * The rendered unit is named `AdomAgent_<digest>.pas`: Altium keeps the .PrjScr open in its
//! Script editor and re-runs the IN-EDITOR copy, so overwriting in place runs stale code.
//! * The bootstrap activates Altium. Capture the user's foreground window first and hand it back.
use crate::ab;
use crate::agent;
use crate::instance;
use serde_json::{json, Value};
use std::path::PathBuf;
use std::time::{Duration, Instant};
const HB_STALE_SEC: u64 = 8;
/// The agent template ships beside the exe (`agent/AdomAgent.pas.tmpl`).
fn agent_dir() -> PathBuf {
std::env::current_exe()
.ok()
.and_then(|p| p.parent().map(|d| d.join("agent")))
.unwrap_or_else(|| PathBuf::from("agent"))
}
fn template_source() -> std::io::Result<String> {
std::fs::read_to_string(agent_dir().join("AdomAgent.pas.tmpl"))
}
/// `AdomAgent_<sha256[..10]>` over the RAW template — a new filename whenever the source changes.
fn agent_base(raw: &str) -> String {
format!("AdomAgent_{}", &sha256_hex(raw.as_bytes())[..10])
}
fn ws_str() -> String {
let s = agent::workspace_dir().to_string_lossy().to_string();
if s.ends_with('\\') { s } else { format!("{s}\\") }
}
fn rendered(raw: &str) -> String {
raw.replace("__WORKSPACE__", &ws_str().replace('\'', "''"))
.replace("__GEN__", &agent_base(raw))
}
/// Write the script project into the workspace. Idempotent per digest.
pub fn install() -> Value {
let raw = match template_source() {
Ok(r) => r,
Err(e) => return json!({"success": false, "errorCode": "template_missing",
"error": format!("cannot read agent/AdomAgent.pas.tmpl beside the exe: {e}")}),
};
let ws = agent::workspace_dir();
if let Err(e) = std::fs::create_dir_all(&ws) {
return json!({"success": false, "error": format!("cannot create workspace: {e}")});
}
let base = agent_base(&raw);
let pas = ws.join(format!("{base}.pas"));
let prj = ws.join(format!("{base}.PrjScr"));
let changed = !pas.exists();
if changed {
if let Err(e) = std::fs::write(&pas, rendered(&raw)) {
return json!({"success": false, "error": format!("cannot write {}: {e}", pas.display())});
}
let prj_body = std::fs::read_to_string(agent_dir().join("AdomAgent.PrjScr"))
.unwrap_or_else(|_| "[Design]\r\nVersion=1.0\r\n[Document1]\r\nDocumentPath=AdomAgent.pas\r\n".into())
.replace("AdomAgent.pas", &format!("{base}.pas"));
if let Err(e) = std::fs::write(&prj, prj_body) {
return json!({"success": false, "error": format!("cannot write {}: {e}", prj.display())});
}
prune_generations(3);
}
json!({"installed": true, "changed": changed, "prjscr": prj.to_string_lossy(),
"pas": pas.to_string_lossy(), "digest": &base[base.len() - 10..]})
}
/// Keep the newest `keep` generations so an open editor tab never dangles.
fn prune_generations(keep: usize) {
let ws = agent::workspace_dir();
let mut gens: Vec<(std::time::SystemTime, PathBuf)> = match std::fs::read_dir(&ws) {
Ok(rd) => rd
.filter_map(|e| e.ok())
.filter(|e| {
let n = e.file_name().to_string_lossy().to_string();
n.starts_with("AdomAgent_") && n.ends_with(".pas")
})
.filter_map(|e| e.metadata().ok().and_then(|m| m.modified().ok()).map(|t| (t, e.path())))
.collect(),
Err(_) => return,
};
gens.sort_by_key(|(t, _)| *t);
if gens.len() <= keep {
return;
}
for (_, p) in &gens[..gens.len() - keep] {
let _ = std::fs::remove_file(p);
let _ = std::fs::remove_file(p.with_extension("PrjScr"));
}
}
pub fn prjscr_path() -> Option<PathBuf> {
let raw = template_source().ok()?;
Some(agent::workspace_dir().join(format!("{}.PrjScr", agent_base(&raw))))
}
fn resident_generation() -> Option<String> {
let hb = std::fs::read_to_string(agent::workspace_dir().join("hb.txt")).ok()?;
hb.lines()
.find_map(|l| l.strip_prefix("gen=").map(|s| s.trim().to_string()))
}
// ---- ab-backed helpers -------------------------------------------------------
fn x2_exe() -> Option<String> {
// The SELECTED instance's own image first — Develop and Designer live in different install
// folders, and the launcher hardcoding AD26 sent a Develop-targeted -R lane to Designer
// (live bug, 2026-09-01).
if let Some(exe) = crate::instance::selected().and_then(|i| i.get("exe").and_then(|e| e.as_str()).map(str::to_string)) {
return Some(exe);
}
if let Some(exe) = crate::instance::instances().first().and_then(|i| i.get("exe").and_then(|e| e.as_str()).map(str::to_string)) {
return Some(exe);
}
for root in ["C:\\Program Files\\Altium\\AD26", "C:\\Program Files\\Altium\\AD25", "C:\\Program Files\\Altium\\AD24"] {
let p = format!("{root}\\X2.EXE");
if std::path::Path::new(&p).exists() {
return Some(p);
}
}
let pl = ab::call("process_list", json!({"nameFilter": "X2"}), &[], 15);
pl.get("processes")
.and_then(|a| a.as_array())
.and_then(|a| a.iter().find_map(|p| p.get("commandLine").and_then(|s| s.as_str()).and_then(crate::instance::exe_from_command_line)))
}
/// The first dialog on the main Altium window that a -R hand-off would bounce off: anything but the
/// script Error / X2 notice (a lone OK), which recover answers itself.
pub fn blocking_dialog() -> Option<Value> {
crate::dialogs::scan_scoped(true).into_iter().find(|d| {
let title = d.get("title").and_then(|t| t.as_str()).unwrap_or("").trim().to_string();
let lone_ok = d.get("buttons").and_then(|b| b.as_array()).map(|a| a.len() == 1 && a[0].as_str().map(|x| x.eq_ignore_ascii_case("ok")).unwrap_or(false)).unwrap_or(false);
!(lone_ok && (title.eq_ignore_ascii_case("error") || title.eq_ignore_ascii_case("x2")))
})
}
/// Spawn the X2 helper. Process spawning is NOT desktop-shaped, and ab's direct API refuses
/// `shell_execute` anyway (412 — that verb is CLI-only, alongside pull_file/send_files), so the
/// bridge owns the child directly: no shell, no quoting layer, and a real handle to wait on.
fn spawn_helper(exe: &str, arg: &str) -> Result<std::process::Child, String> {
// A -R hand-off cannot reach an Altium frozen behind a modal: the helper bounces off it with
// "another instance is busy" on the user's screen. agent::call's auto-recover used to spawn one
// per failing verb, so a single save prompt left mid-battery turned into fifteen minutes of those
// notices (2026-10-16). The script Error box is the one dialog recover answers itself, so it does
// not block; anything else (a save prompt, a reload confirm) does.
if !arg.is_empty() {
if let Some(d) = blocking_dialog() {
return Err(format!("blocked_by_dialog: '{}' is open on Altium's main window; answer it first (altium_check_dialogs / altium_dismiss_dialogs)",
d.get("title").and_then(|t| t.as_str()).unwrap_or("?")));
}
}
let mut cmd = std::process::Command::new(exe);
#[cfg(windows)]
{
use std::os::windows::process::CommandExt;
// `arg()` would RE-QUOTE: the workspace path contains a space ("Adom Bridge"), so the
// helper received backslash-escaped quotes, which Altium cannot parse — it then neither
// forwarded the request nor exited (measured on the Python bridge 2026-08-29, and again
// here 2026-08-31 before switching to raw_arg). CreateProcess must see it verbatim.
if !arg.is_empty() {
cmd.raw_arg(arg);
}
cmd.creation_flags(0x0800_0000); // CREATE_NO_WINDOW — no console flash on the user's screen
}
#[cfg(not(windows))]
if !arg.is_empty() {
cmd.arg(arg);
}
cmd.stdin(std::process::Stdio::null())
.stdout(std::process::Stdio::null())
.stderr(std::process::Stdio::null())
.spawn()
.map_err(|e| format!("{e}"))
}
/// Wait for the helper to exit, capped. Returns true if it exited on its own.
fn wait_helper(child: &mut std::process::Child, cap: Duration) -> bool {
let t0 = Instant::now();
while t0.elapsed() < cap {
match child.try_wait() {
Ok(Some(_)) => return true,
Ok(None) => std::thread::sleep(Duration::from_millis(150)),
Err(_) => return false,
}
}
false
}
/// Altium's single-instance notice ("another instance is busy") is modal on the USER's screen.
/// Watch briefly and press OK through UIA — never leave it for the human.
pub fn reap_busy_notice(wait: Duration) -> Value {
let t0 = Instant::now();
while t0.elapsed() < wait {
let w = ab::call("desktop_find_window", json!({"titleContains": "Information"}), &[], 15);
let hwnds: Vec<i64> = w
.get("windows")
.and_then(|a| a.as_array())
.map(|a| a.iter().filter_map(|x| x.get("hwnd").and_then(|h| h.as_i64())).collect())
.unwrap_or_default();
for hw in hwnds {
let t = ab::call("desktop_find_control", json!({"hwnd": hw, "contains": "another instance", "role": "text"}), &[], 15);
if t.get("success").and_then(|b| b.as_bool()).unwrap_or(false) {
ab::call("desktop_ui_click", json!({"hwnd": hw, "name": "OK", "role": "button"}), &[], 15);
return json!({"reaped": true, "hwnd": hw, "afterMs": t0.elapsed().as_millis() as u64});
}
}
std::thread::sleep(Duration::from_millis(250));
}
json!({"reaped": false})
}
fn foreground_hwnd() -> Option<i64> {
ab::call("desktop_get_foreground", json!({}), &[], 10)
.get("hwnd")
.and_then(|h| h.as_i64())
}
fn restore_foreground(hwnd: i64) {
ab::call("desktop_raise_window", json!({"hwnd": hwnd, "reason": "hand the user's window back after the agent bootstrap activated Altium"}), &[], 15);
}
/// Kill every X2.EXE that is NOT the one owning the main window: those are `-R` helpers left
/// waiting on a busy instance. A killed helper's request can still be DELIVERED later by Altium
/// and start a SECOND loop ("Another script executing now"), so sweep before every bootstrap.
fn kill_stray_helpers() -> Vec<u64> {
// Identify the MAIN instance by the window it owns, not by guesswork: desktop_enum_windows is
// the one ab call that reports a pid per window. The first cut asked licence_state() for a pid
// (it has none) and fell back to a verb that refuses read-only calls, so main_pid was always
// None and this function silently killed nothing — while hung -R helpers piled up (measured
// 2026-09-01: two X2 processes, one a leftover helper). Silently doing nothing is the SAFE
// failure here, but it is still a failure.
// ab reports each process's COMMAND LINE, and a -R helper carries the request right there in
// it. That is a definitive test — far better than "is it the main window's process", which on
// 2026-09-01 mistook a genuine second Altium instance (spawned by the -R lane when the first
// would not accept the request, and hosting the resident agent) for a helper and killed it.
let pl = ab::call("process_list", json!({"nameFilter": "X2"}), &[], 15);
let procs = pl.get("processes").and_then(|a| a.as_array()).cloned().unwrap_or_default();
let pids: Vec<u64> = procs.iter().filter_map(|p| p.get("pid").and_then(|v| v.as_u64())).collect();
let helper_pids: std::collections::BTreeSet<u64> = procs
.iter()
.filter(|p| {
p.get("commandLine")
.and_then(|c| c.as_str())
.map(|c| c.contains("-R") && c.contains("ScriptingSystem"))
.unwrap_or(false)
})
.filter_map(|p| p.get("pid").and_then(|v| v.as_u64()))
.collect();
if helper_pids.is_empty() {
return Vec::new(); // nothing carrying a -R request: nothing to reap
}
// A process that owns a MAIN window is a real Altium instance — NEVER kill it. The previous
// rule picked the first main window as "the" instance and killed every other X2 process; on
// 2026-09-01 that killed a genuine SECOND instance which the -R lane had spawned (Altium starts
// a fresh instance when the running one will not accept a forwarded request) and which was
// hosting the resident agent. Only a process with NO main window is a helper.
let wins = ab::call("desktop_enum_windows", json!({"pids": pids.clone()}), &[], 20);
let instance_pids: std::collections::BTreeSet<u64> = wins
.get("windows")
.and_then(|a| a.as_array())
.map(|a| {
a.iter()
.filter(|w| {
w.get("title")
.and_then(|s| s.as_str())
.map(|x| crate::health::parse_title(x).is_some())
.unwrap_or(false)
})
.filter_map(|w| w.get("pid").and_then(|p| p.as_u64()))
.collect()
})
.unwrap_or_default();
if instance_pids.is_empty() {
return Vec::new(); // cannot identify any real instance — kill NOTHING
}
let mut killed = Vec::new();
for pid in pids {
if !helper_pids.contains(&pid) {
continue; // no -R request in its command line: not a helper
}
if instance_pids.contains(&pid) {
continue; // and belt-and-braces: owning a main window means a real Altium
}
let r = ab::call("process_kill", json!({"pid": pid, "reason": "stray X2 -R helper (owns no Altium main window) left over from a previous bootstrap"}), &[], 15);
if r.get("success").and_then(|b| b.as_bool()).unwrap_or(false) {
killed.push(pid);
}
}
killed
}
// ---- the -R lane -------------------------------------------------------------
pub fn x2_exe_pub() -> Option<String> {
x2_exe()
}
pub fn wait_helper_pub(child: &mut std::process::Child, cap: Duration) -> bool { wait_helper(child, cap) }
pub fn spawn_helper_pub(exe: &str, arg: &str) -> Result<std::process::Child, String> {
spawn_helper(exe, arg)
}
/// The -R lane against a CALLER-SUPPLIED .PrjScr (altium_run_script). Same one-shot rules.
pub fn run_script_lane_in(proc_name: &str, prjscr: Option<&str>, timeout: Duration) -> Value {
match prjscr {
Some(p) => run_script_lane_impl(proc_name, p.to_string(), timeout),
None => run_script_lane(proc_name, timeout),
}
}
/// -R RunScriptText: STATEMENTS ONLY (Begin ... End;). Refused while any agent is resident.
pub fn run_script_text_lane(text: &str, timeout: Duration) -> Value {
if let Some((ws, age)) = agent::any_agent_alive() {
return json!({"success": false, "errorCode": "agent_resident",
"liveWorkspace": ws.to_string_lossy(), "heartbeatAgeSec": age,
"error": "a resident agent loop is already running - a second script would raise Altium's 'Another script executing now' modal on the user's screen",
"_hint": "Use altium_eval (goes through the agent) while the agent is up; altium_agent_stop first if you truly need the text lane."});
}
let exe = match x2_exe() {
Some(e) => e,
None => return json!({"success": false, "errorCode": "host_app_not_installed", "hostApp": "Altium Designer"}),
};
if text.contains('"') {
return json!({"success": false, "error": "RunScriptText text cannot contain double quotes (the -R arg is double-quoted); use single quotes for strings."});
}
let arg = format!("-RScriptingSystem:RunScriptText(Text=\"{text}\")");
let mut child = match spawn_helper(&exe, &arg) {
Ok(c) => c,
Err(e) => return json!({"success": false, "error": format!("could not launch X2 helper: {e}")}),
};
let t0 = Instant::now();
if wait_helper(&mut child, timeout) {
return json!({"success": true, "helperSec": t0.elapsed().as_secs_f64()});
}
let _ = child.kill();
let _ = child.wait();
let reaped = reap_busy_notice(Duration::from_secs(4));
json!({"success": false, "errorCode": "instance_busy", "busyNoticeReaped": reaped,
"_hint": "altium_check_dialogs, then retry."})
}
/// Forward `RunScript` to the running instance. REFUSED while the agent is resident.
/// ONE SHOT: on timeout the helper is killed, the busy notice reaped, and the caller told —
/// never call this in a retry loop.
pub fn run_script_lane(proc_name: &str, timeout: Duration) -> Value {
let prj = match prjscr_path() {
Some(p) => p.to_string_lossy().to_string(),
None => return json!({"success": false, "errorCode": "template_missing"}),
};
run_script_lane_impl(proc_name, prj, timeout)
}
fn run_script_lane_impl(proc_name: &str, prj: String, timeout: Duration) -> Value {
// ANY live loop refuses the lane, not just this instance's: Altium raises "Another script
// executing now" for the second script regardless of which workspace the first one polls
// (an upgraded bridge leaves the OLD loop heartbeating under the legacy path).
if let Some((ws, age)) = agent::any_agent_alive() {
return json!({"success": false, "errorCode": "agent_resident",
"error": "an Adom agent loop is resident; a second script would raise Altium's 'Another script executing now' modal",
"liveWorkspace": ws.to_string_lossy(), "heartbeatAgeSec": age,
"_hint": "send the work through the agent (eval / run_command), or stop the loop polling liveWorkspace first (altium_agent_stop, with instance: if it is another instance's)"});
}
let exe = match x2_exe() {
Some(e) => e,
None => return json!({"success": false, "errorCode": "host_app_not_installed", "hostApp": "Altium Designer"}),
};
let arg = format!("-RScriptingSystem:RunScript(ProjectName=\"{prj}\"|ProcName=\"{proc_name}\")");
let mut child = match spawn_helper(&exe, &arg) {
Ok(c) => c,
Err(e) => return json!({"success": false, "error": format!("could not launch X2 helper: {e}")}),
};
// PRE-EMPT: watch for OUR OWN modals for as long as the helper is in flight and press OK the
// instant one appears — the user should never have to find it. Anything not ours (a save
// prompt, a licence question) is left strictly alone.
let (watcher, cleared) = start_modal_watcher();
let t0 = Instant::now();
if wait_helper(&mut child, timeout) {
watcher.store(true, std::sync::atomic::Ordering::Relaxed);
let mut out = json!({"success": true, "helperSec": t0.elapsed().as_secs_f64(), "arg": arg});
let c = cleared.lock().map(|g| g.clone()).unwrap_or_default();
if !c.is_empty() {
out["modalsPreempted"] = json!(c);
}
return out;
}
let _ = child.kill();
let _ = child.wait();
watcher.store(true, std::sync::atomic::Ordering::Relaxed);
let reaped = reap_busy_notice(Duration::from_secs(4));
json!({"success": false, "errorCode": "instance_busy",
"error": "the -R helper never exited — the running instance did not accept the request",
"busyNotice": reaped,
"modalsPreempted": cleared.lock().map(|g| g.clone()).unwrap_or_default(),
"_hint": "ONE SHOT: do not retry this lane. agent_recover frees a halted engine first."})
}
/// Modals this bridge CAUSES. Only these are auto-cleared while an operation is in flight;
/// anything else (a save prompt, a licence question) belongs to the user.
const OUR_MODALS: [&str; 4] = ["another script executing", "another instance of",
"is busy at the moment", "scripting system is not able"];
fn start_modal_watcher() -> (std::sync::Arc<std::sync::atomic::AtomicBool>, std::sync::Arc<std::sync::Mutex<Vec<Value>>>) {
let stop = std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false));
let cleared = std::sync::Arc::new(std::sync::Mutex::new(Vec::new()));
let (s2, c2) = (stop.clone(), cleared.clone());
let _ = std::thread::Builder::new().name("altium-lane-modal-watcher".into()).spawn(move || {
while !s2.load(std::sync::atomic::Ordering::Relaxed) {
for d in crate::dialogs::scan() {
let blob = format!("{} {}",
d.get("title").and_then(|t| t.as_str()).unwrap_or(""),
d.get("body").and_then(|b| b.as_str()).unwrap_or("")).to_lowercase();
if !OUR_MODALS.iter().any(|k| blob.contains(k)) {
continue;
}
if let Some(hw) = d.get("hwnd").and_then(|h| h.as_i64()) {
ab::call("desktop_ui_click", json!({"hwnd": hw, "name": "OK", "role": "button"}), &[], 8);
if let Ok(mut g) = c2.lock() {
g.push(json!({"title": d.get("title"),
"body": d.get("body").and_then(|b| b.as_str()).map(|b| b.chars().take(120).collect::<String>()),
"clearedInFlight": true}));
}
}
}
std::thread::sleep(Duration::from_millis(400));
}
});
(stop, cleared)
}
// ---- start / stop / recover --------------------------------------------------
pub fn start(mut wait: Duration) -> Value {
// NEVER bootstrap into the legacy workspace. Every hard freeze in adom/altium-bridge#6
// traces back to an agent that started with no instance resolved (auto-launch cold starts,
// and once a transient resolve miss right after an Altium restart): the loop lands in the
// legacy root, every per-instance status and teardown path goes blind to it, and the app
// closes over a live loop. If no instance is selected, RETRY resolution briefly (the window
// often becomes parseable within seconds of a launch), then refuse rather than fall back.
// The legacy root remains a READ target everywhere (union scans), never a bootstrap target.
if crate::instance::selected().and_then(|i| i.get("key").and_then(|k| k.as_str()).map(str::to_string)).is_none() {
let t0 = Instant::now();
let mut resolved = false;
while t0.elapsed() < Duration::from_secs(12) {
crate::instance::invalidate_snapshot();
if let Ok(inst) = crate::instance::resolve(None) {
crate::instance::select(Some(inst));
resolved = true;
break;
}
std::thread::sleep(Duration::from_secs(2));
}
if !resolved {
return json!({"success": false, "errorCode": "instance_unresolved",
"error": "no Altium instance could be resolved, so the agent has nowhere safe to live - bootstrapping into the shared legacy workspace is how loops become invisible to status and teardown (adom/altium-bridge#6)",
"_hint": "is Altium running and its main window up? altium_status, then retry; pass instance:<pid|key> if several run"});
}
}
let inst = install();
if inst.get("success").and_then(|b| b.as_bool()) == Some(false) {
return inst;
}
let want = template_source().map(|r| agent_base(&r)).unwrap_or_default();
if agent::is_alive() {
match resident_generation() {
Some(g) if g == want => return json!({"success": true, "alreadyRunning": true, "generation": g}),
_ => {
// an OLDER generation is still polling — it would answer with stale code
stop();
std::thread::sleep(Duration::from_secs(1));
}
}
}
// a stale heartbeat / inbox / response from a previous life must not confuse this one
let ws = agent::workspace_dir();
for n in ["hb.txt", "stop", "request.txt", "request.payload", "request.txt.tmp", "request.payload.tmp"] {
let _ = std::fs::remove_file(ws.join(n));
}
if let Ok(rd) = std::fs::read_dir(&ws) {
for e in rd.filter_map(|e| e.ok()) {
let n = e.file_name().to_string_lossy().to_string();
if n.starts_with("response_") && n.ends_with(".json") {
let _ = std::fs::remove_file(e.path());
}
}
}
let user_fg = foreground_hwnd();
let t0 = Instant::now();
let strays = kill_stray_helpers();
let mut lane = run_script_lane("StartAgent", Duration::from_secs(30));
if !strays.is_empty() {
lane["strayHelpersKilled"] = json!(strays);
}
if lane.get("success").and_then(|b| b.as_bool()) != Some(true) {
let mut out = lane;
if let Some(o) = out.as_object_mut() {
o.insert("install".into(), inst);
}
return out;
}
// the bootstrap ACTIVATES Altium (it opens the .PrjScr in the editor) — hand the window back
if let Some(fg) = user_fg {
restore_foreground(fg);
}
// The RunScript hand-off can be ACCEPTED and then quietly dropped while Altium is busy with
// something of its own — its own startup, or unloading the script it was just told to stop.
// Measured 2026-09-21 on AD 26.10.1: the first start after a launch, and the verifying start
// inside `recover` right after a halt, each missed a 40-45 s window and reported no_heartbeat
// on a perfectly healthy engine (the very next verb started it). A dropped hand-off is not a
// failure worth surfacing — it is worth sending again. One retry, then the honest answer.
let mut retried = false;
loop {
while t0.elapsed() < wait {
if agent::is_alive() {
if let Some(fg) = user_fg {
restore_foreground(fg);
}
let mut out = json!({"success": true, "startedInSec": t0.elapsed().as_secs_f64(),
"generation": resident_generation(), "install": inst});
// earlier generations' script projects close (each start opens a new one in Altium's
// Projects panel; ten were stacked there by 2026-10-15)
let c = agent::call("close_old_agents", &[], None, Duration::from_secs(30));
out["oldAgentProjectsClosed"] = c.get("data").and_then(|d| d.get("closed")).cloned().unwrap_or(Value::Null);
if retried {
out["resentBootstrap"] = json!(true);
out["_hint"] = json!("The first RunScript hand-off was swallowed (Altium was busy); the bridge sent it again and the agent came up. Nothing for the caller to do.");
}
return out;
}
std::thread::sleep(Duration::from_millis(250));
}
let dialogs = crate::dialogs::scan();
// A dialog is a real answer (a compile error behind a modal): never paper over it.
if retried || !dialogs.is_empty() || !crate::health::is_running() {
return json!({"success": false, "errorCode": "no_heartbeat",
"error": format!("agent did not heartbeat within {}s{}", t0.elapsed().as_secs(), if retried { ", including one re-sent bootstrap" } else { "" }),
"dialogs": dialogs, "resentBootstrap": retried,
"_hint": "a compile error halts the engine behind a script Error modal — check dialogs, then agent_recover"});
}
if let Some(orphan) = modal_orphan() {
return json!({"success": false, "errorCode": "altium_modal_orphan", "orphan": orphan,
"_hint": "Altium is wedged inside a modal loop whose dialog is gone, so it can never run the agent script: its main window is DISABLED and nothing is on screen to answer. altium_close {discardChanges:true} ends it gracefully and RELEASES the licence seat."});
}
retried = true;
let again = run_script_lane("StartAgent", Duration::from_secs(30));
if again.get("success").and_then(|b| b.as_bool()) != Some(true) {
return json!({"success": false, "errorCode": "no_heartbeat",
"error": "the agent did not heartbeat, and re-sending the bootstrap failed",
"resendLane": again, "resentBootstrap": true,
"_hint": "check dialogs, then agent_recover"});
}
if let Some(fg) = user_fg {
restore_foreground(fg);
}
wait = t0.elapsed() + std::cmp::max(wait / 2, Duration::from_secs(25));
}
}
pub fn stop() -> Value {
let ws = agent::workspace_dir();
if !agent::is_alive() {
return json!({"success": true, "wasRunning": false});
}
if let Err(e) = std::fs::write(ws.join("stop"), "1") {
return json!({"success": false, "error": format!("cannot drop the stop file: {e}")});
}
let t0 = Instant::now();
while t0.elapsed() < Duration::from_secs(HB_STALE_SEC + 4) {
if !agent::is_alive() {
return json!({"success": true, "stoppedInSec": t0.elapsed().as_secs_f64()});
}
std::thread::sleep(Duration::from_millis(200));
}
json!({"success": false, "error": "the loop did not exit", "_hint": "agent_recover"})
}
/// Stop EVERY resident loop, in every workspace (legacy root + per-instance subdirs), not just the
/// selected one. This is what a close path must call: in #6 a loop lived in the legacy workspace
/// while the selected one was empty, so the selected-only `stop()` was a no-op and the app closed
/// over a live loop. Drops each stop file, then waits for all heartbeats to go stale. Returns the
/// workspaces it acted on and whether every one exited within the wait.
pub fn stop_all_live() -> Value {
let live = agent::live_workspaces();
if live.is_empty() {
return json!({"success": true, "wasRunning": false, "stopped": []});
}
let mut targeted: Vec<String> = Vec::new();
for (ws, _age) in &live {
let _ = std::fs::write(ws.join("stop"), "1");
targeted.push(ws.to_string_lossy().to_string());
}
let t0 = Instant::now();
while t0.elapsed() < Duration::from_secs(HB_STALE_SEC + 4) {
if agent::live_workspaces().is_empty() {
return json!({"success": true, "stoppedInSec": t0.elapsed().as_secs_f64(), "stopped": targeted});
}
std::thread::sleep(Duration::from_millis(200));
}
let stragglers: Vec<String> = agent::live_workspaces().into_iter()
.map(|(ws, _)| ws.to_string_lossy().to_string()).collect();
json!({"success": false, "error": "one or more loops did not exit", "stopped": targeted,
"stillAlive": stragglers, "_hint": "agent_recover"})
}
/// Is Altium stuck inside a modal loop whose DIALOG IS GONE? Windows disables a process's other
/// top-level windows for the duration of a modal dialog, so "main window disabled + nothing on
/// screen to answer" means the app is waiting on a box that no longer exists. In that state
/// Altium accepts no input at all: no menu, no Alt+F4, no WM_CLOSE, and every `-R` hand-off comes
/// back as "There is another instance ... but it is busy" — which is exactly how arav-rog held the
/// single licence seat hostage for a whole night on 2026-09-21 after a close-time save prompt.
/// Returns `{pid, tid, hwnd}` for the stuck instance.
pub fn modal_orphan() -> Option<Value> {
let rows = crate::win32::visible_windows()?;
let pids = crate::win32::pids_of("X2.EXE").unwrap_or_default();
let pid = pids.into_iter().find(|p| crate::win32::pid_main_window_disabled(*p, &rows))?;
// A dialog we can SEE is not an orphan: it is a question, and answering it is the right fix.
if !crate::dialogs::scan().is_empty() {
return None;
}
let tid = crate::win32::pid_ui_thread(pid, &rows)?;
let hwnd = rows.iter().find(|w| w.pid == pid && w.class == "TDocumentForm").map(|w| w.hwnd);
Some(json!({"pid": pid, "tid": tid, "hwnd": hwnd}))
}
/// Leave the message loop the way Altium's own Exit does (posted WM_QUIT = Application.Terminate),
/// NOT a process kill: the app runs its shutdown and RELEASES the licence seat. Measured on
/// AD 26.10.1, 2026-09-21: the orphaned instance exited in under 8 s and the next launch was
/// licensed. Never prompts, so callers gate it on the user having declared the documents theirs.
pub fn graceful_terminate(orphan: &Value, wait: Duration) -> Value {
let tid = orphan.get("tid").and_then(|t| t.as_u64()).unwrap_or(0) as u32;
let pid = orphan.get("pid").and_then(|p| p.as_u64()).unwrap_or(0) as u32;
if tid == 0 {
return json!({"success": false, "error": "no UI thread for the stuck instance"});
}
let posted = crate::win32::post_quit_to_thread(tid);
let t0 = Instant::now();
while t0.elapsed() < wait {
if !crate::win32::pids_of("X2.EXE").unwrap_or_default().contains(&pid) {
instance::invalidate_snapshot();
return json!({"success": true, "route": "wm_quit", "posted": posted,
"exitedInSec": t0.elapsed().as_secs_f64(), "seatReleased": true});
}
std::thread::sleep(Duration::from_millis(400));
}
json!({"success": false, "route": "wm_quit", "posted": posted,
"error": "the stuck instance did not exit after a posted WM_QUIT"})
}
/// Un-halt the script engine: press OK on the script Error modal, stop the editor's script,
/// clear stale files. Costs the user a modal, so only on a real halt.
pub fn recover(reason: &str) -> Value {
recover_impl(reason, true)
}
/// For altium_close: free the engine but do NOT start a new agent loop (a close must never go out
/// over a live loop, adom/altium-bridge#6).
pub fn recover_without_restart(reason: &str) -> Value {
recover_impl(reason, false)
}
fn recover_impl(reason: &str, verify_by_restart: bool) -> Value {
let mut steps: Vec<Value> = vec![json!({"reason": reason})];
// A modal orphan answers NOTHING: every -R below would bounce off "another instance is busy"
// and each bounce spawns a helper X2. Say so instead of thrashing for 10 s.
if let Some(orphan) = modal_orphan() {
return json!({"success": false, "errorCode": "altium_modal_orphan", "orphan": orphan, "steps": steps,
"_hint": "Altium is stuck inside a modal loop whose dialog is gone: its main window is DISABLED and nothing is on screen to answer. No menu, keystroke or -R hand-off can reach it, and it is still holding the licence seat. altium_close {discardChanges:true} now ends it gracefully (a posted WM_QUIT — Altium's own shutdown, which RELEASES the seat; not a kill). Only pass discardChanges when the open documents are yours."});
}
// 1. the script Error modal, if any
for d in crate::dialogs::scan() {
if let Some(hw) = d.get("hwnd").and_then(|h| h.as_i64()) {
let title = d.get("title").and_then(|t| t.as_str()).unwrap_or("");
// ONLY the script error box: a lone OK titled "Error" (or the X2 notice). This loop used
// to press OK on EVERY dialog, and agent::call runs recover automatically: with the scan
// now seeing save prompts that would have answered them (found 2026-09-21).
let lone_ok = d.get("buttons").and_then(|b| b.as_array()).map(|a| a.len() == 1 && a[0].as_str().map(|x| x.eq_ignore_ascii_case("ok")).unwrap_or(false)).unwrap_or(false);
let ours = lone_ok && (title.trim().eq_ignore_ascii_case("error") || title.trim().eq_ignore_ascii_case("x2"));
if !ours {
steps.push(json!({"dialog": title, "left": "not a script error box: recover never answers other dialogs"}));
continue;
}
ab::call("desktop_ui_click", json!({"hwnd": hw, "name": "OK", "role": "button"}), &[], 15);
steps.push(json!({"dialog": title, "pressed": "OK"}));
}
}
if let Some(d) = blocking_dialog() {
return json!({"success": false, "errorCode": "blocked_by_dialog", "dialog": d, "steps": steps,
"_hint": "A dialog that is not the script Error box is open on Altium's main window (a save prompt, a reload confirm). recover never answers those, and every -R hand-off would bounce off it with an 'another instance is busy' notice on the user's screen. Read it (altium_check_dialogs), answer it with altium_dismiss_dialogs, then retry."});
}
// 2. tell the editor to stop whatever unit it is holding (raises the busy notice → reap it).
// EditScript:Stop ONLY acts when a SCRIPT DOCUMENT is the active document. With a board or a
// library in front it is a silent no-op: recover answered success while the script stayed on
// its breakpoint, the self-test reported halted_and_recovered for an engine that was never
// freed, and Altium then refused to close (measured on AD 26.10.1, 2026-09-21; it had only
// ever worked because the script happened to be in front). So: bring every agent script this
// workspace holds to the front, one at a time, and stop it there.
let scripts: Vec<std::path::PathBuf> = std::fs::read_dir(agent::workspace_dir()).map(|rd| rd.filter_map(|e| e.ok()).map(|e| e.path())
.filter(|p| p.file_name().map(|n| { let n = n.to_string_lossy().to_string(); n.starts_with("AdomAgent_") && n.ends_with(".pas") }).unwrap_or(false))
.collect()).unwrap_or_default();
let instances_before = instance::instances().len();
if let Some(exe) = x2_exe() {
for sc in &scripts {
let arg = format!("-RWorkspaceManager:OpenObject(ObjectKind=Document|FileName=\"{}\")", sc.to_string_lossy());
if let Ok(mut child) = spawn_helper(&exe, &arg) {
let _ = wait_helper(&mut child, Duration::from_secs(8));
let _ = child.kill();
let _ = child.wait();
}
std::thread::sleep(Duration::from_millis(600));
if let Ok(mut child) = spawn_helper(&exe, "-REditScript:Stop") {
let _ = wait_helper(&mut child, Duration::from_secs(8));
let _ = child.kill();
let _ = child.wait();
}
steps.push(json!({"scriptActivatedAndStopped": sc.file_name().map(|n| n.to_string_lossy().to_string())}));
}
}
instance::invalidate_snapshot();
let instances_after = instance::instances().len();
if instances_after > instances_before {
// A -R hand-off that does not reach the running instance makes X2 start a NEW Altium.
steps.push(json!({"warning": "a helper started a second Altium instance instead of forwarding", "instancesBefore": instances_before, "instancesAfter": instances_after}));
}
if let Some(exe) = x2_exe() {
match spawn_helper(&exe, "-REditScript:Stop") {
Ok(mut child) => {
let t0 = Instant::now();
let exited = wait_helper(&mut child, Duration::from_secs(8));
if !exited {
let _ = child.kill();
let _ = child.wait();
}
steps.push(json!({"editScriptStop": "sent", "exited": exited, "sec": t0.elapsed().as_secs_f64()}));
}
Err(e) => steps.push(json!({"editScriptStop": "failed", "error": e})),
}
steps.push(reap_busy_notice(Duration::from_secs(4)));
}
// 3. stale files
let ws = agent::workspace_dir();
for n in ["hb.txt", "stop", "request.txt", "request.payload", "request.txt.tmp"] {
let _ = std::fs::remove_file(ws.join(n));
}
// VERIFY, never assume: the engine is free only if a fresh agent loop can actually start.
instance::invalidate_snapshot();
let mut verified = Value::Null;
if verify_by_restart && crate::health::is_running() && instances_after <= 1 {
let started = start(Duration::from_secs(45));
let ok = started.get("success").and_then(|b| b.as_bool()) == Some(true) && agent::is_alive();
verified = json!(ok);
steps.push(json!({"verifiedByRestartingTheAgent": ok}));
}
let freed = verified.as_bool().unwrap_or(true);
json!({"success": freed, "engineFree": verified, "steps": steps,
"errorCode": if freed { Value::Null } else { json!("engine_still_parked") },
"_hint": if !verify_by_restart { "Sent the stop to every agent script; NOT verified (no agent loop was started, because the caller is about to close Altium)." }
else if freed { "The script engine is free and a fresh agent loop is running (verified by starting it)." }
else { "The engine is STILL parked: a new agent loop could not start. Look at the screen (desktop_screenshot_screen): a script document must be the active document for Altium to accept the stop. Click the agent script's tab in Altium, then altium_agent_recover again. Do not altium_close yet: Altium refuses to close over a parked script." }})
}
// ---- application lifecycle ---------------------------------------------------
/// The window the user is actually working in, for verbs that must hand it back (`misc::launch`).
pub fn foreground_hwnd_pub() -> Option<i64> { foreground_hwnd() }
/// Hand a remembered foreground window back to the user.
pub fn restore_foreground_pub(hwnd: i64) { restore_foreground(hwnd) }
/// (hwnd, pid) of the OS foreground window — the per-verb etiquette wrapper needs the pid to
/// tell "Altium stole the focus" from "the user switched windows themselves" (#12).
pub fn foreground_info() -> Option<(i64, u64)> {
let r = ab::call("desktop_get_foreground", json!({}), &[], 10);
match (r.get("hwnd").and_then(|h| h.as_i64()), r.get("pid").and_then(|p| p.as_u64())) {
(Some(h), Some(p)) if h != 0 => Some((h, p)),
_ => None,
}
}
pub fn launch(wait: Duration) -> Value {
if crate::health::is_running() {
return json!({"success": true, "alreadyRunning": true, "windowEtiquette": "background"});
}
let exe = match x2_exe() {
Some(e) => e,
None => return json!({"success": false, "errorCode": "host_app_not_installed", "hostApp": "Altium Designer"}),
};
let t0 = Instant::now();
if let Err(e) = spawn_helper(&exe, "") {
return json!({"success": false, "error": format!("launch failed: {e}")});
} // the child is Altium itself — let it outlive this call
while t0.elapsed() < wait {
if crate::health::is_running() {
return json!({"success": true, "startedInSec": t0.elapsed().as_secs_f64(),
"windowEtiquette": "foreground",
"_hint": "Altium's own launch foregrounds itself (the one legitimate foreground)."});
}
std::thread::sleep(Duration::from_secs(1));
}
json!({"success": false, "errorCode": "launch_timeout",
"error": format!("no Altium main window after {}s", wait.as_secs())})
}
// NOTE: application close lives in misc::close (the seat-guarded verb); the old lifecycle-level
// close was removed when it landed — one close path, one seat rule.
// ---- user-initiated-close watchdog v2 (adom/altium-bridge#6; v1 was PR 74 / 0.2.8) ---------
//
// altium_close tears every resident loop down BEFORE its WM_CLOSE, but a user pressing the
// window X goes straight to Altium, and a shutdown over a live agent loop wedges — X2 lingers
// headless and the desktop hard-freezes (three times on 2026-09-02). The 0.2.8 watchdog missed
// both freezes it watched because its detection ran through ab, whose window/process enumeration
// hangs or returns empty EXACTLY during a freeze; an empty answer read as "no X2" and it dropped
// nothing. v2 detects natively (win32.rs: non-messaging calls only, and "cannot answer" is
// distinguished from "none") and acts in two stages:
//
// 1. The instant no Altium main window is visible while a previously-seen resident loop still
// heartbeats: drop the stop file into EVERY live workspace (legacy included — the split-brain
// workspaces are exactly the ones nothing else reaches). A pumping loop consumes it within
// ~100 ms and X2 finishes its shutdown cleanly (measured live, 2026-09-02).
// 2. The zombie-breaker: if an X2 that was SEEN owning a main window is still alive
// KILL_AFTER_SEC after every main window vanished, AND the bridge's own machinery was
// involved (a resident loop existed during this close), the shutdown is wedged beyond
// recovery and each further minute is desktop-freeze exposure: warn the user by toast, then
// terminate the pid natively, recording the seat consequence. Scoped deliberately: an Altium
// the bridge never touched is the user's own to manage, and we never kill it.
static CLOSE_WATCHDOG: std::sync::atomic::AtomicBool = std::sync::atomic::AtomicBool::new(false);
const KILL_AFTER_SEC: u64 = 45;
/// Unix seconds of the last bridge-initiated close (misc::close). The zombie-breaker kills a
/// HUNG Altium only when the bridge was involved in the close (its own close verb ran recently,
/// or a resident loop was freed during this hang). A hung window with NO bridge involvement can
/// be a legitimate long computation (a big DRC trips IsHungAppWindow too) and is never killed,
/// only reported.
static LAST_CLOSE_AT: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0);
pub fn note_close_initiated() {
let now = std::time::SystemTime::now().duration_since(std::time::SystemTime::UNIX_EPOCH)
.map(|d| d.as_secs()).unwrap_or(0);
LAST_CLOSE_AT.store(now, std::sync::atomic::Ordering::Relaxed);
}
fn close_was_recent() -> bool {
let at = LAST_CLOSE_AT.load(std::sync::atomic::Ordering::Relaxed);
if at == 0 { return false; }
let now = std::time::SystemTime::now().duration_since(std::time::SystemTime::UNIX_EPOCH)
.map(|d| d.as_secs()).unwrap_or(0);
now.saturating_sub(at) < 300
}
#[derive(Default)]
struct WatchState {
/// workspace keys observed live while a main window existed
seen_ws: std::collections::BTreeSet<String>,
/// pids observed owning a TDocumentForm main window
seen_pids: std::collections::BTreeSet<u32>,
/// when every main window vanished (None while one is visible)
windows_gone_since: Option<Instant>,
/// whether a resident loop was live at (or after) the moment the windows vanished
close_involved_agent: bool,
/// pids already escalated, never re-killed
escalated: std::collections::BTreeSet<u32>,
}
fn live_agent_workspaces() -> Vec<(String, PathBuf)> {
let root = agent::cache_dir().join("agent");
let mut workspaces: Vec<(String, PathBuf)> = vec![("legacy".into(), root.clone())];
if let Ok(rd) = std::fs::read_dir(&root) {
for e in rd.flatten() {
if e.path().is_dir() {
workspaces.push((e.file_name().to_string_lossy().to_string(), e.path()));
}
}
}
workspaces.into_iter().filter(|(_, ws)| {
std::fs::metadata(ws.join("hb.txt")).and_then(|m| m.modified()).ok()
.and_then(|t| std::time::SystemTime::now().duration_since(t).ok())
.map(|d| d < Duration::from_secs(agent::ALIVE_SEC)).unwrap_or(false)
}).collect()
}
fn close_watch_once(st: &mut WatchState) {
// Native, non-messaging reads. `None` means the enumeration itself failed: act on NOTHING
// this tick rather than mistake blindness for absence (the 0.2.8 failure mode).
let windows = match crate::win32::visible_windows() {
Some(w) => w,
None => return,
};
let x2_pids = crate::win32::pids_of("X2.EXE").unwrap_or_default();
let healthy_pids: Vec<u32> = x2_pids.iter().copied()
.filter(|p| crate::win32::pid_has_main_window(*p, &windows)
&& !crate::win32::pid_main_window_hung(*p, &windows)).collect();
let live = live_agent_workspaces();
if !healthy_pids.is_empty() && healthy_pids.len() == x2_pids.len() {
// every X2 has a responsive main window: remember what we saw, clear closing state
for p in &healthy_pids {
st.seen_pids.insert(*p);
}
for (key, _) in &live {
st.seen_ws.insert(key.clone());
}
st.windows_gone_since = None;
st.close_involved_agent = false;
return;
}
// Some X2 is in the freeze signature: its main window is GONE or HUNG (the live freezes of
// 2026-09-02 showed BOTH shapes; the fatal one keeps the frame visible and non-responding).
// Stage 1: free every previously-seen resident loop NOW - harmless if the close is healthy,
// decisive if the shutdown is about to wedge on the script engine.
for (key, ws) in &live {
if st.seen_ws.contains(key) {
let _ = std::fs::write(ws.join("stop"), "1");
st.seen_ws.remove(key);
st.close_involved_agent = true;
crate::dialogs::note_event(json!({"closeWatchdog": key,
"note": "Altium main window gone or hung while a resident loop heartbeats - stop file dropped so the loop exits instead of wedging the shutdown (adom/altium-bridge#6)"}));
}
}
// Stage 2: the zombie-breaker. Killing is scoped to closes the BRIDGE was involved in
// (its close verb ran in the last 5 minutes, or a resident loop was freed during this
// episode): a hung window with no bridge involvement can be a legitimate long computation
// (a big DRC trips IsHungAppWindow too) and is never killed, only reported once.
let wedged: Vec<u32> = x2_pids.iter().copied()
.filter(|p| st.seen_pids.contains(p) && !st.escalated.contains(p)
&& (!crate::win32::pid_has_main_window(*p, &windows)
|| crate::win32::pid_main_window_hung(*p, &windows)))
.collect();
if wedged.is_empty() {
st.windows_gone_since = None;
return;
}
let since = *st.windows_gone_since.get_or_insert_with(Instant::now);
if since.elapsed() < Duration::from_secs(KILL_AFTER_SEC) {
return;
}
let bridge_involved = st.close_involved_agent || close_was_recent();
for pid in wedged {
st.escalated.insert(pid);
if bridge_involved {
ab::call("notify_user", json!({
"title": "Altium shutdown wedged",
"message": format!("Altium (pid {pid}) has been frozen or window-less for {KILL_AFTER_SEC}s after a bridge-involved close. Terminating it to protect the desktop; the On-Demand seat may need a sign-out/sign-in to free."),
"level": "warning",
"reason": "zombie-breaker: a wedged Altium shutdown hard-froze this desktop four times on 2026-09-02"
}), &[], 10);
let killed = crate::win32::kill(pid);
crate::dialogs::note_event(json!({"zombieBreaker": pid, "killed": killed,
"note": format!("X2 frozen/window-less {KILL_AFTER_SEC}s after a bridge-involved close - terminated to prevent the desktop freeze (adom/altium-bridge#6). Seat may be stranded until sign-out/sign-in."),
"_seatWarning": "seat possibly stranded by this kill"}));
} else {
ab::call("notify_user", json!({
"title": "Altium appears frozen",
"message": format!("Altium (pid {pid}) has been non-responding for {KILL_AFTER_SEC}s. The bridge was not involved in a close, so it will NOT auto-kill (this can be a long computation). If it is truly stuck: altium_close force:true acceptSeatLoss:true."),
"level": "warning",
"reason": "watchdog observation, no action taken"
}), &[], 10);
crate::dialogs::note_event(json!({"frozenObserved": pid,
"note": "hung main window with no bridge-involved close - reported, deliberately not killed"}));
}
}
}
pub fn start_close_watchdog() -> bool {
if CLOSE_WATCHDOG.swap(true, std::sync::atomic::Ordering::SeqCst) {
return false;
}
std::thread::Builder::new().name("altium-agent-close-watchdog".into()).spawn(|| {
let mut st = WatchState::default();
loop {
std::thread::sleep(Duration::from_secs(2));
let _ = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
close_watch_once(&mut st);
}));
}
}).is_ok()
}
// ---- sha256 (no external crate) ----------------------------------------------
pub fn sha256_hex(data: &[u8]) -> String {
const K: [u32; 64] = [
0x428a2f98, 0x71374491, 0xb5c0fbcf, 0xe9b5dba5, 0x3956c25b, 0x59f111f1, 0x923f82a4, 0xab1c5ed5,
0xd807aa98, 0x12835b01, 0x243185be, 0x550c7dc3, 0x72be5d74, 0x80deb1fe, 0x9bdc06a7, 0xc19bf174,
0xe49b69c1, 0xefbe4786, 0x0fc19dc6, 0x240ca1cc, 0x2de92c6f, 0x4a7484aa, 0x5cb0a9dc, 0x76f988da,
0x983e5152, 0xa831c66d, 0xb00327c8, 0xbf597fc7, 0xc6e00bf3, 0xd5a79147, 0x06ca6351, 0x14292967,
0x27b70a85, 0x2e1b2138, 0x4d2c6dfc, 0x53380d13, 0x650a7354, 0x766a0abb, 0x81c2c92e, 0x92722c85,
0xa2bfe8a1, 0xa81a664b, 0xc24b8b70, 0xc76c51a3, 0xd192e819, 0xd6990624, 0xf40e3585, 0x106aa070,
0x19a4c116, 0x1e376c08, 0x2748774c, 0x34b0bcb5, 0x391c0cb3, 0x4ed8aa4a, 0x5b9cca4f, 0x682e6ff3,
0x748f82ee, 0x78a5636f, 0x84c87814, 0x8cc70208, 0x90befffa, 0xa4506ceb, 0xbef9a3f7, 0xc67178f2,
];
let mut h: [u32; 8] = [0x6a09e667, 0xbb67ae85, 0x3c6ef372, 0xa54ff53a, 0x510e527f, 0x9b05688c, 0x1f83d9ab, 0x5be0cd19];
let mut msg = data.to_vec();
let bit_len = (data.len() as u64).wrapping_mul(8);
msg.push(0x80);
while msg.len() % 64 != 56 {
msg.push(0);
}
msg.extend_from_slice(&bit_len.to_be_bytes());
for chunk in msg.chunks(64) {
let mut w = [0u32; 64];
for i in 0..16 {
w[i] = u32::from_be_bytes([chunk[i * 4], chunk[i * 4 + 1], chunk[i * 4 + 2], chunk[i * 4 + 3]]);
}
for i in 16..64 {
let s0 = w[i - 15].rotate_right(7) ^ w[i - 15].rotate_right(18) ^ (w[i - 15] >> 3);
let s1 = w[i - 2].rotate_right(17) ^ w[i - 2].rotate_right(19) ^ (w[i - 2] >> 10);
w[i] = w[i - 16].wrapping_add(s0).wrapping_add(w[i - 7]).wrapping_add(s1);
}
let (mut a, mut b, mut c, mut d, mut e, mut f, mut g, mut hh) = (h[0], h[1], h[2], h[3], h[4], h[5], h[6], h[7]);
for i in 0..64 {
let s1 = e.rotate_right(6) ^ e.rotate_right(11) ^ e.rotate_right(25);
let ch = (e & f) ^ ((!e) & g);
let t1 = hh.wrapping_add(s1).wrapping_add(ch).wrapping_add(K[i]).wrapping_add(w[i]);
let s0 = a.rotate_right(2) ^ a.rotate_right(13) ^ a.rotate_right(22);
let maj = (a & b) ^ (a & c) ^ (b & c);
let t2 = s0.wrapping_add(maj);
hh = g; g = f; f = e; e = d.wrapping_add(t1);
d = c; c = b; b = a; a = t1.wrapping_add(t2);
}
for (i, v) in [a, b, c, d, e, f, g, hh].iter().enumerate() {
h[i] = h[i].wrapping_add(*v);
}
}
h.iter().map(|x| format!("{x:08x}")).collect()
}
#[cfg(test)]
mod tests {
use super::sha256_hex;
#[test]
fn sha256_matches_reference_vectors() {
assert_eq!(sha256_hex(b""), "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855");
assert_eq!(sha256_hex(b"abc"), "ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad");
// > 55 bytes: forces a second padding block
assert_eq!(
sha256_hex(b"abcdbcdecdefdefgefghfghighijhijkijkljklmklmnlmnomnopnopq"),
"248d6a61d20638b8e5c026930c3e6039a33ce45964ff2167f6ecedd419db06c1"
);
}
}
#[cfg(test)]
mod interop_tests {
use super::{agent_base, sha256_hex};
/// The Python bridge names the generation `AdomAgent_<sha256(template)[..10]>`. Both bridges
/// MUST agree or they would each install their own generation and fight over the engine.
#[test]
fn generation_digest_matches_python() {
let raw = match std::fs::read_to_string("../src/agent/AdomAgent.pas.tmpl") {
Ok(r) => r,
Err(_) => return, // template not present in this checkout
};
let full = sha256_hex(raw.as_bytes());
assert_eq!(agent_base(&raw), format!("AdomAgent_{}", &full[..10]));
println!("digest = {}", &full[..10]);
}
}