app
Altium - the Altium Bridge
Public Made by Adomby adom
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.
← Commit history
0.2.32 source: recover activates + verifies, close frees a parked engine, outjob_not_found, PUBLISHING.md, Parent skill lines, README facts, changelog tone
10 files changed
+233−10
PUBLISHING.mdadded+120@@ -0,0 +1,120 @@+# Publishing altium-bridge++How a build of this bridge reaches users. The tool is `scripts/release.py`; the developer recipe+with the wiki details is `publish-skills/altium-bridge-publish/SKILL.md`. Page: `adom/altium-bridge`.++## Two tiers++Every publish goes to the INSIDERS tier. The PUBLIC tier moves only when a human says so.++| | Insiders | Public |+|---|---|---|+| Manifest | `altium-bridge/version.json` on the `adom/insiders` page | `adom-bridge-altium-manifest.json` on `adom/altium-bridge` |+| Who follows it | desktops whose login can read the insiders page (`insidersManifestUrl` in `src/bridge.json`) | everyone else |+| Moved by | `--publish` | `--promote` / `--rollback` only |++The release zip itself lives on the `adom/altium-bridge` releases for both tiers; the tiers differ+only in which manifest points at which version.++```bash+python3 scripts/release.py # lint + cargo test + build, zip into dist/. Publishes nothing.+python3 scripts/release.py --publish "<one sentence for the release log>"+python3 scripts/release.py --promote <ver> --attest "<the human's exact words>" --by "<name>"+python3 scripts/release.py --rollback <ver> --attest "<the human's exact words>" --by "<name>"+```++`--publish` does, in order: the lints below, `cargo test`, the release build, the zip, the release+and its asset on `adom/altium-bridge`, the insiders manifest plus a release-log entry on+`adom/insiders` (a copy of the manifest is archived in `scripts/releases/<ver>.json`), the version+stamps to the page repo, the skills pkg (`adom-wiki pkg publish --org adom`), and a final check+that the insiders manifest is blocked for anonymous readers and serves the new version to a+signed-in one. It never touches the public manifest.++`--promote <ver>` needs the archived manifest for that version, re-downloads the zip and compares+its sha256 with the record, then writes the public manifest with `promotion_attestation`,+`promoted_by` and `promoted_at`, pushes it, and marks the release-log entry.++`--rollback <ver>` re-points the public manifest at an older archived build the same way, and+records `rollback_attestation`, `rolled_back_by`, `rolled_back_at`.++### The promotion rule++An AI never promotes on its own judgment. `--attest` carries the words the human actually said,+and `--by` names that human. The script refuses an attestation shorter than 8 characters or a+placeholder ("ok", "yes", "promote", "ship it", "go", "approved", "test"), and refuses a missing+`--by`. The words are stored in the public manifest, so do not paraphrase them.++## Versions++`src/bridge.json` `version` is the single source. These must carry the same number:++| File | Checked by `release.py` |+|---|---|+| `src/BRIDGE_VERSION` | yes |+| `bridge-rs/src/main.rs` (`BRIDGE_VERSION` const) | yes |+| `package.json` | yes |+| `bridge-rs/Cargo.toml` | no, bump by hand |+| `page.json` | no, bump by hand |++One plain semver per build, the same in both tiers. Add the `CHANGELOG.md` entry in the same change.++## What the release script refuses++Every mode runs these first, and any hit stops the build:++- **Version drift**: a stamp in the checked files above that differs from `src/bridge.json`, or an+ `insidersManifestUrl` in `src/bridge.json` that is not the insiders manifest URL.+- **Verbs declared vs dispatchable**: `verbs[]` in `src/bridge.json` must equal the `KNOWN` list in+ `bridge-rs/src/verbs.rs`. Both directions are printed: declared but not dispatchable, and+ dispatchable but not declared.+- **`ENGINE_HALTING` identifiers** in `src/agent/AdomAgent.pas.tmpl`: identifiers measured to halt+ Altium's DelphiScript engine (`.ImplementationCount`, `.Implementation[`, `BottomXSize`,+ `MidXSize`, `eProcessFreePrimitives`, `RunProcessWithParameters`, `.SuperClass :=`,+ `CreateOleObject`). Comments and string literals are ignored. When a new one is found, add it to+ the list in `release.py` and to `dev-skills/altium-bridge-dev`. One case the regex cannot see:+ `GroupIterator_Create` on a board halts but is correct on a component, so review those by hand.+- **Raw `StrToFloat(` / `StrToInt(`** anywhere in the agent template outside the `AdomFloat` /+ `AdomInt` helpers. A raw conversion raises on malformed input, and a raise is an Error box on+ the user's screen.++Then `cargo test` must pass before the release build, and every file in the zip must exist.++Not linted by the script: `verbs[]` entries must be plain strings, never objects (see section 0 of+the publish skill for the one-line check).++## Source changes need their own push++`--publish` pushes only `CHANGELOG.md`, `src/bridge.json`, `src/BRIDGE_VERSION` and `package.json`+to the page repo. Changed source, skills or docs do not travel with it. Push them separately:++```bash+adom-wiki repo push adom/altium-bridge --files <changed files> -m "v<ver>: <what changed>"+```++A docs-only change (README, images) needs no version bump, only this push.++## Before promoting to public++All of these, on the version being promoted:++1. The verb battery (`dev-skills/altium-bridge-test/run_verb_tests.py`) is green, meaning exit+ code 0 with no FAIL and no uncovered verb, on at least two Altium builds. Altium Designer 26.x+ and Altium Designer Develop behave differently on script errors.+2. It is green on a machine that is not the one the change was written on.+3. `altium_selftest_script_error` answers `netWorks: true` on each of those builds. `outcome` may+ be `halted_and_recovered` or `contained_by_agent`; both are passes.+4. Ideally one run on a from-scratch Windows install, since the battery assumes Altium is present.+5. A human has asked for the promotion, and their exact words are in hand.++Never run the battery on a machine somebody is using. It raises Altium and flashes an error box.+The runner refuses unless the target has had no keyboard or mouse input for 180 s (`--min-idle`);+pass `--user-is-away` only when the human has told you the machine is free. Do not run it on two+machines of the same Altium 365 workspace at once, and let it close Altium at the end so the+Author seat is released.++## Verify after a publish++- The script's last lines: `SHIPPED <ver> to INSIDERS; public tier still serves <old>`.+- `adom-wiki release list` shows the release with its zip attached.+- After a promote: `adom-wiki repo show adom/altium-bridge adom-bridge-altium-manifest.json`+ shows the promoted version and the sha256 of the archived manifest.
bridge-rs/Cargo.lock+1−1@@ -10,7 +10,7 @@ checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa" [[package]] name = "altium-bridge"-version = "0.2.31"+version = "0.2.32" dependencies = [ "getrandom", "serde",
bridge-rs/Cargo.toml+1−1@@ -1,6 +1,6 @@ [package] name = "altium-bridge"-version = "0.2.31"+version = "0.2.32" edition = "2021" description = "Adom altium-bridge — the bridge process, in Rust. Calls ab's verbs for the desktop; speaks file IPC to the resident DelphiScript agent inside Altium." license = "MIT"
bridge-rs/src/lifecycle.rs+59−3@@ -15,6 +15,7 @@ //! * The bootstrap activates Altium. Capture the user's foreground window first and hand it back. use crate::ab; use crate::agent;+use crate::instance; use serde_json::{json, Value}; use std::path::PathBuf; use std::time::{Duration, Instant};@@ -560,6 +561,16 @@ pub fn stop_all_live() -> Value { /// Un-halt the script engine: press OK on the script Error modal, stop the editor's script, /// clear stale files. Costs the user a modal, so only on a real halt. pub fn recover(reason: &str) -> Value {+ recover_impl(reason, true)+}++/// For altium_close: free the engine but do NOT start a new agent loop (a close must never go out+/// over a live loop, adom/altium-bridge#6).+pub fn recover_without_restart(reason: &str) -> Value {+ recover_impl(reason, false)+}++fn recover_impl(reason: &str, verify_by_restart: bool) -> Value { let mut steps: Vec<Value> = vec![json!({"reason": reason})]; // 1. the script Error modal, if any for d in crate::dialogs::scan() {@@ -569,7 +580,40 @@ pub fn recover(reason: &str) -> Value { steps.push(json!({"dialog": title, "pressed": "OK"})); } }- // 2. tell the editor to stop whatever unit it is holding (raises the busy notice → reap it)+ // 2. tell the editor to stop whatever unit it is holding (raises the busy notice → reap it).+ // EditScript:Stop ONLY acts when a SCRIPT DOCUMENT is the active document. With a board or a+ // library in front it is a silent no-op: recover answered success while the script stayed on+ // its breakpoint, the self-test reported halted_and_recovered for an engine that was never+ // freed, and Altium then refused to close (measured on AD 26.10.1, 2026-09-21; it had only+ // ever worked because the script happened to be in front). So: bring every agent script this+ // workspace holds to the front, one at a time, and stop it there.+ let scripts: Vec<std::path::PathBuf> = std::fs::read_dir(agent::workspace_dir()).map(|rd| rd.filter_map(|e| e.ok()).map(|e| e.path())+ .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() {+ 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) {+ let _ = wait_helper(&mut child, Duration::from_secs(8));+ let _ = child.kill();+ let _ = child.wait();+ }+ 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!({"scriptActivatedAndStopped": sc.file_name().map(|n| n.to_string_lossy().to_string())}));+ }+ }+ instance::invalidate_snapshot();+ let instances_after = instance::instances().len();+ if instances_after > instances_before {+ // A -R hand-off that does not reach the running instance makes X2 start a NEW Altium.+ steps.push(json!({"warning": "a helper started a second Altium instance instead of forwarding", "instancesBefore": instances_before, "instancesAfter": instances_after}));+ } if let Some(exe) = x2_exe() { match spawn_helper(&exe, "-REditScript:Stop") { Ok(mut child) => {@@ -590,8 +634,20 @@ pub fn recover(reason: &str) -> Value { for n in ["hb.txt", "stop", "request.txt", "request.payload", "request.txt.tmp"] { let _ = std::fs::remove_file(ws.join(n)); }- json!({"success": true, "steps": steps,- "_hint": "the engine should be free now — agent_start. If a modal is still up, it is NOT ours: read it."})+ // VERIFY, never assume: the engine is free only if a fresh agent loop can actually start.+ instance::invalidate_snapshot();+ let mut verified = Value::Null;+ if verify_by_restart && crate::health::is_running() && instances_after <= 1 {+ let started = start(Duration::from_secs(40));+ let ok = started.get("success").and_then(|b| b.as_bool()) == Some(true) && agent::is_alive();+ verified = json!(ok);+ steps.push(json!({"verifiedByRestartingTheAgent": ok}));+ }+ let freed = verified.as_bool().unwrap_or(true);+ json!({"success": freed, "engineFree": verified, "steps": steps,+ "errorCode": if freed { Value::Null } else { json!("engine_still_parked") },+ "_hint": if freed { "The script engine is free and a fresh agent loop is running (verified by starting it)." }+ else { "The engine is STILL parked: a new agent loop could not start. Look at the screen (desktop_screenshot_screen): a script document must be the active document for Altium to accept the stop. Click the agent script's tab in Altium, then altium_agent_recover again. Do not altium_close yet: Altium refuses to close over a parked script." }}) } // ---- application lifecycle ---------------------------------------------------
bridge-rs/src/main.rs+1−1@@ -26,7 +26,7 @@ mod win32; use std::time::Instant; use tiny_http::{Header, Method, Response, Server}; -pub const BRIDGE_VERSION: &str = "0.2.31";+pub const BRIDGE_VERSION: &str = "0.2.32"; 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+41−1@@ -245,6 +245,23 @@ fn kill_instance_pids() -> Vec<u64> { killed } +/// Altium's own Exit process through the -R lane (the only close its skinned title bar honours).+fn send_quit() {+ if let Some(exe) = lifecycle::x2_exe_pub() {+ if let Ok(mut child) = lifecycle::spawn_helper_pub(&exe, "-RClient:QuitFromEDAClient") {+ let t0 = std::time::Instant::now();+ while t0.elapsed() < Duration::from_secs(10) {+ match child.try_wait() {+ Ok(Some(_)) => break,+ Ok(None) => std::thread::sleep(Duration::from_millis(200)),+ Err(_) => break,+ }+ }+ let _ = child.kill();+ }+ }+}+ pub fn close(args: &Value) -> Value { let lic = instance::licence_state(); if lic.get("running").and_then(|b| b.as_bool()) != Some(true) {@@ -279,7 +296,17 @@ pub fn close(args: &Value) -> Value { // executes even while an agent loop is resident (exactly the incident gesture of #6). // (ab's desktop_set_window_state dropped its "close" state the same day; nothing here // depends on another surface's verb set.)+ // A script parked on a breakpoint makes Altium REFUSE to close ("cannot close the current+ // document when a script is stopped on a breakpoint", measured on two builds 2026-09-20/21), and+ // Altium Develop parks silently. Free the engine first; it is a no-op when nothing is parked.+ let mut engine_freed = Value::Null;+ if !agent::is_alive() && agent::is_agent_script_title(&instance::main_window().and_then(|w| w.get("title").and_then(|t| t.as_str()).map(str::to_string)).unwrap_or_default()) {+ engine_freed = lifecycle::recover_without_restart("altium_close: the script engine was parked on a breakpoint");+ } lifecycle::note_close_initiated();+ send_quit();+ let mut breakpoint_retries = 0;+ if false { if let Some(exe) = lifecycle::x2_exe_pub() { if let Ok(mut child) = lifecycle::spawn_helper_pub(&exe, "-RClient:QuitFromEDAClient") { let t0 = std::time::Instant::now();@@ -293,12 +320,13 @@ pub fn close(args: &Value) -> Value { let _ = child.kill(); } }+ } let t0 = support::now(); let wait = args.get("waitSec").and_then(|v| v.as_f64()).unwrap_or(20.0); while support::now() - t0 < wait { instance::invalidate_snapshot(); if instance::instances().is_empty() {- return json!({"success": true, "closedInSec": support::elapsed_round(t0), "agentTeardown": agent_teardown});+ return json!({"success": true, "closedInSec": support::elapsed_round(t0), "agentTeardown": agent_teardown, "engineFreed": engine_freed}); } let mut dl = dialogs::scan_scoped(true); if !dl.is_empty() {@@ -315,6 +343,18 @@ pub fn close(args: &Value) -> Value { // Altium's save prompts. discardChanges:true = "every modified document is mine": press // Save None, then OK on the confirmation, and keep waiting for the exit. Without it, say // exactly what is unsaved instead of timing out.+ let lone_error = dl.iter().find(|d| d.get("title").and_then(|t| t.as_str()).map(|t| t.trim().eq_ignore_ascii_case("error")).unwrap_or(false)+ && d.get("buttons").and_then(|b| b.as_array()).map(|a| a.len() == 1).unwrap_or(false)).cloned();+ if let Some(d) = lone_error {+ if breakpoint_retries < 2 {+ breakpoint_retries += 1;+ if let Some(hw) = d.get("hwnd").and_then(|h| h.as_i64()) { let _ = dialogs::press(hw, "OK"); }+ engine_freed = lifecycle::recover_without_restart("altium_close: Altium refused to close over a parked script; freed it and asked again");+ std::thread::sleep(Duration::from_millis(1200));+ send_quit();+ continue;+ }+ } let is_save = |d: &Value| d.get("category").and_then(|c| c.as_str()) == Some("save_prompt"); if dl.iter().any(is_save) { if args.get("discardChanges").and_then(|b| b.as_bool()) == Some(true) {
bridge-rs/src/outputs.rs+7@@ -45,6 +45,13 @@ pub fn run_outjob(args: &Value) -> Value { } let t0 = support::now(); let outjob = s(args, "outjobPath").or_else(|| s(args, "filePath")).unwrap_or_else(support::demo_outjob_fab);+ // Refuse a missing OutJob BY NAME. The agent answered ok:false with no error text, so the caller+ // saw success:false and nothing else (found by the verb battery, 2026-09-21).+ if !std::path::Path::new(&outjob).is_file() {+ return json!({"success": false, "errorCode": "outjob_not_found", "outjobPath": outjob,+ "error": format!("no OutJob file at {outjob}"),+ "_hint": "Pass outjobPath (an absolute path to a .OutJob on THIS machine) and projectPath. With no outjobPath the verb runs Altium's bundled demo OutJob, which OPENS what it generates on the user's screen (a spreadsheet, a PDF): fine for 'show me', wrong for unattended work."});+ } let medium = s_or(args, "medium", "Folder Structure"); let proj = s(args, "projectPath") .or_else(|| if outjob.starts_with(support::EXAMPLES_DIR) { Some(support::demo_project()) } else { None });
bridge.json+1−1@@ -2,7 +2,7 @@ "manifest_version": 1, "name": "altium", "displayName": "Altium Designer",- "version": "0.2.31",+ "version": "0.2.32", "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",
dev-skills/altium-bridge-test/run_verb_tests.py+1−1@@ -205,7 +205,7 @@ def matrix(w): M["selftest_script_error"] = dict(phase=5, timeout=120, **ok(), check=lambda d: d.get("netWorks") is True) M["agent_recover"] = dict(phase=5, **ok()) M["agent_stop"] = dict(phase=6, **ok())- M["close"] = dict(phase=6, timeout=300, **ok(), note="releases the workspace Author seat")+ M["close"] = dict(phase=6, timeout=300, **ok(discardChanges=True, waitSec=90), note="releases the licence seat; every modified document is the battery's own scratch") # ---- never run ---------------------------------------------------------------------------- M["foreground"] = skip("SAFETY: raises Altium over whatever the user is doing")
page.json+1−1@@ -140,4 +140,4 @@ "skills/altium-wiki-import/SKILL.md", "uninstall.sh" ]-}+}