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
John Lauer
0.2.28 source: one thread per request, named agent_busy; battery aborts on a blocked bridge
c4d9eb4
1mo ago
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300
//! File-drop IPC to the resident DelphiScript agent (protocol identical to the Python bridge):
//! request.txt lines `id=<rid>`, `command=<cmd>`, `k=v`... (CRLF), written atomically via .tmp+rename
//! request.payload optional multi-line body (statements for eval, specs, ...)
//! response_<rid>.json the agent's reply; `data.ok` decides success
//! hb.txt heartbeat the loop refreshes every tick
//!
//! The workspace is PER INSTANCE (`agent/<key>/`): two Altiums polling one inbox means whichever
//! notices first eats the other's request and answers it from the wrong board (measured
//! 2026-09-01 running Designer + Designer Develop for two AI threads). No selected instance =
//! the legacy single-instance folder, so one-Altium users see no change.
use crate::instance;
use serde_json::{json, Value};
use std::path::PathBuf;
use std::time::{Duration, Instant, SystemTime};
/// Two different questions, two thresholds (measured 2026-08-29):
/// responsive = hb < 3 s (idle-polling right now); alive = hb < 60 s (the loop exists but may be
/// INSIDE a long RunProcess and cannot heartbeat until it returns). Treating "busy" as "dead" and
/// starting a second loop raises "Another script executing now" on the user's screen.
pub const ALIVE_SEC: u64 = 60;
pub const RESPONSIVE_SEC: u64 = 3;
pub fn cache_dir() -> PathBuf {
let local = std::env::var("LOCALAPPDATA").unwrap_or_else(|_| r"C:\Users\Public".into());
PathBuf::from(local).join("Adom Bridge").join("bridges-cache").join("altium")
}
fn agent_root() -> PathBuf {
cache_dir().join("agent")
}
pub fn workspace_dir() -> PathBuf {
let root = agent_root();
let d = match instance::selected().and_then(|i| i.get("key").and_then(|k| k.as_str()).map(str::to_string)) {
Some(key) => root.join(key),
None => root,
};
let _ = std::fs::create_dir_all(&d);
d
}
fn hb_age(ws: &std::path::Path) -> Option<Duration> {
std::fs::metadata(ws.join("hb.txt"))
.and_then(|m| m.modified())
.ok()
.and_then(|t| SystemTime::now().duration_since(t).ok())
}
pub fn is_alive() -> bool {
hb_age(&workspace_dir()).map(|d| d < Duration::from_secs(ALIVE_SEC)).unwrap_or(false)
}
pub fn is_responsive() -> bool {
hb_age(&workspace_dir()).map(|d| d < Duration::from_secs(RESPONSIVE_SEC)).unwrap_or(false)
}
/// Heartbeat parsed: first line = tick, then k=v (gen=, phase counters...).
pub fn heartbeat() -> Option<Value> {
let ws = workspace_dir();
let raw = std::fs::read_to_string(ws.join("hb.txt")).ok()?;
let age = hb_age(&ws)?;
let mut out = json!({"ageSec": (age.as_millis() as f64) / 1000.0});
let mut lines = raw.lines();
if let Some(first) = lines.next() {
if let Ok(t) = first.trim().parse::<u64>() {
out["tick"] = json!(t);
}
}
for l in lines {
if let Some((k, v)) = l.split_once('=') {
out[k.trim()] = json!(v.trim());
}
}
Some(out)
}
/// Is ANY agent loop alive in ANY workspace (legacy root or any per-instance subfolder)?
/// A `-R` script lane must refuse if so: Altium raises "Another script executing now" for the
/// second script REGARDLESS of which workspace the first one polls. Returns the workspace path
/// of the live loop so the refusal can name it.
pub fn any_agent_alive() -> Option<(PathBuf, f64)> {
live_workspaces().into_iter().next()
}
/// EVERY workspace with a live heartbeat — the legacy root AND every per-instance subdir —
/// each with its heartbeat age. The freeze in #6 happened because a loop bootstrapped into the
/// legacy root (no instance was resolved at the time) while `is_alive()` — which reads only the
/// SELECTED workspace — reported "not resident", so `altium_close` skipped its agent teardown and
/// the polite WM_CLOSE went out over a live loop. Teardown and status must scan the UNION, never
/// one workspace: a loop anywhere is a loop that can wedge the shutdown.
pub fn live_workspaces() -> Vec<(PathBuf, f64)> {
let root = agent_root();
let mut candidates = vec![root.clone()];
if let Ok(rd) = std::fs::read_dir(&root) {
for e in rd.flatten() {
if e.path().is_dir() {
candidates.push(e.path());
}
}
}
let mut live = Vec::new();
for ws in candidates {
if let Some(age) = hb_age(&ws) {
if age < Duration::from_secs(ALIVE_SEC) {
live.push((ws, (age.as_millis() as f64) / 1000.0));
}
}
}
live
}
fn rid() -> String {
let n = SystemTime::now().duration_since(SystemTime::UNIX_EPOCH).map(|d| d.as_nanos()).unwrap_or(0);
format!("{:x}", n & 0xffff_ffff_ffff)
}
pub fn call(command: &str, params: &[(&str, String)], payload: Option<&str>, timeout: Duration) -> Value {
if !is_alive() {
// A dead agent with Altium still showing the agent's script is a script PARKED ON A
// BREAKPOINT: Altium's debugger breaks on any raised exception, even one the agent's own
// Try/Except handled, and Altium Develop does it with no error box. The command that raised
// already answered, so nobody noticed; the NEXT verb (this one) would now start a second
// script into a parked engine and block until ab's HTTP timeout, and so would every verb after
// it (the 30-verb cascade the battery found, 2026-09-20). Free the engine first.
let mut freed = Value::Null;
if crate::health::is_running() && is_agent_script_title(&main_title()) {
freed = crate::lifecycle::recover(&format!("auto: agent dead and the engine parked on the agent script before '{command}'"));
crate::dialogs::note_event(json!({"title": "(silent script halt)", "category": "engine_parked_cleared", "pressed": "EditScript:Stop", "ok": true,
"note": format!("the agent had stopped on a breakpoint without an error box; cleared before running '{command}'")}));
}
// parity with the Python bridge: bootstrap on demand, so a cold box needs no ceremony
let started = crate::lifecycle::start(Duration::from_secs(45));
if !freed.is_null() {
let mut r = if started.get("success").and_then(|b| b.as_bool()) == Some(true) {
call_in(&workspace_dir(), command, params, payload, timeout)
} else { started.clone() };
r["enginePreviouslyParked"] = json!(true);
r["_parkedNote"] = json!("Before this verb the agent was found stopped on a script breakpoint with no error box (an earlier command raised an exception Altium's debugger broke on). The bridge cleared it and restarted the agent. altium_dialog_log shows when.");
if started.get("success").and_then(|b| b.as_bool()) == Some(true) { return r; }
}
if started.get("success").and_then(|b| b.as_bool()) != Some(true) {
let mut out = started;
if let Some(o) = out.as_object_mut() {
o.entry("errorCode").or_insert(json!("agent_not_running"));
o.insert("_hint".into(), json!("the resident agent could not be bootstrapped — see this reply's fields; altium_agent_recover if the engine is halted behind a modal"));
}
return out;
}
}
call_in(&workspace_dir(), command, params, payload, timeout)
}
/// The file-drop round trip against an EXPLICIT workspace. Instance-exempt verbs (state,
/// diagnostics) have no selection, so `workspace_dir()` points at the legacy root and goes blind
/// to a per-instance loop (adom/altium-bridge#8); they resolve a live workspace via
/// `live_workspaces()` and call here directly.
/// The agent command currently posted (the agent serves one at a time): named in agent_busy.
static IN_FLIGHT: std::sync::Mutex<Option<(String, Instant)>> = std::sync::Mutex::new(None);
pub fn call_in(ws: &std::path::Path, command: &str, params: &[(&str, String)], payload: Option<&str>, timeout: Duration) -> Value {
let ws = ws.to_path_buf();
let id = rid();
let mut lines = vec![format!("id={id}"), format!("command={command}")];
for (k, v) in params {
if v.contains('\n') || v.contains('\r') {
return json!({"success": false, "error": format!("param {k} must be single-line; use payload")});
}
lines.push(format!("{k}={v}"));
}
let inbox = ws.join("request.txt");
let t0 = Instant::now();
while inbox.exists() && t0.elapsed() < timeout {
std::thread::sleep(Duration::from_millis(5));
}
let t_inbox = t0.elapsed().as_millis() as u64;
if inbox.exists() {
let holder = IN_FLIGHT.lock().ok().and_then(|g| g.clone());
let (what, secs) = holder.map(|(c, at)| (c, at.elapsed().as_secs())).unwrap_or_else(|| ("(unknown)".into(), 0));
return json!({"success": false, "errorCode": "agent_busy", "busyWith": what, "busyForSec": secs,
"error": format!("the Altium agent is still running '{what}' ({secs} s); '{command}' was not started"),
"_hint": "The agent runs ONE command at a time inside Altium. Wait and retry, or if busyForSec is far beyond what that command should take: altium_check_dialogs (a box may be blocking it), then altium_agent_recover."});
}
if let Ok(mut g) = IN_FLIGHT.lock() { *g = Some((command.to_string(), Instant::now())); }
if let Some(p) = payload {
let tmp = ws.join("request.payload.tmp");
if std::fs::write(&tmp, p).is_ok() {
let _ = std::fs::rename(&tmp, ws.join("request.payload"));
}
}
let tmp = ws.join("request.txt.tmp");
if let Err(e) = std::fs::write(&tmp, format!("{}\r\n", lines.join("\r\n"))) {
return json!({"success": false, "error": format!("cannot write request: {e}")});
}
if let Err(e) = std::fs::rename(&tmp, &inbox) {
return json!({"success": false, "error": format!("cannot post request: {e}")});
}
let t_post = t0.elapsed().as_millis() as u64;
let posted_unix_ms = SystemTime::now().duration_since(SystemTime::UNIX_EPOCH).map(|x| x.as_millis() as u64).unwrap_or(0);
let mut next_halt_check = Duration::from_millis(400);
let resp = ws.join(format!("response_{id}.json"));
let mut t_seen: Option<u64> = None;
let mut parse_retries = 0u32;
while t0.elapsed() < timeout {
if resp.exists() {
if t_seen.is_none() { t_seen = Some(t0.elapsed().as_millis() as u64); }
if let Ok(raw) = std::fs::read_to_string(&resp) {
if let Ok(mut v) = serde_json::from_str::<Value>(&raw) {
let _ = std::fs::remove_file(&resp);
let ok = v.get("data").and_then(|d| d.get("ok")).and_then(|b| b.as_bool()).unwrap_or(true);
if let Some(o) = v.as_object_mut() {
o.insert("success".into(), json!(ok));
o.insert("roundtripMs".into(), json!(t0.elapsed().as_millis() as u64));
o.insert("_timing".into(), json!({"inboxWaitMs": t_inbox, "postedAtMs": t_post, "responseSeenAtMs": t_seen, "parseRetries": parse_retries, "totalMs": t0.elapsed().as_millis() as u64}));
}
return v;
}
}
parse_retries += 1;
std::thread::sleep(Duration::from_millis(5));
continue;
}
// The engine halted on a script runtime error: the reaper pressed the Error box, the
// script now sits on a breakpoint and will NEVER answer. Do not burn the timeout and do
// not leave Altium parked in its debugger: stop the script now and say what happened.
if t0.elapsed() >= next_halt_check {
next_halt_check = t0.elapsed() + Duration::from_millis(400);
// Variant 2 (script run from INSIDE the agent, e.g. eval): Altium asks "Undeclared
// identifier ... Continue execution?" in an 'X2' box with OK + Cancel. It is OURS (it
// only exists while our command runs), the reaper rightly never touches a two-button
// box, and the agent loop is blocked behind it. OK = continue: the loop survives.
for d in crate::dialogs::scan() {
let title = d.get("title").and_then(|t| t.as_str()).unwrap_or("").trim().to_string();
let labels: Vec<String> = d.get("buttons").and_then(|b| b.as_array())
.map(|a| a.iter().filter_map(|x| x.as_str()).map(|s| s.trim().to_lowercase()).collect()).unwrap_or_default();
let hwnd = d.get("hwnd").and_then(|h| h.as_i64());
if title != "X2" || labels != ["ok", "cancel"] || hwnd.is_none() { continue; }
let hwnd = hwnd.unwrap();
let shot = crate::ab::call("desktop_screenshot_base64", json!({"hwnd": hwnd}), &[], 10);
let img = shot.get("image").or_else(|| shot.get("base64")).cloned().unwrap_or(Value::Null);
let pressed = crate::dialogs::press(hwnd, "OK").get("success").and_then(|b| b.as_bool()).unwrap_or(false);
crate::dialogs::note_event(json!({"title": "X2", "category": "script_error_continue", "pressed": "OK", "ok": pressed,
"note": format!("script runtime error inside agent command '{command}': photographed, answered OK (continue) so the agent loop survives")}));
let settle = Instant::now();
while settle.elapsed() < Duration::from_secs(3) && !resp.exists() { std::thread::sleep(Duration::from_millis(20)); }
let _ = std::fs::remove_file(&resp);
return json!({"success": false, "errorCode": "agent_script_error", "command": command,
"error": format!("the script run by '{command}' raised a runtime error (Altium asked 'Continue execution?'; its text is in errorShot)"),
"errorShot": img, "answered": "OK", "answeredOk": pressed, "agentAlive": is_alive(),
"_hint": "READ errorShot (PNG of Altium's box, e.g. 'Undeclared identifier: X at Line:N'). The bridge answered OK so the agent loop is still resident; fix the script text and send it again."});
}
if let Some(ev) = crate::dialogs::script_error_since(posted_unix_ms) {
let recovered = crate::lifecycle::recover(&format!("auto: script Error box during agent command '{command}'"));
return json!({"success": false, "errorCode": "agent_script_error", "command": command,
"error": format!("the agent script raised a runtime error while running '{command}' (Altium showed an 'Error' box; its text is in errorShot)"),
"errorShot": ev.get("shot").cloned().unwrap_or(Value::Null),
"autoRecovered": recovered.get("success").and_then(|b| b.as_bool()).unwrap_or(false),
"recoverSteps": recovered.get("steps").cloned().unwrap_or(Value::Null),
"agentAlive": is_alive(),
"_hint": "READ errorShot (a PNG of Altium's error box, e.g. 'Undeclared identifier: X' = that API does not exist on this Altium build). The bridge already sent EditScript:Stop, so Altium is NOT parked on a breakpoint and the next verb restarts the agent. Do not retry the same command: it will halt again. Report it on adom/altium-bridge with the errorShot."});
}
}
std::thread::sleep(Duration::from_millis(2));
}
json!({"success": false, "errorCode": "agent_timeout", "error": format!("agent did not answer within {}s", timeout.as_secs()),
"agentAlive": is_alive(), "_hint": "a slow Altium process, or the engine halted on a modal — altium_check_dialogs / altium_agent_recover"})
}
/// A call that must NOT bootstrap the agent (status probes). Answers agent_not_running instead.
pub fn call_no_autostart(command: &str, params: &[(&str, String)], timeout: Duration) -> Value {
if !is_alive() {
return json!({"success": false, "errorCode": "agent_not_running"});
}
call(command, params, None, timeout)
}
/// Title of Altium's main window ("" when there is none).
fn main_title() -> String {
instance::main_window().and_then(|w| w.get("title").and_then(|t| t.as_str()).map(str::to_string)).unwrap_or_default()
}
/// True when the focused document is the resident agent's own script: the state Altium puts its
/// window in when that script breaks in the debugger.
pub fn is_agent_script_title(title: &str) -> bool {
let t = title.to_lowercase();
t.starts_with("adomagent_") && (t.contains(".prjscr") || t.contains(".pas"))
}
#[cfg(test)]
mod halt_tests {
use super::*;
#[test]
fn agent_script_title_is_recognised() {
assert!(is_agent_script_title("AdomAgent_00fc18631e.PrjScr - Altium Designer Develop"));
assert!(is_agent_script_title("AdomAgent_9fe87fbf7c.pas - Altium Designer (26.9.1) - Trial"));
assert!(!is_agent_script_title("battery.PcbDoc - Altium Designer Develop"));
assert!(!is_agent_script_title("Home Page - Altium Designer Develop"));
assert!(!is_agent_script_title(""));
}
}