← Commit history

0.2.59 source (not yet released, needs signing): update_pcb closes the change order with a real click (Altium's quit hung on the hidden dialog), altium_close re-posts WM_QUIT, annotate/sch_edit/sheet_params notify the compiler (U? on the board), battery entries for 0.2.56-0.2.58 verbs

Colby Knox ·034b0b7b20 ·1d ago ·parent 38f3eb1
12 files changed +89−17
CHANGELOG.md+6
@@ -1,5 +1,11 @@ # Changelog +## 0.2.59 (2026-10-22)+**Fixes from the full verb battery on signed 0.2.58** (155 pass, `close` failed after `update_pcb`).+- `altium_update_pcb` closes the change order with a real click on Close. UIA Invoke only hid the dialog, and Altium's quit later waited on that hidden change order with its main window disabled and nothing on screen. Every close after a change order failed with `modal_orphan_survived_quit` (reproduced 4 times on AD 26.11.1; it was Altium's own quit too, not only the bridge's).+- `altium_close`: when Altium is stuck in a modal loop, the bridge now posts WM_QUIT again every 8 s (up to 4 times, 75 s). One post ends one modal loop; nested ones needed a second, and every stuck instance exited within a minute of it.+- `altium_annotate`, `altium_sch_edit` and `altium_sheet_params` tell Altium's project compiler each part changed (`SCHM_BeginModify`/`EndModify`). Without that, the change order still saw the old values: a part annotated to U3 reached the board as `U?`, and comment-only edits found no differences. Both now reach the board (verified: U3 on the saved board, and a comment-only edit ran as "Change Component Comments").+ ## 0.2.58 (2026-10-21) - `altium_autoroute`: Altium's Situs autorouter on the current board.   - It picks a strategy and keeps existing routing locked by default; Situs ripped it up with the dialog's default.
bridge-rs/src/lifecycle.rs+12−4
@@ -648,18 +648,26 @@ pub fn graceful_terminate(orphan: &Value, wait: Duration) -> Value {     if tid == 0 {         return json!({"success": false, "error": "no UI thread for the stuck instance"});     }-    let posted = crate::win32::post_quit_to_thread(tid);+    // One WM_QUIT leaves ONE modal loop. A dialog nested in another (the hidden change order under+    // the quit, 2026-10-21) needed a second: every stuck instance that day exited within a minute of+    // the second post. So post again every 8 s while waiting.+    let mut posted = vec![crate::win32::post_quit_to_thread(tid)];     let t0 = Instant::now();+    let mut last_post = Instant::now();     while t0.elapsed() < wait {         if !crate::win32::pids_of("X2.EXE").unwrap_or_default().contains(&pid) {             instance::invalidate_snapshot();-            return json!({"success": true, "route": "wm_quit", "posted": posted,+            return json!({"success": true, "route": "wm_quit", "posted": posted.len(),                           "exitedInSec": t0.elapsed().as_secs_f64(), "seatReleased": true});         }+        if last_post.elapsed() >= Duration::from_secs(8) && posted.len() < 4 {+            posted.push(crate::win32::post_quit_to_thread(tid));+            last_post = Instant::now();+        }         std::thread::sleep(Duration::from_millis(400));     }-    json!({"success": false, "route": "wm_quit", "posted": posted,-           "error": "the stuck instance did not exit after a posted WM_QUIT"})+    json!({"success": false, "route": "wm_quit", "posted": posted.len(),+           "error": "the stuck instance did not exit after repeated posted WM_QUITs"}) }  /// Un-halt the script engine: press OK on the script Error modal, stop the editor's script,
bridge-rs/src/main.rs+1−1
@@ -34,7 +34,7 @@ use tiny_http::{Header, Method, Response, Server}; /// 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.58";+pub const BRIDGE_VERSION: &str = "0.2.59";  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());
bridge-rs/src/misc.rs+1−1
@@ -536,7 +536,7 @@ pub fn close(args: &Value) -> Value {         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));+                    let r = lifecycle::graceful_terminate(&orphan, Duration::from_secs(75));                     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,
bridge-rs/src/sch.rs+27−3
@@ -312,7 +312,7 @@ pub fn update_pcb(args: &Value) -> Value {         let boxed = steps.iter().any(|s| s.get("dialog").is_some());         return json!({"success": boxed, "errorCode": if boxed { Value::Null } else { json!("eco_dialog_not_found") }, "executed": false, "preview": preview,             "steps": steps, "dialogs": dl, "elapsedSec": support::elapsed_round(t0),-            "_hint": "No Engineering Change Order opened: Altium found no differences between the sheets and the board that its change order carries. On AD 26.11.1 a comment-only change on an existing part is one of those (measured). Parts, nets and parameters are carried. If you expected changes, check the sheets are saved and that no second project with the same file names is open."});+            "_hint": "No Engineering Change Order opened: Altium found no differences between the sheets and the board that its change order carries. Parts, nets and parameters are carried. If you expected changes, check the sheets are saved and that no second project with the same file names is open."});     };     let hwnd = eco.get("hwnd").and_then(|h| h.as_i64()).unwrap_or(0);     // the change order's first page, as Altium lists it (group rows carry counts: 'Add Components(1)')@@ -333,8 +333,19 @@ pub fn update_pcb(args: &Value) -> Value {         rows_after = now;         std::thread::sleep(Duration::from_secs(2));     }-    let close = ab::call("desktop_ui_click", json!({"hwnd": hwnd, "contains": "Close", "role": "button"}), &[], 30);-    steps.push(json!({"uiClick": "Close", "success": close.get("success")}));+    // Close with a REAL click: UIA Invoke hides the form without ending it, and Altium's quit later+    // waits on that hidden change order forever (main window disabled, nothing on screen; measured+    // 2026-10-21, AD 26.11.1). Invoke stays as the fallback when no rect is readable.+    let closed_by = match button_rect(hwnd, "Close") {+        Some((x, y)) => {+            let r = ab::call("desktop_click", json!({"hwnd": hwnd, "x": x, "y": y, "reason": "close the change order dialog"}), &[], 20);+            json!({"realClick": r.get("success")})+        }+        None => json!({"uiClick": ab::call("desktop_ui_click", json!({"hwnd": hwnd, "contains": "Close", "role": "button"}), &[], 30).get("success")}),+    };+    std::thread::sleep(Duration::from_secs(1));+    let still_open = find("engineering change order").is_some();+    steps.push(json!({"close": closed_by, "stillOpen": still_open}));     // the agent loop parked while the modal was up: bring it back for the verbs that follow     for _ in 0..20 { if agent::is_responsive() { break; } std::thread::sleep(Duration::from_millis(500)); }     if !agent::is_responsive() { let _ = crate::lifecycle::recover("after the change order"); }@@ -347,6 +358,19 @@ pub fn update_pcb(args: &Value) -> Value {         "_hint": "The change order ran. New parts land beside the board outline: place them (altium_move_parts) and check with altium_routing_drc. changeOrder lists the dialog's first page (groups carry counts)."}) } +/// A dialog button's centre (screen pixels) from its UI Automation rect.+fn button_rect(hwnd: i64, name: &str) -> Option<(Value, Value)> {+    fn walk(n: &Value, name: &str) -> Option<(Value, Value)> {+        if n.get("name").and_then(|v| v.as_str()) == Some(name) {+            if let Some(r) = n.get("rect") { return Some((r["centerX"].clone(), r["centerY"].clone())); }+        }+        n.get("children").and_then(|c| c.as_array()).into_iter().flatten().find_map(|c| walk(c, name))+    }+    let tree = ab::call("desktop_ui_tree", json!({"hwnd": hwnd, "maxDepth": 10, "maxNodes": 300, "roleFilter": ["button"]}), &[], 30);+    let root = tree.get("root").cloned().or_else(|| tree.get("output").and_then(|o| o.as_str()).and_then(|t| serde_json::from_str::<Value>(t).ok()).and_then(|v| v.get("root").cloned())).unwrap_or(Value::Null);+    walk(&root, name).filter(|(x, y)| x.is_number() && y.is_number())+}+ /// The change order's visible rows: their text, as the grid shows it. fn eco_rows(tree: &Value) -> Vec<String> {     fn walk(n: &Value, out: &mut Vec<String>) {
dev-skills/altium-bridge-test/run_verb_tests.py+22−1
@@ -203,6 +203,8 @@ def matrix(w):                                                         parts=lane_parts, nets=lane_nets),                             check=lambda d: d.get("placed") == 2 and not d.get("wrongPins"))     M["routing_state"] = dict(phase=3.5, **ok(expectPath=LB), check=lambda d: n_comps(d) == 2)+    # 0.2.58: the stack-up of the board just built (2 copper layers)+    M["layer_stack"] = dict(phase=3.5, **ok(), check=lambda d: (d.get("data") or {}).get("copperLayers") == 2)     M["set_design_rules"] = dict(phase=3.5, timeout=300, **ok(clearance=0.2, trackWidth={"min": 0.15, "preferred": 0.25, "max": 2},                                                              holeToHole=0.25, polygonConnect="relief"))     M["add_rule"] = dict(phase=3.5, **ok(kind="clearance", name="Battery", scope1="InNet('N1')", gap=0.3))@@ -228,6 +230,25 @@ def matrix(w):                                                            nets=lane_nets))     M["export_manufacturing"] = dict(phase=3.5, timeout=400, **ok(projectPath=LP, pcbPath=LB)) +    # ---- phase 3.5 (0.2.56-0.2.58): BOM and schematic editing on the lane project ----------------+    # build_schematic made LP with LS (U1, U2) and LB. A third part goes on the sheet as U?, gets a+    # number, wires and a comment; update_pcb then carries it to the board (a REAL change order: the+    # first one on a scripted board answers the component-links prompt), and autoroute routes it.+    M["bom"] = dict(phase=3.5, timeout=300, **ok(projectPath=LP, write=False, timeout=240), check=lambda d: (d.get("lineCount") or 0) >= 1)+    M["sheet_params"] = dict(phase=3.5, **ok(projectPath=LP, designators=["U1"], params={"BatteryParam": "1"}), check=lambda d: d.get("updated") == ["U1"])+    M["doc_params"] = dict(phase=3.5, **ok(sheet=LS, set={"Revision": "B"}))+    M["sch_place_part"] = dict(phase=3.5, **ok(sheet=LS, library=SL, symbol="TST_SOIC8", designator="U?", x=8000, y=2000))+    M["annotate"] = dict(phase=3.5, timeout=300, **ok(projectPath=LP), check=lambda d: any(r.get("to") == "U3" for r in d.get("renamed") or []))+    M["sch_connect"] = dict(phase=3.5, **ok(sheet=LS, connections=[{"designator": "U3", "pin": "1", "net": "N1"},+                                                                    {"designator": "U3", "pin": "4", "net": "GND", "kind": "power"}]),+                            check=lambda d: d.get("connected") == 2)+    M["sch_edit"] = dict(phase=3.5, **ok(sheet=LS, edits=[{"designator": "U3", "action": "comment", "text": "BATTERY"}]))+    M["sch_zoom"] = dict(phase=3.5, **ok(sheet=LS, designators=["U3"], shot=False))+    M["update_pcb"] = dict(phase=3.5, timeout=650, **ok(projectPath=LP, execute=True, timeout=600),+                           check=lambda d: "U3" in ((d.get("preview") or {}).get("addParts") or []) and d.get("executed") is True)+    M["export_pdf"] = dict(phase=3.5, timeout=650, **ok(projectPath=LP, output=D + "/lane.pdf", timeout=600), check=lambda d: (d.get("outputBytes") or 0) > 0)+    M["autoroute"] = dict(phase=3.5, timeout=650, **ok(filePath=LB, strategy="Default 2 Layer Board", timeout=600))+     # ---- phase 4: outputs --------------------------------------------------------------------     M["export_step"] = dict(phase=4, timeout=300, **anyo("STEP of the battery board", filePath=PD, output=D + "/battery.step"))     M["export_bom"] = dict(phase=4, **anyo("missing OutJob: named refusal (the demo OutJob opens its .xlsx on screen)", outjobPath=D + "/missing.OutJob"))@@ -280,7 +301,7 @@ def matrix(w):     # Altium is running by phase 2, and the verb refuses in that state (it holds AdomBridge.dll open).     M["install_extension"] = dict(phase=2, **ref("Altium is running: must refuse with altium_running, installing nothing"))     # Both of these validate BEFORE they write or open anything: no workspace = a named refusal.-    M["a365_configure"] = dict(phase=0, **ref("no workspace given: must refuse before writing the config"))+    M["a365_configure"] = dict(phase=0, **anyo("no args: refuses by name on a fresh box, or reports the workspace already stored (DESKTOP-BAR6S0F has adom.365.altium.com)"))     M["a365_oauth_begin"] = dict(phase=0, **ref("no workspace configured: must refuse before opening a browser"))     # THE ONE REAL SKIP. Nothing is configured on a test box, so this would be a no-op here - but the     # battery must be safe to run on ANY machine, and on one with a live Altium 365 session it drops
package.json+1−1
@@ -1,6 +1,6 @@ {   "slug": "altium-bridge",-  "version": "0.2.58",+  "version": "0.2.59",   "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": [
page.json+1−1
@@ -4,7 +4,7 @@   "type": "app",   "title": "Altium - the Altium Bridge",   "brief": "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.",-  "version": "0.2.58",+  "version": "0.2.59",   "hero": {     "type": "image",     "path": "screenshots/hero.png"
skills/altium-schematic-edit/SKILL.md+2−2
@@ -84,8 +84,8 @@ Altium's Design > Update PCB Document (from the board: Import Changes), the engi - New parts land beside the board outline: place them with `altium_move_parts`, check with   `altium_routing_drc`. - Pass `timeout` (seconds) for long verbs: ab waits for the call's own `timeout`.-- Component comment changes alone do not open a change order on AD 26.11.1 (Altium's comparator found no-  differences for them); parts, nets and parameter changes do.+- Edits made with these verbs (designators, comments, parameters) reach the change order. A part changed+  some other way without Altium being told (an outside script) can be missed: reopen the sheet first.  ## Title block and PDF 
src/BRIDGE_VERSION+1−1
@@ -1 +1 @@-0.2.58+0.2.59
src/agent/AdomAgent.pas.tmpl+14−1
@@ -4837,6 +4837,7 @@ Begin     Begin         If Want.IndexOf(UpperCase(Comp.Designator.Text)) >= 0 Then         Begin+            SchServer.RobotManager.SendMessage(Comp.I_ObjectAddress, c_BroadCast, SCHM_BeginModify, c_NoEventData);             For I := 0 To Items.Count - 1 Do             Begin                 Spec := Items[I];@@ -4848,6 +4849,7 @@ Begin                     If K <> '' Then Begin SetSchParam(Comp, K, V); NSet := NSet + 1; End;                 End;             End;+            SchServer.RobotManager.SendMessage(Comp.I_ObjectAddress, c_BroadCast, SCHM_EndModify, c_NoEventData);             If Pos('"' + Comp.Designator.Text + '"', Found) = 0 Then             Begin                 If Found <> '' Then Found := Found + ',';@@ -6708,6 +6710,10 @@ Begin             For J := 0 To CL.Count - 1 Do             Begin                 C := CL[J];+                { Begin/EndModify tell the project's compiler the part changed: without them a renamed or+                  re-commented part reached the change order with its OLD values (U? on the board, and+                  comment edits found no differences at all; measured 2026-10-21, AD 26.11.1). }+                If Act <> 'delete' Then SchServer.RobotManager.SendMessage(C.I_ObjectAddress, c_BroadCast, SCHM_BeginModify, c_NoEventData);                 If Act = 'move' Then C.MoveToXY(MilsToCoord(StrToIntDef(A, 0)), MilsToCoord(StrToIntDef(B, 0)))                 Else If Act = 'moveby' Then C.MoveByXY(MilsToCoord(StrToIntDef(A, 0)), MilsToCoord(StrToIntDef(B, 0)))                 Else If Act = 'rotate' Then@@ -6721,6 +6727,7 @@ Begin                 Else If Act = 'comment' Then C.Comment.Text := A                 Else If Act = 'delete' Then Dead.Add(C)                 Else Res := 'unknown action ' + Act;+                If Act <> 'delete' Then SchServer.RobotManager.SendMessage(C.I_ObjectAddress, c_BroadCast, SCHM_EndModify, c_NoEventData);                 C.GraphicallyInvalidate;             End;         CL.Free;@@ -6954,7 +6961,13 @@ Begin         End;         CL.Free;         If Hit = Nil Then Res := 'no part ' + FieldP(Op, 1) + ' at that position'-        Else Begin Hit.Designator.Text := FieldP(Op, 2); Hit.GraphicallyInvalidate; Res := 'ok'; End;+        Else Begin+            { Begin/EndModify: without them the change order still saw 'U?' (see sch_edit) }+            SchServer.RobotManager.SendMessage(Hit.I_ObjectAddress, c_BroadCast, SCHM_BeginModify, c_NoEventData);+            Hit.Designator.Text := FieldP(Op, 2);+            SchServer.RobotManager.SendMessage(Hit.I_ObjectAddress, c_BroadCast, SCHM_EndModify, c_NoEventData);+            Hit.GraphicallyInvalidate; Res := 'ok';+        End;         If S <> '' Then S := S + ',';         S := S + '{' + JStr('from', FieldP(Op, 1)) + ',' + JStr('to', FieldP(Op, 2)) + ',' + JStr('result', Res) + '}';     End;
src/bridge.json+1−1
@@ -2,7 +2,7 @@   "manifest_version": 1,   "name": "altium",   "displayName": "Altium Designer",-  "version": "0.2.58",+  "version": "0.2.59",   "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",