← Commit history
bridge-rs/Cargo.lock+1−1
@@ -10,7 +10,7 @@ checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa"  [[package]] name = "altium-bridge"-version = "0.2.25"+version = "0.2.26" dependencies = [  "getrandom",  "serde",
bridge-rs/Cargo.toml+1−1
@@ -1,6 +1,6 @@ [package] name = "altium-bridge"-version = "0.2.25"+version = "0.2.26" 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/agent.rs+47
@@ -169,6 +169,10 @@ pub fn call_in(ws: &std::path::Path, command: &str, params: &[(&str, String)], p     let t_post = t0.elapsed().as_millis() as u64;     let posted_unix_ms = SystemTime::now().duration_since(SystemTime::UNIX_EPOCH).map(|x| x.as_millis() as u64).unwrap_or(0);     let mut next_halt_check = Duration::from_millis(400);+    // Title of Altium's main window when the command was posted. When a script breaks, Altium+    // pulls the agent's own script document to the front, with or WITHOUT an Error box (Altium+    // Develop broke silently on board_shape, 2026-09-20: no box, so the box-watcher saw nothing).+    let title_at_post = main_title();     let resp = ws.join(format!("response_{id}.json"));     let mut t_seen: Option<u64> = None;     let mut parse_retries = 0u32;@@ -196,6 +200,23 @@ pub fn call_in(ws: &std::path::Path, command: &str, params: &[(&str, String)], p         // not leave Altium parked in its debugger: stop the script now and say what happened.         if t0.elapsed() >= next_halt_check {             next_halt_check = t0.elapsed() + Duration::from_millis(400);+            // Variant 3, the SILENT halt: no box at all, but Altium switched its window to the+            // agent's script (AdomAgent_*.pas / .PrjScr) while our command was running. Give the box+            // watcher 1.5 s to claim it first (it carries the error text); after that, this is it.+            if t0.elapsed() > Duration::from_millis(1500) && !resp.exists() {+                let now_title = main_title();+                if is_agent_script_title(&now_title) && !is_agent_script_title(&title_at_post) {+                    let shot = crate::shot::shot_of_main();+                    let recovered = crate::lifecycle::recover(&format!("auto: silent script halt during agent command '{command}'"));+                    return json!({"success": false, "errorCode": "agent_script_error", "silentHalt": true, "command": command,+                        "error": format!("the agent script halted while running '{command}': Altium raised no error box, but it stopped in its script debugger and pulled the agent script to the front"),+                        "errorShot": shot, "windowTitle": now_title,+                        "autoRecovered": recovered.get("success").and_then(|b| b.as_bool()).unwrap_or(false),+                        "recoverSteps": recovered.get("steps").cloned().unwrap_or(Value::Null),+                        "agentAlive": is_alive(),+                        "_hint": "errorShot is Altium's script editor at the moment of the halt (the highlighted line is where the engine stopped, usually the agent loop, not the faulty statement). The bridge already sent EditScript:Stop, so Altium is free and the next verb restarts the agent. Do not retry the same command: it will halt again. Report it on adom/altium-bridge with the command and its args."});+                }+            }             // Variant 2 (script run from INSIDE the agent, e.g. eval): Altium asks "Undeclared             // identifier ... Continue execution?" in an 'X2' box with OK + Cancel. It is OURS (it             // only exists while our command runs), the reaper rightly never touches a two-button@@ -244,3 +265,29 @@ pub fn call_no_autostart(command: &str, params: &[(&str, String)], timeout: Dura     }     call(command, params, None, timeout) }++/// Title of Altium's main window ("" when there is none).+fn main_title() -> String {+    instance::main_window().and_then(|w| w.get("title").and_then(|t| t.as_str()).map(str::to_string)).unwrap_or_default()+}++/// True when the focused document is the resident agent's own script: the state Altium puts its+/// window in when that script breaks in the debugger.+pub fn is_agent_script_title(title: &str) -> bool {+    let t = title.to_lowercase();+    t.starts_with("adomagent_") && (t.contains(".prjscr") || t.contains(".pas"))+}++#[cfg(test)]+mod halt_tests {+    use super::*;++    #[test]+    fn agent_script_title_is_recognised() {+        assert!(is_agent_script_title("AdomAgent_00fc18631e.PrjScr - Altium Designer Develop"));+        assert!(is_agent_script_title("AdomAgent_9fe87fbf7c.pas - Altium Designer (26.9.1) - Trial"));+        assert!(!is_agent_script_title("battery.PcbDoc - Altium Designer Develop"));+        assert!(!is_agent_script_title("Home Page - Altium Designer Develop"));+        assert!(!is_agent_script_title(""));+    }+}
bridge-rs/src/library.rs+9
@@ -510,6 +510,15 @@ pub fn import_part(args: &Value) -> Value {     let mut fp_name: Option<String> = None;     let pcb_lib = s(args, "pcbLib").unwrap_or_default();     let sch_lib = s(args, "schLib").unwrap_or_default();+    // Given nothing to import, this used to run zero steps and answer success:true (found by the+    // verb battery, 2026-09-20). Doing nothing is not success.+    let has_work = ["footprintSpec", "symbolSpec", "step", "params"].iter()+        .any(|k| args.get(*k).map(|v| !v.is_null() && v.as_str().map(|t| !t.is_empty()).unwrap_or(true)).unwrap_or(false));+    if !has_work {+        return json!({"success": false, "errorCode": "nothing_to_import",+            "error": "import_part was given nothing to import: pass footprintSpec and/or symbolSpec (with pcbLib / schLib), optionally step and params",+            "_hint": "See the altium-part-import skill for the spec format; altium_create_footprint / altium_create_symbol build a single item."});+    }     if let Some(spec) = s(args, "footprintSpec").filter(|v| !v.is_empty()) {         let r = agent::call("build_footprint", &[("libraryPath", pcb_lib.clone()), ("spec", spec)], None, Duration::from_secs(240));         if r.get("success").and_then(|b| b.as_bool()) != Some(true) {
bridge-rs/src/main.rs+1−1
@@ -25,7 +25,7 @@ mod win32; use std::time::Instant; use tiny_http::{Header, Method, Response, Server}; -pub const BRIDGE_VERSION: &str = "0.2.25";+pub const BRIDGE_VERSION: &str = "0.2.26";  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.json+1−1
@@ -2,7 +2,7 @@   "manifest_version": 1,   "name": "altium",   "displayName": "Altium Designer",-  "version": "0.2.25",+  "version": "0.2.26",   "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/SKILL.mdadded+72
@@ -0,0 +1,72 @@+---+name: altium-bridge-test+description: "DEVELOPER-only skill (user-invocable:false). The standard end-to-end verb battery for the Altium bridge: one runner that exercises EVERY altium_* verb in the live catalog against a real Altium on a real Windows box, on scratch copies of three test parts (leaded SOIC-8, QFN-24 with 9 heatsink vias, BGA-36). Coverage-enforced: a new verb with no entry turns the run red. Run it before every promotion to the public tier, after any change to the agent template or agent.rs/dialogs.rs, and once on every new Altium build or machine. Trigger words - test the altium bridge, altium verb test, verb battery, test all altium verbs, regression test altium, pre-release check altium, is altium-bridge safe to promote, run_verb_tests."+user-invocable: false+---++# The altium-bridge verb battery++`dev-skills/altium-bridge-test/run_verb_tests.py` drives every `altium_*` verb the LIVE bridge+declares (`altium_describe.allVerbs`) through `adom-bridge --target <box>`, the way a user's AI+does, and prints one line per verb plus a summary. It exists because of 2026-09-20: 0.2.20 looked+finished on the machine it was written on, and the first verb tried on a second machine parked+Altium on a script breakpoint. One machine and the verbs you happened to touch prove nothing.++```+python3 dev-skills/altium-bridge-test/run_verb_tests.py --target <machine> --thread "<your ai thread>"+    [--only verb1,verb2]   # re-run a few+    [--keep-open]          # do NOT close Altium at the end (default closes it: see Seats)+    [--json report.json]+```++Exit code 0 only when there are no FAILs and no uncovered verbs.++## What a result means++| Outcome | Meaning |+|---|---|+| PASS ok | the verb answered `success:true` (and its `check`, when the entry has one: pin counts, 9 via holes, 3 parts placed, `netWorks:true`) |+| PASS refused | the verb answered `success:false` WITH a named reason. For verbs whose real inputs the battery does not have (an OutJob, an IntLib, an Altium 365 token, a project) a fast, named refusal is the contract under test |+| SKIP | never run, reason printed. Only verbs that would harm the machine or the user's account: `foreground`, `demo`, `setup_trial`, `connect_workspace`, `license`, `install_extension`, `a365_configure`, `a365_oauth_begin`, `a365_logout` |+| FAIL | a timeout or transport error (the bridge wedged), `success:false` with NO reason, the wrong outcome for the entry, or **a dialog left on the user's screen after the verb** (checked after every verb with `altium_check_dialogs`) |++The dialog check is the point of the whole thing: John's rule is that a dialog the user has to+dismiss is a defect. A verb that "worked" and left a box up fails the battery.++## Safety++- It only ever touches scratch copies in `<Documents>/Adom/altium-bridge-battery` on the target+  (`Battery.SchLib`, `Battery.PcbLib`, `battery.SchDoc`, `battery.PcbDoc`), rebuilt from+  `fixtures/` on every run. It never opens or saves a user document.+- `uninstall` runs with `dryRun:true` only; `ensure_licensed` runs with defaults only (never signs+  out, never starts a trial).+- **Seats.** An Altium 365 workspace has a fixed number of Author seats. An Altium left running on+  a test box starves every other machine into view-only mode, where new documents silently refuse+  to open (`view_only_no_author_seat`). The battery therefore ENDS with `agent_stop` + `close`.+  Use `--keep-open` only when you are about to keep working on that box, and never run the battery+  on two machines of the same workspace at once.+- `selftest_script_error` flashes one Altium error box for under a second. Do not run the battery+  while someone is mid-edit in Altium on that machine.++## Adding a verb++Add the verb to `src/bridge.json` + `verbs.rs` as usual, then add ONE entry to `matrix()` in the+runner: the phase, real args against the scratch fixtures, and `expect` (`ok` / `refuse` / `any` /+`skip` with a SAFETY reason). Prefer `ok` with a `check`. Use `refuse` when the battery cannot+supply the input; never use `skip` to dodge a verb that is merely awkward to test.++## Fixtures++`fixtures/Adom_Test.SchLib` + `.PcbLib` are built from the `TST_*-fp.json` / `TST_*-sym.json` create+files beside them (adom-footprint + adom-symbol, then `adom-lbr import-kicad` and+`adom-lbr assemble-altium --no-dedupe`). Three deliberately different packages: gull-wing leads, an+exposed pad with heatsink vias, an alphanumeric ball grid. Rebuild them when adom-lbr's Altium+writer changes, so the battery also covers that lane.++## Before promoting to the public tier++1. Battery green on at least TWO Altium builds (today: Altium Designer 26.x and Altium Designer+   Develop behave differently on script errors; see `outcome` on `selftest_script_error`).+2. Battery green on a machine that is NOT the one the change was written on.+3. Ideally once on a from-scratch Windows install (the install lane is the first thing a public+   user touches and the battery assumes Altium is already there).
dev-skills/altium-bridge-test/fixtures/Adom_Test.PcbLibadded
⋯ 1 unchanged line ⋯
dev-skills/altium-bridge-test/fixtures/Adom_Test.SchLibadded
⋯ 1 unchanged line ⋯
dev-skills/altium-bridge-test/fixtures/TST_BGA36-fp.jsonadded+307
@@ -0,0 +1,307 @@+{+ "footprintName": "TST_BGA36",+ "description": "BGA-36 6x6 0.8mm pitch 5x5mm (altium-bridge test)",+ "tags": "BGA test",+ "pads": [+  {+   "number": "A1",+   "x": -2.0,+   "y": -2.0,+   "dx": 0.4,+   "dy": 0.4,+   "shape": "circle"+  },+  {+   "number": "A2",+   "x": -1.2,+   "y": -2.0,+   "dx": 0.4,+   "dy": 0.4,+   "shape": "circle"+  },+  {+   "number": "A3",+   "x": -0.4,+   "y": -2.0,+   "dx": 0.4,+   "dy": 0.4,+   "shape": "circle"+  },+  {+   "number": "A4",+   "x": 0.4,+   "y": -2.0,+   "dx": 0.4,+   "dy": 0.4,+   "shape": "circle"+  },+  {+   "number": "A5",+   "x": 1.2,+   "y": -2.0,+   "dx": 0.4,+   "dy": 0.4,+   "shape": "circle"+  },+  {+   "number": "A6",+   "x": 2.0,+   "y": -2.0,+   "dx": 0.4,+   "dy": 0.4,+   "shape": "circle"+  },+  {+   "number": "B1",+   "x": -2.0,+   "y": -1.2,+   "dx": 0.4,+   "dy": 0.4,+   "shape": "circle"+  },+  {+   "number": "B2",+   "x": -1.2,+   "y": -1.2,+   "dx": 0.4,+   "dy": 0.4,+   "shape": "circle"+  },+  {+   "number": "B3",+   "x": -0.4,+   "y": -1.2,+   "dx": 0.4,+   "dy": 0.4,+   "shape": "circle"+  },+  {+   "number": "B4",+   "x": 0.4,+   "y": -1.2,+   "dx": 0.4,+   "dy": 0.4,+   "shape": "circle"+  },+  {+   "number": "B5",+   "x": 1.2,+   "y": -1.2,+   "dx": 0.4,+   "dy": 0.4,+   "shape": "circle"+  },+  {+   "number": "B6",+   "x": 2.0,+   "y": -1.2,+   "dx": 0.4,+   "dy": 0.4,+   "shape": "circle"+  },+  {+   "number": "C1",+   "x": -2.0,+   "y": -0.4,+   "dx": 0.4,+   "dy": 0.4,+   "shape": "circle"+  },+  {+   "number": "C2",+   "x": -1.2,+   "y": -0.4,+   "dx": 0.4,+   "dy": 0.4,+   "shape": "circle"+  },+  {+   "number": "C3",+   "x": -0.4,+   "y": -0.4,+   "dx": 0.4,+   "dy": 0.4,+   "shape": "circle"+  },+  {+   "number": "C4",+   "x": 0.4,+   "y": -0.4,+   "dx": 0.4,+   "dy": 0.4,+   "shape": "circle"+  },+  {+   "number": "C5",+   "x": 1.2,+   "y": -0.4,+   "dx": 0.4,+   "dy": 0.4,+   "shape": "circle"+  },+  {+   "number": "C6",+   "x": 2.0,+   "y": -0.4,+   "dx": 0.4,+   "dy": 0.4,+   "shape": "circle"+  },+  {+   "number": "D1",+   "x": -2.0,+   "y": 0.4,+   "dx": 0.4,+   "dy": 0.4,+   "shape": "circle"+  },+  {+   "number": "D2",+   "x": -1.2,+   "y": 0.4,+   "dx": 0.4,+   "dy": 0.4,+   "shape": "circle"+  },+  {+   "number": "D3",+   "x": -0.4,+   "y": 0.4,+   "dx": 0.4,+   "dy": 0.4,+   "shape": "circle"+  },+  {+   "number": "D4",+   "x": 0.4,+   "y": 0.4,+   "dx": 0.4,+   "dy": 0.4,+   "shape": "circle"+  },+  {+   "number": "D5",+   "x": 1.2,+   "y": 0.4,+   "dx": 0.4,+   "dy": 0.4,+   "shape": "circle"+  },+  {+   "number": "D6",+   "x": 2.0,+   "y": 0.4,+   "dx": 0.4,+   "dy": 0.4,+   "shape": "circle"+  },+  {+   "number": "E1",+   "x": -2.0,+   "y": 1.2,+   "dx": 0.4,+   "dy": 0.4,+   "shape": "circle"+  },+  {+   "number": "E2",+   "x": -1.2,+   "y": 1.2,+   "dx": 0.4,+   "dy": 0.4,+   "shape": "circle"+  },+  {+   "number": "E3",+   "x": -0.4,+   "y": 1.2,+   "dx": 0.4,+   "dy": 0.4,+   "shape": "circle"+  },+  {+   "number": "E4",+   "x": 0.4,+   "y": 1.2,+   "dx": 0.4,+   "dy": 0.4,+   "shape": "circle"+  },+  {+   "number": "E5",+   "x": 1.2,+   "y": 1.2,+   "dx": 0.4,+   "dy": 0.4,+   "shape": "circle"+  },+  {+   "number": "E6",+   "x": 2.0,+   "y": 1.2,+   "dx": 0.4,+   "dy": 0.4,+   "shape": "circle"+  },+  {+   "number": "F1",+   "x": -2.0,+   "y": 2.0,+   "dx": 0.4,+   "dy": 0.4,+   "shape": "circle"+  },+  {+   "number": "F2",+   "x": -1.2,+   "y": 2.0,+   "dx": 0.4,+   "dy": 0.4,+   "shape": "circle"+  },+  {+   "number": "F3",+   "x": -0.4,+   "y": 2.0,+   "dx": 0.4,+   "dy": 0.4,+   "shape": "circle"+  },+  {+   "number": "F4",+   "x": 0.4,+   "y": 2.0,+   "dx": 0.4,+   "dy": 0.4,+   "shape": "circle"+  },+  {+   "number": "F5",+   "x": 1.2,+   "y": 2.0,+   "dx": 0.4,+   "dy": 0.4,+   "shape": "circle"+  },+  {+   "number": "F6",+   "x": 2.0,+   "y": 2.0,+   "dx": 0.4,+   "dy": 0.4,+   "shape": "circle"+  }+ ],+ "body": {+  "x1": -2.5,+  "y1": -2.5,+  "x2": 2.5,+  "y2": 2.5+ },+ "courtyard": {+  "x1": -3,+  "y1": -3,+  "x2": 3,+  "y2": 3+ }+}\ No newline at end of file
dev-skills/altium-bridge-test/fixtures/TST_BGA36-sym.jsonadded+232
@@ -0,0 +1,232 @@+{+ "symbolName": "TST_BGA36",+ "manufacturer": "Adom Test",+ "package": "Adom_Test:TST_BGA36",+ "description": "FPGA-style IO device, BGA-36 (altium-bridge test part, alphanumeric balls)",+ "datasheetUrl": "https://wiki.adom.inc/adom/altium-bridge",+ "referencePrefix": "U",+ "mpn": "TST-BGA36",+ "distributors": {+  "mouser": "000-TST-BGA36",+  "digikey": "TST-BGA36-ND"+ },+ "wikiUrl": "https://wiki.adom.inc/adom/altium-bridge",+ "pins": [+  {+   "number": "A1",+   "name": "VDD_A1",+   "type": "power_in",+   "group": "Power"+  },+  {+   "number": "A2",+   "name": "PA2",+   "type": "bidirectional",+   "group": "Bank A"+  },+  {+   "number": "A3",+   "name": "PA3",+   "type": "bidirectional",+   "group": "Bank A"+  },+  {+   "number": "A4",+   "name": "PA4",+   "type": "bidirectional",+   "group": "Bank A"+  },+  {+   "number": "A5",+   "name": "PA5",+   "type": "bidirectional",+   "group": "Bank A"+  },+  {+   "number": "A6",+   "name": "GND_A6",+   "type": "power_in",+   "group": "Power"+  },+  {+   "number": "B1",+   "name": "PB1",+   "type": "bidirectional",+   "group": "Bank B"+  },+  {+   "number": "B2",+   "name": "PB2",+   "type": "bidirectional",+   "group": "Bank B"+  },+  {+   "number": "B3",+   "name": "PB3",+   "type": "bidirectional",+   "group": "Bank B"+  },+  {+   "number": "B4",+   "name": "PB4",+   "type": "bidirectional",+   "group": "Bank B"+  },+  {+   "number": "B5",+   "name": "PB5",+   "type": "bidirectional",+   "group": "Bank B"+  },+  {+   "number": "B6",+   "name": "PB6",+   "type": "bidirectional",+   "group": "Bank B"+  },+  {+   "number": "C1",+   "name": "PC1",+   "type": "bidirectional",+   "group": "Bank C"+  },+  {+   "number": "C2",+   "name": "PC2",+   "type": "bidirectional",+   "group": "Bank C"+  },+  {+   "number": "C3",+   "name": "GND_C3",+   "type": "power_in",+   "group": "Power"+  },+  {+   "number": "C4",+   "name": "GND_C4",+   "type": "power_in",+   "group": "Power"+  },+  {+   "number": "C5",+   "name": "PC5",+   "type": "bidirectional",+   "group": "Bank C"+  },+  {+   "number": "C6",+   "name": "PC6",+   "type": "bidirectional",+   "group": "Bank C"+  },+  {+   "number": "D1",+   "name": "PD1",+   "type": "bidirectional",+   "group": "Bank D"+  },+  {+   "number": "D2",+   "name": "PD2",+   "type": "bidirectional",+   "group": "Bank D"+  },+  {+   "number": "D3",+   "name": "GND_D3",+   "type": "power_in",+   "group": "Power"+  },+  {+   "number": "D4",+   "name": "GND_D4",+   "type": "power_in",+   "group": "Power"+  },+  {+   "number": "D5",+   "name": "PD5",+   "type": "bidirectional",+   "group": "Bank D"+  },+  {+   "number": "D6",+   "name": "PD6",+   "type": "bidirectional",+   "group": "Bank D"+  },+  {+   "number": "E1",+   "name": "PE1",+   "type": "bidirectional",+   "group": "Bank E"+  },+  {+   "number": "E2",+   "name": "PE2",+   "type": "bidirectional",+   "group": "Bank E"+  },+  {+   "number": "E3",+   "name": "PE3",+   "type": "bidirectional",+   "group": "Bank E"+  },+  {+   "number": "E4",+   "name": "PE4",+   "type": "bidirectional",+   "group": "Bank E"+  },+  {+   "number": "E5",+   "name": "PE5",+   "type": "bidirectional",+   "group": "Bank E"+  },+  {+   "number": "E6",+   "name": "PE6",+   "type": "bidirectional",+   "group": "Bank E"+  },+  {+   "number": "F1",+   "name": "GND_F1",+   "type": "power_in",+   "group": "Power"+  },+  {+   "number": "F2",+   "name": "PF2",+   "type": "bidirectional",+   "group": "Bank F"+  },+  {+   "number": "F3",+   "name": "PF3",+   "type": "bidirectional",+   "group": "Bank F"+  },+  {+   "number": "F4",+   "name": "PF4",+   "type": "bidirectional",+   "group": "Bank F"+  },+  {+   "number": "F5",+   "name": "PF5",+   "type": "bidirectional",+   "group": "Bank F"+  },+  {+   "number": "F6",+   "name": "VDD_F6",+   "type": "power_in",+   "group": "Power"+  }+ ]+}\ No newline at end of file
dev-skills/altium-bridge-test/fixtures/TST_QFN24-fp.jsonadded+222
@@ -0,0 +1,222 @@+{+ "footprintName": "TST_QFN24",+ "description": "QFN-24 4x4mm 0.5mm pitch EP 2.6mm with heatsink vias (altium-bridge test)",+ "tags": "QFN test",+ "pads": [+  {+   "number": "1",+   "x": -1.95,+   "y": -1.25,+   "dx": 0.8,+   "dy": 0.25,+   "shape": "roundrect"+  },+  {+   "number": "2",+   "x": -1.95,+   "y": -0.75,+   "dx": 0.8,+   "dy": 0.25,+   "shape": "roundrect"+  },+  {+   "number": "3",+   "x": -1.95,+   "y": -0.25,+   "dx": 0.8,+   "dy": 0.25,+   "shape": "roundrect"+  },+  {+   "number": "4",+   "x": -1.95,+   "y": 0.25,+   "dx": 0.8,+   "dy": 0.25,+   "shape": "roundrect"+  },+  {+   "number": "5",+   "x": -1.95,+   "y": 0.75,+   "dx": 0.8,+   "dy": 0.25,+   "shape": "roundrect"+  },+  {+   "number": "6",+   "x": -1.95,+   "y": 1.25,+   "dx": 0.8,+   "dy": 0.25,+   "shape": "roundrect"+  },+  {+   "number": "7",+   "x": -1.25,+   "y": 1.95,+   "dx": 0.25,+   "dy": 0.8,+   "shape": "roundrect"+  },+  {+   "number": "8",+   "x": -0.75,+   "y": 1.95,+   "dx": 0.25,+   "dy": 0.8,+   "shape": "roundrect"+  },+  {+   "number": "9",+   "x": -0.25,+   "y": 1.95,+   "dx": 0.25,+   "dy": 0.8,+   "shape": "roundrect"+  },+  {+   "number": "10",+   "x": 0.25,+   "y": 1.95,+   "dx": 0.25,+   "dy": 0.8,+   "shape": "roundrect"+  },+  {+   "number": "11",+   "x": 0.75,+   "y": 1.95,+   "dx": 0.25,+   "dy": 0.8,+   "shape": "roundrect"+  },+  {+   "number": "12",+   "x": 1.25,+   "y": 1.95,+   "dx": 0.25,+   "dy": 0.8,+   "shape": "roundrect"+  },+  {+   "number": "13",+   "x": 1.95,+   "y": 1.25,+   "dx": 0.8,+   "dy": 0.25,+   "shape": "roundrect"+  },+  {+   "number": "14",+   "x": 1.95,+   "y": 0.75,+   "dx": 0.8,+   "dy": 0.25,+   "shape": "roundrect"+  },+  {+   "number": "15",+   "x": 1.95,+   "y": 0.25,+   "dx": 0.8,+   "dy": 0.25,+   "shape": "roundrect"+  },+  {+   "number": "16",+   "x": 1.95,+   "y": -0.25,+   "dx": 0.8,+   "dy": 0.25,+   "shape": "roundrect"+  },+  {+   "number": "17",+   "x": 1.95,+   "y": -0.75,+   "dx": 0.8,+   "dy": 0.25,+   "shape": "roundrect"+  },+  {+   "number": "18",+   "x": 1.95,+   "y": -1.25,+   "dx": 0.8,+   "dy": 0.25,+   "shape": "roundrect"+  },+  {+   "number": "19",+   "x": 1.25,+   "y": -1.95,+   "dx": 0.25,+   "dy": 0.8,+   "shape": "roundrect"+  },+  {+   "number": "20",+   "x": 0.75,+   "y": -1.95,+   "dx": 0.25,+   "dy": 0.8,+   "shape": "roundrect"+  },+  {+   "number": "21",+   "x": 0.25,+   "y": -1.95,+   "dx": 0.25,+   "dy": 0.8,+   "shape": "roundrect"+  },+  {+   "number": "22",+   "x": -0.25,+   "y": -1.95,+   "dx": 0.25,+   "dy": 0.8,+   "shape": "roundrect"+  },+  {+   "number": "23",+   "x": -0.75,+   "y": -1.95,+   "dx": 0.25,+   "dy": 0.8,+   "shape": "roundrect"+  },+  {+   "number": "24",+   "x": -1.25,+   "y": -1.95,+   "dx": 0.25,+   "dy": 0.8,+   "shape": "roundrect"+  },+  {+   "number": "25",+   "x": 0,+   "y": 0,+   "dx": 2.6,+   "dy": 2.6,+   "shape": "roundrect"+  }+ ],+ "heatsinkVias": {+  "pad": "25"+ },+ "body": {+  "x1": -2,+  "y1": -2,+  "x2": 2,+  "y2": 2+ },+ "courtyard": {+  "x1": -2.6,+  "y1": -2.6,+  "x2": 2.6,+  "y2": 2.6+ }+}\ No newline at end of file
dev-skills/altium-bridge-test/fixtures/TST_QFN24-sym.jsonadded+166
@@ -0,0 +1,166 @@+{+ "symbolName": "TST_QFN24",+ "manufacturer": "Adom Test",+ "package": "Adom_Test:TST_QFN24",+ "description": "GPIO expander, QFN-24 with exposed pad (altium-bridge test part, thermal vias)",+ "datasheetUrl": "https://wiki.adom.inc/adom/altium-bridge",+ "referencePrefix": "U",+ "mpn": "TST-QFN24",+ "distributors": {+  "mouser": "000-TST-QFN24",+  "digikey": "TST-QFN24-ND"+ },+ "wikiUrl": "https://wiki.adom.inc/adom/altium-bridge",+ "pins": [+  {+   "number": "1",+   "name": "VDD",+   "type": "power_in",+   "group": "Power"+  },+  {+   "number": "2",+   "name": "IO2",+   "type": "bidirectional",+   "group": "GPIO A"+  },+  {+   "number": "3",+   "name": "IO3",+   "type": "bidirectional",+   "group": "GPIO A"+  },+  {+   "number": "4",+   "name": "IO4",+   "type": "bidirectional",+   "group": "GPIO A"+  },+  {+   "number": "5",+   "name": "IO5",+   "type": "bidirectional",+   "group": "GPIO A"+  },+  {+   "number": "6",+   "name": "IO6",+   "type": "bidirectional",+   "group": "GPIO A"+  },+  {+   "number": "7",+   "name": "IO7",+   "type": "bidirectional",+   "group": "GPIO A"+  },+  {+   "number": "8",+   "name": "IO8",+   "type": "bidirectional",+   "group": "GPIO A"+  },+  {+   "number": "9",+   "name": "IO9",+   "type": "bidirectional",+   "group": "GPIO A"+  },+  {+   "number": "10",+   "name": "IO10",+   "type": "bidirectional",+   "group": "GPIO A"+  },+  {+   "number": "11",+   "name": "IO11",+   "type": "bidirectional",+   "group": "GPIO A"+  },+  {+   "number": "12",+   "name": "IO12",+   "type": "bidirectional",+   "group": "GPIO A"+  },+  {+   "number": "13",+   "name": "VDDIO",+   "type": "power_in",+   "group": "Power"+  },+  {+   "number": "14",+   "name": "IO14",+   "type": "bidirectional",+   "group": "GPIO B"+  },+  {+   "number": "15",+   "name": "IO15",+   "type": "bidirectional",+   "group": "GPIO B"+  },+  {+   "number": "16",+   "name": "IO16",+   "type": "bidirectional",+   "group": "GPIO B"+  },+  {+   "number": "17",+   "name": "IO17",+   "type": "bidirectional",+   "group": "GPIO B"+  },+  {+   "number": "18",+   "name": "IO18",+   "type": "bidirectional",+   "group": "GPIO B"+  },+  {+   "number": "19",+   "name": "IO19",+   "type": "bidirectional",+   "group": "GPIO B"+  },+  {+   "number": "20",+   "name": "IO20",+   "type": "bidirectional",+   "group": "GPIO B"+  },+  {+   "number": "21",+   "name": "IO21",+   "type": "bidirectional",+   "group": "GPIO B"+  },+  {+   "number": "22",+   "name": "IO22",+   "type": "bidirectional",+   "group": "GPIO B"+  },+  {+   "number": "23",+   "name": "IO23",+   "type": "bidirectional",+   "group": "GPIO B"+  },+  {+   "number": "24",+   "name": "IO24",+   "type": "bidirectional",+   "group": "GPIO B"+  },+  {+   "number": "25",+   "name": "EP_GND",+   "type": "power_in",+   "group": "Power"+  }+ ]+}\ No newline at end of file
dev-skills/altium-bridge-test/fixtures/TST_SOIC8-fp.jsonadded+83
@@ -0,0 +1,83 @@+{+ "footprintName": "TST_SOIC8",+ "description": "SOIC-8 3.9x4.9mm 1.27mm pitch (altium-bridge test)",+ "tags": "SOIC test",+ "pads": [+  {+   "number": "1",+   "x": -2.7,+   "y": -1.905,+   "dx": 1.55,+   "dy": 0.6,+   "shape": "roundrect"+  },+  {+   "number": "2",+   "x": -2.7,+   "y": -0.635,+   "dx": 1.55,+   "dy": 0.6,+   "shape": "roundrect"+  },+  {+   "number": "3",+   "x": -2.7,+   "y": 0.635,+   "dx": 1.55,+   "dy": 0.6,+   "shape": "roundrect"+  },+  {+   "number": "4",+   "x": -2.7,+   "y": 1.905,+   "dx": 1.55,+   "dy": 0.6,+   "shape": "roundrect"+  },+  {+   "number": "8",+   "x": 2.7,+   "y": -1.905,+   "dx": 1.55,+   "dy": 0.6,+   "shape": "roundrect"+  },+  {+   "number": "7",+   "x": 2.7,+   "y": -0.635,+   "dx": 1.55,+   "dy": 0.6,+   "shape": "roundrect"+  },+  {+   "number": "6",+   "x": 2.7,+   "y": 0.635,+   "dx": 1.55,+   "dy": 0.6,+   "shape": "roundrect"+  },+  {+   "number": "5",+   "x": 2.7,+   "y": 1.905,+   "dx": 1.55,+   "dy": 0.6,+   "shape": "roundrect"+  }+ ],+ "body": {+  "x1": -1.95,+  "y1": -2.45,+  "x2": 1.95,+  "y2": 2.45+ },+ "courtyard": {+  "x1": -3.75,+  "y1": -2.75,+  "x2": 3.75,+  "y2": 2.75+ }+}\ No newline at end of file
dev-skills/altium-bridge-test/fixtures/TST_SOIC8-sym.jsonadded+64
@@ -0,0 +1,64 @@+{+ "symbolName": "TST_SOIC8",+ "manufacturer": "Adom Test",+ "package": "Adom_Test:TST_SOIC8",+ "description": "Dual op-amp, SOIC-8 (altium-bridge test part, leaded)",+ "datasheetUrl": "https://wiki.adom.inc/adom/altium-bridge",+ "referencePrefix": "U",+ "mpn": "TST-SOIC8",+ "distributors": {+  "mouser": "000-TST-SOIC8",+  "digikey": "TST-SOIC8-ND"+ },+ "wikiUrl": "https://wiki.adom.inc/adom/altium-bridge",+ "pins": [+  {+   "number": "1",+   "name": "OUT1",+   "type": "output",+   "group": "Amp A"+  },+  {+   "number": "2",+   "name": "IN1-",+   "type": "input",+   "group": "Amp A"+  },+  {+   "number": "3",+   "name": "IN1+",+   "type": "input",+   "group": "Amp A"+  },+  {+   "number": "4",+   "name": "V-",+   "type": "power_in",+   "group": "Power"+  },+  {+   "number": "5",+   "name": "IN2+",+   "type": "input",+   "group": "Amp B"+  },+  {+   "number": "6",+   "name": "IN2-",+   "type": "input",+   "group": "Amp B"+  },+  {+   "number": "7",+   "name": "OUT2",+   "type": "output",+   "group": "Amp B"+  },+  {+   "number": "8",+   "name": "V+",+   "type": "power_in",+   "group": "Power"+  }+ ]+}\ No newline at end of file
dev-skills/altium-bridge-test/run_verb_tests.pyadded+311
@@ -0,0 +1,311 @@+#!/usr/bin/env python3+"""The altium-bridge verb battery: every altium_* verb in the LIVE catalog, against a real Altium.++    python3 run_verb_tests.py --target <machine> [--thread "<ai thread>"] [--only a,b] [--keep-open]+                              [--fixtures <dir with Adom_Test.SchLib/.PcbLib>] [--json out.json]++Coverage is ENFORCED: a verb in `altium_describe.allVerbs` with no entry in MATRIX turns the run red,+so a new verb cannot ship untested. Modelled on adom/kicad-bridge's run_verb_tests.py.++What a PASS is+  ok       the verb answered success:true (plus the entry's `check`, when it has one)+  refuse   the verb answered success:false with a NAMED reason (errorCode or data.error / error text).+           For verbs whose real inputs this battery does not have (an OutJob, an IntLib, an APS token)+           a clean, fast refusal IS the contract under test.+  skip     never run, with the reason printed. Only for verbs that would harm the machine or the+           user's account (sign-out, trial activation, uninstall, foreground steal, extension install).+What a FAIL is+  a timeout or transport error, success:false with NO reason, the wrong outcome for the entry,+  a dialog left on the user's screen after the verb, or the agent dead when it should be alive.++SAFETY+  - Works only on SCRATCH copies (Battery.SchLib / Battery.PcbLib / battery.SchDoc / battery.PcbDoc)+    in <Documents>/Adom/altium-bridge-battery on the target; never the user's documents.+  - Closes Altium when done unless --keep-open: an Altium 365 workspace has a fixed number of Author+    seats, and an Altium left running on a test box starves every other machine (view-only mode).+"""+import argparse+import json+import os+import subprocess+import sys+import time++HERE = os.path.dirname(os.path.abspath(__file__))+++def ab(target, thread, verb, args, timeout=240):+    args = dict(args)+    args.setdefault("reason", "altium-bridge verb battery")+    cmd = ["adom-bridge", "--ai-thread", thread, "--target", target, verb, json.dumps(args)]+    t0 = time.time()+    try:+        p = subprocess.run(cmd, capture_output=True, text=True, timeout=timeout)+    except subprocess.TimeoutExpired:+        return {"success": False, "_transport": "battery_timeout", "_sec": time.time() - t0}+    out = p.stdout.strip()+    try:+        d, _ = json.JSONDecoder().raw_decode(out[out.index("{"):])+    except Exception:+        return {"success": False, "_transport": "unparseable", "_raw": (out or p.stderr)[:300], "_sec": time.time() - t0}+    d["_sec"] = round(time.time() - t0, 2)+    return d+++IDLE_PS = r"""+Add-Type @'+using System; using System.Runtime.InteropServices;+public static class AdomIdle { [StructLayout(LayoutKind.Sequential)] struct LII { public uint cbSize; public uint dwTime; }+  [DllImport("user32.dll")] static extern bool GetLastInputInfo(ref LII p);+  public static uint Seconds() { LII l = new LII(); l.cbSize = (uint)Marshal.SizeOf(l); GetLastInputInfo(ref l); return ((uint)Environment.TickCount - l.dwTime) / 1000; } }+'@+[AdomIdle]::Seconds()+"""+++def idle_seconds(target, thread):+    """Seconds since the last keyboard/mouse input on the target (None when it cannot be read)."""+    import base64+    enc = base64.b64encode(IDLE_PS.encode("utf-16-le")).decode()+    r = ab(target, thread, "shell_execute", {"command": f"powershell -NoProfile -EncodedCommand {enc}", "timeoutSeconds": 60,+                                             "reason": "verb battery: is anyone using this machine right now?"}, timeout=90)+    try:+        return int(str(r.get("stdout", "")).strip().splitlines()[-1])+    except Exception:+        return None+++def reason_of(d):+    data = d.get("data") if isinstance(d.get("data"), dict) else {}+    return d.get("errorCode") or data.get("errorCode") or d.get("error") or data.get("error") or ""+++def matrix(w):+    """verb -> dict(phase, args, expect='ok'|'refuse'|'any'|'skip', check=callable?, note, timeout)."""+    D, SL, PL, SD, PD = w["dir"], w["schlib"], w["pcblib"], w["schdoc"], w["pcbdoc"]+    ok = lambda **a: dict(expect="ok", args=a)+    ref = lambda note, **a: dict(expect="refuse", args=a, note=note)+    anyo = lambda note, **a: dict(expect="any", args=a, note=note)+    skip = lambda note: dict(expect="skip", args={}, note=note)+    M = {}++    # ---- phase 0: answers without the script engine ------------------------------------------+    for v in ["describe", "status", "readiness", "diagnostics", "instances", "agent_status", "progress",+              "check_dialogs", "window_info", "license_status", "signin", "signin_status", "install_receipt", "a365_status",+              "a365_oauth_status"]:+        M[v] = dict(phase=0, **ok())+    M["dialog_log"] = dict(phase=0, **ok(limit=5))+    M["list_commands"] = dict(phase=0, **ok(query="PCB", limit=5))+    M["uninstall"] = dict(phase=0, **ok(dryRun=True), note="SAFETY: dryRun only, never removes anything")+    M["extension_ping"] = dict(phase=0, **anyo("the Altium extension is optional; ok or a named 'not installed' both pass"))+    # Altium 365 API lane: no token on a test box, so the contract is a clean refusal+    for v, a in [("a365_refresh", {}), ("a365_query", {"query": "{ __typename }"}), ("a365_library_overview", {}),+                 ("a365_library_schema", {}), ("a365_files_upload", {"filePath": PL})]:+        M[v] = dict(phase=0, **anyo("no API token on a test box: a named not-configured refusal is the pass", **a))+    M["a365_oauth_cancel"] = dict(phase=0, **anyo("nothing pending: ok or a named refusal"))++    # ---- phase 1: lifecycle ------------------------------------------------------------------+    M["launch"] = dict(phase=1, timeout=300, **ok(waitSec=150))+    M["agent_start"] = dict(phase=1, **ok())+    M["ensure_licensed"] = dict(phase=1, timeout=300, **ok(), note="defaults only: never signs out, never starts a trial")+    M["state"] = dict(phase=1, **ok(screenshots=False))+    M["eval"] = dict(phase=1, **ok(text="Begin End;"))+    M["run_command"] = dict(phase=1, **anyo("a harmless view command", command="PCB:Zoom", parameters="Action=Redraw"))++    # ---- phase 2: library lane on SCRATCH copies ---------------------------------------------+    M["install_library"] = dict(phase=2, **ok(libraryPath=SL))+    M["open_document"] = dict(phase=2, **ok(filePath=SL))+    M["activate_document"] = dict(phase=2, **ok(path=SL))+    M["list_library"] = dict(phase=2, **ok(libraryPath=SL, limit=10))+    M["show_library"] = dict(phase=2, **ok(libraryPath=SL))+    M["read_symbol"] = dict(phase=2, **ok(libraryPath=SL, symbolName="TST_QFN24"))+    M["symbol_prims"] = dict(phase=2, **ok(libraryPath=SL, symbolName="TST_BGA36", models=True),+                             check=lambda d: (d.get("data") or {}).get("pins") == 36)+    M["show_symbol"] = dict(phase=2, **ok(libraryPath=SL, symbolName="TST_SOIC8"))+    M["symbol_mode"] = dict(phase=2, **anyo("single-mode test symbols: ok or a named refusal", libraryPath=SL, mode=0))+    M["set_params"] = dict(phase=2, **ok(libraryPath=SL, symbolName="TST_SOIC8", params={"BatteryParam": "1"}))+    M["set_visibility"] = dict(phase=2, **anyo("param visibility", libraryPath=SL, symbolName="TST_SOIC8", param="BatteryParam", visible=False))+    M["set_models"] = dict(phase=2, **ok(libraryPath=SL, symbolName="TST_QFN24", footprint="TST_QFN24"))+    M["add_models"] = dict(phase=2, **anyo("adds a footprint model row", libraryPath=SL, symbolName="TST_SOIC8", models=[{"type": "PCBLIB", "name": "TST_SOIC8"}]))+    M["create_symbol"] = dict(phase=2, **ok(libraryPath=SL, name="BAT_SYM", designator="U?", description="battery",+                                          pins=[{"number": "1", "name": "A", "side": "left"}, {"number": "2", "name": "B", "side": "right"}]))+    M["clone_symbol"] = dict(phase=2, **ok(libraryPath=SL, **{"from": "BAT_SYM", "to": "BAT_SYM_CLONE"}))+    M["rename_symbol"] = dict(phase=2, **ok(libraryPath=SL, **{"from": "BAT_SYM_CLONE", "to": "BAT_SYM_RENAMED"}))+    M["remove_symbol"] = dict(phase=2, **ok(libraryPath=SL, symbolName="BAT_SYM_RENAMED"))+    M["build_symbol_modes"] = dict(phase=2, **ref("no spec given: must refuse by name, not halt", libraryPath=SL))+    M["add_symbol_art"] = dict(phase=2, **ref("no kicadSym given: must refuse by name", libraryPath=SL, symbolName="TST_SOIC8"))++    M["show_footprint"] = dict(phase=2, **ok(libraryPath=PL, footprintName="TST_QFN24"))+    M["show_3d_chip"] = dict(phase=2, **anyo("test footprints carry no 3D body: ok or named refusal", libraryPath=PL, footprintName="TST_QFN24"))+    M["footprint_primitives"] = dict(phase=2, **anyo("reads a footprint's primitives", libraryPath=PL, footprintName="TST_QFN24"))+    M["plate_vias"] = dict(phase=2, **ok(libraryPath=PL), check=lambda d: (d.get("data") or {}).get("holes") == 9)+    M["normalize_bodies"] = dict(phase=2, **anyo("no bodies on the test footprints", libraryPath=PL))+    M["create_footprint"] = dict(phase=2, **ok(libraryPath=PL, name="BAT_FP", description="battery",+                                             pads="1,-40,0,24,24,rect,0;2,40,0,24,24,rect,0"))+    M["copy_footprint"] = dict(phase=2, **ok(libraryPath=PL, **{"from": "BAT_FP", "to": "BAT_FP_COPY"}))+    M["rename_footprint"] = dict(phase=2, **ok(libraryPath=PL, **{"from": "BAT_FP_COPY", "to": "BAT_FP_RENAMED"}))+    M["remove_footprint"] = dict(phase=2, **ok(libraryPath=PL, footprintName="BAT_FP_RENAMED"))+    M["xcopy_footprint"] = dict(phase=2, **anyo("cross-library copy onto itself: ok or named refusal", fromLib=PL, toLib=PL, **{"from": "BAT_FP", "to": "BAT_FP_X"}))+    M["add_body"] = dict(phase=2, **anyo("extruded body on the scratch footprint", libraryPath=PL, footprintName="BAT_FP", hMil=40))+    M["build_footprint"] = dict(phase=2, **ref("no spec given: must refuse by name", libraryPath=PL))+    M["cleanup_libs"] = dict(phase=2, **anyo("library housekeeping", libraryPath=SL))+    M["save"] = dict(phase=2, **ok(filePath=SL))++    # ---- phase 3: schematic + board built from the scratch libraries --------------------------+    M["place_on_sch"] = dict(phase=3, **ok(libraryPath=SL, symbolName="TST_SOIC8;TST_QFN24;TST_BGA36", schPath=SD, title="battery",+                                         number="B-1", revision="A", drawnBy="battery", description="battery sheet"),+                             check=lambda d: (d.get("data") or {}).get("count") == 3)+    M["test_board"] = dict(phase=3, **ok(libraryPath=PL, footprintName="TST_SOIC8;TST_QFN24;TST_BGA36", wMm="50", hMm="25", pcbPath=PD),+                           check=lambda d: (d.get("data") or {}).get("count") == 3)+    M["place_on_pcb"] = dict(phase=3, **anyo("place one more footprint on the battery board", libraryPath=PL, footprintName="TST_SOIC8", pcbPath=PD))+    M["swap_footprint"] = dict(phase=3, **anyo("swap on the battery board", libraryPath=PL, pcbPath=PD, footprintName="TST_SOIC8", assigns="U1=TST_SOIC8"))+    M["board_shape"] = dict(phase=3, **ok(pcbPath=PD, wMm=50, hMm=25))+    for v in ["read_board", "board_summary", "list_designators", "list_nets", "layers", "layers_in_use", "copper_layers",+              "read_rules", "rules_by_kind"]:+        M[v] = dict(phase=3, **ok(filePath=PD, pcbPath=PD, path=PD))+    M["component_data"] = dict(phase=3, **anyo("U1 on the battery board", filePath=PD, designator="U1"))+    M["component_pins"] = dict(phase=3, **anyo("U1 pins", filePath=PD, designator="U1"))+    M["net_connections"] = dict(phase=3, **anyo("the battery board has no nets: named refusal is a pass", filePath=PD, net="GND"))+    M["set_layer_visibility"] = dict(phase=3, **anyo("toggle Top Overlay", filePath=PD, layer="Top Overlay", visible=True))+    M["pcb_report"] = dict(phase=3, **anyo("board report", path=PD, want="summary"))+    M["read_schematic"] = dict(phase=3, **ok(filePath=SD, path=SD))+    M["schematic_data"] = dict(phase=3, **anyo("schematic data", filePath=SD))+    M["cross_check"] = dict(phase=3, **anyo("sch vs pcb", board=PD, sheet=SD))+    M["review_board"] = dict(phase=3, timeout=300, **anyo("design review", board=PD, sheet=SD, limit=5))+    M["compile"] = dict(phase=3, **anyo("free documents have no project to compile: named refusal is a pass", filePath=SD))+    M["run_drc"] = dict(phase=3, timeout=300, **anyo("DRC on the battery board", filePath=PD, limit=5))+    M["show_schematic"] = dict(phase=3, **ok(filePath=SD))+    M["show_2d_board"] = dict(phase=3, **ok(filePath=PD))+    M["show_3d_board"] = dict(phase=3, timeout=300, **ok(filePath=PD))+    M["animate_view"] = dict(phase=3, **anyo("short orbit", preset="orbit", steps=3, pauseMs=50))+    M["show_project"] = dict(phase=3, **ref("no project given: must refuse by name"))+    M["open_project"] = dict(phase=3, **ref("no project given: must refuse by name"))+    M["run_erc"] = dict(phase=3, **anyo("ERC needs a compiled project: named refusal is a pass", filePath=SD))++    # ---- phase 4: outputs --------------------------------------------------------------------+    M["export_step"] = dict(phase=4, timeout=300, **anyo("STEP of the battery board", filePath=PD, output=D + "/battery.step"))+    M["export_bom"] = dict(phase=4, **anyo("BOM needs a project: named refusal is a pass", filePath=SD))+    M["export_gerber"] = dict(phase=4, **anyo("gerbers need an OutJob/project: named refusal is a pass", filePath=PD))+    M["run_outjob"] = dict(phase=4, **ref("no OutJob in the fixtures: must refuse by name"))+    M["compile_intlib"] = dict(phase=4, **ref("no LibPkg in the fixtures: must refuse by name"))+    M["extract_intlib"] = dict(phase=4, **ref("no IntLib in the fixtures: must refuse by name"))+    M["import_kicad"] = dict(phase=4, **ref("no KiCad file in the fixtures: must refuse by name"))+    M["import_part"] = dict(phase=4, **ref("no spec given: must refuse by name"))+    M["install_library_bundle"] = dict(phase=4, **ref("no bundle given: must refuse by name"))+    M["close_document"] = dict(phase=4, **anyo("close the battery sheet", filePath=SD, path=SD))++    # ---- phase 5: the script lanes and the safety net -----------------------------------------+    M["run_script"] = dict(phase=5, **ref("refused while the agent is resident (agent_resident)", proc="Nope"))+    M["dismiss_dialogs"] = dict(phase=5, **anyo("nothing to dismiss"))+    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")++    # ---- never run ----------------------------------------------------------------------------+    M["foreground"] = skip("SAFETY: raises Altium over whatever the user is doing")+    M["demo"] = skip("SAFETY: a long foreground show with captions; has its own acceptance")+    M["setup_trial"] = skip("SAFETY: activates a trial on the user's Altium account")+    M["connect_workspace"] = skip("SAFETY: changes the user's workspace session")+    M["license"] = skip("SAFETY: can release/claim the user's seat")+    M["install_extension"] = skip("SAFETY: installs into the user's Altium")+    M["a365_configure"] = skip("SAFETY: would overwrite the stored API client/token")+    M["a365_oauth_begin"] = skip("SAFETY: opens a browser consent the user has to answer")+    M["a365_logout"] = skip("SAFETY: drops the stored API token")+    return M+++def main():+    ap = argparse.ArgumentParser()+    ap.add_argument("--target", required=True)+    ap.add_argument("--thread", default="altium-bridge battery")+    ap.add_argument("--only", default="")+    ap.add_argument("--keep-open", action="store_true")+    ap.add_argument("--fixtures", default=os.path.join(HERE, "fixtures"))+    ap.add_argument("--json", default="")+    ap.add_argument("--min-idle", type=int, default=180, help="refuse to run unless the target has had no keyboard/mouse input for this many seconds")+    ap.add_argument("--user-is-away", action="store_true", help="the human told you this machine is free: skip the idle check")+    a = ap.parse_args()+    T, TH = a.target, a.thread+    # The battery launches Altium, opens documents and shows views: it WILL pull Altium in front of+    # whoever is at the keyboard (it did, 2026-09-20, while the user was typing). Never run it on a+    # machine somebody is using.+    if not a.user_is_away:+        idle = idle_seconds(T, TH)+        if idle is None or idle < a.min_idle:+            sys.exit(f"REFUSED: {T} had keyboard/mouse input {idle} s ago (need {a.min_idle} s idle). Someone is using it; "+                     "the battery would raise Altium over their work. Ask the user, then pass --user-is-away.")+        print(f"{T} idle for {idle} s: ok to run")+    home = "C:/Users/john"+    r = ab(T, TH, "shell_execute", {"command": "cmd /c echo %USERPROFILE%"})+    if r.get("stdout"):+        home = r["stdout"].strip().replace("\\", "/")+    D = home + "/Documents/Adom/altium-bridge-battery"+    w = dict(dir=D, schlib=D + "/Battery.SchLib", pcblib=D + "/Battery.PcbLib", schdoc=D + "/battery.SchDoc", pcbdoc=D + "/battery.PcbDoc")++    # fixtures -> scratch copies on the target+    fx = [os.path.join(a.fixtures, n) for n in ("Adom_Test.SchLib", "Adom_Test.PcbLib")]+    for f in fx:+        if not os.path.isfile(f):+            sys.exit(f"missing fixture {f}")+    ab(T, TH, "send_files", {"filePaths": fx}, timeout=300)+    dw = D.replace("/", "\\"); hw = home.replace("/", "\\")+    ab(T, TH, "shell_execute", {"command": f'cmd /c rmdir /S /Q "{dw}" 2>nul & mkdir "{dw}" & move /Y "{hw}\\Downloads\\Adom_Test.SchLib" "{dw}\\Battery.SchLib" & move /Y "{hw}\\Downloads\\Adom_Test.PcbLib" "{dw}\\Battery.PcbLib"'})++    live = ab(T, TH, "altium_describe", {})+    verbs = sorted(v[len("altium_"):] for v in (live.get("allVerbs") or []))+    if not verbs:+        sys.exit(f"altium_describe returned no allVerbs: {str(live)[:300]}")+    M = matrix(w)+    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]++    rows = []+    order = sorted(M.items(), key=lambda kv: (kv[1].get("phase", 9), list(M).index(kv[0])))+    for verb, spec in order:+        if verb not in verbs or (only and verb not in only):+            continue+        if a.keep_open and verb in ("close", "agent_stop"):+            rows.append(dict(verb=verb, outcome="SKIP", note="--keep-open", sec=0)); continue+        if spec["expect"] == "skip":+            rows.append(dict(verb=verb, outcome="SKIP", note=spec.get("note", ""), sec=0)); continue+        d = ab(T, TH, "altium_" + verb, spec["args"], timeout=spec.get("timeout", 200))+        okv, why = d.get("success") is True, reason_of(d)+        if d.get("_transport") or "HTTP error" in str(d.get("error", "")) or d.get("errorCode") in ("timed_out", "agent_timeout"):+            outcome, detail = "FAIL", f"transport/timeout: {d.get('_transport') or d.get('errorCode') or d.get('error')}"+        elif okv:+            chk = spec.get("check")+            good = spec["expect"] in ("ok", "any") and (chk is None or chk(d))+            outcome = "PASS" if good else "FAIL"+            detail = "ok" if good else ("check failed" if spec["expect"] != "refuse" else "expected a refusal, got success")+        elif why:+            outcome = "PASS" if spec["expect"] in ("refuse", "any") else "FAIL"+            detail = ("refused: " if outcome == "PASS" else "unexpected failure: ") + str(why)[:150]+        else:+            outcome, detail = "FAIL", "success:false with NO reason (no errorCode, no error text)"+        # nothing may be left on the user's screen+        if verb not in ("close", "agent_stop", "launch"):+            dl = ab(T, TH, "altium_check_dialogs", {}, timeout=60)+            left = [x.get("title") for x in (dl.get("dialogs") or [])]+            if left:+                outcome, detail = "FAIL", f"left dialog(s) on screen: {left} | was: {detail}"+                ab(T, TH, "altium_agent_recover", {}, timeout=90)+        rows.append(dict(verb=verb, outcome=outcome, detail=detail, sec=d.get("_sec"), note=spec.get("note", "")))+        print(f"{outcome:5s} {verb:28s} {str(d.get('_sec')):>6}s  {detail[:130]}", flush=True)++    n = {k: sum(1 for r in rows if r["outcome"] == k) for k in ("PASS", "FAIL", "SKIP")}+    red = n["FAIL"] or uncovered+    print("\n==== altium-bridge verb battery on", T, "| bridge", live.get("bridgeVersion"), "====")+    print(f"verbs in catalog {len(verbs)} | PASS {n['PASS']} | FAIL {n['FAIL']} | SKIP {n['SKIP']} (safety) | uncovered {len(uncovered)} | stale {len(stale)}")+    if uncovered: print("UNCOVERED (add to MATRIX):", uncovered)+    if stale: print("stale matrix entries (verb gone):", stale)+    for r in rows:+        if r["outcome"] == "FAIL": print("  FAIL", r["verb"], "-", r["detail"])+    if a.json:+        json.dump(dict(target=T, bridgeVersion=live.get("bridgeVersion"), counts=n, uncovered=uncovered, stale=stale, rows=rows), open(a.json, "w"), indent=1)+    sys.exit(1 if red else 0)+++if __name__ == "__main__":+    main()
page.json+1−1
@@ -140,4 +140,4 @@     "skills/altium-wiki-import/SKILL.md",     "uninstall.sh"   ]-}\ No newline at end of file+}
scripts/release.py+5
@@ -130,6 +130,11 @@ def lint_agent_script():     for pat in ENGINE_HALTING:         for m in re.finditer(pat, code):             hits.append(f"{pat}  ->  ...{code[max(0, m.start() - 40):m.end() + 20].strip()}...")+    # A raw StrToFloat/StrToInt on a caller value RAISES on malformed input, and a raise is an Error+    # box on the user's screen. Only the AdomFloat/AdomInt helpers may call them.+    body = re.sub(r"Function AdomFloat\(.*?^End;|Function AdomInt\(.*?^End;", "", code, flags=re.S | re.M)+    for m in re.finditer(r"(?<![A-Za-z_])(StrToFloat|StrToInt)\(", body):+        hits.append(f"raw {m.group(1)}( outside AdomFloat/AdomInt  ->  ...{body[max(0, m.start() - 40):m.end() + 25].strip()}...")     if hits:         raise SystemExit("agent template uses engine-halting identifiers (see ENGINE_HALTING):\n  " + "\n  ".join(hits)) 
src/agent/AdomAgent.pas.tmpl+85−38
@@ -42,6 +42,49 @@ Var     gLastSaveMs, gLastRenameMs, gLastDeleteMs : Integer;     gLastSaveTries, gLastSaveExcept : Integer;     gDirectTries, gDirectExcept, gDirectMs : Integer;+    gInputError : String;   { first caller value that was not a number; see AdomFloat }++{ ------------------------------------------------------- safe numbers ---- }+{ StrToFloat / StrToInt on a CALLER value raise EConvertError on anything malformed, and in+  Altium a raise is an Error box on the user's screen plus a script parked on a breakpoint+  (measured 2026-09-20: create_footprint with pads in the wrong shape). These never raise: a bad+  token is remembered in gInputError and the dispatcher answers errorCode bad_number. }+Function AdomIsNumber(T : String; AllowDot : Boolean) : Boolean;+Var+    I, N, Dots, Digits : Integer;+    C : String;+Begin+    N := Length(T); Dots := 0; Digits := 0; Result := N > 0;+    For I := 1 To N Do+    Begin+        C := Copy(T, I, 1);+        If (C >= '0') And (C <= '9') Then Digits := Digits + 1+        Else If (C = '.') And AllowDot Then Dots := Dots + 1+        Else If ((C = '-') Or (C = '+')) And (I = 1) Then Dots := Dots+        Else Result := False;+    End;+    If (Digits = 0) Or (Dots > 1) Then Result := False;+End;++Function AdomFloat(S : String) : Double;+Var+    T : String;+Begin+    Result := 0;+    T := Trim(S);+    If AdomIsNumber(T, True) Then Result := StrToFloat(T)+    Else If gInputError = '' Then gInputError := T;+End;++Function AdomInt(S : String) : Integer;+Var+    T : String;+Begin+    Result := 0;+    T := Trim(S);+    If AdomIsNumber(T, False) Then Result := StrToInt(T)+    Else If gInputError = '' Then gInputError := T;+End;  { ---------------------------------------------------------------- files ---- } @@ -820,14 +863,14 @@ Begin         Spec := Pads[I];         Pad := PCBServer.PCBObjectFactory(ePadObject, eNoDimension, eCreate_Default);         Pad.Name := Field(Spec, 1);-        Pad.X := MilsToCoord(StrToFloat(Field(Spec, 2)));-        Pad.Y := MilsToCoord(StrToFloat(Field(Spec, 3)));-        Pad.TopXSize := MilsToCoord(StrToFloat(Field(Spec, 4)));-        Pad.TopYSize := MilsToCoord(StrToFloat(Field(Spec, 5)));+        Pad.X := MilsToCoord(AdomFloat(Field(Spec, 2)));+        Pad.Y := MilsToCoord(AdomFloat(Field(Spec, 3)));+        Pad.TopXSize := MilsToCoord(AdomFloat(Field(Spec, 4)));+        Pad.TopYSize := MilsToCoord(AdomFloat(Field(Spec, 5)));         Shape := LowerCase(Field(Spec, 6));         If Shape = 'round' Then Pad.TopShape := eRounded Else Pad.TopShape := eRectangular;         Hole := 0;-        If Field(Spec, 7) <> '' Then Hole := StrToInt(Field(Spec, 7));+        If Field(Spec, 7) <> '' Then Hole := AdomInt(Field(Spec, 7));         If Hole > 0 Then         Begin             Pad.HoleSize := MilsToCoord(Hole);@@ -1453,8 +1496,8 @@ Begin         Result := '{' + JRaw('ok', 'false') + ',' + JStr('error', 'component has no body rectangle to fit the artwork into') + '}';         Exit;     End;-    KX0 := StrToFloat(Field(KR, 1)); KY0 := StrToFloat(Field(KR, 2));-    KX1 := StrToFloat(Field(KR, 3)); KY1 := StrToFloat(Field(KR, 4));+    KX0 := AdomFloat(Field(KR, 1)); KY0 := AdomFloat(Field(KR, 2));+    KX1 := AdomFloat(Field(KR, 3)); KY1 := AdomFloat(Field(KR, 4));     If KX1 < KX0 Then Begin Tmp := KX0; KX0 := KX1; KX1 := Tmp; End;     If KY1 < KY0 Then Begin Tmp := KY0; KY0 := KY1; KY1 := Tmp; End;     If AX1 < AX0 Then Begin Tmp := AX0; AX0 := AX1; AX1 := Tmp; End;@@ -1508,8 +1551,8 @@ Begin                 For J := 1 To N Do                 Begin                     Tok := Pts[J - 1];-                    MX := AX0 + (StrToFloat(Field(Tok, 1)) - KX0) * SX;-                    MY := AY0 + (StrToFloat(Field(Tok, 2)) - KY0) * SY;+                    MX := AX0 + (AdomFloat(Field(Tok, 1)) - KX0) * SX;+                    MY := AY0 + (AdomFloat(Field(Tok, 2)) - KY0) * SY;                     If J > 1 Then                     Begin                         Seg := SchServer.SchObjectFactory(eLine, eCreate_Default);@@ -1536,8 +1579,8 @@ Begin                 For J := 1 To N Do                 Begin                     Tok := Pts[J - 1];-                    MX := AX0 + (StrToFloat(Field(Tok, 1)) - KX0) * SX;-                    MY := AY0 + (StrToFloat(Field(Tok, 2)) - KY0) * SY;+                    MX := AX0 + (AdomFloat(Field(Tok, 1)) - KX0) * SX;+                    MY := AY0 + (AdomFloat(Field(Tok, 2)) - KY0) * SY;                     Poly.Vertex[J] := Point(MilsToCoord(MX), MilsToCoord(MY));                 End;                 Comp.AddSchObject(Poly);@@ -1887,12 +1930,12 @@ Begin     Cur := MarginA;     For K := 0 To RefList.Count - 1 Do     Begin-    XK := Cur + StrToInt(PWs[K]) Div 2;-    Cur := Cur + StrToInt(PWs[K]) + GapA;+    XK := Cur + AdomInt(PWs[K]) Div 2;+    Cur := Cur + AdomInt(PWs[K]) + GapA;     { the row sits a little below centre: the description strip takes the top 1700 mil }     YK := (H Div 2) - 300;-    DX := XK - StrToInt(CXs[K]);-    DY := YK - StrToInt(CYs[K]);+    DX := XK - AdomInt(CXs[K]);+    DY := YK - AdomInt(CYs[K]);     LibCmp := Nil;     Iter := SLib.SchLibIterator_Create;     Iter.AddFilter_ObjectSet(MkSet(eSchComponent));@@ -1914,7 +1957,7 @@ Begin     Inst.ComponentDescription := LibCmp.ComponentDescription;     Inst.Location := Point(MilsToCoord(XK), MilsToCoord(YK));     { body rectangle centre (mil, library frame) for the designator / comment columns }-    BCX := StrToInt(CXs[K]); BCY := StrToInt(CYs[K]);+    BCX := AdomInt(CXs[K]); BCY := AdomInt(CYs[K]);     RIt := LibCmp.SchIterator_Create;     RIt.AddFilter_ObjectSet(MkSet(eRectangle));     LRect := RIt.FirstSchObject;@@ -1948,12 +1991,12 @@ Begin     Inst.Designator.Text := 'U' + IntToStr(K + 1);     Inst.Designator.IsHidden := False;     Inst.Designator.Justification := eJustify_BottomCenter;-    Inst.Designator.Location := Point(MilsToCoord(BCX + DX), MilsToCoord(StrToInt(TPs[K]) + DY + 100));+    Inst.Designator.Location := Point(MilsToCoord(BCX + DX), MilsToCoord(AdomInt(TPs[K]) + DY + 100));     { never leave the star placeholder: the comment is the part number }     Inst.Comment.Text := LibCmp.LibReference;     Inst.Comment.IsHidden := False;     Inst.Comment.Justification := eJustify_TopCenter;-    Inst.Comment.Location := Point(MilsToCoord(BCX + DX), MilsToCoord(StrToInt(BTs[K]) + DY - 100));+    Inst.Comment.Location := Point(MilsToCoord(BCX + DX), MilsToCoord(AdomInt(BTs[K]) + DY - 100));     Sheet.RegisterSchObjectInContainer(Inst);     SchServer.RobotManager.SendMessage(Sheet.I_ObjectAddress, c_BroadCast, SCHM_PrimitiveRegistration, Inst.I_ObjectAddress);     If Placed <> '' Then Placed := Placed + ';';@@ -2377,7 +2420,7 @@ End;  Function MmC(S : String) : Integer; Begin-    Result := MMsToCoord(StrToFloat(S));+    Result := MMsToCoord(AdomFloat(S)); End;  Function SpecLayer(Tag : String) : Integer;@@ -2490,7 +2533,7 @@ Begin             Arc.XCenter := MmC(FieldP(L, 3)); Arc.YCenter := MmC(FieldP(L, 4));             Arc.Radius := MmC(FieldP(L, 5));             Arc.LineWidth := MmC(FieldP(L, 6));-            Arc.StartAngle := StrToFloat(FieldP(L, 7)); Arc.EndAngle := StrToFloat(FieldP(L, 8));+            Arc.StartAngle := AdomFloat(FieldP(L, 7)); Arc.EndAngle := AdomFloat(FieldP(L, 8));             Comp.AddPCBObject(Arc);             NPrims := NPrims + 1;         End@@ -2576,48 +2619,48 @@ Begin         End         Else If Kind = 'MODE' Then         Begin-            Md := StrToInt(FieldP(L, 2));+            Md := AdomInt(FieldP(L, 2));             If Md + 1 > NModes Then NModes := Md + 1;         End         Else If Kind = 'RECT' Then         Begin-            Md := StrToInt(FieldP(L, 2));+            Md := AdomInt(FieldP(L, 2));             Rect := SchServer.SchObjectFactory(eRectangle, eCreate_Default);             Rect.OwnerPartId := 1;             Rect.OwnerPartDisplayMode := Md;-            Rect.Location := Point(MilsToCoord(StrToInt(FieldP(L, 3))), MilsToCoord(StrToInt(FieldP(L, 4))));-            Rect.Corner := Point(MilsToCoord(StrToInt(FieldP(L, 5))), MilsToCoord(StrToInt(FieldP(L, 6))));+            Rect.Location := Point(MilsToCoord(AdomInt(FieldP(L, 3))), MilsToCoord(AdomInt(FieldP(L, 4))));+            Rect.Corner := Point(MilsToCoord(AdomInt(FieldP(L, 5))), MilsToCoord(AdomInt(FieldP(L, 6))));             Rect.IsSolid := True;             Comp.AddSchObject(Rect);         End         Else If Kind = 'PIN' Then         Begin-            Md := StrToInt(FieldP(L, 2));+            Md := AdomInt(FieldP(L, 2));             Pin := SchServer.SchObjectFactory(ePin, eCreate_Default);             Pin.OwnerPartId := 1;             Pin.OwnerPartDisplayMode := Md;             Pin.Designator := FieldP(L, 3);             Pin.Name := FieldP(L, 4);-            Pin.PinLength := MilsToCoord(StrToInt(FieldP(L, 8)));+            Pin.PinLength := MilsToCoord(AdomInt(FieldP(L, 8)));             Orient := FieldP(L, 7);             { orientation = the direction the pin points AWAY from the body }             If Orient = 'left' Then Pin.Orientation := eRotate180             Else If Orient = 'right' Then Pin.Orientation := eRotate0             Else If Orient = 'up' Then Pin.Orientation := eRotate90             Else Pin.Orientation := eRotate270;-            Pin.Location := Point(MilsToCoord(StrToInt(FieldP(L, 5))), MilsToCoord(StrToInt(FieldP(L, 6))));+            Pin.Location := Point(MilsToCoord(AdomInt(FieldP(L, 5))), MilsToCoord(AdomInt(FieldP(L, 6))));             Pin.Electrical := eElectricPassive;             Comp.AddSchObject(Pin);             NPins := NPins + 1;         End         Else If Kind = 'LINE' Then         Begin-            Md := StrToInt(FieldP(L, 2));+            Md := AdomInt(FieldP(L, 2));             Ln := SchServer.SchObjectFactory(eLine, eCreate_Default);             Ln.OwnerPartId := 1;             Ln.OwnerPartDisplayMode := Md;-            Ln.Location := Point(MilsToCoord(StrToInt(FieldP(L, 3))), MilsToCoord(StrToInt(FieldP(L, 4))));-            Ln.Corner := Point(MilsToCoord(StrToInt(FieldP(L, 5))), MilsToCoord(StrToInt(FieldP(L, 6))));+            Ln.Location := Point(MilsToCoord(AdomInt(FieldP(L, 3))), MilsToCoord(AdomInt(FieldP(L, 4))));+            Ln.Corner := Point(MilsToCoord(AdomInt(FieldP(L, 5))), MilsToCoord(AdomInt(FieldP(L, 6))));             Comp.AddSchObject(Ln);             NLines := NLines + 1;         End;@@ -3037,8 +3080,8 @@ Begin     { footprint may be a ';' list: every part lands on ONE board, spread evenly along X       (John, 2026-09-19: three ToF sensors on one board for the demo). }     FpList := SplitSemi(FpName);-    WC := MMsToCoord(StrToFloat(Param(Lines, 'wMm')));-    HC := MMsToCoord(StrToFloat(Param(Lines, 'hMm')));+    WC := MMsToCoord(AdomFloat(Param(Lines, 'wMm')));+    HC := MMsToCoord(AdomFloat(Param(Lines, 'hMm')));     PLib := PCBServer.GetCurrentPCBLibrary;     If PLib = Nil Then     Begin@@ -3132,8 +3175,8 @@ Begin         Item := SL[I];         If Pos('=', Item) = 0 Then Continue;         Des := Copy(Item, 1, Pos('=', Item) - 1);-        PX := CX + MMsToCoord(StrToFloat(Field(Copy(Item, Pos('=', Item) + 1, Length(Item)), 1)));-        PY := CY + MMsToCoord(StrToFloat(Field(Copy(Item, Pos('=', Item) + 1, Length(Item)), 2)));+        PX := CX + MMsToCoord(AdomFloat(Field(Copy(Item, Pos('=', Item) + 1, Length(Item)), 1)));+        PY := CY + MMsToCoord(AdomFloat(Field(Copy(Item, Pos('=', Item) + 1, Length(Item)), 2)));         GIt := Inst.GroupIterator_Create;         GIt.AddFilter_ObjectSet(MkSet(ePadObject));         GIt.AddFilter_LayerSet(AllLayers);@@ -3253,10 +3296,10 @@ Begin         Result := '{' + JRaw('ok','false') + ',' + JStr('error','no board focused') + '}';         Exit;     End;-    X0 := MMsToCoord(StrToFloat(Param(Lines, 'x0Mm')));-    Y0 := MMsToCoord(StrToFloat(Param(Lines, 'y0Mm')));-    X1 := X0 + MMsToCoord(StrToFloat(Param(Lines, 'wMm')));-    Y1 := Y0 + MMsToCoord(StrToFloat(Param(Lines, 'hMm')));+    X0 := MMsToCoord(AdomFloat(Param(Lines, 'x0Mm')));+    Y0 := MMsToCoord(AdomFloat(Param(Lines, 'y0Mm')));+    X1 := X0 + MMsToCoord(AdomFloat(Param(Lines, 'wMm')));+    Y1 := Y0 + MMsToCoord(AdomFloat(Param(Lines, 'hMm')));     PCBServer.PreProcess;     For I := 0 To 3 Do     Begin@@ -4619,8 +4662,12 @@ Begin         gRequests := gRequests + 1;         gLastReqTick := GetTickCount;         T0 := GetTickCount;+        gInputError := '';         Try             Body := Dispatch(Cmd, Lines, Payload);+            If gInputError <> '' Then+                Body := '{' + JRaw('ok', 'false') + ',' + JStr('errorCode', 'bad_number') + ',' ++                    JStr('error', 'a value that must be a number was not: ' + gInputError) + ',' + JStr('command', Cmd) + '}';         Except             Body := '{' + JRaw('ok', 'false') + ',' + JStr('error', 'exception in agent while handling ' + Cmd) + '}';         End;