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.69 source (not yet released, needs signing; includes 0.2.68): fixes adom/altium-bridge#27 (agent leaves 15 s after the last request, status reads don't keep it, agentResident hand-back note, driving badge clears; UniqueId reissues reported by every sheet-editing verb), export_manufacturing formats odb and ipc2581; 181 verbs; batteries 180/0 on AD 26.11.1 and Altium Designer Develop
15 files changed
+205−30
CHANGELOG.md+10SKILL.md+5−1bridge-rs/src/main.rs+1−1bridge-rs/src/manufacturing.rs+47−3bridge-rs/src/support.rs+36bridge-rs/src/taskbar.rs+12−9bridge-rs/src/verbs.rs+52−5dev-skills/altium-bridge-test/run_verb_tests.py+4−1package.json+5−2page.json+5−2skills/altium-outputs/SKILL.md+7skills/altium-schematic-edit/SKILL.md+12src/BRIDGE_VERSION+1−1src/agent/AdomAgent.pas.tmpl+7−4src/bridge.json+1−1CHANGELOG.md+10@@ -1,5 +1,15 @@ # Changelog +## 0.2.69 (2026-10-23)+**The user gets Altium back (adom/altium-bridge#27), and ODB++ / IPC-2581.**+- The agent loop leaves 15 s after the last request (it was 5 minutes). While it runs, Altium ignores the user's clicks: Barrett handed Altium back after a few verbs and his user sat at a frozen window until `altium_agent_stop`. Restarting costs 2 to 4 s on the next verb (measured 1.8 s).+- Status reads (`altium_state`'s logical read, `ping`) no longer keep the loop resident: an AI polling `state` every 3 s held it indefinitely; now it leaves at 15 to 17 s regardless.+- Every reply that leaves the agent running carries `agentResident` with the hand-back instruction; the skill (convention 6) and `agent_stop`'s doc say it plainly.+- The "Adom is driving" taskbar badge clears on `agent_stop`, on close, and within 5 s of the loop leaving; a clear whose reply did not read as success no longer leaves `drivingBadge.active` stuck true.+- UniqueIDs: every sheet-editing verb reads the sheets' component UniqueIDs from disk before and after and reports `uniqueIdsReissued` with a warning when ids were replaced. Reproduced Barrett's report: `sch_edit` keeps ids (renamed U2/U3 and back, ids unchanged), but Altium gives a sheet copy that shares ids with another sheet of the project new ids the moment it opens it, and the verb's save writes them. The altium-schematic-edit skill explains it and the fix.+- `altium_export_manufacturing {formats: ["odb", "ipc2581"]}`: ODB++ (folder, .tgz, .zip) and IPC-2581 (.cvg) beside the Gerbers. Measured on the battery board: both written, the Gerber checks unchanged.+- Verb battery: `export_manufacturing` asks for both formats and checks their files.+ ## 0.2.68 (2026-10-23) **Length, rules, rooms and teardrops.** - New verb `altium_net_lengths`: each net's routed length (tracks and arcs, per layer, vias counted), the skew of every differential pair the board defines, and matched-length groups against a tolerance (spread, longest net, how short each net is). Measured on the battery board, and Altium's own Matched Lengths DRC agreed with it.
SKILL.md+5−1@@ -76,7 +76,11 @@ Altium is a licensed host app. The bridge never installs it silently; it detects 3. **`ok:true` is not enough — verify.** `show_*` and `state` return frames; look at them. `run_outjob` returns `files[]` actually written. 4. **One script at a time.** While the agent is resident, never run `altium_run_script`; it raises Altium's "Another script executing now" modal. Send work through `altium_eval` / `altium_run_command`. 5. **Never leave a modal.** Prefer commands that carry their parameters (`altium_run_command` with `params`) so no picker opens. After any mutating step, `altium_check_dialogs`.-6. **Reuse the running Altium.** Altium is single-instance; the agent survives across your calls (auto-stops after 5 min idle from 0.2.9, auto-restarts on the next verb).+6. **Reuse the running Altium, and hand it back.** Altium is single-instance. The agent is a script running+ inside Altium, and **while it runs, Altium ignores the user's clicks**. It stops by itself 15 s after your last+ verb (from 0.2.69; it was 5 minutes) and restarts on the next verb in 2.5-4 s. Every reply that leaves it running+ carries `agentResident`. When you hand Altium back to a user who is waiting to work in it, call+ `altium_agent_stop` first. 7. **Absolute Windows paths.** `C:/…` or `C:\\…`; no `%VAR%` expansion. Files from the container must be sent first (`send_files`). 8. **Surface `_hint` verbatim + branch on `errorCode`.**
bridge-rs/src/main.rs+1−1@@ -42,7 +42,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.68";+pub const BRIDGE_VERSION: &str = "0.2.69"; 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/manufacturing.rs+47−3@@ -67,7 +67,7 @@ pub fn outjob_path(prj: &str) -> String { /// Write the project's manufacturing OutJob (only when its text changes: an open document rewritten on /// disk makes Altium ask to reload it). Answers its path.-pub fn write_outjob(prj: &str, pcb: &str, bom_columns: Option<&Vec<Value>>) -> Result<String, Value> { write_outjob_layers(prj, pcb, bom_columns, &[]) }+pub fn write_outjob(prj: &str, pcb: &str, bom_columns: Option<&Vec<Value>>) -> Result<String, Value> { write_outjob_layers(prj, pcb, bom_columns, &[], &[]) } /// Altium's V7 layer ids for the inner copper the template does not plot: mid N and internal plane N. pub fn inner_layer_ids(stack: &Value) -> Vec<i64> {@@ -84,7 +84,7 @@ pub fn plot_with(rec: &str, ids: &[i64]) -> String { field(rec, "Plot.Set", &v) } -pub fn write_outjob_layers(prj: &str, pcb: &str, bom_columns: Option<&Vec<Value>>, inner: &[i64]) -> Result<String, Value> {+pub fn write_outjob_layers(prj: &str, pcb: &str, bom_columns: Option<&Vec<Value>>, inner: &[i64], formats: &[String]) -> Result<String, Value> { let (prj, pcb) = (prj.to_string(), pcb.to_string()); // Altium's own OutJob templates, from the running install's Public Documents folder let read = |f: &str| crate::support::altium_public_file(&format!(r"OutputJobs\{f}"))@@ -102,6 +102,22 @@ pub fn write_outjob_layers(prj: &str, pcb: &str, bom_columns: Option<&Vec<Value> match take_output(src, kind, outputs.len() + 1) { Some(o) => outputs.push(o), None => missing.push(kind) } } if !missing.is_empty() { return Err(json!({"success": false, "errorCode": "template_output_missing", "error": format!("the templates have no {missing:?} output")})); }+ // more fab formats on request: IPC-2581 from Altium's Fabrication template, ODB++ from the Fabrication OutJob of the+ // Bluetooth Sentinel example both builds install (no template carries ODB++, measured 2026-10-23)+ for f in formats {+ let (src, kind) = match f.to_lowercase().as_str() {+ "ipc2581" | "ipc-2581" => (Ok(fab.clone()), "IPC2581"),+ "odb" | "odb++" => (crate::support::altium_public_file(r"Examples\Bluetooth Sentinel\Fabrication.OutJob")+ .and_then(|p| std::fs::read_to_string(&p).map_err(|e| format!("{p}: {e}"))).map(|t| parse_ini(&t)), "ODB"),+ "gerber" => continue,+ other => return Err(json!({"success": false, "errorCode": "bad_format", "error": format!("format '{other}' is not one of gerber, odb, ipc2581")})),+ };+ let src = match src { Ok(s) => s, Err(e) => return Err(json!({"success": false, "errorCode": "no_templates", "error": format!("no OutJob carries {kind} on this install: {e}")})) };+ match take_output(&src, kind, outputs.len() + 1) {+ Some(o) => outputs.push(o),+ None => return Err(json!({"success": false, "errorCode": "template_output_missing", "error": format!("no {kind} output in the OutJob it comes from")})),+ }+ } for (i, o) in outputs.iter_mut().enumerate() { let n = i + 1; let kind = o.iter().find(|(k, _)| k == &format!("OutputType{n}")).map(|(_, v)| v.clone()).unwrap_or_default();@@ -167,7 +183,8 @@ pub fn export_manufacturing(args: &Value) -> Value { let inner = if crate::support::must_open(&pcb, 60).is_none() { crate::agent::call("layer_stack", &[], None, std::time::Duration::from_secs(60)).get("data").map(inner_layer_ids).unwrap_or_default() } else { vec![] };- let outjob = match write_outjob_layers(&prj, &pcb, args.get("bomColumns").and_then(|v| v.as_array()), &inner) { Ok(p) => p, Err(e) => return e };+ let formats: Vec<String> = args.get("formats").and_then(|f| f.as_array()).map(|a| a.iter().filter_map(|x| x.as_str().map(str::to_string)).collect()).unwrap_or_default();+ let outjob = match write_outjob_layers(&prj, &pcb, args.get("bomColumns").and_then(|v| v.as_array()), &inner, &formats) { Ok(p) => p, Err(e) => return e }; // an OutJob generates for the project it belongs to: one that is not in the project runs for Free // Documents and writes nothing (measured 2026-10-15) let oj_name = outjob.rsplit(['\\', '/']).next().unwrap_or(&outjob).to_lowercase();@@ -214,6 +231,20 @@ pub fn export_manufacturing(args: &Value) -> Value { e == "GTL" || e == "GBL" || (e.len() > 1 && (e.starts_with('G') && e[1..].chars().all(|c| c.is_ascii_digit())) ) || (e.starts_with("GP") && e[2..].chars().all(|c| c.is_ascii_digit()) && e.len() > 2) }).map(str::to_string).collect(); let want_copper = 2 + inner.len();+ // the extra formats each write their own folder or file under outputDir+ if !formats.is_empty() {+ let dir = r["outputDir"].as_str().unwrap_or("").to_string();+ let mut found = serde_json::Map::new();+ for f in &formats {+ let key = f.to_lowercase();+ let hit: Vec<String> = walk_names(std::path::Path::new(&dir)).into_iter().filter(|n| {+ let l = n.to_lowercase();+ (key.starts_with("odb") && (l.contains("odb") || l.ends_with(".tgz"))) || (key.starts_with("ipc") && (l.ends_with(".xml") || l.ends_with(".cvg") || l.contains("2581")))+ }).take(20).collect();+ found.insert(f.clone(), json!({"files": hit, "ok": !hit.is_empty()}));+ }+ r["formats"] = json!(found);+ } r["fabChecks"] = json!({"outline": outline, "copperLayers": {"board": want_copper, "gerbers": copper_files.len(), "files": copper_files, "ok": copper_files.len() >= want_copper}}); r["_hint"] = json!(if ok_outline { if outline.get("added").is_some() { "files[] are the manufacturing set under outputDir. The board had no outline on any Gerber layer: the bridge drew its shape on Mechanical 1 (in memory; altium_save keeps it) and generated again, so GM1 carries it." }@@ -260,3 +291,16 @@ mod tests { assert_eq!(inner_layer_ids(&serde_json::json!({"layers": [{"name": "Top"}, {"plane": 1}, {"mid": 1}, {"plane": 2}, {"name": "Bottom"}]})), vec![16842753, 16777218, 16842754]); } }++/// Every file under a folder, as paths relative to it (for the formats that write folders, like ODB++).+fn walk_names(dir: &std::path::Path) -> Vec<String> {+ let mut out = Vec::new();+ let mut stack = vec![dir.to_path_buf()];+ while let Some(d) = stack.pop() {+ for e in std::fs::read_dir(&d).into_iter().flatten().flatten() {+ let p = e.path();+ if p.is_dir() { stack.push(p); } else if let Ok(rel) = p.strip_prefix(dir) { out.push(rel.to_string_lossy().to_string()); }+ }+ }+ out+}
bridge-rs/src/support.rs+36@@ -296,3 +296,39 @@ pub fn altium_public_file(rel: &str) -> Result<String, String> { } Err(format!(r"{rel} is not under {ROOT}\<install> (looked in: {})", keys.join(", "))) }++/// The UniqueIDs of the components a .SchDoc holds, read from the file on disk (its component records are plain text:+/// `|RECORD=1|...|UniqueID=XXXXXXXX|`, measured on AD 26 / Develop files 2026-10-23). The schematic-to-board link is this+/// id; None when the file cannot be read.+pub fn sheet_unique_ids(path: &str) -> Option<Vec<String>> {+ let b = std::fs::read(path).ok()?;+ Some(unique_ids_in(&b))+}++pub fn unique_ids_in(b: &[u8]) -> Vec<String> {+ let mut out = Vec::new();+ let pat = b"|RECORD=1|";+ let key = b"|UniqueID=";+ let mut i = 0;+ while let Some(off) = b[i..].windows(pat.len()).position(|w| w == pat) {+ let start = i + off + pat.len();+ let end = b[start..].iter().position(|&c| c == 0).map(|e| start + e).unwrap_or(b.len());+ if let Some(k) = b[start..end].windows(key.len()).position(|w| w == key) {+ let v = start + k + key.len();+ let id: String = b[v..end].iter().take_while(|&&c| c != b'|').map(|&c| c as char).collect();+ if !id.is_empty() { out.push(id); }+ }+ i = end.max(start);+ }+ out+}++#[cfg(test)]+mod uid_tests {+ use super::*;+ #[test]+ fn component_ids_come_from_component_records_only() {+ let b = b"x|HEADER=Protel|UniqueID=SEWNJQIE\x00\x10\x00|RECORD=1|LibReference=TST|UniqueID=HXDEWKXY|AreaColor=1\x00\x20|RECORD=34|Text=U2|UniqueID=XXXXXXXX\x00|RECORD=1|LibReference=R|PartIDLocked=T|UniqueID=ZYGXDGVK\x00";+ assert_eq!(unique_ids_in(b), vec!["HXDEWKXY".to_string(), "ZYGXDGVK".to_string()]);+ }+}
bridge-rs/src/taskbar.rs+12−9@@ -22,7 +22,7 @@ struct ProgressHandle { static PROGRESS_STOP: Mutex<BTreeMap<String, ProgressHandle>> = Mutex::new(BTreeMap::new()); -pub const IDLE_CLEAR_SEC: i64 = 300;+pub const IDLE_CLEAR_SEC: i64 = 16; // the agent leaves 15 s after the last request (#27): the badge follows it fn now() -> i64 { std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).map(|d| d.as_secs() as i64).unwrap_or(0)@@ -82,12 +82,13 @@ pub fn clear_driving_badge() -> bool { let targets: Vec<i64> = BADGED.lock().map(|g| g.iter().cloned().collect()).unwrap_or_default(); let mut ok = true; for h in targets {- if taskbar(json!({"hwnd": h, "overlay": {"badge": "none"}})) {- if let Ok(mut g) = BADGED.lock() {- g.remove(&h);- }- } else {- ok = false;+ // forgotten either way: a clear whose reply did not read as success left the badge "active" for good and+ // altium_state reported a driving badge with no agent running (#27). A window that is gone has no badge.+ if !taskbar(json!({"hwnd": h, "overlay": {"badge": "none"}})) {+ ok = taskbar(json!({"hwnd": h, "overlay": {"badge": "none"}})) && ok;+ }+ if let Ok(mut g) = BADGED.lock() {+ g.remove(&h); } } ok@@ -111,9 +112,11 @@ pub fn start_idle_watcher() -> bool { std::thread::Builder::new() .name("altium-badge-idle-clear".into()) .spawn(|| loop {- std::thread::sleep(Duration::from_secs(15));+ std::thread::sleep(Duration::from_secs(5)); let last = LAST_DRIVING.load(Ordering::Relaxed);- if badge_active() && last > 0 && now() - last > IDLE_CLEAR_SEC {+ // the badge says "Adom is driving": it goes when the agent loop has gone (it leaves 15 s after the last+ // request, #27), not on a separate clock+ if badge_active() && last > 0 && now() - last > IDLE_CLEAR_SEC && !crate::agent::is_alive() { clear_driving_badge(); } })
bridge-rs/src/verbs.rs+52−5@@ -267,10 +267,10 @@ fn describe() -> Value { &["agent_status", "agent_stop", "agent_recover", "eval"], &["The bootstrap ACTIVATES Altium for a moment (it opens the .PrjScr in the script editor); the bridge hands the user's foreground window back.", "no_heartbeat = the engine halted behind a script Error modal: altium_check_dialogs, then altium_agent_recover.",- "The loop exits by itself after 5 minutes idle (residency is the freeze-on-close exposure window); the next verb restarts it."], json!({})),+ "The loop exits by itself 15 s after the last request (while it runs Altium ignores the user's clicks, #27); the next verb restarts it in 2.5-4 s. Call altium_agent_stop when you hand Altium back to the user."], json!({})), verb_doc("agent_stop", "Stop the selected instance's resident agent loop (drops a stop file and waits up to 12 s for the heartbeat to go stale).", json!({}), json!({"wasRunning": "bool", "stoppedInSec": "float"}), 20,- "Needed before altium_run_script (the -R lanes refuse while a loop is resident). altium_close stops every workspace's loop itself. The next agent-backed verb restarts the agent.",+ "Call it when you hand Altium back to the user: while the loop is resident Altium ignores their clicks (it also stops by itself 15 s after the last verb). Needed before altium_run_script (the -R lanes refuse while a loop is resident). altium_close stops every workspace's loop itself. The next agent-backed verb restarts the agent. Clears the 'Adom is driving' taskbar badge.", &["agent_start", "agent_status", "run_script", "close"], &["'the loop did not exit' -> altium_agent_recover.", "With several Altiums running pass instance: to stop the right loop."], json!({})), verb_doc("agent_status", "Is the agent loop alive and responsive, in which workspace, and is any loop resident anywhere? Read-only; never starts the agent.", json!({}), json!({"alive": "bool - selected instance", "responsive": "bool", "residentAnywhere": "bool", "liveWorkspaces": "[{workspace, heartbeatAgeSec}]", "heartbeat": "{ageSec, tick, ...} | null", "workspace": "path"}), 10,@@ -474,7 +474,7 @@ fn describe() -> Value { &["schematic_data", "compile", "build_board"], &["The SchLib must hold every symbol (altium_import_part).", "Parts are placed unrotated."], json!({"schPath": "C:\\work\\Buck.SchDoc", "libraryPath": "C:\\work\\library\\Buck.SchLib", "parts": [{"ref": "U1", "symbol": "TPS54202DDCR", "block": "core", "comment": "TPS54202"}], "nets": {"VIN": ["U1.3"]}})), verb_doc("export_manufacturing", "A project's manufacturing set in one call: Gerbers, NC drill, pick and place (CSV, mm) and the BOM, from an OutJob the bridge writes beside the project (composed from this Altium's own OutJob templates, every output on one medium that opens nothing).",- json!({"projectPath": "the .PrjPcb", "pcbPath": "its .PcbDoc", "bomColumns": "(optional) [column or parameter name] - default Comment, Description, Designator, Footprint, Quantity, MPN, Manufacturer, Mouser/DigiKey/LCSC Part #", "clean": "bool (default true) - empty the output folder first", "silkPrep": "bool (default true) - Altium's Silkscreen Preparation first (silk off the pads, text moved where it fits)", "silkPrepCheck": "bool (default false) - DRC silk counts before/after in silkPrep", "timeout": "seconds, default 300"}),+ json!({"projectPath": "the .PrjPcb", "pcbPath": "its .PcbDoc", "bomColumns": "(optional) [column or parameter name] - default Comment, Description, Designator, Footprint, Quantity, MPN, Manufacturer, Mouser/DigiKey/LCSC Part #", "clean": "bool (default true) - empty the output folder first", "formats": "[odb, ipc2581] - more fab formats beside the Gerbers (ODB++ folder + .tgz, IPC-2581 .cvg); default Gerbers only", "silkPrep": "bool (default true) - Altium's Silkscreen Preparation first (silk off the pads, text moved where it fits)", "silkPrepCheck": "bool (default false) - DRC silk counts before/after in silkPrep", "timeout": "seconds, default 300"}), json!({"outjob": "path", "outputDir": "path", "files": "[relative paths]", "dialogs": "[..]", "elapsedSec": "float"}), 600, "Pull files[] with pull_file and check them against the board (layers, drill hits, every placed part in the pick and place and the BOM) before handing them over. AI Flow's `altium export` does both.", &["run_outjob", "build_schematic", "routing_state"], &["The board must be a document of the project.", "The BOM is the compiled project's (schematic parts); board-only pads are not in it."],@@ -1136,7 +1136,7 @@ fn describe() -> Value { "never foreground Altium; proofs are captured in the background", "one On-Demand seat serves every Altium app on the machine; killing Altium strands it", "instance:<pid|key|word> targets one of several running Altiums",- "agent-backed verbs bootstrap the resident agent on demand (~8 s) and answer {success, data:{ok,...}, roundtripMs}; the loop exits after 5 min idle",+ "agent-backed verbs bootstrap the resident agent on demand (~8 s) and answer {success, data:{ok,...}, roundtripMs}; the loop exits 15 s after the last request (Altium ignores the user's clicks while it runs)", "errorCode agent_busy names the command the agent is still running (busyWith, busyForSec): wait and retry", "errorCode agent_script_error = a DelphiScript runtime error the bridge ALREADY cleared (read errorShot); do not resend the same command", "errorCode bad_number = a caller value that must be numeric was not",@@ -1250,7 +1250,7 @@ pub fn handle(command: &str, args: &Value, forwarded: &[(String, String)]) -> Va let mut bar_started = false; if driving { taskbar::set_driving_badge();- taskbar::note_driving(); // feeds the 5-min idle clock that auto-clears the overlay+ taskbar::note_driving(); // feeds the idle clock that auto-clears the overlay (IDLE_CLEAR_SEC) let (est, conf) = progress::estimate(phase, "warm"); if est >= 3.0 { taskbar::start_activity_progress(cmd, Some(est), &conf);@@ -1269,8 +1269,20 @@ pub fn handle(command: &str, args: &Value, forwarded: &[(String, String)]) -> Va let etiquette = driving && !matches!(cmd, "foreground" | "launch" | "demo" | "close" | "install" | "uninstall"); let user_fg = if etiquette { crate::lifecycle::foreground_info() } else { None };+ // sheet-editing verbs: the components' UniqueIDs (the schematic-to-board link) read from disk before and after,+ // so a reissue is reported (#27: Altium gives a sheet copy that shares ids with another sheet of the project new+ // ids when it opens it, and the verb's save then writes them: Update PCB would delete and re-add those parts)+ let id_sheets = sheets_of(cmd, args);+ let ids_before: Vec<(String, Vec<String>)> = id_sheets.iter().filter_map(|s| crate::support::sheet_unique_ids(s).map(|v| (s.clone(), v))).collect(); let t0 = std::time::Instant::now(); let mut out = dispatch(command, args, forwarded);+ let reissued = reissued_ids(&ids_before);+ if !reissued.is_empty() {+ if let Some(o) = out.as_object_mut() {+ o.insert("uniqueIdsReissued".into(), json!(reissued));+ o.insert("uniqueIdWarning".into(), json!("Components on these sheets came back with NEW UniqueIDs. The board links each part by that id, so Update PCB would delete and re-add them instead of updating them. Altium does this when a sheet shares ids with another sheet in the project (a Save Copy As of a sheet that is still in the project). Before Update PCB, re-link them in Altium (Project > Component Links), or remove the duplicate sheet and restore the copy from before this verb."));+ }+ } let ok = out.get("success").and_then(|b| b.as_bool()).unwrap_or(true); progress::log_run(cmd, t0.elapsed().as_secs_f64(), ok); if bar_started {@@ -1302,9 +1314,44 @@ pub fn handle(command: &str, args: &Value, forwarded: &[(String, String)]) -> Va o.entry("windowEtiquette").or_insert(json!("background")); } }+ // While the agent loop is resident Altium ignores the user's clicks (#27): say so in every reply that leaves it+ // running, so the AI stops it before handing Altium back instead of leaving the user at a frozen window.+ if cmd == "agent_stop" || cmd == "close" {+ taskbar::clear_driving_badge();+ } else if driving && crate::agent::is_alive() {+ if let Some(o) = out.as_object_mut() {+ o.insert("agentResident".into(), json!({"autoStopSec": 15,+ "note": "The agent loop is still running inside Altium, and while it runs Altium ignores the user's clicks. It stops by itself 15 s after the last verb; when you hand Altium back to the user, call altium_agent_stop."}));+ }+ } out } +/// The .SchDoc files a sheet-editing verb touches: its sheet, or every sheet of its project.+fn sheets_of(cmd: &str, args: &Value) -> Vec<String> {+ if !matches!(cmd, "sch_edit" | "sch_connect" | "sch_place_part" | "sch_swap_part" | "annotate" | "sheet_params" | "doc_params" | "sch_sheet_symbol") { return vec![]; }+ let mut out: Vec<String> = ["sheet", "filePath"].iter().filter_map(|k| s(args, k)).filter(|p| p.to_lowercase().ends_with(".schdoc")).collect();+ if let Some(prj) = s(args, "projectPath") {+ if let Ok(b) = std::fs::read(&prj) {+ let dir = std::path::Path::new(&prj).parent().map(|d| d.to_path_buf()).unwrap_or_default();+ out.extend(crate::bom::parse_project(&String::from_utf8_lossy(&b), &dir).sheets);+ }+ }+ out.sort();+ out.dedup();+ out+}++/// Sheets whose components lost ids that were replaced by new ones (parts added or deleted alone are not reissues).+fn reissued_ids(before: &[(String, Vec<String>)]) -> Vec<Value> {+ before.iter().filter_map(|(sheet, b)| {+ let a = crate::support::sheet_unique_ids(sheet)?;+ let lost: Vec<&String> = b.iter().filter(|x| !a.contains(x)).collect();+ let new: Vec<&String> = a.iter().filter(|x| !b.contains(x)).collect();+ (!lost.is_empty() && !new.is_empty()).then(|| json!({"sheet": sheet, "lost": lost, "new": new}))+ }).collect()+}+ pub fn dispatch(command: &str, args: &Value, _forwarded: &[(String, String)]) -> Value { let cmd = command.strip_prefix("altium_").unwrap_or(command); let timeout = args.get("timeout").and_then(|t| t.as_u64()).unwrap_or(60);
dev-skills/altium-bridge-test/run_verb_tests.py+4−1@@ -261,7 +261,10 @@ def matrix(w): parts=[{"ref": "U1", "symbol": "TST_SOIC8", "block": "core", "footprint": "TST_SOIC8"}, {"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))+ # 0.2.69: ODB++ and IPC-2581 beside the Gerbers; both must write files and the Gerber checks still hold+ M["export_manufacturing"] = dict(phase=3.5, timeout=500, **ok(projectPath=LP, pcbPath=LB, formats=["odb", "ipc2581"], timeout=480),+ check=lambda d: all((v or {}).get("ok") for v in (d.get("formats") or {}).values()) and len(d.get("formats") or {}) == 2+ and ((d.get("fabChecks") or {}).get("copperLayers") or {}).get("ok") is True) 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) # 0.2.66: board furniture after the placement checks (the vias would crowd find_space): 2 mm corner holes (under the
package.json+5−2@@ -1,6 +1,6 @@ { "slug": "altium-bridge",- "version": "0.2.68",+ "version": "0.2.69", "type": "app", "description": "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.", "tags": [@@ -156,7 +156,10 @@ "altium room", "altium component class", "altium via style rule",- "altium skew"+ "altium skew",+ "altium odb++ export",+ "altium ipc-2581 export",+ "export odb++ from altium" ], "discovery_pitch": "An AI that runs Altium Designer in the user's own session: libraries, schematics, Update PCB, stack-up, routing, DRC, Gerbers and BOMs.", "sample_prompts": [
page.json+5−2@@ -4,7 +4,7 @@ "type": "app", "title": "Altium Bridge: AI for Altium Designer", "brief": "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.",- "version": "0.2.68",+ "version": "0.2.69", "hero": { "type": "image", "path": "screenshots/hero.png"@@ -148,7 +148,10 @@ "altium room", "altium component class", "altium via style rule",- "altium skew"+ "altium skew",+ "altium odb++ export",+ "altium ipc-2581 export",+ "export odb++ from altium" ], "discovery_pitch": "An AI that runs Altium Designer in the user's own session: libraries, schematics, Update PCB, stack-up, routing, DRC, Gerbers and BOMs.", "metadata": {},
skills/altium-outputs/SKILL.md+7@@ -68,6 +68,13 @@ Altium need not be running. Run one container with `altium_run_outjob {outjobPat - OutJob→PDF was blocked in the predecessor bridge; on this one run it through `altium_run_outjob` and verify the files[] like any medium. +## ODB++ and IPC-2581 (0.2.69)++`altium_export_manufacturing {projectPath, pcbPath, formats: ["odb", "ipc2581"]}` writes them beside the Gerbers, drill,+pick and place and BOM: ODB++ as a folder plus `.tgz` and `.zip`, IPC-2581 as a `.cvg`. The reply's `formats` lists each+one's files. IPC-2581 comes from Altium's Fabrication OutJob template, ODB++ from the Bluetooth Sentinel example's+Fabrication OutJob that both builds install (no template carries ODB++).+ ## Silkscreen off the copper: Silkscreen Preparation (0.2.67) ```bash
skills/altium-schematic-edit/SKILL.md+12@@ -90,6 +90,18 @@ altium_sheet_params '{"projectPath":"C:\\proj\\board.PrjPcb","designators":["R5" Wires do not follow a moved or rotated part: reconnect with `sch_connect`. Every part of a multi-part component with that designator changes together. +## UniqueIDs: the link between a sheet's parts and the board (0.2.69)++The board finds each schematic part by its UniqueID. If a part's id changes, Update PCB deletes the placed part and+adds a new one instead of updating it. Altium itself reissues ids when it opens a sheet whose parts share ids with+another sheet of the same project, typically a **Save Copy As of a sheet that is still in the project** (measured on+Develop 2026-10-23: the copy's ids were new the moment it opened, before any edit; renames by `sch_edit` keep ids).++Every sheet-editing verb (`sch_edit`, `sch_connect`, `sch_place_part`, `sch_swap_part`, `annotate`, `sheet_params`,+`doc_params`, `sch_sheet_symbol`) reads the sheets' ids from disk before and after, and when ids were replaced the reply+carries `uniqueIdsReissued: [{sheet, lost, new}]` and a `uniqueIdWarning`. Then, before Update PCB, re-link the parts in+Altium (Project > Component Links), or take the duplicate sheet out of the project and restore the copy.+ ## Hierarchical sheets and the netlist (0.2.67) ```bash
src/BRIDGE_VERSION+1−1@@ -1 +1 @@-0.2.68+0.2.69
src/agent/AdomAgent.pas.tmpl+7−4@@ -28,9 +28,10 @@ Const POLL_IDLE_MS = 100; IDLE_AFTER_TICKS = 300; { ~3 s of no requests -> idle cadence } HEARTBEAT_MS = 500;- AUTO_STOP_MS = 300000; { 5 min idle -> exit. Residency is the freeze exposure- window - a user X-press over a live loop can wedge- the shutdown, so the loop does not linger. }+ AUTO_STOP_MS = 15000; { 15 s idle -> exit. While this loop is resident Altium ignores the user's+ clicks (a script is running), so it leaves soon after the last request;+ restarting costs 2.5-4 s on the next verb. It was 5 min until a user+ sat at a frozen Altium (adom/altium-bridge#27, 2026-10-23). } Var gRunning : Boolean;@@ -8743,7 +8744,9 @@ Begin Id := Param(Lines, 'id'); Cmd := Param(Lines, 'command'); gRequests := gRequests + 1;- gLastReqTick := GetTickCount;+ { a status read does not keep the loop resident: an AI polling altium_state held Altium (and the user's+ clicks) for as long as it polled (adom/altium-bridge#27) }+ If (Cmd <> 'state') And (Cmd <> 'ping') Then gLastReqTick := GetTickCount; T0 := GetTickCount; gInputError := ''; Try
src/bridge.json+1−1@@ -2,7 +2,7 @@ "manifest_version": 1, "name": "altium", "displayName": "Altium Designer",- "version": "0.2.68",+ "version": "0.2.69", "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",