app
Altium Bridge: AI for Altium Designer
Public Made by Adomby adom
Let an AI (Claude, ChatGPT, Codex or any agent) run Altium Designer for you: create symbols and footprints, place parts (also from Altium 365), wire and annotate schematics, Update PCB, set the stack-up and rules, route, pour copper, add mounting holes and stitching vias, run ERC and DRC, and make Gerbers, BOMs and STEP. Free in Hydrogen; bring your own AI and Altium licence.
← 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
25 files changed
+820−82
CHANGELOG.md+32README.md+7−7SKILL.md+5−1bridge-rs/src/bom.rs+6−1bridge-rs/src/design.rs+148−2bridge-rs/src/dialogs.rs+3bridge-rs/src/inspect.rs+152bridge-rs/src/instance.rs+6bridge-rs/src/license.rs+49−1bridge-rs/src/lifecycle.rs+46−8bridge-rs/src/main.rs+2−1bridge-rs/src/misc.rs+35−6bridge-rs/src/routing.rs+18bridge-rs/src/verbs.rs+115−38dev-skills/altium-bridge-test/run_verb_tests.py+11package.json+1−1page.json+1−1skills/altium-boards/SKILL.md+23skills/altium-instances/SKILL.md+21skills/altium-libraries/SKILL.md+18skills/altium-licensing/SKILL.md+4skills/altium-outputs/SKILL.md+6src/BRIDGE_VERSION+1−1src/agent/AdomAgent.pas.tmpl+96−12src/bridge.json+14−2CHANGELOG.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": [ {