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.45 source (not yet released): save-into-missing-folder fix + saved check, sheet grows on append, board_shape/pcb_re…
23ad0df
6d ago
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745
//! Odds and ends: readiness/status, instances, dialog_log, state, diagnostics, the process-
//! launcher catalog (list_commands), launch/close with the seat guard, uninstall.
use crate::support::{self, s, s_or};
use crate::{ab, agent, dialogs, instance, lifecycle, progress, taskbar};
use serde_json::{json, Value};
use std::time::Duration;
pub fn readiness(_args: &Value) -> Value {
let inst = support::first_install();
let lic = instance::licence_state();
let alive = agent::is_alive();
// The UNION view: is a loop resident in ANY workspace, not just the selected one? In #6 a loop
// lived in the legacy workspace while the selected one was empty, and `agentAlive` (selected
// only) reported false while the engine was occupied. `agentAlive` still answers "can I drive
// the resolved instance now"; `agentResidentAnywhere` is the safety answer status must never
// hide, so a resident loop can never again read as "not resident".
let live_ws = agent::live_workspaces();
let resident_anywhere = !live_ws.is_empty();
let running = lic.get("running").and_then(|b| b.as_bool()).unwrap_or(false);
let licensed = lic.get("licensed").and_then(|b| b.as_bool());
let ready = inst.is_some() && running && licensed == Some(true) && alive;
let hint = if inst.is_none() {
"Not installed — see altium_launch's hint to install.".to_string()
} else if !running {
"Altium is installed but not running: altium_launch (cold start ~60s, measured).".to_string()
} else if licensed == Some(false) {
format!("Altium is running but the title says {:?} — scripting is refused without a licence. altium_license '{{}}' drives the trial/On-Demand flow.",
lic.get("state").and_then(|st| st.as_str()).unwrap_or("?"))
} else if !alive {
"Licensed and running; the agent loop is not resident — altium_agent_start (≈8s), then drive.".to_string()
} else {
"Ready. altium_state for the picture, altium_eval / altium_run_command to act.".to_string()
};
let mut out = json!({"success": true, "hostApp": support::HOST_APP, "installed": inst.is_some(), "install": inst,
"running": running, "licensed": licensed, "licenceState": lic.get("state"),
"adVersion": lic.get("adVersion"), "agentAlive": alive, "agentResidentAnywhere": resident_anywhere,
"ready": ready, "engine": "rust", "_hint": hint});
// Surface a loop that is resident somewhere the current selection does not point at — the exact
// blind spot behind #6. It is a live freeze-on-close hazard until torn down (altium_close does
// that across all workspaces now).
if resident_anywhere && !alive {
out["agentLiveWorkspaces"] = json!(live_ws.iter().map(|(ws, age)|
json!({"workspace": ws.to_string_lossy(), "heartbeatAgeSec": age})).collect::<Vec<_>>());
out["_agentWarning"] = json!("An agent loop is resident in a workspace the current selection does not point at (agentAlive is the selected-instance view). Close via altium_close, which tears down every workspace's loop before the app closes; never let the user press the window X while a loop is resident (#6).");
}
out
}
pub fn status(args: &Value) -> Value {
let mut r = readiness(args);
r["windows"] = json!(instance::altium_windows());
r["dialogs"] = json!(dialogs::scan());
r["reapedDialogs"] = json!(dialogs::reaped_log(5));
// The last bundle install, light: a client whose install outlived ab's budget polls this.
r["lastInstall"] = crate::library::last_install_summary();
r
}
pub fn diagnostics(_args: &Value) -> Value {
let env: serde_json::Map<String, Value> = ["LOCALAPPDATA", "APPDATA", "USERPROFILE", "ProgramFiles", "ADOM_BIND_HOST", "ADOM_DIRECT_API_URL"]
.iter()
.map(|k| (k.to_string(), std::env::var(k).map(Value::from).unwrap_or(Value::Null)))
.collect();
json!({"success": true, "engine": "rust", "env": env,
"installs": support::detect_installs(),
"windows": instance::altium_windows(),
"agent": agent_status(&json!({})),
"cacheDir": agent::cache_dir().to_string_lossy()})
}
pub fn agent_status(_args: &Value) -> Value {
let alive = agent::is_alive();
let live_ws = agent::live_workspaces();
let resident_anywhere = !live_ws.is_empty();
let mut out = json!({"success": true, "alive": alive, "responsive": agent::is_responsive(),
"residentAnywhere": resident_anywhere,
"liveWorkspaces": live_ws.iter().map(|(ws, age)|
json!({"workspace": ws.to_string_lossy(), "heartbeatAgeSec": age})).collect::<Vec<_>>(),
"heartbeat": agent::heartbeat(), "workspace": agent::workspace_dir().to_string_lossy(),
"engine": "rust"});
if alive && !agent::is_responsive() {
out["_hint"] = json!("Loop is alive but busy inside a long Altium process (no heartbeat while a RunProcess runs). Wait; do not start a second agent.");
} else if resident_anywhere && !alive {
// the #6 blind spot: a loop is resident in a workspace the selection does not point at
out["_hint"] = json!("A loop is resident in another workspace (see liveWorkspaces) — the selected-instance view reads not-alive. This is a freeze-on-close hazard until torn down; altium_close stops every workspace's loop.");
}
out
}
/// Every running Altium, and how to address it. Each instance gets its own agent workspace
/// (`bridges-cache/altium/agent/<key>`), so two resident loops never share an inbox.
pub fn instances_verb(_args: &Value) -> Value {
let root = agent::cache_dir().join("agent");
let found: Vec<Value> = instance::instances().into_iter().map(|mut i| {
let key = i.get("key").and_then(|k| k.as_str()).unwrap_or("").to_string();
let ws = root.join(&key);
let alive = std::fs::metadata(ws.join("hb.txt")).and_then(|m| m.modified()).ok()
.and_then(|t| std::time::SystemTime::now().duration_since(t).ok())
.map(|d| d < Duration::from_secs(agent::ALIVE_SEC))
.unwrap_or(false);
i["agentAlive"] = json!(alive);
i["agentWorkspace"] = json!(ws.to_string_lossy());
i
}).collect();
let n = found.len();
let out = json!({"success": true, "count": n, "instances": found,
"_hint": if n > 1 {
"Pass instance:<pid|key|word> to any verb to target one. With several running, verbs without it fail with errorCode ambiguous_instance."
} else {
"One Altium running — `instance` is optional."
}});
support::seat_hints(out, None, n)
}
/// Activate a docked panel TAB in the background (the "ganged-up view": symbol on canvas, 3D chip
/// in the Editor preview, footprint in the SCH Library panel). No server process activates a tab:
/// `Client:CustomizeResources Action=Show|ObjectKind=Panel` only makes sure the panel EXISTS.
///
/// Bounded by construction. A UIA search by name walks Altium's whole window tree and can take
/// longer than the per-call timeout on a licensed box, so the first cut (read, select, read,
/// click, read at 10+15+10+15+10 s) spent exactly ab's 60 s budget and answered ab's generic
/// timeout, and the searches it abandoned kept ab busy for the next verb. Now: ONE discovery
/// call that reads existence and selection together, at most ONE select, ONE verify, all inside
/// PANEL_BUDGET, and every exit is a named answer. No click fallback: a click does not switch a
/// panel tab. If ab's select falls back to its raising SendInput click, the reply says so.
const PANEL_BUDGET_SEC: u64 = 24;
fn find_tab(hwnd: i64, name: &str, secs: u64) -> Result<Option<Value>, ()> {
let r = ab::call("desktop_find_controls", json!({"hwnd": hwnd, "name": name, "maxResults": 5}), &[], secs.max(1));
if r.get("success").and_then(|b| b.as_bool()) == Some(false) && r.get("matches").is_none() {
return Err(());
}
let matches = r.get("matches").and_then(|m| m.as_array()).cloned().unwrap_or_default();
// prefer the element UIA calls a tab; else the first exact-name match
let tab = matches.iter().find(|m| m.get("role").and_then(|r| r.as_str())
.map(|r| r.to_ascii_lowercase().contains("tab")).unwrap_or(false))
.or_else(|| matches.first()).cloned();
Ok(tab)
}
fn tab_selected(m: &Value) -> Option<bool> {
m.get("selected").or_else(|| m.get("isSelected")).and_then(|b| b.as_bool())
}
pub fn panel(args: &Value) -> Value {
let name = match s(args, "name") {
Some(n) if !n.is_empty() => n,
_ => return json!({"success": false, "errorCode": "name_required", "error": "name is required - the dock tab's caption exactly as painted, e.g. 'SCH Library', 'PCB Library', 'Components', 'Projects', 'Properties'"}),
};
let lic = instance::licence_state();
let hwnd = match lic.get("hwnd").and_then(|h| h.as_i64()) {
Some(h) if lic.get("running").and_then(|b| b.as_bool()) == Some(true) => h,
_ => return json!({"success": false, "errorCode": "altium_not_running"}),
};
let t0 = std::time::Instant::now();
let left = || PANEL_BUDGET_SEC.saturating_sub(t0.elapsed().as_secs());
let mut ensured = Value::Null;
if let Some(id) = s(args, "ensureId") {
let r = agent::call("run_process",
&[("command", "Client:CustomizeResources".into()),
("params", format!("Action=Show|ObjectKind=Panel|ID={id}"))],
None, Duration::from_secs(10));
ensured = json!({"id": id, "ok": r.get("success").and_then(|b| b.as_bool()).unwrap_or(false)});
}
let timed_out = |stage: &str, t0: std::time::Instant| json!({"success": false, "errorCode": "panel_search_timeout",
"panel": name, "stage": stage, "elapsedSec": t0.elapsed().as_secs_f64(), "ensured": ensured,
"_hint": "A UI Automation search of Altium's main window did not finish inside the panel budget, so the tab could not be reached; nothing was clicked. On Altium Designer Develop this is the normal case (one search measured over 20 s on 2026-10-15), and no Altium process switches a docked tab either (Client:CustomizeResources Show/Toggle only ensures the panel exists). What does work: opening a library document brings its own panel to the front, so altium_show_library / show_symbol lands on SCH Library and show_footprint on PCB Library."});
// 1. discovery: existence + selection in one search
let tab = match find_tab(hwnd, &name, left().min(10)) {
Err(_) => return timed_out("find", t0),
Ok(None) => return json!({"success": false, "errorCode": "panel_not_found", "panel": name, "ensured": ensured,
"elapsedSec": t0.elapsed().as_secs_f64(),
"_hint": "No control with that caption on Altium's main window. Check the spelling against the painted tab, and pass ensureId if the panel was never opened in this session."}),
Ok(Some(m)) => m,
};
if tab_selected(&tab) == Some(true) {
return json!({"success": true, "panel": name, "selected": true, "lane": "already",
"ensured": ensured, "elapsedSec": t0.elapsed().as_secs_f64()});
}
if left() < 4 {
return timed_out("select", t0);
}
// 2. one select
let sel = ab::call("desktop_ui_select", json!({"hwnd": hwnd, "name": name,
"reason": format!("activate the '{name}' panel tab in the background (altium_panel)")}), &[], left().min(10));
let err = sel.get("error").and_then(|e| e.as_str()).unwrap_or("").to_string();
if err.contains("approval") || err.contains("SHELL") {
return json!({"success": false, "errorCode": "ui_select_gated", "panel": name, "ensured": ensured,
"elapsedSec": t0.elapsed().as_secs_f64(),
"_hint": "ab gates desktop_ui_select behind the shell approval grant (Adom Bridge footer 'Auto' button / Settings > Shell Commands > Auto mode). Turn it on, or select the tab by hand."});
}
if sel.get("raised").and_then(|b| b.as_bool()) == Some(true) {
// ab fell back to a SendInput click, which foregrounds Altium: report it, never hide it
return json!({"success": sel.get("success").and_then(|b| b.as_bool()).unwrap_or(false),
"panel": name, "lane": "ui_select_raised", "windowEtiquette": "foreground",
"_hint": "The tab exposes no SelectionItemPattern, so ab clicked it with synthetic input, which raised Altium. The dispatch wrapper hands the user's window back, but this tab cannot be switched silently."});
}
// 3. verify
if left() < 3 {
return json!({"success": sel.get("success").and_then(|b| b.as_bool()).unwrap_or(false),
"panel": name, "lane": "ui_select", "verified": false, "elapsedSec": t0.elapsed().as_secs_f64()});
}
match find_tab(hwnd, &name, left().min(8)) {
Ok(Some(m)) if tab_selected(&m) == Some(true) =>
json!({"success": true, "panel": name, "selected": true, "lane": "ui_select", "ensured": ensured,
"elapsedSec": t0.elapsed().as_secs_f64()}),
Ok(Some(m)) if tab_selected(&m).is_none() =>
// the tree exposes no selection state for this tab: trust the pattern call, say so
json!({"success": sel.get("success").and_then(|b| b.as_bool()).unwrap_or(false), "panel": name,
"lane": "ui_select", "verified": false, "elapsedSec": t0.elapsed().as_secs_f64(),
"_hint": "The tab reports no selection state, so the select could not be verified; check the returned frame of the next show_* verb."}),
Err(_) => timed_out("verify", t0),
_ => json!({"success": false, "errorCode": "panel_not_selected", "panel": name, "ensured": ensured,
"selectReply": {"success": sel.get("success"), "error": err.chars().take(160).collect::<String>()},
"elapsedSec": t0.elapsed().as_secs_f64(),
"_hint": "The tab exists but did not become selected. It may be a floating panel's title rather than a dock tab."}),
}
}
/// What the standing dialog reaper has pre-empted.
pub fn dialog_log(args: &Value) -> Value {
let limit = args.get("limit").and_then(|l| l.as_u64()).unwrap_or(25) as usize;
let shots = if args.get("shots").and_then(|b| b.as_bool()) == Some(true) { json!(dialogs::reaped_shots()) } else { Value::Null };
json!({"success": true, "reaped": dialogs::reaped_log(limit), "running": true, "shots": shots,
"_shotsHint": "shots:true returns PNGs (base64) of the last 8 boxes the reaper pressed. Altium paints its message text, so the picture is the only copy of what a box SAID: read it whenever a verb is refused for no visible reason.",
"_hint": "Anything NOT listed here that reached the user is either a save prompt (never auto-answered, by design) or a dialog missing from the catalog — report it so it can be added with a safe action."})
}
/// Screenshot ARRAY of every Altium window + logical state from the agent.
pub fn state(args: &Value) -> Value {
let wins = instance::altium_windows();
let mut shots: Vec<Value> = Vec::new();
if args.get("screenshots").and_then(|b| b.as_bool()).unwrap_or(true) {
for w in &wins {
let kind = w.get("kind").and_then(|k| k.as_str()).unwrap_or("");
if !matches!(kind, "main" | "dialog" | "script_error_modal") {
continue;
}
if let Some(hwnd) = w.get("hwnd").and_then(|h| h.as_i64()) {
let r = ab::call("desktop_screenshot_base64", json!({"hwnd": hwnd}), &[], 15);
if let Some(img) = r.get("image").or_else(|| r.get("base64")).and_then(|i| i.as_str()) {
shots.push(json!({"title": w.get("title"), "hwnd": hwnd, "kind": kind,
"image": img, "width": r.get("width"), "height": r.get("height")}));
}
}
}
}
// state is instance-exempt (it must answer with several Altiums running), so the selection
// is None and `is_alive()` would read only the legacy root — blind to a per-instance loop
// (adom/altium-bridge#8: agentAlive:false / logical:null beside a healthy agent). Use the
// UNION: any live workspace counts, and the logical read goes to the live one. With several
// live loops the selected instance's wins, else the first (and the reply names which).
let live_ws = agent::live_workspaces();
let agent_alive = !live_ws.is_empty();
let selected_ws = agent::workspace_dir();
let logical_ws = live_ws.iter().map(|(w, _)| w.clone())
.find(|w| *w == selected_ws)
.or_else(|| live_ws.first().map(|(w, _)| w.clone()));
let logical = match &logical_ws {
Some(ws) => agent::call_in(ws, "state", &[], None, Duration::from_secs(15)),
None => Value::Null,
};
json!({"success": true, "licence": instance::licence_state(), "windows": wins,
"dialogs": dialogs::scan(), "shots": shots, "logical": logical, "focusEvents": [],
"agentAlive": agent_alive,
"agentWorkspace": logical_ws.map(|w| w.to_string_lossy().to_string()),
"liveWorkspaces": live_ws.iter().map(|(w, age)|
json!({"workspace": w.to_string_lossy(), "heartbeatAgeSec": age})).collect::<Vec<_>>(),
"drivingBadge": {"active": taskbar::badge_active()},
"_hint": "shots[] are base64 PNGs of every Altium window (render inline). logical.data.documents lists open docs (agent required). agentAlive is the union over every workspace; agentWorkspace names the one logical came from."})
}
pub fn progress_verb(_args: &Value) -> Value {
json!({"success": true, "active": progress::live(false, ""), "history": progress::history(),
"verbTimes": progress::verb_report(40)})
}
pub fn uninstall_verb(args: &Value) -> Value {
let mut removed: Vec<String> = Vec::new();
let dry = args.get("dryRun").and_then(|b| b.as_bool()) == Some(true);
// A DRY run changes nothing: it used to stop the resident agent anyway (found 2026-09-21).
if agent::is_alive() && !dry {
lifecycle::stop();
}
let d = agent::cache_dir();
if d.exists() && args.get("dryRun").and_then(|b| b.as_bool()) != Some(true) {
let _ = std::fs::remove_dir_all(&d);
removed.push(d.to_string_lossy().to_string());
}
json!({"success": true, "removed": removed, "dryRun": args.get("dryRun").and_then(|b| b.as_bool()).unwrap_or(false),
"note": "Altium Designer itself and the user's designs are never touched."})
}
// ---- launch / close (seat-aware) ---------------------------------------------
pub fn launch(args: &Value) -> Value {
let inst = match support::first_install() {
Some(i) => i,
None => return support::not_installed(),
};
let lic = instance::licence_state();
if lic.get("running").and_then(|b| b.as_bool()) == Some(true) {
return json!({"success": true, "alreadyRunning": true, "licence": lic,
"windowEtiquette": "background", "_hint": "Already running — nothing launched."});
}
let t0 = support::now();
progress::begin("cold_start", "cold", "launching Altium Designer", "bridge");
// John, 2026-09-20/21: "dude u keep forcing altium in front of me [while] i'm typing".
// Altium's own launch foregrounds itself and there is no switch for it, so remember the
// window the user was actually in and hand it straight back once Altium is up.
// `background:false` opts out when the launch IS the thing the user asked to see.
let hand_back = args.get("background").and_then(|b| b.as_bool()).unwrap_or(true);
let user_fg = if hand_back { lifecycle::foreground_hwnd_pub() } else { None };
let exe = inst.get("exe").and_then(|e| e.as_str()).unwrap_or("").to_string();
if let Err(e) = lifecycle::spawn_helper_pub(&exe, "") {
progress::end("cold_start", "bridge");
return json!({"success": false, "error": format!("launch failed: {e}")});
}
let wait = args.get("waitSec").and_then(|v| v.as_f64()).unwrap_or(180.0);
while support::now() - t0 < wait {
instance::invalidate_snapshot();
let lic = instance::licence_state();
if lic.get("running").and_then(|b| b.as_bool()) == Some(true) {
progress::record("cold_start", support::now() - t0, "cold");
progress::end("cold_start", "bridge");
// Altium keeps painting for a moment after its main window exists; hand the focus
// back once it has settled, or it simply takes it again.
let mut returned = Value::Null;
if let Some(fg) = user_fg {
std::thread::sleep(Duration::from_secs(3));
lifecycle::restore_foreground_pub(fg);
returned = json!(fg);
}
return json!({"success": true, "startedInSec": support::elapsed_round(t0), "licence": lic,
"windowEtiquette": if returned.is_null() { "foreground" } else { "background" },
"foregroundReturnedTo": returned,
"_hint": if returned.is_null() { "Altium's own launch foregrounds itself. Next: altium_readiness; if unlicensed, altium_license." }
else { "Altium is up and the user's own window was handed back (it launches in front of everything and cannot be told not to). Pass background:false if the user asked to SEE Altium come up. Next: altium_readiness; if unlicensed, altium_license." }});
}
std::thread::sleep(Duration::from_secs(1));
}
progress::end("cold_start", "bridge");
json!({"success": false, "errorCode": "launch_timeout", "error": format!("no Altium main window after {wait}s")})
}
fn strand_refusal(extra: Value) -> Value {
let lic = instance::licence_state();
let mut out = json!({"success": false, "errorCode": "would_strand_licence_seat",
"licenceState": lic.get("state"),
"error": "force:true kills Altium and STRANDS its licence seat — no other Altium will be able to claim it afterwards, and the licence page will report it as free while refusing every claim.",
"_hint": "Try altium_close with no force first: a polite WM_CLOSE releases the seat. If it reports blocked_by_dialog, READ the dialog (an Unsaved Changes prompt names the documents — on a shared machine one may belong to another AI thread) and answer it. Only if you truly must kill: repeat with acceptSeatLoss:true."});
if let (Some(o), Some(e)) = (out.as_object_mut(), extra.as_object()) {
for (k, v) in e {
o.insert(k.clone(), v.clone());
}
}
support::seat_hints(out, lic.get("state").and_then(|st| st.as_str()), instance::instances().len())
}
fn kill_instance_pids() -> Vec<u64> {
let mut killed = Vec::new();
for i in instance::instances() {
if let Some(pid) = i.get("pid").and_then(|p| p.as_u64()) {
let r = ab::call("process_kill", json!({"pid": pid, "reason": "forced altium_close with acceptSeatLoss"}), &[], 20);
if r.get("success").and_then(|b| b.as_bool()).unwrap_or(false) {
killed.push(pid);
}
}
}
killed
}
/// Altium's own Exit process through the -R lane (the only close its skinned title bar honours).
fn send_quit() {
if let Some(exe) = lifecycle::x2_exe_pub() {
if let Ok(mut child) = lifecycle::spawn_helper_pub(&exe, "-RClient:QuitFromEDAClient") {
let t0 = std::time::Instant::now();
while t0.elapsed() < Duration::from_secs(10) {
match child.try_wait() {
Ok(Some(_)) => break,
Ok(None) => std::thread::sleep(Duration::from_millis(200)),
Err(_) => break,
}
}
let _ = child.kill();
}
}
}
pub fn close(args: &Value) -> Value {
let lic = instance::licence_state();
if lic.get("running").and_then(|b| b.as_bool()) != Some(true) {
return json!({"success": true, "wasRunning": false});
}
let force = args.get("force").and_then(|b| b.as_bool()).unwrap_or(false);
let accept = args.get("acceptSeatLoss").and_then(|b| b.as_bool()).unwrap_or(false);
// The INTENT to force is the risk, not whether a dialog happens to block: force:true KILLS
// the process, which strands the On-Demand seat. Refuse up front.
if force && !accept && lic.get("licensed").and_then(|b| b.as_bool()) != Some(false) {
return strand_refusal(json!({}));
}
// Tear down EVERY resident loop, in every workspace, BEFORE the polite WM_CLOSE — not just the
// one the current selection points at. In #6 a loop lived in the legacy workspace while the
// selected one was empty; the old selected-only `is_alive()`/`stop()` was a silent no-op and
// the app closed over a live loop, wedging the shutdown and hard-freezing the desktop. The
// teardown MUST precede the close so the scripting engine is unoccupied when Altium unloads it.
//
// `unsafeSkipTeardown: true` is the #6 ACCEPTANCE LEVER, nothing else: it reproduces a user
// X-press (a close dispatched over whatever loops are resident) so the freeze fix stays
// re-testable on every release. Altium's themed title bar exposes no UIA close button and
// synthetic input is approval-gated, so the gesture cannot be reproduced from outside.
// Parked = no loop was alive BEFORE the teardown below. Asked after it, every close looked
// parked (the teardown itself makes is_alive false) and paid ~10 s of pointless -R hand-offs.
let loop_was_alive = agent::is_alive();
let agent_teardown = if args.get("unsafeSkipTeardown").and_then(|b| b.as_bool()) == Some(true) {
json!({"skipped": "unsafeSkipTeardown — the issue-6 acceptance lever: the close goes out over any resident loop, exactly like a user X-press. Never use outside a freeze acceptance run."})
} else {
lifecycle::stop_all_live()
};
// The user's close gesture, faithfully: Altium's skinned title bar ignores WM_CLOSE (it
// closes the ACTIVE DOCUMENT instead) and WM_SYSCOMMAND/SC_CLOSE outright (both measured
// 2026-09-02); even Alt+F4 maps to the internal Exit process. So the polite close IS that
// process, forwarded through the -R lane — which, being a server process and not a script,
// executes even while an agent loop is resident (exactly the incident gesture of #6).
// (ab's desktop_set_window_state dropped its "close" state the same day; nothing here
// depends on another surface's verb set.)
// A script parked on a breakpoint makes Altium REFUSE to close ("cannot close the current
// document when a script is stopped on a breakpoint", measured on two builds 2026-09-20/21), and
// Altium Develop parks silently. Free the engine first; it is a no-op when nothing is parked.
let mut engine_freed = Value::Null;
if !loop_was_alive && agent::is_agent_script_title(&instance::main_window().and_then(|w| w.get("title").and_then(|t| t.as_str()).map(str::to_string)).unwrap_or_default()) {
engine_freed = lifecycle::recover_without_restart("altium_close: the script engine was parked on a breakpoint");
}
lifecycle::note_close_initiated();
send_quit();
let mut breakpoint_retries = 0;
if false {
if let Some(exe) = lifecycle::x2_exe_pub() {
if let Ok(mut child) = lifecycle::spawn_helper_pub(&exe, "-RClient:QuitFromEDAClient") {
let t0 = std::time::Instant::now();
while t0.elapsed() < Duration::from_secs(10) {
match child.try_wait() {
Ok(Some(_)) => break,
Ok(None) => std::thread::sleep(Duration::from_millis(200)),
Err(_) => break,
}
}
let _ = child.kill();
}
}
}
let t0 = support::now();
let wait = args.get("waitSec").and_then(|v| v.as_f64()).unwrap_or(20.0);
while support::now() - t0 < wait {
instance::invalidate_snapshot();
if instance::instances().is_empty() {
return json!({"success": true, "closedInSec": support::elapsed_round(t0), "agentTeardown": agent_teardown, "engineFreed": engine_freed});
}
let mut dl = dialogs::scan_scoped(true);
if !dl.is_empty() {
if force {
if !accept {
return strand_refusal(json!({"dialogs": dl}));
}
let killed = kill_instance_pids();
return json!({"success": true, "forced": true, "killedPids": killed,
"dismissedDialogs": dl.iter().map(|d| d.get("title").cloned().unwrap_or(Value::Null)).collect::<Vec<_>>(),
"_seatWarning": format!("The licence seat is now STRANDED. {}", support::SEAT_STRANDED_FIX),
"_hint": "force:true kills even when a dialog blocks the polite close (e.g. the 'script stopped on a breakpoint' Error that refuses WM_CLOSE)."});
}
// Altium's save prompts. discardChanges:true = "every modified document is mine": press
// Save None, then OK on the confirmation, and keep waiting for the exit. Without it, say
// exactly what is unsaved instead of timing out.
let lone_error = dl.iter().find(|d| d.get("title").and_then(|t| t.as_str()).map(|t| t.trim().eq_ignore_ascii_case("error")).unwrap_or(false)
&& d.get("buttons").and_then(|b| b.as_array()).map(|a| a.len() == 1).unwrap_or(false)).cloned();
if let Some(d) = lone_error {
if breakpoint_retries < 2 {
breakpoint_retries += 1;
if let Some(hw) = d.get("hwnd").and_then(|h| h.as_i64()) { let _ = dialogs::press(hw, "OK"); }
engine_freed = lifecycle::recover_without_restart("altium_close: Altium refused to close over a parked script; freed it and asked again");
std::thread::sleep(Duration::from_millis(1200));
send_quit();
continue;
}
}
let is_save = |d: &Value| d.get("category").and_then(|c| c.as_str()) == Some("save_prompt");
if dl.iter().any(is_save) {
if args.get("discardChanges").and_then(|b| b.as_bool()) == Some(true) {
for d in dl.iter().filter(|d| is_save(d)) {
if let Some(hw) = d.get("hwnd").and_then(|h| h.as_i64()) {
let title = d.get("title").and_then(|t| t.as_str()).unwrap_or("").to_lowercase();
// "Save None" does NOT dismiss this dialog: it unticks every document
// and RENAMES the same window to "Confirm Not Saving (N) Modified
// Documents", which OK then commits (measured 2026-09-21, AD 26.10.1).
// Leaving the second press to the next poll leaves Altium sitting
// modal — and a modal Altium disables its own main window, which is
// the orphan state above. So finish the exchange here.
let button = if title.contains("not saving") { "OK" } else { "Save None" };
let r = dialogs::press(hw, button);
dialogs::note_event(json!({"title": d.get("title"), "category": "save_prompt_discarded", "pressed": button,
"ok": r.get("success"), "note": "altium_close {discardChanges:true}: the caller declared the modified documents its own"}));
if button == "Save None" {
let t1 = std::time::Instant::now();
while t1.elapsed() < Duration::from_secs(5) {
std::thread::sleep(Duration::from_millis(300));
let still = dialogs::scan_scoped(true);
let confirm = still.iter().find(|x| x.get("hwnd").and_then(|h| h.as_i64()) == Some(hw)
&& x.get("title").and_then(|t| t.as_str()).map(|t| t.to_lowercase().contains("not saving")).unwrap_or(false));
match confirm {
Some(_) => {
let r2 = dialogs::press(hw, "OK");
dialogs::note_event(json!({"title": "Confirm Not Saving", "category": "save_prompt_discarded",
"pressed": "OK", "ok": r2.get("success"),
"note": "the second half of the Save None exchange, pressed in the same pass"}));
break;
}
None => { if still.iter().all(|x| x.get("hwnd").and_then(|h| h.as_i64()) != Some(hw)) { break; } }
}
}
}
}
}
std::thread::sleep(Duration::from_millis(1500));
continue;
}
crate::shot::attach_dialog_shots(&mut dl);
return json!({"success": false, "errorCode": "unsaved_changes", "dialogs": dl,
"_hint": "Altium will not close: documents are modified. dialogs[].shot NAMES them: read it. If EVERY one is yours (a scratch board, a test library, the agent script), call altium_close {discardChanges:true}: the bridge presses Save None and confirms. If ANY belongs to the user or another thread, altium_save it first or ask. Never force-kill over unsaved work."});
}
crate::shot::attach_dialog_shots(&mut dl);
return json!({"success": false, "errorCode": "close_blocked_by_dialog", "dialogs": dl,
"_hint": "Altium asked something on close (save?). Read dialogs[] and answer via altium_dismiss_dialogs {hwnd,button}, or pass force:true (with acceptSeatLoss:true — a kill strands the seat)."});
}
// Nothing to answer, yet Altium is disabled: it is inside a modal loop whose dialog is
// gone. It will never honour the quit, so stop waiting out the clock.
if dl.is_empty() {
if let Some(orphan) = lifecycle::modal_orphan() {
if args.get("discardChanges").and_then(|b| b.as_bool()) == Some(true) || force {
let r = lifecycle::graceful_terminate(&orphan, Duration::from_secs(20));
if r.get("success").and_then(|b| b.as_bool()) == Some(true) {
return json!({"success": true, "closedInSec": support::elapsed_round(t0),
"agentTeardown": agent_teardown, "engineFreed": engine_freed,
"stuckModalOrphan": orphan, "endedBy": r,
"_hint": "Altium was stuck in a modal loop with no dialog left to answer (main window DISABLED). The bridge ended it with a posted WM_QUIT — Altium's own shutdown path, so the licence seat was RELEASED, not stranded."});
}
return json!({"success": false, "errorCode": "modal_orphan_survived_quit",
"stuckModalOrphan": orphan, "endedBy": r,
"_hint": "Altium is wedged in a modal loop and did not leave it even on a posted WM_QUIT. Screenshot the desktop, then force:true + acceptSeatLoss:true is the only route left — it kills and STRANDS the seat, so say so to the user first."});
}
return json!({"success": false, "errorCode": "altium_modal_orphan", "stuckModalOrphan": orphan,
"_hint": "Altium is stuck inside a modal loop whose dialog is gone (main window DISABLED, nothing on screen to answer). It cannot be reached by menu, keystroke or -R, and it is holding the licence seat. If every open document is yours, altium_close {discardChanges:true} ends it gracefully with a posted WM_QUIT and RELEASES the seat. If a document may be the user's, its unsaved content cannot be recovered from this state — ask first."});
}
}
std::thread::sleep(Duration::from_millis(500));
}
if force && accept {
let killed = kill_instance_pids();
return json!({"success": true, "forced": true, "killedPids": killed,
"_seatWarning": format!("The licence seat is now STRANDED. {}", support::SEAT_STRANDED_FIX)});
}
json!({"success": false, "errorCode": "close_timeout",
"_hint": "still running — a modal may be blocking WM_CLOSE (altium_check_dialogs); force:true + acceptSeatLoss:true taskkills and strands the seat"})
}
// ---- the process-launcher catalog (list_commands) ----------------------------
//
// Every menu/toolbar action in Altium is a "process launcher" line in a System/*.rcs table:
// PL PLPCB:Undo Command='PCB:Undo' Caption='&Undo' Shortcut1='Ctrl+Z' ... End
// AD 26.9.1 ships 47 .rcs files -> 3,205 launchers -> 1,109 distinct commands.
static RCS_CACHE: std::sync::Mutex<Option<(std::time::Instant, String, Value)>> = std::sync::Mutex::new(None);
/// Parse `Key='value'` attributes ('' = escaped quote) off a launcher line.
fn parse_attrs(rest: &str) -> Vec<(String, String)> {
let bytes = rest.as_bytes();
let mut out = Vec::new();
let mut i = 0;
while i < bytes.len() {
// find identifier start
while i < bytes.len() && !bytes[i].is_ascii_alphanumeric() && bytes[i] != b'_' {
i += 1;
}
let ks = i;
while i < bytes.len() && (bytes[i].is_ascii_alphanumeric() || bytes[i] == b'_') {
i += 1;
}
if i >= bytes.len() || ks == i {
break;
}
if bytes[i] != b'=' || i + 1 >= bytes.len() || bytes[i + 1] != b'\'' {
continue;
}
let key = rest[ks..i].to_string();
i += 2; // past ='
let vs = i;
loop {
if i >= bytes.len() {
return out; // unterminated — drop the tail
}
if bytes[i] == b'\'' {
if i + 1 < bytes.len() && bytes[i + 1] == b'\'' {
i += 2; // escaped ''
continue;
}
break;
}
i += 1;
}
out.push((key, rest[vs..i].replace("''", "'")));
i += 1;
}
out
}
fn parse_rcs_dir(system_dir: &str) -> Value {
if let Ok(g) = RCS_CACHE.lock() {
if let Some((t, dir, v)) = g.as_ref() {
if dir == system_dir && t.elapsed() < Duration::from_secs(3600) {
return v.clone();
}
}
}
let mut commands: std::collections::BTreeMap<String, Value> = std::collections::BTreeMap::new();
let mut files = 0;
let mut launchers = 0;
if let Ok(rd) = std::fs::read_dir(system_dir) {
let mut paths: Vec<_> = rd.flatten().map(|e| e.path())
.filter(|p| p.extension().map(|e| e.to_string_lossy().to_lowercase() == "rcs").unwrap_or(false))
.collect();
paths.sort();
for rcs in paths {
files += 1;
let Ok(bytes) = std::fs::read(&rcs) else { continue };
let text: String = bytes.iter().map(|b| *b as char).collect(); // latin-1
let fname = rcs.file_name().map(|n| n.to_string_lossy().to_string()).unwrap_or_default();
for line in text.lines() {
let t = line.trim_start();
if t.starts_with("//") || !t.starts_with("PL") {
continue;
}
let Some(rest) = t.trim_end().strip_suffix("End") else { continue };
// PL<word> <id> <attrs>
let mut it = rest.splitn(3, char::is_whitespace).filter(|q| !q.is_empty());
let (Some(_pl), Some(_id), Some(attrs_str)) = (it.next(), it.next(), it.next()) else { continue };
launchers += 1;
let attrs = parse_attrs(attrs_str);
let cmd = attrs.iter().find(|(k, _)| k == "Command").map(|(_, v)| v.clone()).unwrap_or_default();
if cmd.is_empty() {
continue;
}
let server = cmd.split(':').next().unwrap_or("").to_string();
let e = commands.entry(cmd.clone()).or_insert_with(|| json!({
"command": cmd, "server": server, "captions": [], "shortcuts": [],
"params": [], "descriptions": [], "sources": []}));
let push_unique = |e: &mut Value, list: &str, v: String| {
if v.is_empty() {
return;
}
let arr = e[list].as_array_mut().unwrap();
if !arr.iter().any(|x| x.as_str() == Some(v.as_str())) {
arr.push(json!(v));
}
};
for (k, list) in [("Caption", "captions"), ("Description", "descriptions"), ("Params", "params")] {
let mut v = attrs.iter().find(|(a, _)| a == k).map(|(_, v)| v.clone()).unwrap_or_default();
if k == "Caption" {
v = v.replace('&', "");
}
push_unique(e, list, v);
}
for k in ["Shortcut1", "Shortcut2"] {
let v = attrs.iter().find(|(a, _)| a == k).map(|(_, v)| v.clone()).unwrap_or_default();
push_unique(e, "shortcuts", v);
}
push_unique(e, "sources", fname.clone());
}
}
}
let mut servers: std::collections::BTreeMap<String, u64> = std::collections::BTreeMap::new();
for c in commands.values() {
*servers.entry(c.get("server").and_then(|st| st.as_str()).unwrap_or("").to_string()).or_default() += 1;
}
let data = json!({"systemDir": system_dir, "files": files, "launchers": launchers,
"distinctCommands": commands.len(), "servers": servers,
"commands": commands.into_iter().collect::<serde_json::Map<_, _>>()});
if let Ok(mut g) = RCS_CACHE.lock() {
*g = Some((std::time::Instant::now(), system_dir.to_string(), data.clone()));
}
data
}
pub fn list_commands(args: &Value) -> Value {
let inst = match support::first_install() {
Some(i) => i,
None => return support::not_installed(),
};
let system_dir = inst.get("system_dir").and_then(|d| d.as_str()).unwrap_or("").to_string();
let data = parse_rcs_dir(&system_dir);
let q = s_or(args, "query", "").to_lowercase();
let server = s(args, "server");
let limit = args.get("limit").and_then(|l| l.as_u64()).unwrap_or(50) as usize;
let mut hits = Vec::new();
if let Some(cmds) = data.get("commands").and_then(|c| c.as_object()) {
for (cmd, e) in cmds {
if let Some(srv) = &server {
if e.get("server").and_then(|st| st.as_str()).map(|x| !x.eq_ignore_ascii_case(srv)).unwrap_or(true) {
continue;
}
}
if !q.is_empty() {
let hay = format!("{cmd} {} {} {}",
e["captions"].to_string(), e["descriptions"].to_string(), e["params"].to_string()).to_lowercase();
if !hay.contains(&q) {
continue;
}
}
let mut hit = serde_json::Map::new();
for k in ["command", "server", "captions", "shortcuts", "params", "descriptions"] {
hit.insert(k.into(), e.get(k).cloned().unwrap_or(Value::Null));
}
hits.push(Value::Object(hit));
if hits.len() >= limit {
break;
}
}
}
json!({"success": true, "query": q, "server": server, "count": hits.len(),
"total": data.get("distinctCommands"), "servers": data.get("servers"), "hits": hits,
"_hint": "Run one with altium_run_command {command:'PCB:Zoom', params:'Action=All'} (goes through the agent). Params are Name=Value|Name=Value."})
}
#[cfg(test)]
mod tests {
use super::parse_attrs;
#[test]
fn rcs_attr_parser_handles_escapes() {
let attrs = parse_attrs("Command='PCB:Undo' Caption='&Undo' Shortcut1='Ctrl+Z' Description='It''s undo' Params=''");
let get = |k: &str| attrs.iter().find(|(a, _)| a == k).map(|(_, v)| v.clone());
assert_eq!(get("Command").as_deref(), Some("PCB:Undo"));
assert_eq!(get("Caption").as_deref(), Some("&Undo"));
assert_eq!(get("Description").as_deref(), Some("It's undo"));
assert_eq!(get("Params").as_deref(), Some(""));
}
}