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
v0.1.18: instances - two Altiums, two AI threads, one bridge
12 files changed
+305−56
CHANGELOG.md+8@@ -1,5 +1,13 @@ # Changelog +## 0.1.18 — 2026-09-01+- **Instances.** More than one Altium can run at once — John runs Altium Designer Develop beside Altium Designer so two AI threads can drive both — and the bridge assumed one in every place it mattered. New verb `altium_instances` lists each running Altium with the pid/key to address it; every other verb takes an optional `instance` (a pid, a key like `ADDevelop`, or a word from the edition/title). One Altium running: omit it, nothing changes. Several: verbs REFUSE with `ambiguous_instance` rather than guess, because guessing means acting inside the other thread's Altium.+- **Each instance gets its own agent workspace** (`bridges-cache/altium/agent/<key>`), keyed by install folder so it survives a restart. Two resident loops sharing one `request.txt` is the failure that does not announce itself: whichever loop notices first eats the other's request and answers it from the wrong board.+- **The -R lane spawns the selected instance's own exe**, not a hardcoded AD26 path — Designer and Designer Develop live in different install folders.+- **Dialog scanning is scoped to the selected instance**, so one thread can no longer see, or `dismiss_dialogs` clear, the other thread's modal.+- **The stray-helper reaper can no longer kill a real Altium.** It now requires `-R…ScriptingSystem` in the process's own command line (ab reports it). The old rule — "any X2 that is not the main window's process" — killed John's Develop instance on 2026-09-01 while it was hosting another thread's agent.+- 97 verbs.+ ## 0.1.17 — 2026-09-01 - **New verb `altium_layers_in_use`: how many layers the board ACTUALLY has.** Until now the only layer answer was "48", which is every copper layer Altium CAN address, not a property of the board. Altium's in-stack flag has no identifier proven on AD26 and a wrong guess halts the engine behind a modal, so the agent's new `layer_occupancy` counts primitives by layer in ONE pass (using only identifiers already shipped in this unit) and the bridge filters that tally to the copper layers. Verified live: LAN7800_test reports **1 copper layer in use (Top Layer)**, 7 layers carrying primitives, against 48 addressable. - Read the result as a FLOOR: an inner plane carrying no primitives is invisible to a primitive count. The reply says so, and `copperLayersAddressable` is labelled as a capability rather than a board property.
adom-bridge-altium-manifest.json+4−4@@ -1,9 +1,9 @@ { "name": "altium",- "version": "0.1.17",- "url": "https://wiki.adom.inc/download/adom/altium-bridge/0.1.17/adom-bridge-altium-v0.1.17.zip",- "sha256": "b732f4be98cb86443f9665c0ad2329e59b8609296e35f79c68b705abedc5c758",- "size": 106316,+ "version": "0.1.18",+ "url": "https://wiki.adom.inc/download/adom/altium-bridge/0.1.18/adom-bridge-altium-v0.1.18.zip",+ "sha256": "c3cdd963c5b5a872ae83885b69971abc212d1e2115a0299cddb4bd8144a501ce",+ "size": 110023, "verbPrefixes": [ "altium_" ],
adom-bridge-altium-v0.1.18.zipadded⋯ 1 unchanged line ⋯
bridge-rs/src/dialogs.rs+20−7@@ -18,15 +18,28 @@ const DIALOG_CLASSES: [&str; 5] = ["TMessageForm", "#32770", "TForm", "TMessageD /// this bridge must never do, so: ask ab which pids are Altium (`process_list`), enumerate only /// those windows (`desktop_enum_windows {pids}`), and let class + "not the main window" decide. pub fn scan() -> Vec<Value> {- let pids: Vec<u64> = ab::call("process_list", json!({"nameContains": "X2"}), &[], 15)- .get("processes")- .and_then(|a| a.as_array())- .map(|a| a.iter().filter_map(|p| p.get("pid").and_then(|v| v.as_u64())).collect())- .unwrap_or_default();+ scan_checked().unwrap_or_default()+}++/// `Err(reason)` when we could not LOOK — which must never be reported to a user as "clean".+/// (2026-09-01: this called `process_list {nameContains}`, which ab rejects as bad_args. The error+/// yielded an empty pid list, the empty list yielded an empty dialog list, and `check_dialogs`+/// cheerfully answered "clean" while a Warning sat on John's screen. A failed look and an empty+/// screen must not share a return value.)+pub fn scan_checked() -> Result<Vec<Value>, String> {+ let pl = ab::call("process_list", json!({"nameFilter": "X2"}), &[], 15);+ if pl.get("success").and_then(|b| b.as_bool()) == Some(false) {+ return Err(format!("process_list failed: {}", pl.get("error").and_then(|e| e.as_str()).unwrap_or("unknown")));+ }+ 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(); if pids.is_empty() {- return Vec::new(); // Altium is not running — nothing of ours can be on screen+ return Ok(Vec::new()); // Altium genuinely is not running } let r = ab::call("desktop_enum_windows", json!({"pids": pids}), &[], 20);+ if r.get("success").and_then(|b| b.as_bool()) == Some(false) {+ return Err(format!("desktop_enum_windows failed: {}", r.get("error").and_then(|e| e.as_str()).unwrap_or("unknown")));+ } let wins = r.get("windows").and_then(|a| a.as_array()).cloned().unwrap_or_default(); let mut out = Vec::new(); for w in wins {@@ -47,7 +60,7 @@ pub fn scan() -> Vec<Value> { "pid": w.get("pid").cloned().unwrap_or(Value::Null), "text": dialog_text(hwnd), "buttons": buttons(hwnd)})); }- out+ Ok(out) } /// Windows that merely CONTAIN the app name are the app itself, never a modal.
bridge-rs/src/lifecycle.rs+61−29@@ -126,7 +126,7 @@ fn x2_exe() -> Option<String> { } } // last resort: ask a running instance- let pl = ab::call("process_list", json!({"nameContains": "X2"}), &[], 15);+ 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("path").and_then(|s| s.as_str()).map(str::to_string)))@@ -211,35 +211,67 @@ fn restore_foreground(hwnd: i64) { /// 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> {- let main_pid = crate::health::licence_state()- .get("pid")- .and_then(|p| p.as_u64())- .or_else(|| {- crate::health::main_hwnd().and_then(|h| {- ab::call("desktop_ui_window", json!({"hwnd": h}), &[], 10)- .get("pid")- .and_then(|p| p.as_u64())- })- });- let main_pid = match main_pid {- Some(p) => p,- None => return Vec::new(), // cannot tell the app from a helper — kill nothing- };- let pl = ab::call("process_list", json!({"nameContains": "X2"}), &[], 15);+ // 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();- if let Some(procs) = pl.get("processes").and_then(|a| a.as_array()) {- for p in procs {- let pid = match p.get("pid").and_then(|v| v.as_u64()) {- Some(v) => v,- None => continue,- };- if pid == main_pid {- continue;- }- let r = ab::call("process_kill", json!({"pid": pid, "reason": "stray X2 -R helper waiting on a busy instance"}), &[], 15);- if r.get("success").and_then(|b| b.as_bool()).unwrap_or(false) {- killed.push(pid);- }+ 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
bridge.json+2−1@@ -2,7 +2,7 @@ "manifest_version": 1, "name": "altium", "displayName": "Altium Designer",- "version": "0.1.17",+ "version": "0.1.18", "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",@@ -65,6 +65,7 @@ "altium_install_extension", "altium_install_library", "altium_install_library_bundle",+ "altium_instances", "altium_launch", "altium_layers", "altium_layers_in_use",
package.json+1−1@@ -1,6 +1,6 @@ { "slug": "altium-bridge",- "version": "0.1.17",+ "version": "0.1.18", "type": "app", "description": "Skills for your container so your AI knows how to drive the Altium Designer bridge (altium_* verbs). The bridge runtime itself is the release zip; Adom Bridge loads that.", "tags": [
src/BRIDGE_VERSION+1−1@@ -1 +1 @@-0.1.17+0.1.18
src/handlers/agent.py+55−12@@ -53,8 +53,33 @@ def cache_dir() -> Path: return d +# The instance this module is currently acting for. Set by server.py per verb via `use_instance`.+# Default None = the legacy single-instance workspace, so an upgrade changes nothing for the+# common case of one Altium.+_instance: dict | None = None+++def use_instance(inst: dict | None) -> None:+ """Point every workspace-derived path at ONE Altium instance for the duration of a verb."""+ global _instance+ _instance = inst+++def current_instance() -> dict | None:+ return _instance++ def workspace_dir() -> Path:+ """The agent's file-drop directory — PER INSTANCE.++ Two Altiums sharing one workspace is the failure that does not announce itself: both resident+ loops poll the same `request.txt`, so whichever notices first eats the other's request and+ answers it from the wrong board (John, 2026-09-01, running Designer + Designer Develop for two+ AI threads). Keyed by install folder ("AD26", "ADDevelop") so a restart keeps the same folder.+ """ d = cache_dir() / "agent"+ if _instance and _instance.get("key"):+ d = d / str(_instance["key"]) d.mkdir(parents=True, exist_ok=True) return d @@ -171,6 +196,12 @@ def status() -> dict: # ---- start / stop ------------------------------------------------------------- def _x2_exe() -> str | None:+ """The exe to spawn a -R helper against.++ MUST be the selected instance's own image: Designer and Designer Develop live in different+ install folders, so a constant path would forward every request to the wrong Altium."""+ if _instance and _instance.get("exe"):+ return _instance["exe"] inst = altium_detect.detect_installs() return inst[0]["exe"] if inst else None @@ -190,19 +221,31 @@ def _spawn_helper(exe: str, arg: str) -> subprocess.Popen: def _kill_stray_helpers() -> list[int]:- """Kill every X2.exe that is NOT the one owning the main window (those are- -R helpers left waiting on a busy instance)."""- mw = altium_detect.main_window()- main_pid = mw["pid"] if mw else None+ """Kill X2 processes that are LEFT-OVER -R HELPERS — nothing else.++ A helper carries its request in its own command line (`-R…ScriptingSystem`); a real Altium never+ does. The previous rule was "any X2 that is not the main window's process", which on 2026-09-01+ killed a genuine second instance (Altium Designer Develop, running deliberately for another AI+ thread). Never again: no -R in the command line means not a helper, full stop."""+ live = {i["pid"] for i in altium_detect.instances()} out = []- for pid in altium_detect.running_pids():- if main_pid is not None and pid != main_pid:- try:- subprocess.run(["taskkill", "/F", "/PID", str(pid)], capture_output=True, timeout=10,- creationflags=getattr(subprocess, "CREATE_NO_WINDOW", 0))- out.append(pid)- except Exception:- pass+ try:+ r = caller_identity.ad_callback("process_list", {"nameFilter": "X2"}, on_behalf=False, timeout=10) or {}+ procs = r.get("processes") or []+ except Exception:+ return out+ for p in procs:+ pid, cl = p.get("pid"), (p.get("commandLine") or "")+ if pid is None or pid in live:+ continue # owns an Altium main window: a real instance+ if "-R" not in cl or "ScriptingSystem" not in cl:+ continue # not carrying a script request: not a helper+ try:+ subprocess.run(["taskkill", "/F", "/PID", str(pid)], capture_output=True, timeout=10,+ creationflags=getattr(subprocess, "CREATE_NO_WINDOW", 0))+ out.append(pid)+ except Exception:+ pass return out
src/handlers/altium_detect.py+96@@ -18,6 +18,8 @@ from __future__ import annotations import glob import os import re++import caller_identity import subprocess import sys from pathlib import Path@@ -249,3 +251,97 @@ def licence_state() -> dict: licensed = st.lower() not in ("no license", "no licence", "viewer") return {"running": True, "licensed": licensed, "state": st, "adVersion": mw.get("adVersion"), "hwnd": mw["hwnd"], "pid": mw["pid"]}+++# ---- instances: more than one Altium can run at once (John, 2026-09-01) ---------------------+#+# John runs Altium Designer Develop alongside Altium Designer so two AI threads can drive both.+# Everything below therefore treats "the Altium" as a CHOICE, not a given. The bridge previously+# assumed one instance in three places and each was wrong with two: the launcher hardcoded the+# AD26 exe path, `licence_state()` reported whichever main window sorted first, and the dialog+# sweep covered every X2 pid (so one thread could clear a modal belonging to the other).++def _exe_of_pid(pid: int) -> str | None:+ """The process's own image path — never a constant. Develop and Designer live in different+ install folders, so the exe to spawn a -R helper against depends on WHICH instance you mean."""+ try:+ r = caller_identity.ad_callback("process_list", {"pid": pid}, on_behalf=False, timeout=10) or {}+ for p in r.get("processes") or []:+ if p.get("pid") == pid:+ cl = (p.get("commandLine") or "").strip()+ if cl.startswith('"'):+ return cl[1:].split('"', 1)[0]+ return cl.split(" ")[0] or None+ except Exception:+ pass+ return None+++def _key_for(exe: str | None, pid: int) -> str:+ """A STABLE per-instance key, used for the agent workspace folder.++ The install folder name ("AD26", "ADDevelop") survives a restart, which a pid does not — so a+ resident agent is still recognised after Altium is relaunched. Two instances from the SAME+ install fall back to the pid, which is the honest answer: they are genuinely distinguishable+ only by process."""+ if exe:+ parts = [q for q in exe.replace("/", "\\").split("\\") if q]+ if len(parts) >= 2:+ return parts[-2]+ return f"pid{pid}"+++def instances() -> list[dict]:+ """Every running Altium, each with the identity a caller needs to choose between them."""+ out = []+ seen = set()+ for w in altium_windows():+ if w["kind"] != "main" or w["pid"] in seen:+ continue+ seen.add(w["pid"])+ exe = _exe_of_pid(w["pid"])+ st = w.get("licenceState")+ out.append({+ "pid": w["pid"],+ "key": _key_for(exe, w["pid"]),+ "edition": w.get("edition") or "Designer",+ "adVersion": w.get("adVersion"),+ "licenceState": st,+ "licensed": (st.lower() not in ("no license", "no licence", "viewer")) if st else None,+ "exe": exe,+ "hwnd": w["hwnd"],+ "document": w.get("document"),+ "title": w.get("title"),+ })+ out.sort(key=lambda i: i["pid"])+ return out+++def resolve_instance(selector=None) -> dict:+ """Pick the instance a verb should act on.++ No selector + exactly one Altium -> that one (the normal case; nothing changes for users).+ No selector + several -> REFUSE and list them. Guessing here means one thread's verb+ lands in another thread's Altium, which is worse than an error.+ Selector -> match on pid, key, or a case-insensitive substring of the+ edition/title (so "develop" works).+ """+ found = instances()+ if not found:+ return {"error": "no Altium is running", "errorCode": "altium_not_running", "instances": []}+ if selector in (None, "", "auto"):+ if len(found) == 1:+ return {"instance": found[0]}+ return {"error": f"{len(found)} Altium instances are running — say which one",+ "errorCode": "ambiguous_instance", "instances": found,+ "_hint": "pass instance: a pid, a key (e.g. 'ADDevelop'), or a word from the edition/title (e.g. 'develop')"}+ s = str(selector).strip().lower()+ for i in found:+ if s == str(i["pid"]) or s == (i["key"] or "").lower():+ return {"instance": i}+ for i in found:+ hay = f"{i.get('edition') or ''} {i.get('key') or ''} {i.get('title') or ''}".lower()+ if s in hay:+ return {"instance": i}+ return {"error": f"no running Altium matches instance={selector!r}",+ "errorCode": "instance_not_found", "instances": found}
src/handlers/dialogs.py+12−1@@ -101,8 +101,19 @@ def classify(title: str, body: str) -> dict: def scan(main_pid_only: bool = False) -> list[dict]:- """Every dialog window owned by any X2.exe (helpers included, flagged)."""+ """Every dialog window owned by any X2.exe (helpers included, flagged).++ SCOPED to the selected instance when one is set (John, 2026-09-01): with Designer and Designer+ Develop both running, an unscoped sweep lets one AI thread see — and `dismiss_dialogs` CLEAR —+ a modal belonging to the other thread's Altium.""" wins = altium_detect.altium_windows()+ try:+ from handlers import agent as _agent+ sel = _agent.current_instance()+ except Exception:+ sel = None+ if sel and sel.get("pid"):+ wins = [w for w in wins if w.get("pid") == sel["pid"]] main = next((w for w in wins if w["kind"] == "main"), None) main_pid = main["pid"] if main else None out = []
src/server.py+45@@ -1112,6 +1112,31 @@ def h_layers_in_use(args): "silkscreen and mechanical layers."} ++def h_instances(args):+ """Every running Altium, and how to address it.++ More than one can run at once — John runs Altium Designer Develop beside Altium Designer so two+ AI threads can drive both. Every other verb takes an optional `instance` (a pid, a key like+ 'ADDevelop', or a word from the edition/title such as 'develop'). With ONE Altium running you+ can omit it and nothing changes; with several, verbs REFUSE rather than guess, because guessing+ means acting inside the other thread's Altium.++ Each instance gets its own agent workspace (`bridges-cache/altium/agent/<key>`), so two resident+ loops never share an inbox.+ """+ found = altium_detect.instances()+ for i in found:+ agent.use_instance(i)+ i["agentAlive"] = agent.is_alive()+ i["agentWorkspace"] = str(agent.workspace_dir())+ agent.use_instance(None)+ return {"success": True, "count": len(found), "instances": found,+ "_hint": ("Pass instance:<pid|key|word> to any verb to target one. "+ "With several running, verbs without it fail with errorCode ambiguous_instance.")+ if len(found) > 1 else "One Altium running — `instance` is optional."}++ # ---- licence lifecycle: status, ensure-licensed, trial setup (John 2026-08-31) ------------ def h_license_status(args):@@ -1679,6 +1704,7 @@ _VERB_CATALOG = { "close": "WM_CLOSE the main window; reports a save prompt instead of answering it; force:true taskkills.", "activate_document": "Make an ALREADY-OPEN document current by invoking its document-bar tab through UIA — no cursor, no foreground.", "layers_in_use": "How many layers the board ACTUALLY has: copper layers carrying primitives, densest first (a floor, not the stack).",+ "instances": "Every running Altium (Designer, Designer Develop, ...) with the pid/key to address it. Other verbs take instance:<pid|key|word>.", "agent_start": "Install + bootstrap the resident DelphiScript agent via X2 -R RunScript; waits for heartbeat.", "agent_stop": "Drop the stop file; loop exits.", "agent_status": "Heartbeat age, request count, workspace.",@@ -1734,6 +1760,7 @@ COMMAND_HANDLERS = { "window_info": h_window_info, "state": h_state, "check_dialogs": h_check_dialogs, "dismiss_dialogs": h_dismiss_dialogs, "launch": h_launch, "close": h_close, "activate_document": h_activate_document, "layers_in_use": h_layers_in_use,+ "instances": h_instances, "agent_start": h_agent_start, "agent_stop": h_agent_stop, "agent_status": h_agent_status, "agent_recover": h_agent_recover, "eval": h_eval, "run_command": h_run_command, "run_script": h_run_script,@@ -1784,6 +1811,24 @@ def dispatch_command(command: str, args: dict) -> dict: if handler is None: return {"success": False, "error": f"Unknown command: {command}", "_hint": f"Valid: {', '.join(sorted(COMMAND_HANDLERS))}"}+ # ---- instance selection (John, 2026-09-01: Designer + Designer Develop, two AI threads) ----+ # Resolved ONCE, centrally, so all 96 verbs are instance-aware without touching any of them.+ # Verbs that describe the machine rather than drive an Altium are exempt — `instances` itself+ # must work when several are running, which is exactly when resolution refuses.+ instance_exempt = command in ("instances", "describe", "diagnostics", "progress", "state", "uninstall")+ if not instance_exempt:+ sel = (args or {}).get("instance")+ r = altium_detect.resolve_instance(sel)+ if r.get("error"):+ # not-running stays a per-verb concern (many verbs answer usefully without Altium);+ # AMBIGUITY must stop here, or one thread's verb lands in another thread's Altium+ if r.get("errorCode") in ("ambiguous_instance", "instance_not_found"):+ return {"success": False, **r}+ agent.use_instance(None)+ else:+ agent.use_instance(r["instance"])+ else:+ agent.use_instance(None) t0 = time.time() driving = command not in ("readiness", "status", "describe", "diagnostics", "window_info", "check_dialogs", "progress", "agent_status", "list_commands", "state")