← Commit history

0.2.65 source (not yet released, needs signing): coffeenmusic/altium-mcp parity (selection, move by offset, check_orientation, replicate_layout, multi-part symbols, batch symbol/footprint, find_symbol, outjob_info, part_parameters, precise find_space, airline length, mask expansions, eval error detection), launch install:develop|designer, licence diagnosis (no_licence_on_account, no_author_seat), close/recover fixes; 173 verbs; batteries 172/0 on AD 26.11.1 and Altium Designer Develop

Colby Knox ·0daea090ac ·1d ago ·parent da12a89
25 files changed +820−82
CHANGELOG.md+32
@@ -1,5 +1,37 @@ # Changelog +## 0.2.65 (2026-10-22)+**Everything coffeenmusic/altium-mcp does, and the Designer + Develop machine.** Colby asked to compare with that open-source Altium MCP server (43 tools) and make sure we do everything it does. The gaps:+- `altium_selection {select?}`: what is selected in the current board (mm, rotation, side, footprint, size) or sheet (mils), or select exactly the named parts to show the user.+- `altium_move_parts` takes `dx`/`dy` to move by an offset. A part moved without `rotation` now keeps its rotation; it used to be turned to 0.+- `altium_check_orientation`: advisory. Each 2-pad part's rotations are scored by airline length (MST of a routable net; nearest pad of a plane-like net). Measured on the buck: 20 checked, C1 saves 2.5 mm at 0 degrees.+- `altium_replicate_layout {source, targets}`: copy one channel's placement to others, turned by each anchor's rotation. Measured: within 1 mil on a target rotated 90 degrees.+- `altium_create_symbol`: multi-part symbols (a pin's 5th field is its part; 0 = every part). Measured: part 2 of a dual op-amp carried OUTB, -INB, +INB and the shared V+/V-. `create_symbol {symbols:[..]}` and `create_footprint {footprints:[..]}` make batches.+- `altium_find_symbol {query, kind?, libraries?}`: by part of the name, across the installed (or given) libraries.+- `altium_outjob_info {outjobPath}`: an OutJob's containers, outputs and key settings, read from the file.+- `altium_part_parameters {projectPath, name?}`: parameter names with counts, or one parameter's value on every part.+- `altium_find_space {check, precise:true}` adds Altium's own batch-DRC Component Clearance violations (primitive to primitive). `net_connections` gives each net's airline length (`airlineMm`). `footprint_primitives` gives pads' solder and paste mask expansion (um, integers: FloatToStr writes a comma decimal on some locales).+- `altium_eval` in the agent lane watches for the script error box, as the command-line lane does. It answered success:true for a script that halted the engine, and now recovers and returns `script_error`.+- `altium_bom` retries a sheet open that timed out (once in a full battery, right after an OutJob run).++**Altium Designer and Develop on one machine.** Develop was installed on the test laptop beside AD26, so Develop tests no longer need a person's own PC.+- `altium_launch {install}`: `develop`, `designer`, a folder name (AD26, ADDevelop) or a path. Without it the newest numbered install starts, so AD26 always won over ADDevelop.+- The bridge's fallback for "which X2.exe" (used by the -R lanes when nothing is running) uses every install the machine has. It had a fixed AD24-AD26 list that missed Develop.+- The dialog watcher closes Altium Designer Develop's "Update Available" notice (Close, never Update Now). It appeared at every Develop launch and blocked `altium_close` and every verb behind it.+- `altium_agent_recover` tries a second stop and restart before answering `engine_still_parked`. The first restart check missed once on Develop right after the self-test (intermittent); the same recover on its own passed.+- Verb battery: `--install develop` tests Develop on a machine that has both.+- The altium-instances skill documents starting a particular install.++**Licence page and close, found on the laptop's AD26 (2026-10-22)**+- `altium_ensure_licensed` answers `no_licence_on_account` (with `needsHuman`) when the licence page lists only the free Viewer and asks to request access from an administrator. It used to blame a stuck seat and say "retry shortly". `altium_license` replies carry `page {notUsingLicence, requestAccessBanner, viewerOnly}`, read through UI Automation.+- `altium_ensure_licensed` on Altium Designer Develop answers `no_author_seat` when Develop pops "There are no Author seats available" (the workspace's Author seats are all taken), found by the notice's Manage Seats button.+- `recover` no longer opens agent scripts through `-R` while two Altiums run: Altium can hand the request to the other one (closing Develop opened its script inside AD26).+- `altium_launch {install:'develop'}` beside a running AD26 now starts Develop. It used to answer `alreadyRunning` because some Altium was running.+- `altium_license {action:'show'}` opens the License Management page through Altium's `-R` command line when no agent loop runs (an unlicensed Altium refuses scripts). It used to return a picture of whatever document was in front.+- With License Management as the active page, the title carries no licence tail even without a seat; the state now reads unknown there, not "Licensed (no licence tail)".+- `altium_close`: when Altium ignores the quit with nothing on screen, it frees a parked script engine once and asks again. `recover` stops the agent scripts Altium has OPEN (read off its document tabs), so an older generation whose file a redeploy deleted is reached too, and idle scripts are no longer opened from the folder.++ ## 0.2.63 (2026-10-22) - **`altium-wiki-part`** (container command, installed by install.sh) puts an Adom wiki component page into Altium in one command.   - `place <owner/slug> --sheet ... [--x --y --designator]`: clones the page, converts its KiCad symbol and footprint, stages them and its STEP on the PC, imports the part with `altium_import_part`, and places it with `altium_sch_place_part`. `import <owner/slug>` does the library part only.
README.md+7−7
@@ -11,23 +11,23 @@ and the verb prefix `altium_`.  ## What it can do -Tested on Altium Designer 26.11.1 and Altium Designer Develop. 167 verbs; each row names the ones+Tested on Altium Designer 26.11.1 and Altium Designer Develop. 173 verbs; each row names the ones behind it (prefix `altium_`).  | Area | What the AI can do | Verbs | |---|---|---|-| Libraries | Create symbols (with display modes) and footprints, attach STEP 3D bodies, set MPN, manufacturer and Mouser / DigiKey / LCSC parameters, copy, rename and clone across libraries, compile and extract integrated libraries, register libraries in Installed Libraries | `create_symbol`, `build_symbol_modes`, `create_footprint`, `build_footprint`, `add_body`, `set_params`, `add_models`, `set_models`, `xcopy_footprint`, `compile_intlib`, `extract_intlib`, `install_library` |+| Libraries | Create symbols (with display modes) and footprints, attach STEP 3D bodies, set MPN, manufacturer and Mouser / DigiKey / LCSC parameters, copy, rename and clone across libraries, compile and extract integrated libraries, register libraries in Installed Libraries, find a symbol or footprint by name across every library | `find_symbol`, `create_symbol`, `build_symbol_modes`, `create_footprint`, `build_footprint`, `add_body`, `set_params`, `add_models`, `set_models`, `xcopy_footprint`, `compile_intlib`, `extract_intlib`, `install_library` | | Parts in one step | Import a whole part (symbol, footprint, STEP, parameters) in one call; put an Adom wiki part on a sheet with the `altium-wiki-part` command (symbol, footprint, a 3D chip laser-etched with its part number, Mouser / DigiKey / LCSC numbers); import KiCad files | `import_part`, `install_library_bundle`, `add_symbol_art`, `import_kicad` | | Altium 365 | Search the workspace's components and place one on a sheet with its workspace link, parameters and footprint; GraphQL queries and library uploads with a workspace token | `workspace_search`, `workspace_place`, `a365_*` | | Schematics | Draw a schematic from a netlist, place library parts, wire pins (wire, net label or power port), move, rotate, rename and delete parts, swap a part for another without breaking its connections, annotate, fill the title block, cross-probe | `build_schematic`, `sch_place_part`, `sch_connect`, `sch_edit`, `sch_swap_part`, `annotate`, `doc_params`, `sch_zoom` | | Checks | Compile and ERC with the Messages panel read back, DRC with each violation's location, a 14-check design review, schematic-to-board cross-check | `compile`, `run_erc`, `run_drc`, `routing_drc`, `review_board`, `cross_check` | | Schematic to board | Update PCB with the whole change order previewed row by row first, keeping board-only parts, net classes and rules | `update_pcb` | | Board setup | Build a board from a netlist, shape the outline, edit the stack-up (add planes and inner layers, set copper, dielectrics and total thickness), write a fab's design rules, add scoped rules, net classes and differential pairs | `build_board`, `set_outline`, `board_outline`, `layer_stack`, `set_design_rules`, `add_rule`, `net_classes`, `diff_pairs` |-| Placement | Move and rotate parts (read back to 0.001 mm), find free space for a part near another, swap footprints | `move_parts`, `find_space`, `swap_footprint` |+| Placement | Move and rotate parts or shift them by an offset (read back to 0.001 mm), find free space for a part near another, suggest rotations that shorten connections, copy one channel's layout to the others, swap footprints, read or set the selection | `move_parts`, `find_space`, `check_orientation`, `replicate_layout`, `swap_footprint`, `selection` | | Routing and copper | Route a net with waypoints and vias, run Altium's autorouter, rip up nets, resize routed tracks, pour polygons with keepouts, repour, place vias, clean up the silkscreen | `route_net`, `autoroute`, `ripup_nets`, `set_track_width`, `apply_pours`, `repour`, `place_vias`, `apply_silkscreen` | | BOM | Bill of materials straight from Altium, grouped by MPN with a variant applied, JLCPCB / Mouser / DigiKey files, ordering gaps flagged; write parameters back to the sheets; assembly variants | `bom`, `sheet_params`, `variants` |-| Manufacturing outputs | Gerbers, NC drill, pick and place and BOM in one call, checking that the outline and every copper layer reached the Gerbers; any OutJob; the schematic as a PDF; STEP | `export_manufacturing`, `run_outjob`, `export_pdf`, `export_step` |-| Reading the design | The whole board as data in mm, schematic parts with pins, rules, layers, poured copper, silkscreen | `read_board`, `routing_state`, `schematic_data`, `read_rules`, `pour_state`, `silk_state`, `pcb_report` |+| Manufacturing outputs | Gerbers, NC drill, pick and place and BOM in one call, checking that the outline and every copper layer reached the Gerbers; any OutJob, and read an OutJob's containers and settings; the schematic as a PDF; STEP | `export_manufacturing`, `run_outjob`, `outjob_info`, `export_pdf`, `export_step` |+| Reading the design | The whole board as data in mm, schematic parts with pins, rules, layers, poured copper, silkscreen, every part parameter in the project | `read_board`, `routing_state`, `schematic_data`, `part_parameters`, `read_rules`, `pour_state`, `silk_state`, `pcb_report` | | Seeing it | Open any surface with a screenshot, 2D and 3D board views, zoom to parts or nets, camera moves | `show_*`, `zoom_to`, `select_net`, `animate_view` | | Running Altium | Launch in the background, look after the licence seat, close nuisance dialogs, work with several Altiums at once, run any DelphiScript or Altium command, install Altium | `launch`, `close`, `ensure_licensed`, `instances`, `check_dialogs`, `eval`, `run_command`, `install` | @@ -47,7 +47,7 @@ Current, heat and EMC analysis of an Altium board runs through [AI Flow](https:/  Altium Designer is the user's **host app**. The bridge *detects* an existing install (`%ProgramFiles%\Altium\AD*\X2.EXE`), launches it in the background, bootstraps a **resident-DelphiScript agent** inside it (the REPL lane), and hands the AI 167 `altium_*` verbs.+DelphiScript agent** inside it (the REPL lane), and hands the AI 173 `altium_*` verbs.  - **Page / docs:** https://wiki.adom.inc/adom/altium-bridge - **Bridge SDK:** https://wiki.adom.inc/adom/adom-bridge-sdk@@ -155,7 +155,7 @@ A halted engine (DelphiScript runtime errors are modal) recovers with `altium_ag **Board & schematic introspection (0.1.11):** `list_designators`, `component_data`, `component_pins`, `list_nets`, `net_connections`, `layers`, `copper_layers`, `read_rules`, `set_layer_visibility`, `footprint_primitives`, `schematic_data` — read the live design as data (pads→nets, rules, layers, sheet components with pins), all in tens of milliseconds, no dialogs.  -All 167 verbs (prefix `altium_`). Call `altium_describe` for the live catalog.+All 173 verbs (prefix `altium_`). Call `altium_describe` for the live catalog.  **Readiness & lifecycle** — `readiness`, `status`, `launch`, `close`, `diagnostics`, `progress`, `agent_start/_stop/_status/_recover`, `license`, `signin_status`, `connect_workspace`, `uninstall`.
SKILL.md+5−1
@@ -33,7 +33,11 @@ Say **altium** to reach this bridge. Every verb returns `success`, a stable `err | Check the design | `altium_compile` (ERC), `altium_run_drc`, `altium_routing_drc`, `altium_review_board`, `altium_cross_check`, `altium_read_rules` (rule values) | | Find a part on the sheet or board | `altium_sch_zoom`, `altium_zoom_to` | | Place, route, pour, silkscreen a board | `altium_move_parts`, `altium_route_net`, `altium_autoroute` (Situs), `altium_place_vias`, `altium_apply_pours`, `altium_apply_silkscreen`, `altium_set_design_rules`, `altium_layer_stack`, `altium_find_space` (free spots near a part; overlap check) (**altium-boards**) |-| Manufacturing files, STEP, PDF | `altium_export_manufacturing` (checks the outline and copper layers in its Gerbers: `fabChecks`), `altium_board_outline`, `altium_export_step`, `altium_export_pdf`, `altium_run_outjob` (**altium-outputs**) |+| What is selected in Altium; select parts to show the user | `altium_selection {select?}` (**altium-boards**) |+| Rotations, channel copies, a precise clearance check | `altium_check_orientation`, `altium_replicate_layout`, `altium_find_space {check, precise}` (**altium-boards**) |+| Find a symbol or footprint by name; multi-part symbols; batches | `altium_find_symbol`, `altium_create_symbol {pins with part}`, `{symbols:[..]}` (**altium-libraries**) |+| A project's parameters | `altium_part_parameters {projectPath, name?}` |+| Manufacturing files, STEP, PDF | `altium_export_manufacturing` (checks the outline and copper layers in its Gerbers: `fabChecks`), `altium_board_outline`, `altium_export_step`, `altium_export_pdf`, `altium_run_outjob`, `altium_outjob_info` (read an OutJob's containers without running it) (**altium-outputs**) | | Make or fix library parts | `altium_import_part`, `altium_build_symbol_modes`, `altium_build_footprint`, `altium_add_body`, `altium_set_params` (**altium-libraries**, **altium-part-import**) | | Parts from the Adom wiki | one part onto a sheet: `altium-wiki-part place <owner/slug> --sheet ...`; a whole Cloud Library: `adom-wiki library export --format altium` + `altium_install_library_bundle` (**altium-wiki-import**) | | Parts from the Altium 365 workspace (shared cloud library) | `altium_workspace_search {query}`, `altium_workspace_place {sheet, query, pick, x, y}` (through Altium's own Components panel; no API access needed) (**altium-schematic-edit**) |
bridge-rs/src/bom.rs+6−1
@@ -232,7 +232,12 @@ pub fn files(lines: &[Value], parts: &[Part], boards: u64) -> Vec<(String, Strin }  fn sheet_parts(sheet: &str, timeout: Duration) -> Result<(String, Vec<Value>), Value> {-    if let Some(e) = support::must_open(sheet, 60) { return Err(e); }+    if let Some(first) = support::must_open(sheet, 60) {+        // once more, longer: right after an OutJob run Altium can still be busy, and the sheet open timed out+        // at 60 s once in a full battery (2026-10-22) while the same read alone took under a second+        std::thread::sleep(Duration::from_secs(3));+        if let Some(e) = support::must_open(sheet, 120) { return Err(json!({"first": first, "retry": e})); }+    }     // only the values a BOM reads: other parameters can hold anything (an example IC's preview-image blobs     // took minutes to read), so their values are never fetched     let mut names: Vec<&str> = [MPN, MFR, MOUSER, DIGIKEY, LCSC, VALUE, PACKAGE, DNP, DESCRIPTION, &["Fitted"]].concat();
bridge-rs/src/design.rs+148−2
@@ -284,8 +284,15 @@ pub fn find_space(args: &Value) -> Value {             if parts[i].2 == parts[j].2 && hits(parts[i].1, parts[j].1) { overlaps.push(json!([parts[i].0, parts[j].0])); }         } }         let outside: Vec<&String> = parts.iter().filter(|p| !in_outline(p.1)).map(|p| &p.0).collect();-        return json!({"success": true, "overlaps": overlaps, "outsideOutline": outside, "parts": parts.len(),-            "_hint": "Overlaps compare each part's extent as Altium's Component Clearance rule does: 3D bodies and pads when it has a body, else every primitive but text (silkscreen outline included). altium_find_space {near, part} finds a free spot; altium_move_parts moves it."});+        // precise: Altium's own batch DRC, whose Component Clearance rule measures primitive to primitive+        let precise = if args.get("precise").and_then(|b| b.as_bool()) == Some(true) {+            let d = crate::routing::routing_drc(&json!({"full": true}));+            let v: Vec<Value> = d["violations"].as_array().cloned().unwrap_or_default().into_iter()+                .filter(|x| x["rule"].as_str().map(|r| r.to_lowercase().replace(' ', "").contains("componentclearance")).unwrap_or(false)).collect();+            json!({"componentClearance": v, "count": v.len()})+        } else { Value::Null };+        return json!({"success": true, "overlaps": overlaps, "outsideOutline": outside, "parts": parts.len(), "precise": precise,+            "_hint": "Overlaps compare each part's extent as Altium's Component Clearance rule does: 3D bodies and pads when it has a body, else every primitive but text (silkscreen outline included). precise:true also runs Altium's batch DRC and lists its Component Clearance violations (primitive to primitive, against the board's rule). altium_find_space {near, part} finds a free spot; altium_move_parts moves it."});     }     let comp = |d: &str| st["components"].as_array().and_then(|a| a.iter().find(|c| c["ref"].as_str().map(|r| r.eq_ignore_ascii_case(d)).unwrap_or(false)).cloned());     // what to place: an existing part (its extent and origin) or a size@@ -359,3 +366,142 @@ mod tests {         assert!(hits([0.0, 0.0, 1.0, 1.0], grow([1.2, 0.0, 2.0, 1.0], 0.25)));     } }++/// altium_selection {filePath?}: what is selected in the current board or sheet.+pub fn selection(args: &Value) -> Value {+    if let Some(g) = support::need_licensed() { return g; }+    if let Some(p) = s(args, "filePath") { if let Some(e) = support::must_open(&p, 60) { return e; } }+    let set = name_list(args.get("select"));+    let p: Vec<(&str, String)> = if set.is_empty() { vec![] } else { vec![("set", set.join(";"))] };+    let d = match agent_data(&agent::call("selection", &p, None, Duration::from_secs(60))) { Ok(d) => d, Err(e) => return e };+    let board = d["kind"] == json!("board");+    let rows: Vec<Value> = d["selected"].as_array().cloned().unwrap_or_default().iter().map(|r| if board {+        let mm = |v: &Value| (raw_mm(v) * 1000.0).round() / 1000.0;+        json!({"ref": r[0], "x": mm(&r[1]), "y": mm(&r[2]), "rotation": r[3].as_f64().unwrap_or(0.0) / 1000.0, "side": if r[4].as_bool() == Some(true) { "bottom" } else { "top" },+               "footprint": r[5], "widthMm": mm(&r[6]), "heightMm": mm(&r[7])})+    } else {+        json!({"designator": r[0], "xMil": r[1], "yMil": r[2], "orientation": r[3], "libReference": r[4], "comment": r[5]})+    }).collect();+    json!({"success": true, "kind": d["kind"], "document": d["document"], "count": rows.len(), "selected": rows,+        "_hint": if rows.is_empty() { "Nothing is selected in the current document. Select parts in Altium (or altium_select_net / altium_sch_zoom select), then ask again." }+                 else if board { "Board positions in mm (Altium's frame), as altium_move_parts takes them: move these with {ref, x, y} or {ref, dx, dy}." }+                 else { "Sheet positions in mils, as altium_sch_edit {action:'move'} takes them." }})+}++fn dist(a: (f64, f64), b: (f64, f64)) -> f64 { ((a.0 - b.0).powi(2) + (a.1 - b.1).powi(2)).sqrt() }++pub fn mst_pub(pts: &[(f64, f64)]) -> f64 { mst(pts) }++/// Minimum spanning tree length of points (Prim's, O(n^2): nets are small).+fn mst(pts: &[(f64, f64)]) -> f64 {+    if pts.len() < 2 { return 0.0; }+    let mut inside = vec![false; pts.len()];+    let mut best = vec![f64::MAX; pts.len()];+    best[0] = 0.0;+    let mut total = 0.0;+    for _ in 0..pts.len() {+        let mut u = usize::MAX;+        for i in 0..pts.len() { if !inside[i] && (u == usize::MAX || best[i] < best[u]) { u = i; } }+        inside[u] = true;+        total += best[u];+        for v in 0..pts.len() { if !inside[v] { let d = dist(pts[u], pts[v]); if d < best[v] { best[v] = d; } } }+    }+    total+}++/// altium_check_orientation {filePath?, parts?, minImprovementMm?, planeNetPads?}: advisory. For each 2-pad part,+/// every orthogonal rotation is scored in place: a routable net by its airline (MST) length with the pad there, a+/// plane-like net (many pads, GND) by the distance to its nearest same-net pad. Parts whose best rotation beats the+/// current one by minImprovementMm are reported. Nothing is moved.+pub fn check_orientation(args: &Value) -> Value {+    if let Some(e) = open_board(args) { return e; }+    let st = crate::routing::routing_state(&json!({}));+    if st.get("success").and_then(|b| b.as_bool()) != Some(true) { return st; }+    let min_gain = args.get("minImprovementMm").and_then(|v| v.as_f64()).unwrap_or(0.6);+    let plane_pads = args.get("planeNetPads").and_then(|v| v.as_u64()).unwrap_or(12) as usize;+    let want = name_list(args.get("parts")).into_iter().map(|x| x.to_ascii_uppercase()).collect::<Vec<_>>();+    let pads = st["pads"].as_array().cloned().unwrap_or_default();+    let comps = st["components"].as_array().cloned().unwrap_or_default();+    let mut net_pads: std::collections::HashMap<String, Vec<(String, (f64, f64))>> = std::collections::HashMap::new();+    for p in &pads {+        let n = p["net"].as_str().unwrap_or("");+        if n.is_empty() { continue; }+        net_pads.entry(n.to_string()).or_default().push((p["ref"].as_str().unwrap_or("").to_string(), (p["x"].as_f64().unwrap_or(0.0), p["y"].as_f64().unwrap_or(0.0))));+    }+    let mut findings = Vec::new();+    let mut checked = 0;+    for c in &comps {+        let r = c["ref"].as_str().unwrap_or("").to_string();+        if !want.is_empty() && !want.contains(&r.to_ascii_uppercase()) { continue; }+        let mine: Vec<&Value> = pads.iter().filter(|p| p["ref"].as_str() == Some(&r)).collect();+        if mine.len() != 2 { continue; }+        checked += 1;+        let (cx, cy, rot) = (c["x"].as_f64().unwrap_or(0.0), c["y"].as_f64().unwrap_or(0.0), c["rotation"].as_f64().unwrap_or(0.0));+        let score = |delta: f64| -> f64 {+            let a = delta.to_radians();+            let mut total = 0.0;+            for p in &mine {+                let net = p["net"].as_str().unwrap_or("");+                if net.is_empty() { continue; }+                let (px, py) = (p["x"].as_f64().unwrap_or(0.0) - cx, p["y"].as_f64().unwrap_or(0.0) - cy);+                let at = (cx + px * a.cos() - py * a.sin(), cy + px * a.sin() + py * a.cos());+                let others: Vec<(f64, f64)> = net_pads.get(net).map(|v| v.iter().filter(|(o, _)| o != &r).map(|(_, q)| *q).collect()).unwrap_or_default();+                if others.is_empty() { continue; }+                total += if others.len() + 1 > plane_pads {+                    others.iter().map(|q| dist(at, *q)).fold(f64::MAX, f64::min)+                } else {+                    let mut pts = others.clone(); pts.push(at); mst(&pts)+                };+            }+            total+        };+        let now = score(0.0);+        let mut best = (0.0, now);+        for d in [90.0, 180.0, 270.0] { let sc = score(d); if sc < best.1 - 1e-9 { best = (d, sc); } }+        if now - best.1 >= min_gain {+            let round = |v: f64| (v * 1000.0).round() / 1000.0;+            findings.push(json!({"ref": r, "rotation": rot, "suggested": (rot + best.0).rem_euclid(360.0), "airlineNowMm": round(now), "airlineThenMm": round(best.1), "savesMm": round(now - best.1)}));+        }+    }+    findings.sort_by(|a, b| b["savesMm"].as_f64().partial_cmp(&a["savesMm"].as_f64()).unwrap_or(std::cmp::Ordering::Equal));+    json!({"success": true, "checked": checked, "suggestions": findings,+        "_hint": "Advisory only: each 2-pad part's rotations scored by airline length (MST of a routable net with the pad there; distance to the nearest pad of a plane-like net such as GND). Rotate with altium_move_parts {parts:[{ref, rotation}]} (position kept), then reroute those nets. Routed copper is not considered: on a routed board, rip up first or treat these as hints."})+}++/// altium_replicate_layout {filePath?, source: [refs], targets: [[refs]] (same order as source), anchor?}: copy one+/// channel's placement to others. Each target channel keeps its anchor part (the first, or `anchor`'s index) where+/// it is; every other part goes to the same offset and rotation from it as in the source.+pub fn replicate_layout(args: &Value) -> Value {+    if let Some(e) = open_board(args) { return e; }+    let src = name_list(args.get("source"));+    let targets: Vec<Vec<String>> = args.get("targets").and_then(|v| v.as_array()).cloned().unwrap_or_default().iter().map(|t| name_list(Some(t))).collect();+    if src.len() < 2 || targets.is_empty() { return json!({"success": false, "errorCode": "bad_args", "error": "source: [refs] (2 or more) and targets: [[refs], ...] in the same order are required"}); }+    if let Some(bad) = targets.iter().find(|t| t.len() != src.len()) { return json!({"success": false, "errorCode": "bad_args", "error": format!("target {:?} has {} parts, the source {}", bad, bad.len(), src.len())}); }+    let anchor = args.get("anchor").and_then(|v| v.as_str()).and_then(|a| src.iter().position(|x| x.eq_ignore_ascii_case(a))).unwrap_or(0);+    let st = crate::routing::routing_state(&json!({}));+    if st.get("success").and_then(|b| b.as_bool()) != Some(true) { return st; }+    let comp = |r: &str| st["components"].as_array().and_then(|c| c.iter().find(|c| c["ref"].as_str().map(|x| x.eq_ignore_ascii_case(r)).unwrap_or(false)).cloned());+    let get = |r: &str| -> Option<(f64, f64, f64, String, String)> { comp(r).map(|c| (c["x"].as_f64().unwrap_or(0.0), c["y"].as_f64().unwrap_or(0.0), c["rotation"].as_f64().unwrap_or(0.0), c["side"].as_str().unwrap_or("").to_string(), c["pattern"].as_str().unwrap_or("").to_string())) };+    let mut srcp = Vec::new();+    for r in &src { match get(r) { Some(p) => srcp.push(p), None => return json!({"success": false, "errorCode": "part_not_found", "error": format!("{r} is not on the board")}) } }+    let (ax, ay, arot, _, _) = srcp[anchor].clone();+    let mut moves = Vec::new();+    let mut warnings = Vec::new();+    for t in &targets {+        let Some((tx, ty, trot, _, _)) = get(&t[anchor]) else { return json!({"success": false, "errorCode": "part_not_found", "error": format!("{} is not on the board", t[anchor])}) };+        // the target channel may sit rotated against the source: turn the offsets by the anchors' difference+        let d = (trot - arot).to_radians();+        for (i, r) in t.iter().enumerate() {+            if i == anchor { continue; }+            let Some(tp) = get(r) else { return json!({"success": false, "errorCode": "part_not_found", "error": format!("{r} is not on the board")}) };+            if tp.4 != srcp[i].4 { warnings.push(json!({"ref": r, "footprint": tp.4, "source": src[i], "sourceFootprint": srcp[i].4})); }+            let (ox, oy) = (srcp[i].0 - ax, srcp[i].1 - ay);+            let (x, y) = (tx + ox * d.cos() - oy * d.sin(), ty + ox * d.sin() + oy * d.cos());+            moves.push(json!({"ref": r, "x": (x * 10000.0).round() / 10000.0, "y": (y * 10000.0).round() / 10000.0, "rotation": (srcp[i].2 + (trot - arot)).rem_euclid(360.0)}));+        }+    }+    let m = crate::routing::move_parts(&json!({"parts": moves}));+    json!({"success": m.get("success").and_then(|b| b.as_bool()) == Some(true), "moved": moves.len(), "moves": moves, "footprintMismatches": warnings,+        "errorCode": m.get("errorCode"), "mismatches": m.get("mismatches"),+        "_hint": "Each target channel's anchor stayed put and its other parts took the source's offsets and rotations (turned by the anchors' rotation difference). Routing is not copied: route the targets (altium_route_net) or use Altium's Copy Room Formats for copper. Check with altium_find_space {check:true} and altium_routing_drc."})+}
bridge-rs/src/dialogs.rs+3
@@ -62,6 +62,8 @@ const CATALOG: &[(&str, &str, &str, &str)] = &[      "Altium's own crash-report dialog, usually raised when a window's state changed while it was focusing a document view. Its only button SENDS a report to Altium — try the title-bar X first if the user's data matters."),     ("unsaved changes", "save_prompt", "",      "Names the document(s) at risk — READ IT. On a shared machine the document may belong to ANOTHER AI thread; Save preserves their work, Don't Save destroys it. Never default this."),+    ("update available", "update_notice", "Close",+     "Altium Designer Develop's 'New Version Available' notice (Update Now | Close), shown at launch. The bridge closes it WITHOUT updating: it blocked altium_close and every verb behind it (2026-10-22), and an update is the user's call (Help > Check for Updates)."),     ("license", "licence", "", "Licence prompt — read it; usually informational."),     ("trial", "licence", "", "Trial notice."), ];@@ -498,6 +500,7 @@ mod tests {         assert!(!has_dialog_buttons(&v(&["Close"])), "a floating panel's lone Close is not a dialog");         assert!(!has_dialog_buttons(&v(&["Place", "Properties"])));         assert_eq!(classify("Confirm Save Locally for (3) Modified Documents", "").0, "save_prompt");+        assert_eq!(classify("Update Available", "New Version Available").1.as_deref(), Some("Close"));         assert_eq!(classify("Confirm Not Saving (3) Modified Documents", "").0, "save_prompt");         assert_eq!(classify("Zoom Value (0.001x..4x)", "").0, "input_prompt");     }
bridge-rs/src/inspect.rsadded+152
@@ -0,0 +1,152 @@+//! Read-only inspection: an OutJob's containers and settings from its file, symbol and footprint search across+//! libraries, and the parameters a project's parts carry.+use crate::support::{self, s};+use crate::{agent, bom};+use serde_json::{json, Value};+use std::collections::BTreeMap;+use std::path::Path;+use std::time::Duration;++/// An OutJob file as sections of key=value lines.+fn ini(text: &str) -> Vec<(String, Vec<(String, String)>)> {+    let mut out: Vec<(String, Vec<(String, String)>)> = Vec::new();+    for line in text.lines() {+        let l = line.trim();+        if l.starts_with('[') && l.ends_with(']') { out.push((l[1..l.len() - 1].to_string(), vec![])); continue; }+        if let (Some((k, v)), Some(sec)) = (l.split_once('='), out.last_mut()) { sec.1.push((k.to_string(), v.to_string())); }+    }+    out+}++/// The containers (output media), outputs and their settings of an OutJob file. Pure, for the tests.+pub fn outjob_summary(text: &str) -> Value {+    let mut groups = Vec::new();+    for (name, kv) in ini(text) {+        if !name.starts_with("OutputGroup") { continue; }+        let get = |k: &str| kv.iter().find(|(a, _)| a == k).map(|(_, b)| b.clone());+        let mut media: BTreeMap<usize, Value> = BTreeMap::new();+        let mut outputs: BTreeMap<usize, Value> = BTreeMap::new();+        for (k, v) in &kv {+            if let Some(n) = k.strip_prefix("OutputMedium").and_then(|r| r.parse::<usize>().ok()) {+                media.insert(n, json!({"index": n, "name": v, "type": get(&format!("OutputMedium{n}_Type")).unwrap_or_default()}));+            }+            if let Some(n) = k.strip_prefix("OutputType").and_then(|r| r.parse::<usize>().ok()) {+                let in_media: Vec<usize> = kv.iter().filter_map(|(a, b)| a.strip_prefix(&format!("OutputEnabled{n}_OutputMedium")).and_then(|m| m.parse::<usize>().ok()).filter(|_| b != "0")).collect();+                let config: Vec<String> = kv.iter().filter(|(a, _)| a.starts_with(&format!("Configuration{n}_Item"))).map(|(_, b)| b.clone()).collect();+                // the settings an engineer checks first, out of the configuration record+                let field = |key: &str| config.iter().flat_map(|c| c.split('|')).find_map(|f| f.strip_prefix(&format!("{key}="))).map(str::to_string);+                let mut settings = serde_json::Map::new();+                for key in ["GerberUnit", "NumberOfDecimals", "Units", "GenerateCSVFormat", "BatchMode", "OriginPosition", "PlotPositivePlaneLayers", "Record"] {+                    if let Some(v) = field(key) { settings.insert(key.to_string(), json!(v)); }+                }+                if let Some(ps) = field("Plot.Set") { settings.insert("plottedLayers".into(), json!(ps.split(',').filter(|x| x.ends_with("~1")).count())); }+                outputs.insert(n, json!({"index": n, "type": v, "name": get(&format!("OutputName{n}")).unwrap_or_default(),+                    "document": get(&format!("OutputDocumentPath{n}")).unwrap_or_default(), "enabled": get(&format!("OutputEnabled{n}")).as_deref() != Some("0"),+                    "inMedia": in_media, "settings": settings}));+            }+        }+        let media: Vec<Value> = media.into_values().map(|mut m| {+            let i = m["index"].as_u64().unwrap_or(0) as usize;+            m["outputs"] = json!(outputs.values().filter(|o| o["inMedia"].as_array().map(|a| a.iter().any(|x| x.as_u64() == Some(i as u64))).unwrap_or(false)).map(|o| o["name"].clone()).collect::<Vec<_>>());+            m+        }).collect();+        groups.push(json!({"group": name, "name": get("Name").unwrap_or_default(), "variant": get("VariantName").unwrap_or_default(),+            "containers": media, "outputs": outputs.into_values().collect::<Vec<_>>()}));+    }+    json!(groups)+}++/// altium_outjob_info {outjobPath}: an OutJob's containers, outputs and their key settings, read from the file+/// (Altium need not be running; unsaved edits in an open OutJob are not seen).+pub fn outjob_info(args: &Value) -> Value {+    let Some(p) = s(args, "outjobPath").or_else(|| s(args, "filePath")) else { return json!({"success": false, "errorCode": "bad_args", "error": "outjobPath (.OutJob) is required"}) };+    let text = match std::fs::read(&p) { Ok(b) => String::from_utf8_lossy(&b).to_string(), Err(e) => return json!({"success": false, "errorCode": "not_found", "error": format!("{p}: {e}")}) };+    let groups = outjob_summary(&text);+    json!({"success": true, "outjobPath": p, "groups": groups,+        "_hint": "Run one container with altium_run_outjob {outjobPath, projectPath, medium:'<container name>'}. This reads the saved file: save an OutJob open in Altium first."})+}++/// altium_find_symbol {query, libraries?, kind?}: symbols (or footprints, kind:'footprint') whose name contains the+/// query, across the given libraries or every installed .SchLib/.PcbLib.+pub fn find_symbol(args: &Value) -> Value {+    if let Some(g) = support::need_licensed() { return g; }+    let Some(q) = s(args, "query").map(|q| q.to_lowercase()).filter(|q| !q.is_empty()) else { return json!({"success": false, "errorCode": "bad_args", "error": "query is required (part of the name)"}) };+    let footprints = s(args, "kind").map(|k| k.starts_with("foot")).unwrap_or(false);+    let ext = if footprints { ".pcblib" } else { ".schlib" };+    let libs: Vec<String> = match args.get("libraries").and_then(|v| v.as_array()) {+        Some(a) => a.iter().filter_map(|x| x.as_str().map(str::to_string)).collect(),+        None => {+            let r = crate::library::list_installed_libraries(&json!({}));+            r["libraries"].as_array().cloned().unwrap_or_default().iter().filter_map(|x| x.as_str().map(|p| p.replace('/', "\\"))).collect()+        }+    };+    let mut matches = Vec::new();+    let mut skipped = Vec::new();+    for lib in &libs {+        if !lib.to_lowercase().ends_with(ext) { skipped.push(lib.clone()); continue; }+        if support::agent_open(lib, 60).get("success").and_then(|b| b.as_bool()) != Some(true) { skipped.push(lib.clone()); continue; }+        let r = agent::call("lib_list", &[("kind", (if footprints { "pcb" } else { "sch" }).to_string()), ("limit", "5000".into())], None, Duration::from_secs(120));+        let names = r.get("data").and_then(|d| d.get("names")).and_then(|n| n.as_array()).cloned().unwrap_or_default();+        for n in names.iter().filter_map(|n| n.as_str()) {+            if n.to_lowercase().contains(&q) {+                matches.push(json!({"name": n, "library": lib, "exact": n.to_lowercase() == q}));+            }+        }+    }+    matches.sort_by_key(|m| (m["exact"] != json!(true), m["name"].as_str().map(|x| x.len()).unwrap_or(0)));+    let best = matches.first().cloned();+    json!({"success": true, "query": q, "matches": matches, "best": best, "searched": libs.len() - skipped.len(), "skipped": skipped,+        "_hint": if best.is_some() { if footprints { "altium_show_footprint {libraryPath, footprintName} shows the best match; altium_footprint_primitives / altium_read_symbol read geometry." } else { "altium_show_symbol {libraryPath, symbolName} shows the best match; altium_read_symbol / altium_symbol_prims read it as a style reference; altium_sch_place_part places it." } }+                 else { "No match. Integrated libraries (.IntLib) are not searched (altium_extract_intlib gives their sources); the Altium 365 workspace is searched with altium_workspace_search." }})+}++/// altium_part_parameters {projectPath, name?}: every parameter name the project's parts carry (with how many parts+/// carry it), or with name, that parameter's value on every part.+pub fn part_parameters(args: &Value) -> Value {+    if let Some(g) = support::need_licensed() { return g; }+    let Some(prj) = s(args, "projectPath") else { return json!({"success": false, "errorCode": "bad_args", "error": "projectPath (.PrjPcb) is required"}) };+    let text = std::fs::read(&prj).map(|b| String::from_utf8_lossy(&b).to_string()).unwrap_or_default();+    let project = bom::parse_project(&text, Path::new(&prj).parent().unwrap_or(Path::new(".")));+    if project.sheets.is_empty() { return json!({"success": false, "errorCode": "no_sheets", "error": "the project lists no sheet"}); }+    let want = s(args, "name");+    let mut counts: BTreeMap<String, usize> = BTreeMap::new();+    let mut values = Vec::new();+    for sheet in &project.sheets {+        if let Some(e) = support::must_open(sheet, 60) { return e; }+        let mut p: Vec<(&str, String)> = vec![("pins", "0".into()), ("params", "1".into()), ("limit", "5000".into())];+        match &want { Some(n) => p.push(("paramFilter", format!("|{}|", n.to_ascii_uppercase()))), None => p.push(("paramNamesOnly", "1".into())) }+        let r = agent::call("schematic_data", &p, None, Duration::from_secs(180));+        for c in r.get("data").and_then(|d| d.get("components")).and_then(|c| c.as_array()).cloned().unwrap_or_default() {+            let des = c["designator"].as_str().unwrap_or("").to_string();+            for prm in c["params"].as_array().cloned().unwrap_or_default() {+                let n = prm["name"].as_str().unwrap_or("").to_string();+                if n.is_empty() { continue; }+                match &want {+                    Some(w) if n.eq_ignore_ascii_case(w) => values.push(json!({"designator": des, "value": prm["value"], "sheet": sheet})),+                    Some(_) => {}+                    None => *counts.entry(n).or_insert(0) += 1,+                }+            }+        }+    }+    match want {+        Some(n) => json!({"success": true, "name": n, "count": values.len(), "values": values, "_hint": "Change values with altium_sheet_params {projectPath, designators, params}."}),+        None => json!({"success": true, "names": counts.iter().map(|(k, v)| json!({"name": k, "parts": v})).collect::<Vec<_>>(),+            "_hint": "Pass name to get that parameter's value on every part. altium_bom groups parts by MPN with the purchasing parameters."}),+    }+}++#[cfg(test)]+mod tests {+    use super::*;+    #[test]+    fn outjob_containers_and_outputs() {+        let t = "[OutputJobFile]\r\nVersion=1.0\r\n\r\n[OutputGroup1]\r\nName=Fab\r\nVariantName=[No Variations]\r\nOutputMedium1=Gerbers\r\nOutputMedium1_Type=GeneratedFiles\r\nOutputMedium2=PDF\r\nOutputMedium2_Type=Publish\r\nOutputType1=Gerber\r\nOutputName1=Gerber Files\r\nOutputDocumentPath1=b.PcbDoc\r\nOutputEnabled1=1\r\nOutputEnabled1_OutputMedium1=1\r\nConfiguration1_Item1=GerberUnit=Imperial|NumberOfDecimals=5|Plot.Set=SerializeLayerHash.Version~2,ClassName~TLayerToBoolean,1~1,2~1|Record=GerberView\r\nOutputType2=Schematic Print\r\nOutputName2=Sheets\r\nOutputEnabled2=1\r\nOutputEnabled2_OutputMedium2=1\r\n";+        let g = outjob_summary(t);+        assert_eq!(g[0]["containers"][0]["name"], json!("Gerbers"));+        assert_eq!(g[0]["containers"][0]["outputs"], json!(["Gerber Files"]));+        assert_eq!(g[0]["containers"][1]["outputs"], json!(["Sheets"]));+        assert_eq!(g[0]["outputs"][0]["settings"]["GerberUnit"], json!("Imperial"));+        assert_eq!(g[0]["outputs"][0]["settings"]["plottedLayers"], json!(2));+    }+}
bridge-rs/src/instance.rs+6
@@ -191,6 +191,9 @@ fn licence_of(win: &Value) -> (Option<bool>, Option<String>) {             let l = s.to_lowercase();             (Some(l != "no license" && l != "no licence" && l != "viewer"), Some(s))         }+        // the licence page drops the tail even without a seat ("License Management - Altium Designer (26.11.1)" on an+        // account with only the Viewer, 2026-10-22), so no tail there says nothing+        None if win.get("document").and_then(|d| d.as_str()).map(|d| d.eq_ignore_ascii_case("License Management")).unwrap_or(false) => (None, None),         None if win.get("edition").and_then(|e| e.as_str()) == Some("Develop") => {             (Some(true), Some("Licensed (Develop reports no licence tail)".into()))         }@@ -360,6 +363,9 @@ mod tests {         // Designer with a version and no tail holds its seat (AD 26.11.1, #19)         let w = json!({"edition": null, "adVersion": "26.11.1", "licenceState": null});         assert_eq!(licence_of(&w), (Some(true), Some("Licensed (no licence tail)".to_string())));+        // ...except on the licence page, which never carries a tail+        let w = json!({"edition": null, "adVersion": "26.11.1", "licenceState": null, "document": "License Management"});+        assert_eq!(licence_of(&w), (None, None));         // ...a title with neither (starting up, or not parsed) stays unknown         let w = json!({"edition": null, "licenceState": null});         assert_eq!(licence_of(&w).0, None);
bridge-rs/src/license.rs+49−1
@@ -80,6 +80,30 @@ fn find_shot_id(v: &Value) -> Option<Value> {     } } +/// What the License Management page says, read through UI Automation (its WPF view exposes the banner and product+/// texts): whether the account has any licence this build can use, or only the free Viewer.+fn page_facts(hwnd: i64) -> Value {+    let texts = |q: &str| -> Vec<String> {+        ab::call("desktop_find_controls", json!({"hwnd": hwnd, "contains": q}), &[], 20).get("matches").and_then(|m| m.as_array()).cloned().unwrap_or_default()+            .iter().filter_map(|m| m.get("name").and_then(|n| n.as_str()).map(str::to_string)).collect()+    };+    let request_access = texts("Request access to").into_iter().next();+    let not_using = texts("not using a license").into_iter().next();+    let viewer_only = texts("Altium Designer Viewer").iter().any(|n| n == "Altium Designer Viewer") && request_access.is_some();+    json!({"notUsingLicence": not_using.is_some(), "requestAccessBanner": request_access, "viewerOnly": viewer_only})+}++/// Altium Designer Develop licenses through the workspace's Author seats; with none free it pops a notice ("There are no+/// Author seats available ... view-only mode", a TThemedNotificationPopup whose text UI Automation does not expose) with+/// a Manage Seats button (measured 2026-10-22).+fn author_seat_notice(pid: i64) -> bool {+    let wl = ab::call("desktop_list_windows", json!({"includeHidden": true}), &[], 20);+    let wins = wl.get("windows").or_else(|| wl.get("data").and_then(|d| d.get("windows"))).and_then(|w| w.as_array()).cloned().unwrap_or_default();+    wins.iter().filter(|w| w.get("pid").and_then(|p| p.as_i64()) == Some(pid) && w.get("className").and_then(|c| c.as_str()) == Some("TThemedNotificationPopup"))+        .filter_map(|w| w.get("hwnd").and_then(|h| h.as_i64()))+        .any(|hw| ab::call("desktop_find_controls", json!({"hwnd": hw, "contains": "Manage Seats"}), &[], 15).get("count").and_then(|c| c.as_u64()).unwrap_or(0) > 0)+}+ /// Drive the License Management page. Every call returns a capture so the caller can see the /// state and click anything else itself. pub fn license(args: &Value) -> Value {@@ -99,6 +123,15 @@ pub fn license(args: &Value) -> Value {         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)         agent::call_no_autostart("run_process", &[("process", "Client:MyAccount".into()), ("params", "".into())], Duration::from_secs(20));+    } else if let Some(exe) = crate::lifecycle::x2_exe_pub() {+        // no agent loop (an unlicensed Altium refuses scripts): Altium's own -R command line opens the page, where+        // `show` used to answer with a picture of whatever document was in front (2026-10-22)+        if let Ok(mut child) = crate::lifecycle::spawn_helper_pub(&exe, "-RClient:MyAccount") {+            let t = std::time::Instant::now();+            while t.elapsed() < Duration::from_secs(10) { if let Ok(Some(_)) = child.try_wait() { break; } std::thread::sleep(Duration::from_millis(200)); }+            let _ = child.kill();+        }+        std::thread::sleep(Duration::from_secs(2));     }     std::thread::sleep(Duration::from_secs(1));     // the pixel fallback clicks in THIS screenshot's coordinates, so it needs its shotId (#19: the@@ -111,7 +144,7 @@ pub fn license(args: &Value) -> Value {         frame["coordMap"] = win.get("coordMap").cloned().unwrap_or(Value::Null);     }     let mut out = json!({"success": true, "licenceState": instance::licence_state().get("state"),-        "shot": frame, "windowEtiquette": "background"});+        "page": page_facts(hwnd), "shot": frame, "windowEtiquette": "background"});     if shot_id.is_none() {         out["pixelFallbackUnavailable"] = json!({"screenshotSuccess": win.get("success"), "error": win.get("error"),             "errorCode": win.get("errorCode"), "keys": win.as_object().map(|o| o.keys().cloned().collect::<Vec<_>>())});@@ -207,6 +240,21 @@ pub fn ensure_licensed(args: &Value) -> Value {             "summary": "Started the free-trial flow — finish it in the browser (name the workspace, accept terms, Activate), then altium_ensure_licensed again.",             "needsHuman": "browser trial activation", "shot": tr.get("shot")});     }+    // the account itself may have no licence this build can use (only the free Viewer, with a "Request access ... from+    // an Administrator" banner): no seat will ever free up, so say that rather than blaming a stuck seat (2026-10-22)+    if lic.get("pid").and_then(|p| p.as_i64()).map(author_seat_notice).unwrap_or(false) {+        return json!({"success": false, "errorCode": "no_author_seat", "steps": steps,+            "summary": "Altium Designer Develop says there are no Author seats available in the workspace, so it runs view-only. Every Author seat is taken (by another person or machine on the workspace) or the workspace has none.",+            "needsHuman": "an Author seat: a workspace admin frees or adds one (Manage Seats on Develop's notice), or whoever holds one closes Altium",+            "_hint": "Ask the user. altium_instances on each of their machines shows whether one of THEIR Altiums holds it; if so altium_close it there and call again."});+    }+    let facts = page_facts(lic.get("hwnd").and_then(|h| h.as_i64()).unwrap_or(0));+    if facts["viewerOnly"] == json!(true) {+        return json!({"success": false, "errorCode": "no_licence_on_account", "steps": steps, "page": facts,+            "summary": "Signed in, but the account has no Altium Designer licence this build can use: the licence page lists only the free Viewer and asks to request access from an administrator. No seat will free up by waiting.",+            "needsHuman": "an Altium licence: the account's administrator assigns one (Request Access on the licence page), or the user starts the 15-day trial (altium_ensure_licensed {allowTrial:true}), or switches to the Altium build the licence covers (altium_launch {install})",+            "_hint": "Ask the user which: this is their account and their licence, not a click to make for them."});+    }     let st = license_status(&json!({}));     support::seat_hints(         json!({"success": false, "errorCode": "no_seat_available", "steps": steps,
bridge-rs/src/lifecycle.rs+46−8
@@ -127,11 +127,9 @@ fn x2_exe() -> Option<String> {     if let Some(exe) = crate::instance::instances().first().and_then(|i| i.get("exe").and_then(|e| e.as_str()).map(str::to_string)) {         return Some(exe);     }-    for root in ["C:\\Program Files\\Altium\\AD26", "C:\\Program Files\\Altium\\AD25", "C:\\Program Files\\Altium\\AD24"] {-        let p = format!("{root}\\X2.EXE");-        if std::path::Path::new(&p).exists() {-            return Some(p);-        }+    // every install the machine has (AD26, ADDevelop, ...), newest numbered first; a fixed AD24-26 list missed Develop+    if let Some(exe) = crate::support::first_install().and_then(|i| i.get("exe").and_then(|e| e.as_str()).map(str::to_string)) {+        return Some(exe);     }     let pl = ab::call("process_list", json!({"nameFilter": "X2"}), &[], 15);     pl.get("processes")@@ -722,7 +720,30 @@ fn recover_impl(reason: &str, verify_by_restart: bool) -> Value {         .filter(|p| p.file_name().map(|n| { let n = n.to_string_lossy().to_string(); n.starts_with("AdomAgent_") && n.ends_with(".pas") }).unwrap_or(false))         .collect()).unwrap_or_default();     let instances_before = instance::instances().len();-    if let Some(exe) = x2_exe() {+    // The scripts Altium has OPEN, read off its document tabs: a redeploy deletes the old generation's files, so the+    // one parked on a breakpoint may no longer be on disk (2026-10-22: AdomAgent_a6050c5afb.pas stayed parked through+    // recover, which then opened three idle scripts from the folder instead)+    let main = instance::main_window().and_then(|w| w.get("hwnd").and_then(|h| h.as_i64()));+    let open_tabs: Vec<String> = main.map(|hw| ab::call("desktop_find_controls", json!({"hwnd": hw, "contains": "AdomAgent_"}), &[], 20))+        .and_then(|r| r.get("matches").and_then(|m| m.as_array()).cloned()).unwrap_or_default().iter()+        .filter_map(|m| m.get("name").and_then(|n| n.as_str())).filter(|n| n.to_lowercase().ends_with(".pas")).map(str::to_string).collect();+    if let (Some(exe), Some(hw)) = (x2_exe(), main.filter(|_| !open_tabs.is_empty())) {+        for name in &open_tabs {+            ab::call("desktop_ui_click", json!({"hwnd": hw, "name": name, "role": "button", "reason": "bring an agent script to the front so EditScript:Stop reaches it"}), &[], 15);+            std::thread::sleep(Duration::from_millis(600));+            if let Ok(mut child) = spawn_helper(&exe, "-REditScript:Stop") {+                let _ = wait_helper(&mut child, Duration::from_secs(8));+                let _ = child.kill();+                let _ = child.wait();+            }+            steps.push(json!({"openScriptActivatedAndStopped": name}));+        }+    } else if instances_before > 1 {+        // two Altiums: Altium hands a -R request to whichever instance it likes, whatever image the helper runs from+        // (2026-10-22: closing Develop opened Develop's agent script inside AD26), so opening documents through -R+        // would put them in the wrong Altium+        steps.push(json!({"skipped": "opening agent scripts through -R: two Altiums are running and the request can land in the other one"}));+    } else if let Some(exe) = x2_exe() {         for sc in &scripts {             let arg = format!("-RWorkspaceManager:OpenObject(ObjectKind=Document|FileName=\"{}\")", sc.to_string_lossy());             if let Ok(mut child) = spawn_helper(&exe, &arg) {@@ -770,9 +791,26 @@ fn recover_impl(reason: &str, verify_by_restart: bool) -> Value {     let mut verified = Value::Null;     if verify_by_restart && crate::health::is_running() && instances_after <= 1 {         let started = start(Duration::from_secs(45));-        let ok = started.get("success").and_then(|b| b.as_bool()) == Some(true) && agent::is_alive();-        verified = json!(ok);+        let mut ok = started.get("success").and_then(|b| b.as_bool()) == Some(true) && agent::is_alive();         steps.push(json!({"verifiedByRestartingTheAgent": ok}));+        if !ok {+            // once more: the first restart missed on Altium Designer Develop right after the self-test+            // (intermittent, 2026-10-22; the same recover alone passed): stop again, then start again+            if let Some(exe) = x2_exe() {+                if let Ok(mut child) = spawn_helper(&exe, "-REditScript:Stop") {+                    let _ = wait_helper(&mut child, Duration::from_secs(8));+                    let _ = child.kill();+                    let _ = child.wait();+                }+                steps.push(reap_busy_notice(Duration::from_secs(4)));+            }+            for n in ["hb.txt", "stop", "request.txt", "request.payload", "request.txt.tmp"] { let _ = std::fs::remove_file(ws.join(n)); }+            std::thread::sleep(Duration::from_secs(2));+            let again = start(Duration::from_secs(45));+            ok = again.get("success").and_then(|b| b.as_bool()) == Some(true) && agent::is_alive();+            steps.push(json!({"secondTry": ok}));+        }+        verified = json!(ok);     }     let freed = verified.as_bool().unwrap_or(true);     json!({"success": freed, "engineFree": verified, "steps": steps,
bridge-rs/src/main.rs+2−1
@@ -11,6 +11,7 @@ mod design; mod dialogs; mod health; mod install;+mod inspect; mod instance; mod library; mod license;@@ -37,7 +38,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.63";+pub const BRIDGE_VERSION: &str = "0.2.65";  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+35−6
@@ -297,12 +297,31 @@ pub fn uninstall_verb(args: &Value) -> Value { // ---- launch / close (seat-aware) ---------------------------------------------  pub fn launch(args: &Value) -> Value {-    let inst = match support::first_install() {-        Some(i) => i,-        None => return support::not_installed(),+    // install: which Altium when several are installed: "develop", "designer", a folder name (AD26, ADDevelop)+    // or a path. The default is the newest numbered one, so Designer won over Develop on a machine with both.+    let installs = support::detect_installs();+    let inst = match s(args, "install").map(|w| w.to_lowercase()) {+        Some(w) => {+            let pick = installs.iter().find(|i| {+                let dir = i["base_dir"].as_str().unwrap_or("").to_lowercase();+                let name = dir.rsplit(['\\', '/']).next().unwrap_or("").to_string();+                name == w || dir == w.trim_end_matches(['\\', '/']) || (w == "develop" && name.contains("develop")) || (w == "designer" && !name.contains("develop"))+            }).cloned();+            match pick {+                Some(i) => i,+                None => return json!({"success": false, "errorCode": "install_not_found", "error": format!("no Altium install matches {w}"),+                    "installs": installs.iter().map(|i| i["base_dir"].clone()).collect::<Vec<_>>()}),+            }+        }+        None => match installs.first().cloned() { Some(i) => i, None => return support::not_installed() },     };+    // with install named, "already running" means THAT install: Develop asked for beside a running AD26 used to answer+    // alreadyRunning and start nothing (2026-10-22)+    let named = s(args, "install").is_some();+    let want_exe = inst.get("exe").and_then(|e| e.as_str()).unwrap_or("").to_lowercase();+    let mine_running = || instance::instances().iter().any(|i| i.get("exe").and_then(|e| e.as_str()).map(|e| e.to_lowercase() == want_exe).unwrap_or(false));     let lic = instance::licence_state();-    if lic.get("running").and_then(|b| b.as_bool()) == Some(true) {+    if lic.get("running").and_then(|b| b.as_bool()) == Some(true) && (!named || mine_running()) {         return json!({"success": true, "alreadyRunning": true, "licence": lic,             "windowEtiquette": "background", "_hint": "Already running — nothing launched."});     }@@ -323,7 +342,7 @@ pub fn launch(args: &Value) -> Value {     while support::now() - t0 < wait {         instance::invalidate_snapshot();         let lic = instance::licence_state();-        if lic.get("running").and_then(|b| b.as_bool()) == Some(true) {+        if lic.get("running").and_then(|b| b.as_bool()) == Some(true) && (!named || mine_running()) {             progress::record("cold_start", support::now() - t0, "cold");             progress::end("cold_start", "bridge");             // Altium keeps painting for a moment after its main window exists; hand the focus@@ -454,7 +473,17 @@ pub fn close(args: &Value) -> Value {     }     let t0 = support::now();     let wait = args.get("waitSec").and_then(|v| v.as_f64()).unwrap_or(20.0);-    while support::now() - t0 < wait {+    let mut requit_done = false;+    while support::now() - t0 < wait * if requit_done { 2.0 } else { 1.0 } {+        // Quit ignored with nothing on screen: a script parked on a breakpoint refuses it silently, and the title+        // check above misses one when another document is in front (the licence page, 2026-10-22). Free the engine+        // once and ask again.+        if !requit_done && engine_freed.is_null() && support::now() - t0 > wait * 0.5 && dialogs::scan_scoped(true).is_empty() && lifecycle::modal_orphan().is_none() {+            requit_done = true;+            engine_freed = lifecycle::recover_without_restart("altium_close: Altium ignored the quit with no dialog up; freeing a parked script engine");+            std::thread::sleep(Duration::from_millis(1200));+            send_quit();+        }         instance::invalidate_snapshot();         if instance::instances().is_empty() {             return json!({"success": true, "closedInSec": support::elapsed_round(t0), "agentTeardown": agent_teardown, "engineFreed": engine_freed});
bridge-rs/src/routing.rs+18
@@ -420,6 +420,24 @@ pub fn route_net(args: &Value) -> Value { /// altium_move_parts {parts:[{ref,x,y,rotation}], expectedRevision?} pub fn move_parts(args: &Value) -> Value {     let st = match guarded(args) { Ok(s) => s, Err(e) => return e };+    // each part: absolute x/y, or dx/dy from where it sits; rotation kept when not given (it used to fall+    // back to 0, turning a part that was only meant to move)+    let mut resolved = Vec::new();+    for p in args.get("parts").and_then(|v| v.as_array()).cloned().unwrap_or_default() {+        let r = p.get("ref").and_then(|v| v.as_str()).unwrap_or("").to_string();+        let cur = st["components"].as_array().and_then(|c| c.iter().find(|c| c["ref"].as_str().map(|x| x.eq_ignore_ascii_case(&r)).unwrap_or(false)).cloned());+        let mut q = p.clone();+        if p.get("x").is_none() || p.get("y").is_none() {+            let (Some(c), true) = (cur.as_ref(), p.get("dx").is_some() || p.get("dy").is_some()) else {+                return err("bad_args", format!("part {r}: give x and y (mm), or dx/dy to move it from where it is"), "");+            };+            q["x"] = json!(c["x"].as_f64().unwrap_or(0.0) + p.get("dx").and_then(|v| v.as_f64()).unwrap_or(0.0));+            q["y"] = json!(c["y"].as_f64().unwrap_or(0.0) + p.get("dy").and_then(|v| v.as_f64()).unwrap_or(0.0));+        }+        if p.get("rotation").is_none() { if let Some(c) = cur.as_ref() { q["rotation"] = c["rotation"].clone(); } }+        resolved.push(q);+    }+    let args = &{ let mut a = args.clone(); a["parts"] = json!(resolved); a };     let mut ops = Vec::new();     for p in args.get("parts").and_then(|v| v.as_array()).cloned().unwrap_or_default() {         let r = p.get("ref").and_then(|v| v.as_str()).unwrap_or("");
bridge-rs/src/verbs.rs+115−38
@@ -20,7 +20,7 @@ pub const KNOWN: &[&str] = &[     "read_board", "board_summary", "rules_by_kind", "layers_in_use", "read_schematic",     "cross_check", "review_board", "list_designators", "component_data", "component_pins",     "list_nets", "net_connections", "layers", "copper_layers", "read_rules",-    "set_layer_visibility", "footprint_primitives", "schematic_data", "layer_stack", "net_classes", "diff_pairs", "board_outline", "set_track_width", "find_space",+    "set_layer_visibility", "footprint_primitives", "schematic_data", "layer_stack", "net_classes", "diff_pairs", "board_outline", "set_track_width", "find_space", "selection", "check_orientation", "replicate_layout", "outjob_info", "find_symbol", "part_parameters",     // the live-board lane AI Flow drives     "routing_state", "route_net", "move_parts", "place_vias", "remove_items", "ripup_nets", "apply_pours", "pour_state", "remove_pours", "repour", "routing_drc", "build_board", "set_outline", "set_design_rules", "zoom_to", "add_rule", "build_schematic", "export_manufacturing", "select_net", "silk_state", "apply_silkscreen",     // surfaces@@ -240,8 +240,8 @@ fn describe() -> Value {               "hwnd is NOT read by this verb, although other verbs' hints say {hwnd, button}: target a dialog with titles.",               "success is true even when nothing was pressed or the click failed: check pressed[].ok."],             json!({"button": "OK", "titles": ["Error"]})),-        verb_doc("launch", "Start Altium (newest install) and wait for its main window, then hand the user's own window back. Answers alreadyRunning when one is up.",-            json!({"waitSec": "number, default 180", "background": "bool, default TRUE: remember the user's foreground window and restore it once Altium is up. Pass false only when the user asked to SEE Altium come up."}),+        verb_doc("launch", "Start Altium (the newest numbered install, or the one install names) and wait for its main window, then hand the user's own window back. Answers alreadyRunning when one is up.",+            json!({"install": "optional: develop | designer | a folder name (AD26, ADDevelop) | a path, when several Altiums are installed", "waitSec": "number, default 180", "background": "bool, default TRUE: remember the user's foreground window and restore it once Altium is up. Pass false only when the user asked to SEE Altium come up."}),             json!({"alreadyRunning": "bool", "startedInSec": "float", "licence": "{running, licensed, state, adVersion, edition, hwnd, pid}", "foregroundReturnedTo": "hwnd|null", "windowEtiquette": "background once the user's window is handed back; foreground only with background:false"}), 240,             "Cold start is about 60 s. Next: altium_readiness; licensed:false -> altium_ensure_licensed. Most verbs need this first (errorCode altium_not_running).",             &["readiness", "ensure_licensed", "close", "install"],@@ -374,7 +374,7 @@ fn describe() -> Value {             json!({"data": "{count, nets: [{name, pads}]}", "roundtripMs": "int"}), 90,             "Then altium_net_connections {net} for the pads of one net.",             &["net_connections", "read_board"], &["Needs a .PcbDoc current.", "Pad counts are tallied over pads: a net with no pads on it does not appear."], json!({})),-        verb_doc("net_connections", "Every pad on the board with its net and owner (Designator.Pad), plus a bounding span per net. net filters to one net.",+        verb_doc("net_connections", "Every pad on the board with its net and owner (Designator.Pad), plus a bounding span and the airline length (minimum spanning tree over its pads, airlineMm) per net. net filters to one net.",             json!({"net": "string - optional net name", "limit": "int, default 4000 - caps pads"}),             json!({"data": "{count, pads: [{net, pad: 'U1.3', xMil, yMil}], nets: [{net, minXminYmaxXmaxYpads}]}", "roundtripMs": "int"}), 120,             "Pass net on anything but a small board. The span string is 'minX,minY,maxX,maxY,pads' in mils: a quick measure of how far a net travels.",@@ -413,7 +413,7 @@ fn describe() -> Value {             "Coordinates are mm in Altium's frame, the same as altium_routing_state. There is no clearance check here yet: run your own DRC (AI Flow's gate) or altium_run_drc after.",             &["routing_state", "place_vias", "remove_items", "ripup_nets"], &["Needs the board current.", "A net name must already exist on the board (nets come from the schematic / pads)."], json!({"net": "VOUT", "width": 0.5, "paths": [[{"pad": "L1.2"}, [120.0, 90.0], {"pad": "C6.1"}]]})),         verb_doc("move_parts", "Move and rotate placed components; each part is read back to 0.001 mm and the exact rotation. One undo step.",-            json!({"parts": "[{ref, x, y, rotation}] - mm and degrees, Altium's frame", "expectedRevision": "string (optional) - the revision you last read; refused with stale_board if the board changed", "boardId": "string (optional) - refused with board_mismatch unless this is the current board", "expectPath": "string (optional)"}),+            json!({"parts": "[{ref, x, y, rotation}] - mm and degrees, Altium's frame; or {ref, dx, dy} to move by an offset; rotation kept when left out", "expectedRevision": "string (optional) - the revision you last read; refused with stale_board if the board changed", "boardId": "string (optional) - refused with board_mismatch unless this is the current board", "expectPath": "string (optional)"}),             json!({"revision": "string", "results": "[ok|err:...]", "mismatches": "[{ref, asked, got}] when a part did not land where asked (errorCode readback_mismatch)"}), 300,             "Rotation is absolute, not a delta.", &["routing_state", "route_net"], &["Moving to the other side of the board is not supported yet."], json!({"parts": [{"ref": "U1", "x": 112.0, "y": 92.0, "rotation": 90}]})),         verb_doc("place_vias", "Place vias (through, top to bottom). One undo step, read back.",@@ -498,7 +498,7 @@ fn describe() -> Value {             json!({"rect": "[left, bottom, right, top] mm framed"}), 60,             "For a close look at a joint or a camera shot of one part or net; altium_run_command PCB:Zoom Action=All frames the whole board.",             &["routing_state", "run_command", "animate_view"], &["Needs the board current.", "Coordinates are Altium's (y up), as routing_state reports them."], json!({"net": "SW", "margin": 0.5})),-        verb_doc("footprint_primitives", "Geometry dump of one PLACED component: its tracks, arcs, pads and texts in mils with layer ids; other primitive kinds are counted.",+        verb_doc("footprint_primitives", "Geometry dump of one PLACED component: its tracks, arcs, pads (with solder and paste mask expansion in um) and texts in mils with layer ids; other primitive kinds are counted.",             json!({"designator": "string (required) - a component on the current board, e.g. U1"}),             json!({"data": "{designator, pattern, count, otherPrimitives, primitives: [{kind: track|arc|pad|text, layerId, ...}]}", "roundtripMs": "int"}), 120,             "Use it to verify an imported footprint on a altium_test_board: pad layerId 74 = multi-layer (plated through), which is what altium_plate_vias produces.",@@ -707,12 +707,42 @@ fn describe() -> Value {             &["Takes 15-70 s. Uses real clicks (double-click the result, click the sheet): Altium comes to the front.", "The part lands near the view's lower-left corner first, then moves to x,y: wires do not follow, so connect it after."],             json!({"sheet": "C:/proj/Main.SchDoc", "query": "100nF 0402 50V", "x": 3000, "y": 2000})),         verb_doc("find_space", "Free spots on the board for a part near another (clear of every other part on that side, of that side's tracks and vias, and inside the outline), nearest first; or check:true lists parts that already overlap or leave the outline.",-            json!({"near": "designator to place beside (or at: {x, y} mm)", "part": "designator of the part to place (its own size and origin), or sizeMm: [w, h]", "side": "top | bottom (default the part's)", "clearanceMm": "default 0.25", "edgeMm": "from the outline, default 0.3", "count": "default 5", "radiusMm": "search radius, default 10", "copper": "bool, default true - avoid that side's tracks and vias", "check": "bool - report overlaps instead"}),+            json!({"near": "designator to place beside (or at: {x, y} mm)", "part": "designator of the part to place (its own size and origin), or sizeMm: [w, h]", "side": "top | bottom (default the part's)", "clearanceMm": "default 0.25", "edgeMm": "from the outline, default 0.3", "count": "default 5", "radiusMm": "search radius, default 10", "copper": "bool, default true - avoid that side's tracks and vias", "check": "bool - report overlaps instead", "precise": "bool, with check - also run Altium's batch DRC and list its Component Clearance violations (primitive to primitive)"}),             json!({"spots": "[{x, y (the part origin, for move_parts), distanceMm, extent}]", "overlaps": "[[a, b]] (check)", "outsideOutline": "[designator] (check)"}), 180,             "Move the part with altium_move_parts {parts:[{ref, x, y, rotation}]} to a spot, then altium_routing_drc. Extents follow Altium's Component Clearance rule: 3D bodies and pads, or every primitive but text for a part with no body.",             &["move_parts", "routing_state", "routing_drc"],             &["A part with no 3D body is measured by its silkscreen outline too, as Altium does: dense boards report silk-to-silk overlaps.", "Rotations are kept: rotate first if the part should sit another way."],             json!({"near": "U1", "part": "C10", "clearanceMm": 0.3})),+        verb_doc("selection", "What the user has selected in the current board (components with position mm, rotation, side, footprint, size) or sheet (components with location mils and orientation).",+            json!({"filePath": "optional: the document to make current first", "select": "[refs] - optional: select exactly these first (everything else deselected), to show the user which parts you mean"}),+            json!({"kind": "board | sheet", "selected": "[{ref, x, y, rotation, side, footprint, widthMm, heightMm}] or [{designator, xMil, yMil, orientation, libReference, comment}]"}), 60,+            "Lets the user point at parts in Altium and say 'these': move them with altium_move_parts {ref, dx, dy}, replicate them with altium_replicate_layout.",+            &["move_parts", "replicate_layout", "zoom_to"], &["Only components are reported (selected tracks and vias are not)."], json!({})),+        verb_doc("check_orientation", "Advisory: 2-pad parts whose rotation could be better. Each orthogonal rotation is scored in place by airline length (a routable net's MST with the pad there; a plane-like net's distance to its nearest pad); parts whose best rotation saves minImprovementMm are listed. Nothing is moved.",+            json!({"filePath": "optional .PcbDoc", "parts": "[refs] - optional, default every 2-pad part", "minImprovementMm": "default 0.6", "planeNetPads": "a net with more pads than this is treated as a plane (nearest-pad distance), default 12"}),+            json!({"suggestions": "[{ref, rotation, suggested, airlineNowMm, airlineThenMm, savesMm}]", "checked": "int"}), 120,+            "Rotate with altium_move_parts {parts:[{ref, rotation}]} (position kept).",+            &["move_parts", "find_space", "routing_state"], &["Routed copper is not considered: on a routed board these are hints."], json!({"minImprovementMm": 0.5})),+        verb_doc("replicate_layout", "Copy one channel's placement to matching channels: each target keeps its anchor part where it is and every other part takes the source's offset and rotation from the anchor (turned by the anchors' rotation difference).",+            json!({"filePath": "optional .PcbDoc", "source": "[refs] of the placed channel (2 or more)", "targets": "[[refs], ...] - each in the same order as source", "anchor": "optional ref from source (default the first)"}),+            json!({"moved": "int", "moves": "[{ref, x, y, rotation}]", "footprintMismatches": "[..] parts whose footprint differs from their source part"}), 180,+            "Read the channels with altium_selection or altium_component_data; route the targets after (routing is not copied). Check with altium_find_space {check:true}.",+            &["move_parts", "selection", "find_space"], &["Routing and pours are not copied."], json!({"source": ["U1", "R1", "C1"], "targets": [["U2", "R2", "C2"], ["U3", "R3", "C3"]]})),+        verb_doc("outjob_info", "An OutJob's containers (output media), the outputs in each, and their key settings (units, decimals, plotted layer count), read from the .OutJob file: Altium need not be running.",+            json!({"outjobPath": "path to the .OutJob (required)"}),+            json!({"groups": "[{name, variant, containers: [{name, type, outputs}], outputs: [{type, name, document, enabled, inMedia, settings}]}]"}), 30,+            "Run a container with altium_run_outjob {outjobPath, projectPath, medium:'<container name>'}.",+            &["run_outjob", "export_manufacturing"], &["Reads the saved file: an OutJob open in Altium with unsaved edits is read as saved."], json!({"outjobPath": "C:/proj/board.OutJob"})),+        verb_doc("find_symbol", "Find symbols (or footprints with kind:'footprint') by part of their name across libraries: the given ones, or every installed .SchLib/.PcbLib.",+            json!({"query": "part of the name (required)", "libraries": "[paths] - optional, default the installed libraries", "kind": "symbol (default) | footprint"}),+            json!({"matches": "[{name, library, exact}]", "best": "the exact match, else the shortest name containing it"}), 300,+            "altium_show_symbol / altium_read_symbol on the best match; it is a style reference for a new symbol too.",+            &["show_symbol", "read_symbol", "list_library", "workspace_search"], &[".IntLib files are not searched (altium_extract_intlib gives their sources); the Altium 365 workspace is searched with altium_workspace_search."], json!({"query": "LM358"})),+        verb_doc("part_parameters", "The parameter names a project's parts carry (with how many parts carry each), or with name, that parameter's value on every part.",+            json!({"projectPath": "path to the .PrjPcb (required)", "name": "a parameter name - optional"}),+            json!({"names": "[{name, parts}]", "values": "[{designator, value, sheet}] (with name)"}), 300,+            "Change values with altium_sheet_params; altium_bom groups the purchasing ones by MPN.",+            &["sheet_params", "bom", "schematic_data"], &[], json!({"projectPath": "C:/proj/board.PrjPcb", "name": "MPN"})),         verb_doc("net_classes", "Net classes on the current board: list them with their nets, or make one (replacing a class of that name) with an optional width and clearance rule scoped to it, or remove one with its rules. Power nets wider, a high-voltage class with more clearance.",             json!({"filePath": "path to the .PcbDoc - optional, else the current board", "name": "class name; omit to list", "nets": "[string] board net names (required to make one)", "width": "mm (0.5) or {min, preferred, max} in mm - optional rule 'Adom <name> width'", "clearance": "mm - optional rule 'Adom <name> clearance' (class to All)", "remove": "bool - delete the class and its two rules"}),             json!({"classes": "[{name, superClass, nets}] (list)", "members": "int", "notOnBoard": "[string] nets that were not found", "rules": "[{kind, success, ..}]", "scope": "InNetClass('<name>')"}), 90,@@ -801,14 +831,14 @@ fn describe() -> Value {             &["install_extension", "export_step", "import_kicad"], &["It spawns an X2 -R helper against the running instance: do not loop it."], json!({})),         // ---- library authoring ----         verb_doc("create_footprint", "Create one footprint from a pad list in a PcbLib (the library is created and saved if the path does not exist) and return a proof frame.",-            json!({"name": "string (required)", "libraryPath": "path to a .PcbLib (required)", "description": "string", "pads": "'des,x_mil,y_mil,w_mil,h_mil,rect|round,hole_mil;...' - hole 0 = SMD", "timeout": "seconds, default 90"}),+            json!({"name": "string (required)", "libraryPath": "path to a .PcbLib (required)", "description": "string", "pads": "'des,x_mil,y_mil,w_mil,h_mil,rect|round,hole_mil;...' - hole 0 = SMD", "timeout": "seconds, default 90", "footprints": "[{name, pads, ...}] - optional: make several in one call (each over the shared args)"}),             json!({"data": "{footprint, library, libraryState: opened|created, pads: int}", "shot": "{image: base64 png, width, height}", "windowEtiquette": "background"}), 90,             "The footprint is registered but the library is NOT saved: altium_save {filePath: libraryPath}. Then altium_add_body for 3D and altium_show_footprint to check. For a real part use altium_build_footprint (spec with silkscreen and courtyard).",             &["build_footprint", "add_body", "save", "show_footprint"],             &["errorCode bad_number = a pad field that must be a number was not; nothing is created.", "Units are MILS here; altium_build_footprint specs are millimetres.", "A fresh library carries the stray PCBComponent_1: altium_cleanup_libs."],             json!({"name": "R0603", "libraryPath": "C:/lib/Test.PcbLib", "pads": "1,-30,0,35,37,rect,0;2,30,0,35,37,rect,0"})),         verb_doc("create_symbol", "Create one rectangular symbol from a pin list in a SchLib (the library is created and saved if the path does not exist) and return a proof frame.",-            json!({"name": "string (required)", "libraryPath": "path to a .SchLib (required)", "description": "string", "designator": "string, default 'U'", "bodyWidthMil": "int, default 800", "bodyHeightMil": "int, default 600", "pins": "'des,name,left|right|top|bottom,offset_mil;...'", "timeout": "seconds, default 90"}),+            json!({"name": "string (required)", "libraryPath": "path to a .SchLib (required)", "description": "string", "designator": "string, default 'U'", "bodyWidthMil": "int, default 800", "bodyHeightMil": "int, default 600", "pins": "'des,name,left|right|top|bottom,offset_mil[,part];...' - part 1..N makes a multi-part symbol (a dual op-amp: its A pins part 1, B pins part 2), 0 puts a pin on every part (power)", "symbols": "[{name, pins, ...}] - optional: make several in one call (each over the shared args)", "timeout": "seconds, default 90"}),             json!({"data": "{symbol, library, libraryState: opened|created, pins: int}", "shot": "{image: base64 png, width, height}", "windowEtiquette": "background"}), 90,             "Library is UNSAVED: altium_set_params {footprint, params}, then altium_save {filePath: libraryPath}. For multi-mode symbols use altium_build_symbol_modes.",             &["build_symbol_modes", "set_params", "read_symbol", "save"],@@ -959,8 +989,8 @@ fn describe() -> Value {             &["It brings up the License Management page inside Altium (when the agent is resident), replacing the view the user had.", "One On-Demand seat serves every Altium on the account: 'no seats available' usually means ANOTHER running Altium holds it - altium_close that one."], json!({})),         verb_doc("ensure_licensed", "One call to get a usable Altium: launch if needed, then claim a seat (Use License via UIA). Optional escalations: sign out to force a seat release, or start the free-trial flow.",             json!({"launchWaitSec": "int, default 240", "allowSignInReset": "bool - if no seat is free, Sign Out (the human must sign back in)", "allowTrial": "bool - if no seat is free, open the trial flow in the browser"}),-            json!({"alreadyLicensed": "bool", "licenceState": "string", "steps": "[..]", "summary": "string", "needsHuman": "string - only for browser sign-in / trial activation", "seatStuck": "bool", "shot": "{..}"}), 300,-            "success -> drive. errorCode no_seat_available: find the Altium holding the seat (altium_instances here, or another machine) and altium_close it, then call again.",+            json!({"alreadyLicensed": "bool", "licenceState": "string", "steps": "[..]", "summary": "string", "needsHuman": "string - only for browser sign-in / trial activation / a licence the account lacks", "seatStuck": "bool", "page": "{notUsingLicence, requestAccessBanner, viewerOnly} read off the licence page", "shot": "{..}"}), 300,+            "success -> drive. errorCode no_seat_available: find the Altium holding the seat (altium_instances here, or another machine) and altium_close it, then call again. errorCode no_licence_on_account: the account has only the Viewer; ask the user (admin assigns a licence, trial, or the other Altium build). errorCode no_author_seat (Develop): every workspace Author seat is taken; ask the user.",             &["license", "license_status", "instances", "close"],             &["One On-Demand seat serves EVERY Altium on the account. A KILLED Altium strands the seat (page shows Seats 0/1 while every claim fails): altium_close releases it, waiting does not.",               "allowSignInReset:true signs the user OUT and ends with needsHuman (browser sign-in). Use it only when nothing else holds the seat.",@@ -1074,6 +1104,51 @@ fn board_read(cmd: &str, params: Vec<(&str, String)>, timeout: u64) -> Value { /// The dispatch chokepoint — instance selection, taskbar badge/progress and the run log wrap /// EVERY verb here (one wrapper, not per-verb calls someone forgets), exactly like the Python /// bridge's `dispatch_command`.+/// A verb that makes one library item, or with `list` an array of items (each merged over the shared args, e.g.+/// libraryPath): every item is made in turn and reported, so a whole family is one call.+fn batch_or_one(args: &Value, list: &str, one: fn(&Value) -> Value) -> Value {+    let Some(items) = args.get(list).and_then(|v| v.as_array()).cloned() else { return one(args) };+    let mut results = Vec::new();+    for it in items {+        let mut a = args.clone();+        if let Some(o) = a.as_object_mut() { o.remove(list); }+        if let (Some(base), Some(over)) = (a.as_object_mut(), it.as_object()) { for (k, v) in over { base.insert(k.clone(), v.clone()); } }+        let r = one(&a);+        results.push(json!({"name": it.get("name"), "success": r.get("success"), "errorCode": r.get("errorCode"), "error": r.get("error")}));+    }+    let failed = results.iter().filter(|r| r["success"] != json!(true)).count();+    json!({"success": failed == 0, "made": results.len() - failed, "failed": failed, "results": results,+        "_hint": "One call per item under the hood: a failure leaves the others made. Check them with altium_list_library."})+}++/// After an eval: did Altium raise a script error box? The reaper photographs and presses a lone-OK "Error";+/// a two-button "X2 ... Continue execution?" is answered OK here. Watches 3.5 s.+fn eval_error_since(since: u64, lane: &str) -> Option<Value> {+    let t = std::time::Instant::now();+    while t.elapsed() < Duration::from_millis(3500) {+        if let Some(ev) = crate::dialogs::script_error_since(since) {+            return Some(json!({"success": false, "errorCode": "script_error", "lane": lane, "errorShot": ev.get("shot"),+                "_hint": "Altium raised a script error (errorShot; a blank box usually means an API call this build does not have). The bridge pressed it away. A Var block before Begin is allowed; strings take single quotes."}));+        }+        for d in crate::dialogs::scan() {+            let title = d.get("title").and_then(|x| x.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(|x| x.trim().to_lowercase()).collect()).unwrap_or_default();+            if title == "X2" && labels == ["ok", "cancel"] {+                if let Some(hw) = d.get("hwnd").and_then(|h| h.as_i64()) {+                    std::thread::sleep(Duration::from_millis(500));+                    let shot = crate::ab::call("desktop_screenshot_base64", json!({"hwnd": hw}), &[], 10);+                    let _ = crate::dialogs::press(hw, "OK");+                    return Some(json!({"success": false, "errorCode": "script_error", "lane": lane,+                        "errorShot": shot.get("image").or_else(|| shot.get("base64")).cloned(),+                        "_hint": "READ errorShot (e.g. 'Undeclared identifier: X ... Continue execution?'); the bridge answered OK."}));+                }+            }+        }+        std::thread::sleep(Duration::from_millis(250));+    }+    None+}+ pub fn handle(command: &str, args: &Value, forwarded: &[(String, String)]) -> Value {     let cmd = command.strip_prefix("altium_").unwrap_or(command);     // Verbs that describe the machine rather than drive ONE Altium are exempt from instance@@ -1248,39 +1323,25 @@ pub fn dispatch(command: &str, args: &Value, _forwarded: &[(String, String)]) ->                 let since = std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).map(|d| d.as_millis() as u64).unwrap_or(0);                 let mut r = lifecycle::run_script_text_lane(&text, Duration::from_secs(timeout.max(30)));                 r["lane"] = json!("cli_run_script_text");-                // the helper returns once Altium has the text; a compile or runtime error shows up as a box-                // afterwards. Every failing eval used to answer success:true (2026-10-22): watch for it.                 if r.get("success").and_then(|b| b.as_bool()) == Some(true) {-                    let t = std::time::Instant::now();-                    while t.elapsed() < Duration::from_millis(3500) {-                        if let Some(ev) = crate::dialogs::script_error_since(since) {-                            return json!({"success": false, "errorCode": "script_error", "lane": "cli_run_script_text", "errorShot": ev.get("shot"),-                                "_hint": "READ errorShot: Altium's error box (e.g. 'Undeclared identifier: X at Line N'). The bridge pressed it away. A Var block before Begin is allowed; strings take single quotes."});-                        }-                        for d in crate::dialogs::scan() {-                            let title = d.get("title").and_then(|x| x.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(|x| x.trim().to_lowercase()).collect()).unwrap_or_default();-                            if title == "X2" && labels == ["ok", "cancel"] {-                                if let Some(hw) = d.get("hwnd").and_then(|h| h.as_i64()) {-                                    std::thread::sleep(Duration::from_millis(500));-                                    let shot = crate::ab::call("desktop_screenshot_base64", json!({"hwnd": hw}), &[], 10);-                                    let _ = crate::dialogs::press(hw, "OK");-                                    return json!({"success": false, "errorCode": "script_error", "lane": "cli_run_script_text",-                                        "errorShot": shot.get("image").or_else(|| shot.get("base64")).cloned(),-                                        "_hint": "READ errorShot (e.g. 'Undeclared identifier: X ... Continue execution?'); the bridge answered OK. A Var block before Begin is allowed."});-                                }-                            }-                        }-                        std::thread::sleep(Duration::from_millis(250));-                    }+                    if let Some(e) = eval_error_since(since, "cli_run_script_text") { return e; }                 }                 if r.get("_hint").is_none() {                     r["_hint"] = json!("Ran via X2 -RScriptingSystem:RunScriptText; no error box appeared within 3.5 s. A Var block before Begin is allowed. Script on the CURRENT document: open the one you mean first (altium_open_document), and capture output by writing a file.");                 }                 return r;             }+            let since = std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).map(|d| d.as_millis() as u64).unwrap_or(0);             let mut r = agent::call("eval", &[], Some(&text), Duration::from_secs(timeout.max(30)));             r["lane"] = json!("agent");+            // the agent hands the text to Altium's script engine and answers at once; an error box comes after,+            // so the agent lane answered success:true for a script that halted the engine (2026-10-22)+            if r.get("success").and_then(|b| b.as_bool()) == Some(true) {+                if let Some(mut e) = eval_error_since(since, "agent") {+                    e["recovered"] = lifecycle::recover("auto: the eval script raised an error").get("success").cloned().unwrap_or(Value::Null);+                    return e;+                }+            }             r         }         "run_command" => {@@ -1371,7 +1432,17 @@ pub fn dispatch(command: &str, args: &Value, _forwarded: &[(String, String)]) ->             Some(d) => board_read("component_pins", vec![("designator", d)], 90),         },         "list_nets" => board_read("list_nets", vec![], 90),-        "net_connections" => board_read("net_connections", vec![("net", s(args, "net").unwrap_or_default()), ("limit", s(args, "limit").unwrap_or("4000".into()))], 120),+        "net_connections" => {+            let mut r = board_read("net_connections", vec![("net", s(args, "net").unwrap_or_default()), ("limit", s(args, "limit").unwrap_or("4000".into()))], 120);+            // each net's airline length: the minimum spanning tree over its pads (what routing it will at least cost)+            if let Some(d) = r.get_mut("data") {+                let pads = d["pads"].as_array().cloned().unwrap_or_default();+                let mut by: std::collections::BTreeMap<String, Vec<(f64, f64)>> = std::collections::BTreeMap::new();+                for p in &pads { if let Some(n) = p["net"].as_str().filter(|n| !n.is_empty()) { by.entry(n.to_string()).or_default().push((p["xMil"].as_f64().unwrap_or(0.0) * 0.0254, p["yMil"].as_f64().unwrap_or(0.0) * 0.0254)); } }+                d["airlineMm"] = json!(by.iter().map(|(n, pts)| (n.clone(), json!((crate::design::mst_pub(pts) * 100.0).round() / 100.0))).collect::<serde_json::Map<String, Value>>());+            }+            r+        }         "layers" => board_read("layers", vec![], 90),         "copper_layers" => board_read("copper_layers", vec![], 90),         "read_rules" => board_read("read_rules", vec![], 90),@@ -1470,6 +1541,12 @@ pub fn dispatch(command: &str, args: &Value, _forwarded: &[(String, String)]) ->         "sch_swap_part" => crate::sch::swap_part(args),         "autoroute" => crate::route::autoroute(args),         "net_classes" => crate::design::net_classes(args),+        "selection" => crate::design::selection(args),+        "check_orientation" => crate::design::check_orientation(args),+        "replicate_layout" => crate::design::replicate_layout(args),+        "outjob_info" => crate::inspect::outjob_info(args),+        "find_symbol" => crate::inspect::find_symbol(args),+        "part_parameters" => crate::inspect::part_parameters(args),         "find_space" => crate::design::find_space(args),         "workspace_search" => crate::workspace::workspace_search(args),         "workspace_place" => crate::workspace::workspace_place(args),@@ -1483,8 +1560,8 @@ pub fn dispatch(command: &str, args: &Value, _forwarded: &[(String, String)]) ->         "install_extension" => outputs::install_extension(args),         "extension_ping" => outputs::extension_ping(args),         // ---- library authoring -----        "create_footprint" => library::create_footprint(args),-        "create_symbol" => library::create_symbol(args),+        "create_footprint" => batch_or_one(args, "footprints", library::create_footprint),+        "create_symbol" => batch_or_one(args, "symbols", library::create_symbol),         "build_footprint" => library::build_footprint(args),         "build_symbol_modes" => library::build_symbol_modes(args),         "add_body" => library::add_body(args),
dev-skills/altium-bridge-test/run_verb_tests.py+11
@@ -231,6 +231,12 @@ def matrix(w):     M["diff_pairs"] = dict(phase=3.5, **ok(filePath=LB, name="BAT", pos="N1", neg="GND"))     M["set_track_width"] = dict(phase=3.5, timeout=300, **ok(filePath=LB, netClass="BatteryPwr", widthMm=0.3))     M["board_outline"] = dict(phase=3.5, **ok(filePath=LB, mech=1), check=lambda d: (d.get("drawn") or 0) > 0 or (d.get("alreadyThere") or 0) > 0)+    # 0.2.65: selection (select U1, U2 and read them back), orientation advice, layout replication (U2 keeps its+    # offset from U1 on itself: a no-op move), and the read-only OutJob / symbol / parameter inspection+    M["selection"] = dict(phase=3.5, **ok(filePath=LB, select=["U1", "U2"]), check=lambda d: d.get("count") == 2)+    M["check_orientation"] = dict(phase=3.5, timeout=200, **ok(filePath=LB))+    M["replicate_layout"] = dict(phase=3.5, timeout=200, **ok(filePath=LB, source=["U1", "U2"], targets=[["U1", "U2"]]))+    M["find_symbol"] = dict(phase=3.5, timeout=300, **ok(query="TST", libraries=[SL]), check=lambda d: len(d.get("matches") or []) >= 1)     # 0.2.61: free space beside U1 for U2's footprint, and the overlap check     M["find_space"] = dict(phase=3.5, timeout=200, **ok(filePath=LB, near="U1", part="U2", count=3), check=lambda d: len(d.get("spots") or []) >= 1)     M["build_schematic"] = dict(phase=3.5, timeout=600, **ok(schPath=LS, libraryPath=SL, pcbLibPath=PL, pcbPath=LB, projectPath=LP, title="battery lane",@@ -238,6 +244,8 @@ def matrix(w):                                                                   {"ref": "U2", "symbol": "TST_SOIC8", "block": "core", "footprint": "TST_SOIC8"}],                                                            nets=lane_nets))     M["export_manufacturing"] = dict(phase=3.5, timeout=400, **ok(projectPath=LP, pcbPath=LB))+    M["outjob_info"] = dict(phase=3.5, **ok(outjobPath=D + "/lane.Manufacturing.OutJob"), check=lambda d: len((d.get("groups") or [{}])[0].get("containers") or []) >= 1)+    M["part_parameters"] = dict(phase=3.5, timeout=300, **ok(projectPath=LP), check=lambda d: len(d.get("names") or []) >= 1)      # ---- 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@@ -334,6 +342,7 @@ def main():     ap.add_argument("--target", required=True)     ap.add_argument("--thread", default="altium-bridge battery")     ap.add_argument("--only", default="")+    ap.add_argument("--install", default="", help="which Altium to launch when several are installed: develop | designer | AD26 | ADDevelop")     ap.add_argument("--keep-open", action="store_true")     ap.add_argument("--fixtures", default=os.path.join(HERE, "fixtures"))     ap.add_argument("--json", default="")@@ -379,6 +388,8 @@ def main():     if not verbs:         sys.exit(f"altium_describe returned no allVerbs: {str(live)[:300]}")     M = matrix(w)+    if a.install:+        M["launch"]["args"]["install"] = a.install     uncovered = [v for v in verbs if v not in M]     stale = [v for v in M if v not in verbs]     only = [x for x in a.only.split(",") if x]
package.json+1−1
@@ -1,6 +1,6 @@ {   "slug": "altium-bridge",-  "version": "0.2.63",+  "version": "0.2.65",   "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.63",+  "version": "0.2.65",   "hero": {     "type": "image",     "path": "screenshots/hero.png"
skills/altium-boards/SKILL.md+23
@@ -213,3 +213,26 @@ every primitive but text for a part with no body), of that side's tracks and via nearest first. Copper pours are not avoided: `altium_repour` after the move clears them around the new pads. Measured: the first version measured pads only and put C20 against TP2's silkscreen, which Altium's DRC flagged; the Altium-matched extent avoids it.++## Selection, offsets, orientation and channel replication (0.2.65)++```bash+altium_selection '{}'                                         # what the user selected (board: mm; sheet: mils)+altium_selection '{"select":["U1","C3"]}'                     # select exactly these, to show the user+altium_move_parts '{"parts":[{"ref":"R1","dx":0.5,"dy":-0.25}]}'   # by an offset; rotation kept when left out+altium_check_orientation '{"minImprovementMm":0.5}'           # advisory: 2-pad parts with a better rotation+altium_replicate_layout '{"source":["U1","R1","C1"],"targets":[["U2","R2","C2"],["U3","R3","C3"]]}'+altium_find_space '{"check":true,"precise":true}'             # + Altium's own Component Clearance DRC+altium_net_connections '{"net":"FB"}'                         # airlineMm: each net's MST length+```++- `check_orientation` scores each 2-pad part's four rotations by airline length: the MST of a routable net with+  the pad there, or the distance to the nearest pad of a plane-like net (GND). Measured on the buck: 20 parts+  checked; C1 at 0 degrees saves 2.5 mm, R8 at 180 saves 1.3 mm.+- `replicate_layout` keeps each target channel's anchor (the first part, or `anchor`) and puts the others at the+  source's offsets and rotations, turned by the anchors' rotation difference; routing is not copied. Measured: a+  pair copied onto a pair rotated 90 degrees landed at the rotated offset within 1 mil. Footprint differences are+  reported in `footprintMismatches`.+- A move without `rotation` now keeps the part's rotation (it used to turn it to 0).+- `footprint_primitives` reports each pad's solder and paste mask expansion (um).+
skills/altium-instances/SKILL.md+21
@@ -27,6 +27,21 @@ altium_status  '{"instance":30108}'       # by pid The `key` is the install folder (`AD26`, `ADDevelop`) because it survives a restart, which a pid does not. Two instances of the SAME install fall back to `pid<n>`. +## Starting a particular one (0.2.64)++```bash+altium_launch '{"install":"develop"}'    # Altium Designer Develop+altium_launch '{"install":"designer"}'   # Altium Designer (AD26)+```++With several installed and none running, a plain `altium_launch` starts the newest NUMBERED install, so+Designer (AD26) always won over Develop (ADDevelop). `install` takes `develop`, `designer`, a folder name or a+path. The two share the machine's one licence seat, so run one at a time. Develop greets every launch with+an "Update Available" notice that blocked `altium_close` and every verb; the bridge's dialog watcher now+closes it (Close, never Update Now). Develop asks about unsaved documents one at a time ("Unsaved Changes":+Save / Don't Save / Cancel), and `altium_close {discardChanges:true}` answers Don't Save there (0.2.62).+The verb battery takes `--install develop` to test Develop on a machine that has both.+ ## The failure you cannot see: one shared agent workspace  The resident agent talks over files (`request.txt`, `hb.txt`, `response_<id>.json`). Before@@ -62,6 +77,12 @@ All three were live bugs, all found within an hour of running two Altiums:    built on it — so a Develop-targeted verb was judged against Designer's licence and refused    `not_licensed` while Develop held the seat. Instance-awareness has to reach all the way down. +4. **A `-R` hand-off can land in the other Altium even from the right image.** On 2026-10-22,+   closing Develop beside a running AD26 opened Develop's agent script inside AD26. `recover` no+   longer opens documents through `-R` while two Altiums run; it stops only the scripts it can+   see on the selected instance's own tabs. Prefer one Altium at a time for anything that+   relies on `-R` (close, recover, licence page).+ ## The stray-helper reaper must never kill a real Altium  A `-R` helper carries its request in its own command line (`-R…ScriptingSystem`); a real Altium never
skills/altium-libraries/SKILL.md+18
@@ -149,3 +149,21 @@ refuse, `errorCode: name_required`, naming the count. Always pass the name. **`extract_intlib` reports what it actually extracted (from 0.2.38).** `sources[]` used to list every `.SchLib`/`.PcbLib` open anywhere in the workspace; it is now scoped to the IntLib's own folder, which the reply names in `searchedUnder`.++## Search, multi-part symbols, batches (0.2.65)++```bash+altium_find_symbol '{"query":"LM358"}'                         # every installed .SchLib (or libraries:[...])+altium_find_symbol '{"query":"0402","kind":"footprint"}'+altium_create_symbol '{"name":"LM358","libraryPath":"C:\\lib\\Parts.SchLib","pins":"1,OUTA,right,200,1;2,-INA,left,100,1;3,+INA,left,300,1;7,OUTB,right,200,2;6,-INB,left,100,2;5,+INB,left,300,2;8,V+,top,300,0;4,V-,bottom,300,0"}'+altium_create_symbol '{"libraryPath":"C:\\lib\\Parts.SchLib","symbols":[{"name":"A","pins":"..."},{"name":"B","pins":"..."}]}'+altium_create_footprint '{"libraryPath":"C:\\lib\\Parts.PcbLib","footprints":[{"name":"FP1","pads":"..."},{"name":"FP2","pads":"..."}]}'+```++- A pin's 5th field is its part: 1..N makes a multi-part symbol, and 0 puts the pin on every part (power).+  Measured: part 2 of a dual op-amp, placed with `altium_sch_place_part {partId:2}`, carried OUTB, -INB and +INB+  plus the shared V+ and V-.+- `find_symbol` puts an exact name first, then the shortest names containing the query. .IntLib files are not+  searched; the Altium 365 workspace is searched with `altium_workspace_search`.+- `symbols` / `footprints` lists make each item in turn and report each one.+
skills/altium-licensing/SKILL.md+4
@@ -63,6 +63,10 @@ Do NOT reach for `force:true`: killing the process is what strands the seat.   free it returns `no_seat_available`; with `allowSignInReset:true` it signs out instead and   returns `signed_out_for_reset` (`needsHuman: "browser sign-in"`); with `allowTrial:true` it   opens the trial flow and returns `trial_needs_browser`. Every reply carries `steps[]`.+  `no_licence_on_account` is different from a stuck seat: the licence page lists only the free+  Viewer and asks to "Request access ... from an Administrator", so waiting frees nothing. The+  account's admin assigns a licence, the user starts a trial, or the other Altium build (the+  one the licence covers) is launched. That choice is the user's: ask. - `altium_setup_trial`: onboarding from nothing to a working trial. Returns ONE next action   per call with a `stage` (`install`, `launch`, `signin`, `done`) and names human steps in   `needsHuman` (the installer's EULA click, the browser sign-in) rather than faking them.
skills/altium-outputs/SKILL.md+6
@@ -41,6 +41,12 @@ Parent skill: **altium-bridge**   another OutJob and run it with `altium_run_outjob`. - `altium_export_pdf` answers the file in `pdfPath`. +## Read an OutJob without running it (0.2.65)++`altium_outjob_info {outjobPath}` reads the .OutJob file: its containers (output media) with the outputs in+each, and each output's key settings (Gerber units and decimals, the number of plotted layers, CSV or text).+Altium need not be running. Run one container with `altium_run_outjob {outjobPath, projectPath, medium:'<name>'}`.+ ## Caveats (current, honest)  - **Headless STEP export works (0.2.46)**: `altium_export_step {filePath, output}` writes an
src/BRIDGE_VERSION+1−1
@@ -1 +1 @@-0.2.63+0.2.65
src/agent/AdomAgent.pas.tmpl+96−12
@@ -14,7 +14,7 @@       shell while this loop is resident raises the modal "Another script       executing now" on the MAIN instance and makes it refuse every later -R       until dismissed.  So while resident, EVERYTHING goes through this loop.-    - RunScriptText accepts STATEMENTS only (Begin ... End;), no Var block.+    - RunScriptText accepts an optional Var block, then Begin ... End; (verified AD 26.11.1, 2026-10-22).     - CreateOleObject is NOT available ("Undeclared identifier"), so no HTTP       from inside; file drop is the transport.     - DelphiScript is untyped: no records/classes; Try/Except works.@@ -927,7 +927,7 @@ Var     Rect  : ISch_Rectangle;     Pin   : ISch_Pin;     Pins  : TStringList;-    I, W, H, Off : Integer;+    I, W, H, Off, Parts, P : Integer;     Name, LibPath, Desc, Des, Spec, Side, LibState : String; Begin     Name := Param(Lines, 'name');@@ -955,19 +955,27 @@ Begin     Comp.LibReference := Name;     Comp.ComponentDescription := Desc;     Comp.Designator.Text := Des;-    Rect := SchServer.SchObjectFactory(eRectangle, eCreate_Default);-    Rect.OwnerPartId := 1;-    Rect.OwnerPartDisplayMode := 0;-    Rect.Location := Point(MilsToCoord(0), MilsToCoord(0));-    Rect.Corner := Point(MilsToCoord(W), MilsToCoord(H));-    Rect.IsSolid := True;-    Comp.AddSchObject(Rect);     Pins := SplitSemi(Param(Lines, 'pins'));+    { multi-part: a pin's 5th field is its part (1..N; 0 = on every part, e.g. power); one body per part }+    Parts := 1;+    For I := 0 To Pins.Count - 1 Do+        If StrToIntDef(Field(Pins[I], 5), 1) > Parts Then Parts := StrToIntDef(Field(Pins[I], 5), 1);+    If Parts > 1 Then Comp.PartCount := Parts;+    For P := 1 To Parts Do+    Begin+        Rect := SchServer.SchObjectFactory(eRectangle, eCreate_Default);+        Rect.OwnerPartId := P;+        Rect.OwnerPartDisplayMode := 0;+        Rect.Location := Point(MilsToCoord(0), MilsToCoord(0));+        Rect.Corner := Point(MilsToCoord(W), MilsToCoord(H));+        Rect.IsSolid := True;+        Comp.AddSchObject(Rect);+    End;     For I := 0 To Pins.Count - 1 Do     Begin         Spec := Pins[I];         Pin := SchServer.SchObjectFactory(ePin, eCreate_Default);-        Pin.OwnerPartId := 1;+        Pin.OwnerPartId := StrToIntDef(Field(Spec, 5), 1);         Pin.OwnerPartDisplayMode := 0;         Pin.Designator := Field(Spec, 1);         Pin.Name := Field(Spec, 2);@@ -1005,7 +1013,7 @@ Begin     ResetParameters;     AddStringParameter('Action', 'All');     RunProcess('Sch:Zoom');-    Result := '{' + JRaw('ok', 'true') + ',' + JStr('symbol', Name) + ',' + JStr('library', LibPath) + ',' + JStr('libraryState', LibState) + ',' + JRaw('pins', IntToStr(I)) + '}';+    Result := '{' + JRaw('ok', 'true') + ',' + JStr('symbol', Name) + ',' + JStr('library', LibPath) + ',' + JStr('libraryState', LibState) + ',' + JRaw('pins', IntToStr(I)) + ',' + JRaw('parts', IntToStr(Parts)) + '}'; End;  { --------------------------------------------- library management (Barrett port) --- }@@ -5277,6 +5285,78 @@ Begin     Want.Free; Cnt.Free; Hits.Free; End; +{ selection: what the user has selected in the CURRENT document. Board: components with position (raw, relative+  to the origin), rotation, side, footprint and extent. Sheet: components with location (mils) and orientation. }+Function CmdSelection(Lines : TStringList) : String;+Var+    Board : IPCB_Board;+    BIt   : IPCB_BoardIterator;+    Comp  : IPCB_Component;+    Doc   : ISch_Document;+    It    : ISch_Iterator;+    C     : ISch_Component;+    R     : TCoordRect;+    S, SetTo : String;+    Want  : TStringList;+Begin+    S := '';+    { set=A;B: select exactly these (everything else deselected) before reading }+    SetTo := UpperCase(Param(Lines, 'set'));+    Want := TStringList.Create; Want.Delimiter := ';'; Want.StrictDelimiter := True; Want.DelimitedText := SetTo;+    Board := PCBServer.GetCurrentPCBBoard;+    If Board <> Nil Then+    Begin+        BIt := Board.BoardIterator_Create;+        BIt.AddFilter_ObjectSet(MkSet(eComponentObject));+        BIt.AddFilter_LayerSet(AllLayers);+        BIt.AddFilter_Method(eProcessAll);+        Comp := BIt.FirstPCBObject;+        While Comp <> Nil Do+        Begin+            If SetTo <> '' Then Comp.Selected := Want.IndexOf(UpperCase(Comp.Name.Text)) >= 0;+            If Comp.Selected Then+            Begin+                R := Comp.BoundingRectangle;+                If S <> '' Then S := S + ',';+                S := S + '["' + JsonEsc(Comp.Name.Text) + '",' + IntToStr(Comp.x - Board.XOrigin) + ',' + IntToStr(Comp.y - Board.YOrigin) + ',' ++                    IntToStr(Round(Comp.Rotation * 1000)) + ',' + JBool(Comp.Layer = eBottomLayer) + ',"' + JsonEsc(Comp.Pattern) + '",' ++                    IntToStr(R.Right - R.Left) + ',' + IntToStr(R.Top - R.Bottom) + ']';+            End;+            Comp := BIt.NextPCBObject;+        End;+        Board.BoardIterator_Destroy(BIt);+        If SetTo <> '' Then Board.ViewManager_FullUpdate;+        Want.Free;+        Result := '{' + JRaw('ok','true') + ',' + JStr('kind','board') + ',' + JStr('document', Board.FileName) + ',' + JRaw('selected', '[' + S + ']') + '}';+        Exit;+    End;+    Doc := SchServer.GetCurrentSchDocument;+    If (Doc <> Nil) And (Doc.ObjectID <> eSchLib) Then+    Begin+        It := Doc.SchIterator_Create;+        It.AddFilter_ObjectSet(MkSet(eSchComponent));+        C := It.FirstSchObject;+        While C <> Nil Do+        Begin+            If SetTo <> '' Then C.Selection := Want.IndexOf(UpperCase(C.Designator.Text)) >= 0;+            If C.Selection Then+            Begin+                If S <> '' Then S := S + ',';+                S := S + '["' + JsonEsc(C.Designator.Text) + '",' + IntToStr(Round(CoordToMils(C.Location.X))) + ',' + IntToStr(Round(CoordToMils(C.Location.Y))) + ',' ++                    IntToStr(C.Orientation) + ',"' + JsonEsc(C.LibReference) + '","' + JsonEsc(C.Comment.Text) + '"]';+            End;+            C := It.NextSchObject;+        End;+        Doc.SchIterator_Destroy(It);+        If SetTo <> '' Then Doc.GraphicallyInvalidate;+        Want.Free;+        Result := '{' + JRaw('ok','true') + ',' + JStr('kind','sheet') + ',' + JStr('document', Doc.DocumentName) + ',' + JRaw('selected', '[' + S + ']') + '}';+        Exit;+    End;+    Want.Free;+    Result := '{' + JRaw('ok','false') + ',' + JStr('errorCode','no_current_document') + ',' + JStr('error','the current document is neither a board nor a schematic sheet') + '}';+End;+ { every copper layer of a width rule (top, mid 1-30, bottom): a rule set on top and bottom only left inner   layers at Altium's default once a board had them } Procedure SetWidthAllLayers(Rule : IPCB_Rule; A, B, C : Integer);@@ -5492,7 +5572,10 @@ Begin             Item := '{' + JStr('kind','pad') + ',' + JStr('name', Pad.Name) + ',' + JRaw('layerId', IntToStr(Pad.Layer)) + ',' +                 JRaw('x', IntToStr(Round(CoordToMils(Pad.x)))) + ',' + JRaw('y', IntToStr(Round(CoordToMils(Pad.y)))) + ',' +                 JRaw('wMil', IntToStr(Round(CoordToMils(Pad.TopXSize)))) + ',' + JRaw('hMil', IntToStr(Round(CoordToMils(Pad.TopYSize)))) + ',' +-                JRaw('holeMil', IntToStr(Round(CoordToMils(Pad.HoleSize)))) + '}';+                JRaw('holeMil', IntToStr(Round(CoordToMils(Pad.HoleSize)))) + ',' ++                { micrometres as integers: FloatToStr writes a comma decimal on some Windows locales, which breaks JSON }+                JRaw('solderMaskExpUm', IntToStr(Round(CoordToMils(Pad.SolderMaskExpansion) * 25.4))) + ',' ++                JRaw('pasteMaskExpUm', IntToStr(Round(CoordToMils(Pad.PasteMaskExpansion) * 25.4))) + '}';         End         Else If Prim.ObjectId = eTextObject Then         Begin@@ -8184,6 +8267,7 @@ Begin     Else If Cmd = 'sch_rename_at' Then Result := CmdSchRenameAt(Lines, Payload)     Else If Cmd = 'sch_wires' Then Result := CmdSchWires(Lines, Payload)     Else If Cmd = 'net_classes' Then Result := CmdNetClasses(Lines)+    Else If Cmd = 'selection' Then Result := CmdSelection(Lines)     Else If Cmd = 'comp_rects' Then Result := CmdCompRects(Lines)     Else If Cmd = 'track_width' Then Result := CmdTrackWidth(Lines)     Else If Cmd = 'outline_tracks' Then Result := CmdOutlineTracks(Lines)
src/bridge.json+14−2
@@ -2,7 +2,7 @@   "manifest_version": 1,   "name": "altium",   "displayName": "Altium Designer",-  "version": "0.2.63",+  "version": "0.2.65",   "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",@@ -55,6 +55,7 @@     "altium_build_schematic",     "altium_build_symbol_modes",     "altium_check_dialogs",+    "altium_check_orientation",     "altium_cleanup_libs",     "altium_clone_symbol",     "altium_close",@@ -86,6 +87,7 @@     "altium_extension_ping",     "altium_extract_intlib",     "altium_find_space",+    "altium_find_symbol",     "altium_footprint_primitives",     "altium_foreground",     "altium_import_kicad",@@ -113,7 +115,9 @@     "altium_normalize_bodies",     "altium_open_document",     "altium_open_project",+    "altium_outjob_info",     "altium_panel",+    "altium_part_parameters",     "altium_pcb_report",     "altium_place_on_pcb",     "altium_place_on_sch",@@ -132,6 +136,7 @@     "altium_remove_symbol",     "altium_rename_footprint",     "altium_rename_symbol",+    "altium_replicate_layout",     "altium_repour",     "altium_review_board",     "altium_ripup_nets",@@ -152,6 +157,7 @@     "altium_sch_zoom",     "altium_schematic_data",     "altium_select_net",+    "altium_selection",     "altium_selftest_script_error",     "altium_set_design_rules",     "altium_set_layer_visibility",@@ -307,7 +313,13 @@       "workspace_search": 180,       "workspace_place": 300,       "find_space": 180,-      "sch_swap_part": 180+      "sch_swap_part": 180,+      "selection": 60,+      "check_orientation": 120,+      "replicate_layout": 180,+      "outjob_info": 30,+      "find_symbol": 300,+      "part_parameters": 300     },     "prefixes": [       {