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.
← Commit history
0.2.34 source
12 files changed
+75−11
bridge-rs/Cargo.lock+1−1@@ -10,7 +10,7 @@ checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa" [[package]] name = "altium-bridge"-version = "0.2.33"+version = "0.2.34" dependencies = [ "getrandom", "serde",
bridge-rs/Cargo.toml+1−1@@ -1,6 +1,6 @@ [package] name = "altium-bridge"-version = "0.2.33"+version = "0.2.34" edition = "2021" description = "Adom altium-bridge — the bridge process, in Rust. Calls ab's verbs for the desktop; speaks file IPC to the resident DelphiScript agent inside Altium." license = "MIT"
bridge-rs/src/dialogs.rs+15−1@@ -276,6 +276,12 @@ static REAPER_RUNNING: AtomicBool = AtomicBool::new(false); /// refuses to close, and the caller used to see nothing but a timeout (found on AD 26.9.1, /// 2026-09-20). agent::call watches this so the halt is reported and cleared, never silent. static LAST_SCRIPT_ERROR: Mutex<Option<Value>> = Mutex::new(None);+/// Pictures of the last 8 boxes the reaper pressed (newest last): `altium_dialog_log {shots:true}`.+static REAPED_SHOTS: Mutex<Vec<Value>> = Mutex::new(Vec::new());++pub fn reaped_shots() -> Vec<Value> {+ REAPED_SHOTS.lock().map(|g| g.clone()).unwrap_or_default()+} fn now_ms() -> u64 { std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).map(|x| x.as_millis() as u64).unwrap_or(0)@@ -346,13 +352,21 @@ pub fn reap_once() -> usize { // photograph it BEFORE pressing OK: the picture is the only copy of the error text. let title_str = d.get("title").and_then(|t| t.as_str()).unwrap_or("").to_string(); let is_script_error = cat == "single_ok_acknowledgement" && title_str.trim().eq_ignore_ascii_case("error");+ // Altium PAINTS its message text, so the picture taken here is the ONLY copy of what the box+ // said. Until 0.2.34 only "Error" boxes were photographed: an "Information" box that explained+ // why Altium refused to quit was pressed away five times in a row, unread (2026-09-21). let mut err_shot = Value::Null;- if is_script_error {+ { let r = ab::call("desktop_screenshot_base64", json!({"hwnd": hwnd}), &[], 10); if let Some(img) = r.get("image").or_else(|| r.get("base64")).and_then(|i| i.as_str()) { err_shot = json!(img); } }+ if let Ok(mut g) = REAPED_SHOTS.lock() {+ g.push(json!({"atMs": now_ms(), "title": title_str, "shot": err_shot.clone()}));+ let len = g.len();+ if len > 8 { g.drain(..len - 8); }+ } let ok = press(hwnd, &act).get("success").and_then(|b| b.as_bool()).unwrap_or(false); if is_script_error { if let Ok(mut g) = LAST_SCRIPT_ERROR.lock() {
bridge-rs/src/license.rs+6@@ -66,6 +66,12 @@ pub fn license(args: &Value) -> Value { } let hwnd = lic.get("hwnd").and_then(|h| h.as_i64()).unwrap_or(0); let action = s_or(args, "action", "show");+ // An unknown action used to do nothing and answer success:true (found 2026-09-21).+ if !matches!(action.as_str(), "show" | "refresh" | "use_license" | "start_trial" | "sign_out") {+ return json!({"success": false, "errorCode": "unknown_action", "action": action,+ "error": format!("unknown licence action '{action}'"),+ "_hint": "action is one of: show (default, returns the page screenshot), refresh, use_license, start_trial, sign_out. altium_ensure_licensed does the usual sequence in one call; altium_install {stage:'license'} does it after a fresh install."});+ } if agent::is_alive() { agent::call_no_autostart("run_process", &[("process", "Client:LicenseSubscriptionNotification".into()), ("params", "".into())], Duration::from_secs(15)); // bring the License Management view up via its process launcher (background)
bridge-rs/src/main.rs+10−1@@ -26,7 +26,10 @@ mod win32; use std::time::Instant; use tiny_http::{Header, Method, Response, Server}; -pub const BRIDGE_VERSION: &str = "0.2.33";+/// Attached to every failed reply (see the /command handler).+const REPORT_ISSUES: &str = "If this looks like an altium-bridge BUG (not a refusal you expected), report it: check for a duplicate with `adom-wiki issue list adom/altium-bridge`, then `adom-wiki issue create adom/altium-bridge --title \"<verb>: <one-line symptom>\" --category bug-report --body \"<what you ran, what you expected, what happened, the VERBATIM response JSON (and errorShot if present), bridgeVersion, the Altium version from altium_status, the OS>\"`. A relay, routing, toast or lifecycle problem belongs on adom/adom-bridge instead. Reports from real machines are how this bridge gets correct: three different Altium builds behaved three different ways in one weekend.";++pub const BRIDGE_VERSION: &str = "0.2.34"; fn json_response(status: u16, body: &serde_json::Value) -> Response<std::io::Cursor<Vec<u8>>> { let data = serde_json::to_vec(body).unwrap_or_else(|_| b"{}".to_vec());@@ -83,6 +86,12 @@ fn main() { let vargs = v.get("args").cloned().unwrap_or_else(|| serde_json::json!({})); let mut out = verbs::handle(&command, &vargs, &forwarded); if let Some(o) = out.as_object_mut() {+ // Bridge SDK: invite the bug report IN THE OUTPUT, on every failure. The AI driving+ // this bridge is standing on the one machine where the failure happened and holds+ // the only copy of the evidence. Never overwrites a field a handler already set.+ if o.get("success").and_then(|b| b.as_bool()) == Some(false) && !o.contains_key("_reportIssues") {+ o.insert("_reportIssues".into(), serde_json::json!(REPORT_ISSUES));+ } o.insert("durationMs".into(), serde_json::json!(t0.elapsed().as_millis() as u64)); o.entry("bridgeVersion").or_insert(serde_json::json!(BRIDGE_VERSION)); }
bridge-rs/src/misc.rs+6−2@@ -115,7 +115,9 @@ pub fn instances_verb(_args: &Value) -> Value { /// 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;- json!({"success": true, "reaped": dialogs::reaped_log(limit), "running": true,+ 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."}) } @@ -170,7 +172,9 @@ pub fn progress_verb(_args: &Value) -> Value { pub fn uninstall_verb(args: &Value) -> Value { let mut removed: Vec<String> = Vec::new();- if agent::is_alive() {+ 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();
bridge-rs/src/support.rs+4@@ -193,6 +193,10 @@ fn workspace_unavailable_seen() -> bool { }) } +pub fn workspace_unavailable_seen_pub() -> bool {+ workspace_unavailable_seen()+}+ fn is_project_path(path: &str) -> bool { let l = path.to_lowercase(); [".prjpcb", ".prjscr", ".dsnwrk", ".libpkg", ".prjfpg", ".prjemb", ".prjmbd"].iter().any(|e| l.ends_with(e))
bridge-rs/src/surfaces.rs+3−1@@ -15,6 +15,7 @@ pub fn open_then(path: &str, commands: &[(&str, &str)], timeout: Duration, label let t0 = support::now(); let mut steps: Vec<Value> = Vec::new(); let r = agent::call("open", &[("path", path.to_string())], None, timeout);+ let r = support::open_verdict(path, r, std::path::Path::new(path).is_file(), support::workspace_unavailable_seen_pub()); steps.push(json!({"open": path, "result": r.get("data"), "ms": r.get("roundtripMs")})); if r.get("success").and_then(|b| b.as_bool()) != Some(true) { let mut out = r;@@ -211,7 +212,8 @@ pub fn activate_document(args: &Value) -> Value { return bad; } let wait = timeout_of(args, 60).as_secs_f64().min(support::OPEN_BUDGET_SEC);- let mut r = agent::call("open", &[("path", path.clone())], None, Duration::from_secs_f64(wait));+ let r0 = agent::call("open", &[("path", path.clone())], None, Duration::from_secs_f64(wait));+ let mut r = support::open_verdict(&path, r0, std::path::Path::new(&path).is_file(), support::workspace_unavailable_seen_pub()); if r.get("errorCode").and_then(|c| c.as_str()) == Some("agent_timeout") { return json!({"success": true, "status": "in_progress", "path": path, "lane": "agent", "windowEtiquette": "background", "waitedSec": wait,
bridge-rs/src/verbs.rs+1−1@@ -168,7 +168,7 @@ fn describe() -> Value { verb_doc("license", "Drive the License Management page (UIA first - no cursor, no foreground).", json!({"action": "show|refresh|use_license|start_trial|sign_out"}), shot.clone(), 60, "One On-Demand seat serves EVERY Altium app on this machine. use_license claims a free seat.", &["ensure_licensed", "license_status", "close"], &["NEVER kill an Altium holding the seat: the lease strands, the page shows Seats 0/1 while every claim fails. altium_close releases it."], json!({"action": "use_license"})),- verb_doc("close", "Polite close (releases the licence seat); force needs acceptSeatLoss.", json!({"force": "bool", "acceptSeatLoss": "bool"}), json!({"closedInSec": "float"}), 30,+ verb_doc("close", "Polite close (releases the licence seat). Frees a script engine parked on a breakpoint first (Altium refuses to close over one). discardChanges:true answers Altium's save prompts with Save None for callers whose modified documents are ALL their own; force needs acceptSeatLoss.", json!({"discardChanges": "bool, default false: press Save None + confirm on 'Confirm Save Locally'", "waitSec": "float, default 20", "force": "bool", "acceptSeatLoss": "bool", "instance": "pid, when several Altiums run"}), json!({"closedInSec": "float", "engineFreed": "object|null", "errorCode": "unsaved_changes | close_blocked_by_dialog | close_timeout"}), 120, "A save prompt on close NAMES the documents - read it; on a shared machine one may belong to another AI thread. Never defaulted.", &["license", "launch"], &["force:true without acceptSeatLoss is refused: a kill strands the On-Demand seat."], json!({})), verb_doc_long("install_library_bundle", "One-click install of a wiki library bundle (zip of .SchLib/.PcbLib): unpack, register, open the library, prove it with a frame.",
bridge.json+1−1@@ -2,7 +2,7 @@ "manifest_version": 1, "name": "altium", "displayName": "Altium Designer",- "version": "0.2.33",+ "version": "0.2.34", "description": "Drive Altium Designer from Adom: a resident DelphiScript agent (eval + the 1,100-command process launcher), the X2 -R command line, UIA over the VCL shell, dialogs by PID, screenshots of every Altium window, and Altium 365.", "homepage": "https://wiki.adom.inc/adom/altium-bridge", "docs": "https://wiki.adom.inc/adom/altium-bridge",
page.json+1−1@@ -140,4 +140,4 @@ "skills/altium-wiki-import/SKILL.md", "uninstall.sh" ]-}+}
src/agent/AdomAgent.pas.tmpl+26−1@@ -3477,6 +3477,16 @@ Begin Result := '{' + JRaw('ok','false') + ',' + JStr('error','component not found: ' + Param(Lines, 'from')) + '}'; Exit; End;+ If Trim(Param(Lines, 'to')) = '' Then+ Begin+ Result := '{' + JRaw('ok','false') + ',' + JStr('errorCode','name_required') + ',' + JStr('error','to (the new name) is required') + '}';+ Exit;+ End;+ If FindSchComp(SLib, Param(Lines, 'to')) <> Nil Then+ Begin+ Result := '{' + JRaw('ok','false') + ',' + JStr('errorCode','name_taken') + ',' + JStr('error','a component named ' + Param(Lines, 'to') + ' already exists in this library') + '}';+ Exit;+ End; C.LibReference := Param(Lines, 'to'); SLib.GraphicallyInvalidate; Result := '{' + JRaw('ok','true') + ',' + JStr('renamed', Param(Lines, 'to')) + '}';@@ -3604,6 +3614,16 @@ Begin Result := '{' + JRaw('ok','false') + ',' + JStr('error','footprint not found: ' + Param(Lines, 'from')) + '}'; Exit; End;+ If Trim(Param(Lines, 'to')) = '' Then+ Begin+ Result := '{' + JRaw('ok','false') + ',' + JStr('errorCode','name_required') + ',' + JStr('error','to (the new name) is required') + '}';+ Exit;+ End;+ If FindFp(PLib, Param(Lines, 'to')) <> Nil Then+ Begin+ Result := '{' + JRaw('ok','false') + ',' + JStr('errorCode','name_taken') + ',' + JStr('error','a footprint named ' + Param(Lines, 'to') + ' already exists in this library') + '}';+ Exit;+ End; F.Name := Param(Lines, 'to'); PLib.Board.ViewManager_FullUpdate; Result := '{' + JRaw('ok','true') + ',' + JStr('renamed', Param(Lines, 'to')) + '}';@@ -4255,16 +4275,21 @@ Var Board : IPCB_Board; L : Integer; Show : Boolean;+ LName : String; Begin Board := PCBServer.GetCurrentPCBBoard; If Board = Nil Then Begin Result := '{' + JRaw('ok','false') + ',' + JStr('error','no current PCB board (open a .PcbDoc first)') + '}'; Exit; End; L := StrToIntDef(Param(Lines, 'layerId'), -1); If L < 0 Then Begin Result := '{' + JRaw('ok','false') + ',' + JStr('error','layerId (from altium_layers) is required') + '}'; Exit; End; Show := Param(Lines, 'displayed') <> '0';+ { an id that is not in THIS board's stack has a nil LayerObject: reading .Name off it halts the+ engine. Refuse by name instead (found by review, 2026-09-21). }+ If Board.LayerStack.LayerObject[L] = Nil Then Begin Result := '{' + JRaw('ok','false') + ',' + JStr('errorCode','unknown_layer') + ',' + JStr('error','layerId ' + IntToStr(L) + ' is not in this board; altium_layers lists the valid ids') + '}'; Exit; End;+ LName := Board.LayerStack.LayerObject[L].Name; Board.LayerIsDisplayed[L] := Show; Board.ViewManager_UpdateLayerTabs; Board.ViewManager_FullUpdate;- Result := '{' + JRaw('ok','true') + ',' + JRaw('layerId', IntToStr(L)) + ',' + JStr('name', Board.LayerStack.LayerObject[L].Name) + ',' + JRaw('displayed', JBool(Board.LayerIsDisplayed[L])) + '}';+ Result := '{' + JRaw('ok','true') + ',' + JRaw('layerId', IntToStr(L)) + ',' + JStr('name', LName) + ',' + JRaw('displayed', JBool(Board.LayerIsDisplayed[L])) + '}'; End; { create_net_class: name=<class> nets=<A;B;C> }