← Commit history
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")