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
v0.1.15: dump_board primitive, rust cold bootstrap proven, dump views with recorded fixtures
17 files changed
+1991−17
CHANGELOG.md+5adom-bridge-altium-manifest.json+4−4adom-bridge-altium-v0.1.15.zipbridge-rs/README.md+68−8bridge-rs/fixtures/README.md+12bridge-rs/fixtures/dump_board.json+817bridge-rs/src/dialogs.rs+88bridge-rs/src/dump.rs+252bridge-rs/src/health.rs+8bridge-rs/src/lifecycle.rs+548bridge-rs/src/main.rs+3bridge-rs/src/verbs.rs+134−1bridge.json+1−1package.json+1−1skills/altium-bridge-dev/SKILL.md+22src/BRIDGE_VERSION+1−1src/agent/AdomAgent.pas.tmpl+27−1CHANGELOG.md+5@@ -1,5 +1,10 @@ # Changelog +## 0.1.15 — 2026-09-01+- Agent gains `dump_board`: components + nets + layers + copper + rules in ONE round trip, composed from the existing readers so it adds no new Altium API surface (and therefore cannot introduce an engine-halting identifier). This is the primitive the Rust bridge shapes its views from; the Python verbs are unchanged.+- Rust bridge (`bridge-rs/`): **cold bootstrap through the `-R` lane proven live — 1.59 s, zero dialogs**, which was the last unverified path. Dump-derived views land with it (`board_summary` 232 ms, `rules_by_kind` 152 ms), plus `fixtures/dump_board.json` — a recorded live dump that lets `cargo test` exercise all the shaping logic with no Altium, no Windows and no agent. 8 tests; they already caught a dispatch bug and one wrong assumption about the Fanout rule group.+- `rule_kind_name()`: Altium emits `TRuleKind` as a bare ordinal; 31 ordinals are now mapped to names read off a live default rule set. Unknown ordinals render as `kind <n>` rather than being invented, and a test fails loudly if a board carries one we do not know.+ ## 0.1.14 — 2026-09-01 - `altium_activate_document` re-implemented over the AGENT instead of UIA. 0.1.13 shipped it as a UIA document-bar click; measured on a live box that call takes **>100 s** (ab's budget is 60 s) because Altium's main-window UIA tree is huge. The agent runs IN-PROCESS: ~4 s, no cursor, no OS foreground steal, and Altium activates an already-open document rather than reloading it. Verified: title switched to the target .PcbDoc and board verbs answered in 0.2 s. - altium-uia skill corrected accordingly — the rule has two halves: **UIA for dialogs and WebView pages** (small trees, fast), **the resident agent for anything inside the application**. `desktop_click` only when neither can reach it.
adom-bridge-altium-manifest.json+4−4@@ -1,9 +1,9 @@ { "name": "altium",- "version": "0.1.14",- "url": "https://wiki.adom.inc/download/adom/altium-bridge/0.1.14/adom-bridge-altium-v0.1.14.zip",- "sha256": "af3d17f6e4e4819973e5c79ff2a2e81b6fdcc05aa8f0dbdce59b60f4e60c1e3e",- "size": 104820,+ "version": "0.1.15",+ "url": "https://wiki.adom.inc/download/adom/altium-bridge/0.1.15/adom-bridge-altium-v0.1.15.zip",+ "sha256": "2b3ef85f0c1cdbfed1bbd9b7b01e794520da1c61e6dd0c9e81447d22de8ff69a",+ "size": 105130, "verbPrefixes": [ "altium_" ],
adom-bridge-altium-v0.1.15.zipadded⋯ 1 unchanged line ⋯
bridge-rs/README.md+68−8@@ -12,13 +12,41 @@ on record (John, 2026-08-31): 3. **No standalone MCP server.** External agents reach Altium through `adom-bridge-cli` on PATH (the Adom Anywhere lane), same as this container does. -## Status — v0 vertical slice+## Status — 2026-09-01: cold bootstrap proven, dump primitives landed++**26 of the Python bridge's 95 verbs (27%).** The Python bridge (`../src`, 0.1.14) stays the shipped+runtime until Rust covers the paths users actually hit.++Proven live on the box this session:++* **Cold bootstrap through the `-R` lane: 1.59 s**, new generation installed, heartbeat up, zero+ dialogs. This was the last unproven path. Two bugs it flushed out: ab's direct API refuses+ `shell_execute` (412 — CLI-only, with pull_file/send_files/hd_send_files), so the bridge owns its+ child processes directly; and `Command::arg()` re-quotes an argument containing a space, which+ Altium cannot parse — `raw_arg` is required (the Python bridge hit the identical trap in August).+* **Dump primitives.** `dump_board` is ONE agent round trip for components + nets + layers + copper+ + rules, composed in DelphiScript from the EXISTING readers (no new Altium API surface, so it+ cannot introduce an engine-halting identifier). `src/dump.rs` derives every view from it:+ `board_summary` 232 ms, `rules_by_kind` 152 ms, raw dump 157 ms.+* **The shaping is now tested with no Altium anywhere.** `fixtures/dump_board.json` is a recorded+ live dump; `cargo test` runs 8 tests against it on any machine. They have already earned their+ keep: one caught a dispatch bug (an inner `match` on the un-stripped verb name, so+ `altium_board_summary` silently returned the raw dump), and one caught a wrong assumption of mine+ about the Fanout rule group.+* **A real view the agent could not give cheaply:** Altium emits `TRuleKind` as a bare ordinal.+ `rule_kind_name()` maps 31 ordinals to names, read off a live default rule set (each default rule+ is named after its own kind — observed, not guessed); unknown ordinals render as `kind <n>` rather+ than being invented, and a test fails loudly if a board carries an ordinal we do not know.++## Original v0 vertical slice Speaks the **existing** agent's file-IPC protocol unchanged (`request.txt` / `request.payload` / `response_<id>.json` / `hb.txt`), so it serves these verbs with **zero template changes**: -`status`, `readiness`, `agent_status`, `eval`, `run_command`, `open_document`, and the Phase 1-introspection surface — `list_designators`, `component_data`, `component_pins`, `list_nets`,+`status`, `readiness`, `agent_status`, `eval`, `run_command`, `open_document`, `describe` (ab's+Verbs tab renders it verbatim — one entry per verb with input/output schemas, hints and pitfalls),+`uninstall` (the cascade verb: drops the agent's stop file and clears the agent workspace, never a+user design file), and the Phase 1 introspection surface — `list_designators`, `component_data`, `component_pins`, `list_nets`, `net_connections`, `layers`, `copper_layers`, `read_rules`, `set_layer_visibility`, `footprint_primitives`, `schematic_data`. @@ -29,11 +57,28 @@ Python detector does (edition word without version parens — issue #2 — inclu frames, checks/outputs, library authoring, boards, licence driving, web-control bundle install. Until parity, the Python bridge (`../src`) stays the shipped runtime. -## The gate: ab needs a compiled-binary runtime kind+## The gate is OPEN — the runtime kind is `"exe"` (ab, on #987)++ab shipped the compiled-binary runtime kind; it is spelled **`spawn.kind: "exe"`** (there is no+`"binary"` alias). ab spawns `spawn.entrypoint` directly with `--port <n>` appended, same env+contract as node/python bridges (`ADOM_BIND_HOST`, `ADOM_DIRECT_API_URL` + bridge token, forwarded+`X-Adom-Caller-*`), same health probing, PID tracking, log capture and reap/respawn supervision.+`bridge_readiness` reports `runtime: "none", runtimeReady: true`, so no spurious needs-runtime state.++Three things to know: -ab's SDK provisions `spawn.kind: "node" | "python"` only. This exe needs `spawn.kind: "binary"`-— requested as **adom/adom-bridge#987**. Until it lands the exe is tested by running it manually on-the box on a spare port and calling it directly (see below).+- **Integrity is gated at the zip, not the exe.** ab sha256-verifies the release zip against the+ manifest before extracting. A spawn-time re-hash was deliberately not added (the reference hash+ would live in `bridge.json` next to the exe — same tamper domain, no trust boundary).+- **Manifests are single-zip today**, so a compiled bridge is per-OS. Fine here: Altium is+ Windows-only.+- **Set `killImageName`** to the exe basename so stop/kill is image-verified. Ours is in+ [`bridge.json`](bridge.json) alongside this README (the shipped Python bridge keeps `../src/bridge.json`+ until parity).++Reference implementation: `scripts/sample-bridges/hello-rust` in adom-inc/adom-bridge — page+`adom/hello-rust-bridge`, v1.3.1. ~290 lines covering `--port`, `ADOM_BIND_HOST`, the `/status`+led/summary/tooltip self-report, caller-header logging, `describe`, and the `uninstall` cascade. ## Build (cross-compile from the container; the box has no MSVC linker) @@ -76,4 +121,19 @@ at a time) — but only ONE session should drive it at a time; see the dev skill `dump_rules`, `dump_layers`, `dump_sch_components`, `dump_sch_pins`, `apply`) and re-express the verbs as views; record fixtures from Bluetooth Sentinel for unit tests. 3. Surfaces + frames (all via ab screenshots), checks/outputs, library authoring, boards.-4. Swap: `bridge.json` → `spawn.kind: "binary"` once ab#987 ships; retire `../src`.+4. Swap: publish the zip (`altium-bridge.exe` + `bridge.json` + `BRIDGE_VERSION` + README) as a+ release asset, repoint the manifest, and retire `../src`.++## Two script-engine laws this design depends on++Measured on the box 2026-08-31, and the reason the agent shrinks to dumb primitives:++1. **`Break` inside a `Try..Except` does not break** — the engine raises it, the `Except` eats it, and+ a 20-iteration retry loop always runs all 20 with its `Sleep(10)`. That was the entire mysterious+ ~300 ms per agent file write. Flag-based loops (`While (Not Done)`) fix it.+2. **`hb.txt` must be tmp+rename; responses can be written directly** — readers hold the heartbeat's+ final path open, so a direct `SaveToFile` onto it raises `EFCreateError` and halts the engine with+ a modal. A fresh `response_<id>.json` has no readers and writes in **0 ms**.++With both applied, in-engine file writes measure 0 ms and the round-trip floor is the poll interval,+not I/O — which is exactly the budget this bridge's dump-primitive design assumes.
bridge-rs/fixtures/README.mdadded+12@@ -0,0 +1,12 @@+# Fixtures++Recorded `dump_board` payloads. They exist so the bridge's shaping logic (`src/dump.rs`) can be+unit-tested with **no Altium, no Windows, no agent** — `cargo test` on any machine.++Re-record from a live box:++```bash+adom-bridge --ai-thread "<t>" altium_dump_board '{}' > bridge-rs/fixtures/dump_board.json+```++Keep one small board (fast tests) and add a larger one when a view starts depending on scale.
bridge-rs/fixtures/dump_board.jsonadded+817@@ -0,0 +1,817 @@+{+ "board": "C:\\Users\\john\\Documents\\adom-altium-lan7800\\LAN7800_test.PcbDoc",+ "components": {+ "components": [+ {+ "bottom": false,+ "comment": "Comment",+ "designator": "Designator1",+ "hMil": 337,+ "layerId": 1,+ "nets": "49=;48=;47=;46=;45=;44=;43=;42=;41=;40=;39=;38=;37=;36=;35=;34=;33=;32=;31=;30=;29=;28=;27=;26=;25=;24=;23=;22=;21=;20=;19=;18=;17=;16=;15=;14=;13=;12=;11=;10=;9=;8=;7=;6=;5=;4=;3=;2=;1=;49=;49=;49=;49=;49=;49=;49=;49=;49=;49=;49=;49=;49=;49=;49=;49=",+ "pads": 65,+ "pattern": "",+ "rotation": 0,+ "wMil": 337,+ "xMil": 295,+ "yMil": 295+ }+ ],+ "count": 1,+ "ok": true+ },+ "copper": {+ "layers": [+ {+ "displayed": true,+ "id": 1,+ "kind": "signal",+ "name": "Top Layer"+ },+ {+ "displayed": true,+ "id": 2,+ "kind": "signal",+ "name": "Mid-Layer 1"+ },+ {+ "displayed": true,+ "id": 3,+ "kind": "signal",+ "name": "Mid-Layer 2"+ },+ {+ "displayed": true,+ "id": 4,+ "kind": "signal",+ "name": "Mid-Layer 3"+ },+ {+ "displayed": true,+ "id": 5,+ "kind": "signal",+ "name": "Mid-Layer 4"+ },+ {+ "displayed": true,+ "id": 6,+ "kind": "signal",+ "name": "Mid-Layer 5"+ },+ {+ "displayed": true,+ "id": 7,+ "kind": "signal",+ "name": "Mid-Layer 6"+ },+ {+ "displayed": true,+ "id": 8,+ "kind": "signal",+ "name": "Mid-Layer 7"+ },+ {+ "displayed": true,+ "id": 9,+ "kind": "signal",+ "name": "Mid-Layer 8"+ },+ {+ "displayed": true,+ "id": 10,+ "kind": "signal",+ "name": "Mid-Layer 9"+ },+ {+ "displayed": true,+ "id": 11,+ "kind": "signal",+ "name": "Mid-Layer 10"+ },+ {+ "displayed": true,+ "id": 12,+ "kind": "signal",+ "name": "Mid-Layer 11"+ },+ {+ "displayed": true,+ "id": 13,+ "kind": "signal",+ "name": "Mid-Layer 12"+ },+ {+ "displayed": true,+ "id": 14,+ "kind": "signal",+ "name": "Mid-Layer 13"+ },+ {+ "displayed": true,+ "id": 15,+ "kind": "signal",+ "name": "Mid-Layer 14"+ },+ {+ "displayed": true,+ "id": 16,+ "kind": "signal",+ "name": "Mid-Layer 15"+ },+ {+ "displayed": true,+ "id": 17,+ "kind": "signal",+ "name": "Mid-Layer 16"+ },+ {+ "displayed": true,+ "id": 18,+ "kind": "signal",+ "name": "Mid-Layer 17"+ },+ {+ "displayed": true,+ "id": 19,+ "kind": "signal",+ "name": "Mid-Layer 18"+ },+ {+ "displayed": true,+ "id": 20,+ "kind": "signal",+ "name": "Mid-Layer 19"+ },+ {+ "displayed": true,+ "id": 21,+ "kind": "signal",+ "name": "Mid-Layer 20"+ },+ {+ "displayed": true,+ "id": 22,+ "kind": "signal",+ "name": "Mid-Layer 21"+ },+ {+ "displayed": true,+ "id": 23,+ "kind": "signal",+ "name": "Mid-Layer 22"+ },+ {+ "displayed": true,+ "id": 24,+ "kind": "signal",+ "name": "Mid-Layer 23"+ },+ {+ "displayed": true,+ "id": 25,+ "kind": "signal",+ "name": "Mid-Layer 24"+ },+ {+ "displayed": true,+ "id": 26,+ "kind": "signal",+ "name": "Mid-Layer 25"+ },+ {+ "displayed": true,+ "id": 27,+ "kind": "signal",+ "name": "Mid-Layer 26"+ },+ {+ "displayed": true,+ "id": 28,+ "kind": "signal",+ "name": "Mid-Layer 27"+ },+ {+ "displayed": true,+ "id": 29,+ "kind": "signal",+ "name": "Mid-Layer 28"+ },+ {+ "displayed": true,+ "id": 30,+ "kind": "signal",+ "name": "Mid-Layer 29"+ },+ {+ "displayed": true,+ "id": 31,+ "kind": "signal",+ "name": "Mid-Layer 30"+ },+ {+ "displayed": true,+ "id": 39,+ "kind": "plane",+ "name": "Internal Plane 1"+ },+ {+ "displayed": true,+ "id": 40,+ "kind": "plane",+ "name": "Internal Plane 2"+ },+ {+ "displayed": true,+ "id": 41,+ "kind": "plane",+ "name": "Internal Plane 3"+ },+ {+ "displayed": true,+ "id": 42,+ "kind": "plane",+ "name": "Internal Plane 4"+ },+ {+ "displayed": true,+ "id": 43,+ "kind": "plane",+ "name": "Internal Plane 5"+ },+ {+ "displayed": true,+ "id": 44,+ "kind": "plane",+ "name": "Internal Plane 6"+ },+ {+ "displayed": true,+ "id": 45,+ "kind": "plane",+ "name": "Internal Plane 7"+ },+ {+ "displayed": true,+ "id": 46,+ "kind": "plane",+ "name": "Internal Plane 8"+ },+ {+ "displayed": true,+ "id": 47,+ "kind": "plane",+ "name": "Internal Plane 9"+ },+ {+ "displayed": true,+ "id": 48,+ "kind": "plane",+ "name": "Internal Plane 10"+ },+ {+ "displayed": true,+ "id": 49,+ "kind": "plane",+ "name": "Internal Plane 11"+ },+ {+ "displayed": true,+ "id": 50,+ "kind": "plane",+ "name": "Internal Plane 12"+ },+ {+ "displayed": true,+ "id": 51,+ "kind": "plane",+ "name": "Internal Plane 13"+ },+ {+ "displayed": true,+ "id": 52,+ "kind": "plane",+ "name": "Internal Plane 14"+ },+ {+ "displayed": true,+ "id": 53,+ "kind": "plane",+ "name": "Internal Plane 15"+ },+ {+ "displayed": true,+ "id": 54,+ "kind": "plane",+ "name": "Internal Plane 16"+ },+ {+ "displayed": true,+ "id": 32,+ "kind": "signal",+ "name": "Bottom Layer"+ }+ ],+ "ok": true+ },+ "layers": {+ "layers": [+ {+ "displayed": true,+ "id": 1,+ "kind": "signal",+ "name": "Top Layer"+ },+ {+ "displayed": true,+ "id": 32,+ "kind": "signal",+ "name": "Bottom Layer"+ },+ {+ "displayed": true,+ "id": 33,+ "kind": "overlay",+ "name": "Top Overlay"+ },+ {+ "displayed": true,+ "id": 34,+ "kind": "overlay",+ "name": "Bottom Overlay"+ },+ {+ "displayed": true,+ "id": 35,+ "kind": "paste",+ "name": "Top Paste"+ },+ {+ "displayed": true,+ "id": 36,+ "kind": "paste",+ "name": "Bottom Paste"+ },+ {+ "displayed": true,+ "id": 37,+ "kind": "solder",+ "name": "Top Solder"+ },+ {+ "displayed": true,+ "id": 38,+ "kind": "solder",+ "name": "Bottom Solder"+ },+ {+ "displayed": true,+ "id": 56,+ "kind": "keepout",+ "name": "Keep-Out Layer"+ },+ {+ "displayed": true,+ "id": 74,+ "kind": "multi",+ "name": "Multi-Layer"+ },+ {+ "displayed": true,+ "id": 55,+ "kind": "drill",+ "name": "Drill Guide"+ },+ {+ "displayed": true,+ "id": 73,+ "kind": "drill",+ "name": "Drill Drawing"+ },+ {+ "displayed": true,+ "id": 57,+ "kind": "mechanical",+ "name": "Mechanical 1"+ },+ {+ "displayed": true,+ "id": 58,+ "kind": "mechanical",+ "name": "Mechanical 2"+ },+ {+ "displayed": true,+ "id": 59,+ "kind": "mechanical",+ "name": "Mechanical 3"+ },+ {+ "displayed": true,+ "id": 60,+ "kind": "mechanical",+ "name": "Mechanical 4"+ },+ {+ "displayed": true,+ "id": 61,+ "kind": "mechanical",+ "name": "Mechanical 5"+ },+ {+ "displayed": true,+ "id": 62,+ "kind": "mechanical",+ "name": "Mechanical 6"+ },+ {+ "displayed": true,+ "id": 63,+ "kind": "mechanical",+ "name": "Mechanical 7"+ },+ {+ "displayed": true,+ "id": 64,+ "kind": "mechanical",+ "name": "Mechanical 8"+ },+ {+ "displayed": false,+ "id": 65,+ "kind": "mechanical",+ "name": "Adom.SolderBalls"+ },+ {+ "displayed": true,+ "id": 66,+ "kind": "mechanical",+ "name": "Mechanical 10"+ },+ {+ "displayed": true,+ "id": 67,+ "kind": "mechanical",+ "name": "Mechanical 11"+ },+ {+ "displayed": true,+ "id": 68,+ "kind": "mechanical",+ "name": "Mechanical 12"+ },+ {+ "displayed": true,+ "id": 69,+ "kind": "mechanical",+ "name": "Fab"+ },+ {+ "displayed": true,+ "id": 70,+ "kind": "mechanical",+ "name": "Mechanical 14"+ },+ {+ "displayed": true,+ "id": 71,+ "kind": "mechanical",+ "name": "Courtyard"+ },+ {+ "displayed": true,+ "id": 72,+ "kind": "mechanical",+ "name": "Mechanical 16"+ }+ ],+ "ok": true+ },+ "nets": {+ "count": 0,+ "nets": [],+ "ok": true+ },+ "ok": true,+ "rules": {+ "count": 35,+ "ok": true,+ "rules": [+ {+ "comment": "",+ "enabled": true,+ "kind": 62,+ "name": "UnpouredPolygon",+ "priority": 1,+ "scope1": "All",+ "scope2": "All"+ },+ {+ "comment": "",+ "enabled": true,+ "kind": 56,+ "name": "NetAntennae",+ "priority": 1,+ "scope1": "All",+ "scope2": "All"+ },+ {+ "comment": "",+ "enabled": true,+ "kind": 59,+ "name": "SilkToBoardRegionClearance",+ "priority": 1,+ "scope1": "All",+ "scope2": "All"+ },+ {+ "comment": "",+ "enabled": true,+ "kind": 55,+ "name": "SilkToSilkClearance",+ "priority": 1,+ "scope1": "All",+ "scope2": "All"+ },+ {+ "comment": "",+ "enabled": true,+ "kind": 54,+ "name": "SilkToSolderMaskClearance",+ "priority": 1,+ "scope1": "IsPad",+ "scope2": "All"+ },+ {+ "comment": "",+ "enabled": true,+ "kind": 53,+ "name": "MinimumSolderMaskSliver",+ "priority": 1,+ "scope1": "All",+ "scope2": "All"+ },+ {+ "comment": "",+ "enabled": true,+ "kind": 52,+ "name": "HoleToHoleClearance",+ "priority": 1,+ "scope1": "All",+ "scope2": "All"+ },+ {+ "comment": "",+ "enabled": true,+ "kind": 51,+ "name": "DiffPairsRouting",+ "priority": 1,+ "scope1": "All",+ "scope2": "All"+ },+ {+ "comment": "Fanout_BGA (Default Rule)",+ "enabled": true,+ "kind": 49,+ "name": "Fanout_BGA",+ "priority": 1,+ "scope1": "IsBGA",+ "scope2": "All"+ },+ {+ "comment": "Fanout_LCC (Default Rule)",+ "enabled": true,+ "kind": 49,+ "name": "Fanout_LCC",+ "priority": 2,+ "scope1": "IsLCC",+ "scope2": "All"+ },+ {+ "comment": "Fanout_SOIC (Default Rule)",+ "enabled": true,+ "kind": 49,+ "name": "Fanout_SOIC",+ "priority": 3,+ "scope1": "IsSOIC",+ "scope2": "All"+ },+ {+ "comment": "Fanout_Small (Default Rule)",+ "enabled": true,+ "kind": 49,+ "name": "Fanout_Small",+ "priority": 4,+ "scope1": "(CompPinCount < 5)",+ "scope2": "All"+ },+ {+ "comment": "Fanout_Default (Default Rule)",+ "enabled": true,+ "kind": 49,+ "name": "Fanout_Default",+ "priority": 5,+ "scope1": "All",+ "scope2": "All"+ },+ {+ "comment": "",+ "enabled": true,+ "kind": 42,+ "name": "HoleSize",+ "priority": 1,+ "scope1": "All",+ "scope2": "All"+ },+ {+ "comment": "",+ "enabled": true,+ "kind": 48,+ "name": "LayerPairs",+ "priority": 1,+ "scope1": "All",+ "scope2": "All"+ },+ {+ "comment": "",+ "enabled": true,+ "kind": 50,+ "name": "Height",+ "priority": 1,+ "scope1": "All",+ "scope2": "All"+ },+ {+ "comment": "",+ "enabled": true,+ "kind": 24,+ "name": "ComponentClearance",+ "priority": 1,+ "scope1": "All",+ "scope2": "All"+ },+ {+ "comment": "",+ "enabled": true,+ "kind": 57,+ "name": "AssemblyTestpoint",+ "priority": 1,+ "scope1": "All",+ "scope2": "All"+ },+ {+ "comment": "",+ "enabled": true,+ "kind": 58,+ "name": "AssemblyTestPointUsage",+ "priority": 1,+ "scope1": "All",+ "scope2": "All"+ },+ {+ "comment": "",+ "enabled": true,+ "kind": 43,+ "name": "FabricationTestpoint",+ "priority": 1,+ "scope1": "All",+ "scope2": "All"+ },+ {+ "comment": "",+ "enabled": true,+ "kind": 44,+ "name": "FabricationTestPointUsage",+ "priority": 1,+ "scope1": "All",+ "scope2": "All"+ },+ {+ "comment": "",+ "enabled": true,+ "kind": 7,+ "name": "RoutingTopology",+ "priority": 1,+ "scope1": "All",+ "scope2": "All"+ },+ {+ "comment": "",+ "enabled": true,+ "kind": 13,+ "name": "SolderMaskExpansion",+ "priority": 1,+ "scope1": "All",+ "scope2": "All"+ },+ {+ "comment": "",+ "enabled": true,+ "kind": 14,+ "name": "PasteMaskExpansion",+ "priority": 1,+ "scope1": "All",+ "scope2": "All"+ },+ {+ "comment": "",+ "enabled": true,+ "kind": 11,+ "name": "RoutingVias",+ "priority": 1,+ "scope1": "All",+ "scope2": "All"+ },+ {+ "comment": "",+ "enabled": true,+ "kind": 10,+ "name": "RoutingCorners",+ "priority": 1,+ "scope1": "All",+ "scope2": "All"+ },+ {+ "comment": "",+ "enabled": true,+ "kind": 9,+ "name": "RoutingLayers",+ "priority": 1,+ "scope1": "All",+ "scope2": "All"+ },+ {+ "comment": "",+ "enabled": true,+ "kind": 8,+ "name": "RoutingPriority",+ "priority": 1,+ "scope1": "All",+ "scope2": "All"+ },+ {+ "comment": "",+ "enabled": true,+ "kind": 2,+ "name": "Width",+ "priority": 1,+ "scope1": "All",+ "scope2": "All"+ },+ {+ "comment": "",+ "enabled": true,+ "kind": 20,+ "name": "PolygonConnect",+ "priority": 1,+ "scope1": "All",+ "scope2": "All"+ },+ {+ "comment": "",+ "enabled": true,+ "kind": 12,+ "name": "PlaneClearance",+ "priority": 1,+ "scope1": "All",+ "scope2": "All"+ },+ {+ "comment": "",+ "enabled": true,+ "kind": 6,+ "name": "PlaneConnect",+ "priority": 1,+ "scope1": "All",+ "scope2": "All"+ },+ {+ "comment": "",+ "enabled": true,+ "kind": 0,+ "name": "Clearance",+ "priority": 1,+ "scope1": "All",+ "scope2": "All"+ },+ {+ "comment": "",+ "enabled": true,+ "kind": 16,+ "name": "UnRoutedNet",+ "priority": 1,+ "scope1": "All",+ "scope2": "All"+ },+ {+ "comment": "",+ "enabled": true,+ "kind": 15,+ "name": "ShortCircuit",+ "priority": 1,+ "scope1": "All",+ "scope2": "All"+ }+ ]+ },+ "sections": "components,nets,layers,copper,rules"+}
bridge-rs/src/dialogs.rsadded+88@@ -0,0 +1,88 @@+//! Modal detection through ab. A dialog on Altium's main process is not just noise: it FREEZES the+//! resident script loop (the engine is single-threaded), so "the agent stopped answering" and+//! "a modal is up" are the same event. Every verb that times out should report what is on screen.+use crate::ab;+use serde_json::{json, Value};++/// Window titles Altium uses for script/engine modals, plus the generic ones worth surfacing.+const MODAL_TITLES: [&str; 6] = ["Error", "Information", "Confirm", "Warning", "Altium Designer", "Script"];++/// Windows that merely CONTAIN these are the app itself / our own tooling, never a modal.+fn is_main_window(title: &str) -> bool {+ crate::health::parse_title(title).is_some()+}++/// Enumerate blocking dialogs. Returns [] when the screen is clean.+pub fn scan() -> Vec<Value> {+ let mut out: Vec<Value> = Vec::new();+ let mut seen: Vec<i64> = Vec::new();+ for t in MODAL_TITLES {+ let r = ab::call("desktop_find_window", json!({"titleContains": t}), &[], 10);+ let wins = r+ .get("windows")+ .and_then(|a| a.as_array())+ .cloned()+ .unwrap_or_default();+ for w in wins {+ let hwnd = match w.get("hwnd").and_then(|h| h.as_i64()) {+ Some(h) => h,+ None => continue,+ };+ let title = w.get("title").and_then(|s| s.as_str()).unwrap_or("").to_string();+ if seen.contains(&hwnd) || is_main_window(&title) || title.is_empty() {+ continue;+ }+ seen.push(hwnd);+ out.push(json!({"hwnd": hwnd, "title": title,+ "text": dialog_text(hwnd),+ "buttons": buttons(hwnd)}));+ }+ }+ out+}++/// The message body — this is what tells a save prompt apart from a compile error.+fn dialog_text(hwnd: i64) -> Value {+ let r = ab::call("desktop_ui_text", json!({"hwnd": hwnd}), &[], 10);+ r.get("text")+ .cloned()+ .or_else(|| r.get("texts").cloned())+ .unwrap_or(Value::Null)+}++fn buttons(hwnd: i64) -> Value {+ let r = ab::call("desktop_find_controls", json!({"hwnd": hwnd, "role": "button"}), &[], 10);+ r.get("controls")+ .and_then(|a| a.as_array())+ .map(|a| {+ Value::Array(+ a.iter()+ .filter_map(|c| c.get("text").or_else(|| c.get("name")).cloned())+ .collect(),+ )+ })+ .unwrap_or(Value::Null)+}++/// Press a named button on every detected dialog. `only_titles` narrows it (e.g. just "Error").+pub fn dismiss(button: &str, only_titles: Option<&[String]>) -> Value {+ let found = scan();+ let mut pressed: Vec<Value> = Vec::new();+ for d in &found {+ let title = d.get("title").and_then(|t| t.as_str()).unwrap_or("");+ if let Some(filter) = only_titles {+ if !filter.iter().any(|f| title.contains(f.as_str())) {+ continue;+ }+ }+ let hwnd = match d.get("hwnd").and_then(|h| h.as_i64()) {+ Some(h) => h,+ None => continue,+ };+ let r = ab::call("desktop_ui_click", json!({"hwnd": hwnd, "name": button, "role": "button"}), &[], 15);+ pressed.push(json!({"title": title, "hwnd": hwnd, "button": button,+ "ok": r.get("success").and_then(|b| b.as_bool()).unwrap_or(false)}));+ }+ json!({"success": true, "detected": found.len(), "pressed": pressed,+ "_hint": "John's rule: a dialog the USER has to dismiss is a defect in this bridge — pre-empt it, don't clean up after it."})+}
bridge-rs/src/dump.rsadded+252@@ -0,0 +1,252 @@+//! Dump primitives → typed views.+//!+//! The point of the Rust bridge: the DelphiScript agent stops shaping JSON per verb and emits a few+//! generic dumps; the bridge derives every view from them. That moves the fragile part (parsing,+//! tallies, filters, sorting) out of a language where a typo halts the engine behind a modal on the+//! user's screen, into one that is typed, unit-tested, and changeable without restarting Altium.+//!+//! `dump_board` is ONE agent round trip for components + nets + layers + copper + rules. Five verbs+//! that used to cost five round trips (~50 ms each, plus Altium's own work) now cost one, and the+//! views below are pure functions over the result — which is why they can be tested against a+//! recorded fixture with no Altium anywhere (see `fixtures/`).+use serde_json::{json, Value};++/// A recorded or live `dump_board` payload (the agent's `data` object).+#[derive(Clone, Debug)]+pub struct BoardDump(pub Value);++impl BoardDump {+ pub fn new(data: Value) -> Self {+ BoardDump(data)+ }++ /// The agent composes sub-envelopes (`{ok, ...}`) per section; unwrap one by name.+ fn section(&self, name: &str) -> Option<&Value> {+ self.0.get(name).filter(|s| s.get("ok").and_then(|b| b.as_bool()).unwrap_or(false))+ }++ pub fn board_path(&self) -> Option<&str> {+ self.0.get("board").and_then(|v| v.as_str())+ }++ fn array(&self, section: &str, key: &str) -> Vec<Value> {+ self.section(section)+ .and_then(|s| s.get(key))+ .and_then(|a| a.as_array())+ .cloned()+ .unwrap_or_default()+ }++ // ---- views ---------------------------------------------------------------++ pub fn components(&self) -> Vec<Value> {+ self.array("components", "components")+ }++ /// Designators, sorted naturally (U2 before U10 — plain string order gets this wrong, and a+ /// BOM or a review list read in the wrong order is the kind of thing nobody notices).+ pub fn designators(&self) -> Vec<String> {+ let mut d: Vec<String> = self+ .components()+ .iter()+ .filter_map(|c| c.get("designator").or_else(|| c.get("name")).and_then(|v| v.as_str()).map(str::to_string))+ .collect();+ d.sort_by(|a, b| natural_cmp(a, b));+ d+ }++ pub fn nets(&self) -> Vec<Value> {+ self.array("nets", "nets")+ }++ pub fn layers(&self) -> Vec<Value> {+ self.array("layers", "layers")+ }++ /// Copper layers only, in stack order, ignoring the ones Altium reports as not present.+ pub fn copper_layers(&self) -> Vec<Value> {+ self.array("copper", "layers")+ .into_iter()+ .filter(|l| l.get("used").and_then(|b| b.as_bool()).unwrap_or(true))+ .collect()+ }++ pub fn rules(&self) -> Vec<Value> {+ self.array("rules", "rules")+ }++ /// Enabled rules grouped by kind NAME, each group sorted by priority — the shape a design+ /// review wants. The agent emits Altium's numeric `TRuleKind` ordinal; the mapping below was+ /// read off a live default rule set (each default rule is named after its own kind), so it is+ /// derived from data rather than guessed. A user-RENAMED rule keeps its ordinal, so the group+ /// label stays correct even when the rule name no longer matches.+ pub fn rules_by_kind(&self) -> Value {+ let mut kinds: std::collections::BTreeMap<String, Vec<Value>> = Default::default();+ for r in self.rules() {+ if r.get("enabled").and_then(|b| b.as_bool()) == Some(false) {+ continue;+ }+ let label = r+ .get("kind")+ .and_then(|k| k.as_i64())+ .map(rule_kind_name)+ .unwrap_or_else(|| "Unknown".to_string());+ kinds.entry(label).or_default().push(r);+ }+ for v in kinds.values_mut() {+ v.sort_by_key(|r| r.get("priority").and_then(|p| p.as_i64()).unwrap_or(i64::MAX));+ }+ json!(kinds)+ }++ /// One-screen summary: what is on this board.+ pub fn summary(&self) -> Value {+ let comps = self.components();+ let (mut top, mut bottom) = (0usize, 0usize);+ for c in &comps {+ if c.get("bottom").and_then(|b| b.as_bool()).unwrap_or(false) { bottom += 1 } else { top += 1 }+ }+ let rules = self.rules();+ json!({+ "board": self.board_path(),+ "components": comps.len(),+ "componentsTop": top,+ "componentsBottom": bottom,+ "nets": self.nets().len(),+ // NOTE: the agent enumerates every copper layer Altium CAN address (top + 30 mid ++ // 16 planes + bottom), not the stack actually in use — there is no "in stack" flag in+ // the dump yet, so this is a capability count, not a layer count. Do not report it to+ // a user as "this board has N layers".+ "copperLayersAddressable": self.copper_layers().len(),+ "rules": rules.len(),+ "rulesEnabled": rules.iter().filter(|r| r.get("enabled").and_then(|b| b.as_bool()) != Some(false)).count(),+ "ruleKinds": self.rules_by_kind().as_object().map(|o| o.len()).unwrap_or(0),+ })+ }+}++/// Altium `TRuleKind` ordinals, read off a live AD26 default rule set (2026-09-01): every default+/// rule is named after its own kind, which makes the mapping observable rather than guessed.+/// Unknown ordinals are rendered as `kind <n>` — never invented.+pub fn rule_kind_name(k: i64) -> String {+ let name = match k {+ 0 => "Clearance", 2 => "Width", 6 => "PlaneConnect", 7 => "RoutingTopology",+ 8 => "RoutingPriority", 9 => "RoutingLayers", 10 => "RoutingCorners", 11 => "RoutingVias",+ 12 => "PlaneClearance", 13 => "SolderMaskExpansion", 14 => "PasteMaskExpansion",+ 15 => "ShortCircuit", 16 => "UnRoutedNet", 20 => "PolygonConnect",+ 24 => "ComponentClearance", 42 => "HoleSize", 43 => "FabricationTestpoint",+ 44 => "FabricationTestPointUsage", 48 => "LayerPairs", 49 => "Fanout", 50 => "Height",+ 51 => "DiffPairsRouting", 52 => "HoleToHoleClearance", 53 => "MinimumSolderMaskSliver",+ 54 => "SilkToSolderMaskClearance", 55 => "SilkToSilkClearance", 56 => "NetAntennae",+ 57 => "AssemblyTestpoint", 58 => "AssemblyTestPointUsage",+ 59 => "SilkToBoardRegionClearance", 62 => "UnpouredPolygon",+ _ => "",+ };+ if name.is_empty() { format!("kind {k}") } else { name.to_string() }+}++/// U2 before U10, R1 before R2 before R10 — compare digit runs numerically.+pub fn natural_cmp(a: &str, b: &str) -> std::cmp::Ordering {+ let (mut x, mut y) = (a.chars().peekable(), b.chars().peekable());+ loop {+ match (x.peek().copied(), y.peek().copied()) {+ (None, None) => return std::cmp::Ordering::Equal,+ (None, _) => return std::cmp::Ordering::Less,+ (_, None) => return std::cmp::Ordering::Greater,+ (Some(ca), Some(cb)) => {+ if ca.is_ascii_digit() && cb.is_ascii_digit() {+ let na: String = std::iter::from_fn(|| x.next_if(|c| c.is_ascii_digit())).collect();+ let nb: String = std::iter::from_fn(|| y.next_if(|c| c.is_ascii_digit())).collect();+ let (va, vb) = (na.parse::<u64>().unwrap_or(0), nb.parse::<u64>().unwrap_or(0));+ if va != vb {+ return va.cmp(&vb);+ }+ } else {+ if ca != cb {+ return ca.cmp(&cb);+ }+ x.next();+ y.next();+ }+ }+ }+ }+}++#[cfg(test)]+mod tests {+ use super::*;++ fn fixture() -> BoardDump {+ // Recorded from a live board; see fixtures/README.md for how to re-record.+ let raw = include_str!("../fixtures/dump_board.json");+ let v: Value = serde_json::from_str(raw).expect("fixture parses");+ BoardDump::new(v.get("data").cloned().unwrap_or(v))+ }++ #[test]+ fn natural_order_beats_string_order() {+ let mut v = vec!["U10", "U2", "R1", "C10", "C9"];+ v.sort_by(|a, b| natural_cmp(a, b));+ assert_eq!(v, vec!["C9", "C10", "R1", "U2", "U10"]);+ }++ #[test]+ fn views_read_the_recorded_dump() {+ let d = fixture();+ assert!(d.board_path().unwrap().ends_with(".PcbDoc"));+ let s = d.summary();+ assert_eq!(s.get("rules").and_then(|v| v.as_u64()), Some(d.rules().len() as u64));+ assert_eq!(+ s.get("components").and_then(|v| v.as_u64()).unwrap(),+ s.get("componentsTop").and_then(|v| v.as_u64()).unwrap()+ + s.get("componentsBottom").and_then(|v| v.as_u64()).unwrap(),+ "every component lands on exactly one side"+ );+ }++ #[test]+ fn every_rule_in_the_fixture_maps_to_a_named_kind() {+ // 35 rules / 31 ordinals on a live AD26 default rule set — if Altium adds a kind we do not+ // know, this fails loudly instead of silently labelling it "Unknown".+ let d = fixture();+ let by = d.rules_by_kind();+ let unnamed: Vec<&String> = by.as_object().unwrap().keys().filter(|k| k.starts_with("kind ")).collect();+ assert!(unnamed.is_empty(), "unmapped TRuleKind ordinals: {unnamed:?}");+ // the five Fanout defaults (BGA/LCC/SOIC/Small/Default) all carry ordinal 49 — the one+ // many-to-one group in the default set, and the reason grouping by ordinal beats by name+ assert_eq!(by.get("Fanout").map(|f| f.as_array().unwrap().len()), Some(5));+ assert!(by.get("Clearance").is_some());+ }++ #[test]+ fn an_unknown_ordinal_is_labelled_not_invented() {+ assert_eq!(rule_kind_name(0), "Clearance");+ assert_eq!(rule_kind_name(999), "kind 999");+ }++ #[test]+ fn rules_by_kind_drops_disabled_and_sorts_by_priority() {+ let d = BoardDump::new(json!({+ "rules": {"ok": true, "rules": [+ {"name": "b", "kind": 0, "priority": 2, "enabled": true},+ {"name": "a", "kind": 0, "priority": 1, "enabled": true},+ {"name": "off", "kind": 0, "priority": 0, "enabled": false},+ {"name": "w", "kind": 2, "priority": 1, "enabled": true}+ ]}+ }));+ let by = d.rules_by_kind();+ let cl = by.get("Clearance").unwrap().as_array().unwrap();+ assert_eq!(cl.len(), 2, "disabled rule dropped");+ assert_eq!(cl[0].get("name").unwrap(), "a", "sorted by priority");+ assert!(by.get("Width").is_some());+ }++ #[test]+ fn a_failed_section_yields_an_empty_view_not_a_panic() {+ // the agent reports {ok:false, error} per section when e.g. no board is current+ let d = BoardDump::new(json!({"components": {"ok": false, "error": "no current PCB board"}}));+ assert!(d.designators().is_empty());+ assert_eq!(d.summary().get("components").and_then(|v| v.as_u64()), Some(0));+ }+}
bridge-rs/src/health.rs+8@@ -72,6 +72,14 @@ fn licence_state_uncached() -> Value { json!({"running": false, "abUnreachable": ab_down}) } +pub fn is_running() -> bool {+ licence_state().get("running").and_then(|b| b.as_bool()).unwrap_or(false)+}++pub fn main_hwnd() -> Option<i64> {+ licence_state().get("hwnd").and_then(|h| h.as_i64())+}+ pub fn report() -> Value { let lic = licence_state(); let running = lic.get("running").and_then(|b| b.as_bool()).unwrap_or(false);
bridge-rs/src/lifecycle.rsadded+548@@ -0,0 +1,548 @@+//! Agent + application lifecycle, ported from the Python bridge's `handlers/agent.py`.+//!+//! Everything desktop-shaped goes through ab (`desktop_find_window`, `desktop_find_control`,+//! `desktop_ui_click`, `desktop_get_foreground`, `desktop_raise_window`, `process_kill`). Process+//! SPAWNING is not desktop-shaped and ab's direct API refuses `shell_execute` (412 — CLI-only,+//! with pull_file/send_files/hd_send_files), so the bridge owns its child processes directly.+//! What is left here is the file protocol and the sequencing rules paid for in modals on a live box:+//!+//! * ONE script at a time. A `-R` lane is REFUSED while the agent is resident, otherwise Altium+//! raises "Another script executing now" on the user's screen.+//! * A `-R` lane is a ONE-SHOT. Never retry it in a loop — each attempt that lands on a busy+//! instance costs the user an "another instance is busy" notice.+//! * The rendered unit is named `AdomAgent_<digest>.pas`: Altium keeps the .PrjScr open in its+//! Script editor and re-runs the IN-EDITOR copy, so overwriting in place runs stale code.+//! * The bootstrap activates Altium. Capture the user's foreground window first and hand it back.+use crate::ab;+use crate::agent;+use serde_json::{json, Value};+use std::path::PathBuf;+use std::time::{Duration, Instant};++const HB_STALE_SEC: u64 = 8;++/// The agent template ships beside the exe (`agent/AdomAgent.pas.tmpl`).+fn agent_dir() -> PathBuf {+ std::env::current_exe()+ .ok()+ .and_then(|p| p.parent().map(|d| d.join("agent")))+ .unwrap_or_else(|| PathBuf::from("agent"))+}++fn template_source() -> std::io::Result<String> {+ std::fs::read_to_string(agent_dir().join("AdomAgent.pas.tmpl"))+}++/// `AdomAgent_<sha256[..10]>` over the RAW template — a new filename whenever the source changes.+fn agent_base(raw: &str) -> String {+ format!("AdomAgent_{}", &sha256_hex(raw.as_bytes())[..10])+}++fn ws_str() -> String {+ let s = agent::workspace_dir().to_string_lossy().to_string();+ if s.ends_with('\\') { s } else { format!("{s}\\") }+}++fn rendered(raw: &str) -> String {+ raw.replace("__WORKSPACE__", &ws_str().replace('\'', "''"))+ .replace("__GEN__", &agent_base(raw))+}++/// Write the script project into the workspace. Idempotent per digest.+pub fn install() -> Value {+ let raw = match template_source() {+ Ok(r) => r,+ Err(e) => return json!({"success": false, "errorCode": "template_missing",+ "error": format!("cannot read agent/AdomAgent.pas.tmpl beside the exe: {e}")}),+ };+ let ws = agent::workspace_dir();+ if let Err(e) = std::fs::create_dir_all(&ws) {+ return json!({"success": false, "error": format!("cannot create workspace: {e}")});+ }+ let base = agent_base(&raw);+ let pas = ws.join(format!("{base}.pas"));+ let prj = ws.join(format!("{base}.PrjScr"));+ let changed = !pas.exists();+ if changed {+ if let Err(e) = std::fs::write(&pas, rendered(&raw)) {+ return json!({"success": false, "error": format!("cannot write {}: {e}", pas.display())});+ }+ let prj_body = std::fs::read_to_string(agent_dir().join("AdomAgent.PrjScr"))+ .unwrap_or_else(|_| "[Design]\r\nVersion=1.0\r\n[Document1]\r\nDocumentPath=AdomAgent.pas\r\n".into())+ .replace("AdomAgent.pas", &format!("{base}.pas"));+ if let Err(e) = std::fs::write(&prj, prj_body) {+ return json!({"success": false, "error": format!("cannot write {}: {e}", prj.display())});+ }+ prune_generations(3);+ }+ json!({"installed": true, "changed": changed, "prjscr": prj.to_string_lossy(),+ "pas": pas.to_string_lossy(), "digest": &base[base.len() - 10..]})+}++/// Keep the newest `keep` generations so an open editor tab never dangles.+fn prune_generations(keep: usize) {+ let ws = agent::workspace_dir();+ let mut gens: Vec<(std::time::SystemTime, PathBuf)> = match std::fs::read_dir(&ws) {+ Ok(rd) => rd+ .filter_map(|e| e.ok())+ .filter(|e| {+ let n = e.file_name().to_string_lossy().to_string();+ n.starts_with("AdomAgent_") && n.ends_with(".pas")+ })+ .filter_map(|e| e.metadata().ok().and_then(|m| m.modified().ok()).map(|t| (t, e.path())))+ .collect(),+ Err(_) => return,+ };+ gens.sort_by_key(|(t, _)| *t);+ if gens.len() <= keep {+ return;+ }+ for (_, p) in &gens[..gens.len() - keep] {+ let _ = std::fs::remove_file(p);+ let _ = std::fs::remove_file(p.with_extension("PrjScr"));+ }+}++pub fn prjscr_path() -> Option<PathBuf> {+ let raw = template_source().ok()?;+ Some(agent::workspace_dir().join(format!("{}.PrjScr", agent_base(&raw))))+}++fn resident_generation() -> Option<String> {+ let hb = std::fs::read_to_string(agent::workspace_dir().join("hb.txt")).ok()?;+ hb.lines()+ .find_map(|l| l.strip_prefix("gen=").map(|s| s.trim().to_string()))+}++// ---- ab-backed helpers -------------------------------------------------------++fn x2_exe() -> Option<String> {+ let r = ab::call("desktop_list_windows", json!({}), &[], 15);+ let _ = &r; // windows do not carry the exe path; fall back to the well-known install roots+ for root in ["C:\\Program Files\\Altium\\AD26", "C:\\Program Files\\Altium\\AD25", "C:\\Program Files\\Altium\\AD24"] {+ let p = format!("{root}\\X2.EXE");+ if std::path::Path::new(&p).exists() {+ return Some(p);+ }+ }+ // last resort: ask a running instance+ let pl = ab::call("process_list", json!({"nameContains": "X2"}), &[], 15);+ pl.get("processes")+ .and_then(|a| a.as_array())+ .and_then(|a| a.iter().find_map(|p| p.get("path").and_then(|s| s.as_str()).map(str::to_string)))+}++/// Spawn the X2 helper. Process spawning is NOT desktop-shaped, and ab's direct API refuses+/// `shell_execute` anyway (412 — that verb is CLI-only, alongside pull_file/send_files), so the+/// bridge owns the child directly: no shell, no quoting layer, and a real handle to wait on.+fn spawn_helper(exe: &str, arg: &str) -> Result<std::process::Child, String> {+ let mut cmd = std::process::Command::new(exe);+ #[cfg(windows)]+ {+ use std::os::windows::process::CommandExt;+ // `arg()` would RE-QUOTE: the workspace path contains a space ("Adom Bridge"), so the+ // helper received backslash-escaped quotes, which Altium cannot parse — it then neither+ // forwarded the request nor exited (measured on the Python bridge 2026-08-29, and again+ // here 2026-08-31 before switching to raw_arg). CreateProcess must see it verbatim.+ if !arg.is_empty() {+ cmd.raw_arg(arg);+ }+ cmd.creation_flags(0x0800_0000); // CREATE_NO_WINDOW — no console flash on the user's screen+ }+ #[cfg(not(windows))]+ if !arg.is_empty() {+ cmd.arg(arg);+ }+ cmd.stdin(std::process::Stdio::null())+ .stdout(std::process::Stdio::null())+ .stderr(std::process::Stdio::null())+ .spawn()+ .map_err(|e| format!("{e}"))+}++/// Wait for the helper to exit, capped. Returns true if it exited on its own.+fn wait_helper(child: &mut std::process::Child, cap: Duration) -> bool {+ let t0 = Instant::now();+ while t0.elapsed() < cap {+ match child.try_wait() {+ Ok(Some(_)) => return true,+ Ok(None) => std::thread::sleep(Duration::from_millis(150)),+ Err(_) => return false,+ }+ }+ false+}++/// Altium's single-instance notice ("another instance is busy") is modal on the USER's screen.+/// Watch briefly and press OK through UIA — never leave it for the human.+pub fn reap_busy_notice(wait: Duration) -> Value {+ let t0 = Instant::now();+ while t0.elapsed() < wait {+ let w = ab::call("desktop_find_window", json!({"titleContains": "Information"}), &[], 15);+ let hwnds: Vec<i64> = w+ .get("windows")+ .and_then(|a| a.as_array())+ .map(|a| a.iter().filter_map(|x| x.get("hwnd").and_then(|h| h.as_i64())).collect())+ .unwrap_or_default();+ for hw in hwnds {+ let t = ab::call("desktop_find_control", json!({"hwnd": hw, "contains": "another instance", "role": "text"}), &[], 15);+ if t.get("success").and_then(|b| b.as_bool()).unwrap_or(false) {+ ab::call("desktop_ui_click", json!({"hwnd": hw, "name": "OK", "role": "button"}), &[], 15);+ return json!({"reaped": true, "hwnd": hw, "afterMs": t0.elapsed().as_millis() as u64});+ }+ }+ std::thread::sleep(Duration::from_millis(250));+ }+ json!({"reaped": false})+}++fn foreground_hwnd() -> Option<i64> {+ ab::call("desktop_get_foreground", json!({}), &[], 10)+ .get("hwnd")+ .and_then(|h| h.as_i64())+}++fn restore_foreground(hwnd: i64) {+ ab::call("desktop_raise_window", json!({"hwnd": hwnd, "reason": "hand the user's window back after the agent bootstrap activated Altium"}), &[], 15);+}+++/// Kill every X2.EXE that is NOT the one owning the main window: those are `-R` helpers left+/// waiting on a busy instance. A killed helper's request can still be DELIVERED later by Altium+/// and start a SECOND loop ("Another script executing now"), so sweep before every bootstrap.+fn kill_stray_helpers() -> Vec<u64> {+ let main_pid = crate::health::licence_state()+ .get("pid")+ .and_then(|p| p.as_u64())+ .or_else(|| {+ crate::health::main_hwnd().and_then(|h| {+ ab::call("desktop_ui_window", json!({"hwnd": h}), &[], 10)+ .get("pid")+ .and_then(|p| p.as_u64())+ })+ });+ let main_pid = match main_pid {+ Some(p) => p,+ None => return Vec::new(), // cannot tell the app from a helper — kill nothing+ };+ let pl = ab::call("process_list", json!({"nameContains": "X2"}), &[], 15);+ let mut killed = Vec::new();+ if let Some(procs) = pl.get("processes").and_then(|a| a.as_array()) {+ for p in procs {+ let pid = match p.get("pid").and_then(|v| v.as_u64()) {+ Some(v) => v,+ None => continue,+ };+ if pid == main_pid {+ continue;+ }+ let r = ab::call("process_kill", json!({"pid": pid, "reason": "stray X2 -R helper waiting on a busy instance"}), &[], 15);+ if r.get("success").and_then(|b| b.as_bool()).unwrap_or(false) {+ killed.push(pid);+ }+ }+ }+ killed+}++// ---- the -R lane -------------------------------------------------------------++/// Forward `RunScript` to the running instance. REFUSED while the agent is resident.+/// ONE SHOT: on timeout the helper is killed, the busy notice reaped, and the caller told —+/// never call this in a retry loop.+pub fn run_script_lane(proc_name: &str, timeout: Duration) -> Value {+ if agent::is_alive() {+ return json!({"success": false, "errorCode": "agent_resident",+ "error": "the Adom agent loop is resident; a second script would raise Altium's 'Another script executing now' modal",+ "_hint": "send the work through the agent (eval / run_command), or agent_stop first"});+ }+ let exe = match x2_exe() {+ Some(e) => e,+ None => return json!({"success": false, "errorCode": "host_app_not_installed", "hostApp": "Altium Designer"}),+ };+ let prj = match prjscr_path() {+ Some(p) => p.to_string_lossy().to_string(),+ None => return json!({"success": false, "errorCode": "template_missing"}),+ };+ let arg = format!("-RScriptingSystem:RunScript(ProjectName=\"{prj}\"|ProcName=\"{proc_name}\")");+ let mut child = match spawn_helper(&exe, &arg) {+ Ok(c) => c,+ Err(e) => return json!({"success": false, "error": format!("could not launch X2 helper: {e}")}),+ };+ let t0 = Instant::now();+ if wait_helper(&mut child, timeout) {+ return json!({"success": true, "helperSec": t0.elapsed().as_secs_f64(), "arg": arg});+ }+ let _ = child.kill();+ let _ = child.wait();+ let reaped = reap_busy_notice(Duration::from_secs(4));+ json!({"success": false, "errorCode": "instance_busy",+ "error": "the -R helper never exited — the running instance did not accept the request",+ "busyNotice": reaped,+ "_hint": "ONE SHOT: do not retry this lane. agent_recover frees a halted engine first."})+}++// ---- start / stop / recover --------------------------------------------------++pub fn start(wait: Duration) -> Value {+ let inst = install();+ if inst.get("success").and_then(|b| b.as_bool()) == Some(false) {+ return inst;+ }+ let want = template_source().map(|r| agent_base(&r)).unwrap_or_default();+ if agent::is_alive() {+ match resident_generation() {+ Some(g) if g == want => return json!({"success": true, "alreadyRunning": true, "generation": g}),+ _ => {+ // an OLDER generation is still polling — it would answer with stale code+ stop();+ std::thread::sleep(Duration::from_secs(1));+ }+ }+ }+ // a stale heartbeat / inbox / response from a previous life must not confuse this one+ let ws = agent::workspace_dir();+ for n in ["hb.txt", "stop", "request.txt", "request.payload", "request.txt.tmp", "request.payload.tmp"] {+ let _ = std::fs::remove_file(ws.join(n));+ }+ if let Ok(rd) = std::fs::read_dir(&ws) {+ for e in rd.filter_map(|e| e.ok()) {+ let n = e.file_name().to_string_lossy().to_string();+ if n.starts_with("response_") && n.ends_with(".json") {+ let _ = std::fs::remove_file(e.path());+ }+ }+ }+ let user_fg = foreground_hwnd();+ let t0 = Instant::now();+ let strays = kill_stray_helpers();+ let mut lane = run_script_lane("StartAgent", Duration::from_secs(30));+ if !strays.is_empty() {+ lane["strayHelpersKilled"] = json!(strays);+ }+ if lane.get("success").and_then(|b| b.as_bool()) != Some(true) {+ let mut out = lane;+ if let Some(o) = out.as_object_mut() {+ o.insert("install".into(), inst);+ }+ return out;+ }+ // the bootstrap ACTIVATES Altium (it opens the .PrjScr in the editor) — hand the window back+ if let Some(fg) = user_fg {+ restore_foreground(fg);+ }+ while t0.elapsed() < wait {+ if agent::is_alive() {+ if let Some(fg) = user_fg {+ restore_foreground(fg);+ }+ return json!({"success": true, "startedInSec": t0.elapsed().as_secs_f64(),+ "generation": resident_generation(), "install": inst});+ }+ std::thread::sleep(Duration::from_millis(250));+ }+ let dialogs = crate::dialogs::scan();+ json!({"success": false, "errorCode": "no_heartbeat",+ "error": format!("agent did not heartbeat within {}s", wait.as_secs()),+ "dialogs": dialogs,+ "_hint": "a compile error halts the engine behind a script Error modal — check dialogs, then agent_recover"})+}++pub fn stop() -> Value {+ let ws = agent::workspace_dir();+ if !agent::is_alive() {+ return json!({"success": true, "wasRunning": false});+ }+ if let Err(e) = std::fs::write(ws.join("stop"), "1") {+ return json!({"success": false, "error": format!("cannot drop the stop file: {e}")});+ }+ let t0 = Instant::now();+ while t0.elapsed() < Duration::from_secs(HB_STALE_SEC + 4) {+ if !agent::is_alive() {+ return json!({"success": true, "stoppedInSec": t0.elapsed().as_secs_f64()});+ }+ std::thread::sleep(Duration::from_millis(200));+ }+ json!({"success": false, "error": "the loop did not exit", "_hint": "agent_recover"})+}++/// 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 {+ let mut steps: Vec<Value> = vec![json!({"reason": reason})];+ // 1. the script Error modal, if any+ for d in crate::dialogs::scan() {+ if let Some(hw) = d.get("hwnd").and_then(|h| h.as_i64()) {+ let title = d.get("title").and_then(|t| t.as_str()).unwrap_or("");+ ab::call("desktop_ui_click", json!({"hwnd": hw, "name": "OK", "role": "button"}), &[], 15);+ steps.push(json!({"dialog": title, "pressed": "OK"}));+ }+ }+ // 2. tell the editor to stop whatever unit it is holding (raises the busy notice → reap it)+ if let Some(exe) = x2_exe() {+ match spawn_helper(&exe, "-REditScript:Stop") {+ Ok(mut child) => {+ let t0 = Instant::now();+ let exited = wait_helper(&mut child, Duration::from_secs(8));+ if !exited {+ let _ = child.kill();+ let _ = child.wait();+ }+ steps.push(json!({"editScriptStop": "sent", "exited": exited, "sec": t0.elapsed().as_secs_f64()}));+ }+ Err(e) => steps.push(json!({"editScriptStop": "failed", "error": e})),+ }+ steps.push(reap_busy_notice(Duration::from_secs(4)));+ }+ // 3. stale files+ let ws = agent::workspace_dir();+ 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."})+}++// ---- application lifecycle ---------------------------------------------------++pub fn launch(wait: Duration) -> Value {+ if crate::health::is_running() {+ return json!({"success": true, "alreadyRunning": true, "windowEtiquette": "background"});+ }+ let exe = match x2_exe() {+ Some(e) => e,+ None => return json!({"success": false, "errorCode": "host_app_not_installed", "hostApp": "Altium Designer"}),+ };+ let t0 = Instant::now();+ if let Err(e) = spawn_helper(&exe, "") {+ return json!({"success": false, "error": format!("launch failed: {e}")});+ } // the child is Altium itself — let it outlive this call+ while t0.elapsed() < wait {+ if crate::health::is_running() {+ return json!({"success": true, "startedInSec": t0.elapsed().as_secs_f64(),+ "windowEtiquette": "foreground",+ "_hint": "Altium's own launch foregrounds itself (the one legitimate foreground)."});+ }+ std::thread::sleep(Duration::from_secs(1));+ }+ json!({"success": false, "errorCode": "launch_timeout",+ "error": format!("no Altium main window after {}s", wait.as_secs())})+}++/// Polite close: WM_CLOSE via ab, report a save prompt instead of answering it.+/// `force` taskkills — which strands the On-Demand licence lease, so it is opt-in only.+pub fn close(force: bool, wait: Duration) -> Value {+ if !crate::health::is_running() {+ return json!({"success": true, "wasRunning": false});+ }+ if agent::is_alive() {+ stop();+ }+ let hw = crate::health::main_hwnd();+ if let Some(h) = hw {+ ab::call("desktop_set_window_state", json!({"hwnd": h, "state": "close", "reason": "close Altium Designer"}), &[], 20);+ }+ let t0 = Instant::now();+ while t0.elapsed() < wait {+ if !crate::health::is_running() {+ return json!({"success": true, "closedInSec": t0.elapsed().as_secs_f64()});+ }+ let dl = crate::dialogs::scan();+ if !dl.is_empty() {+ if force {+ ab::call("process_kill", json!({"name": "X2.EXE", "reason": "forced close past a save prompt"}), &[], 20);+ return json!({"success": true, "forced": true,+ "dialogs": dl,+ "_warning": "a forced kill strands the On-Demand licence lease — reclaim it with license {action:'use'}"});+ }+ return json!({"success": false, "errorCode": "blocked_by_dialog", "dialogs": dl,+ "error": "a dialog (probably a save prompt) is blocking the close",+ "_hint": "answer it, or close {force:true} to taskkill (strands the licence lease)"});+ }+ std::thread::sleep(Duration::from_millis(400));+ }+ json!({"success": false, "errorCode": "close_timeout", "error": format!("still running after {}s", wait.as_secs())})+}++// ---- sha256 (no external crate) ----------------------------------------------++fn sha256_hex(data: &[u8]) -> String {+ const K: [u32; 64] = [+ 0x428a2f98, 0x71374491, 0xb5c0fbcf, 0xe9b5dba5, 0x3956c25b, 0x59f111f1, 0x923f82a4, 0xab1c5ed5,+ 0xd807aa98, 0x12835b01, 0x243185be, 0x550c7dc3, 0x72be5d74, 0x80deb1fe, 0x9bdc06a7, 0xc19bf174,+ 0xe49b69c1, 0xefbe4786, 0x0fc19dc6, 0x240ca1cc, 0x2de92c6f, 0x4a7484aa, 0x5cb0a9dc, 0x76f988da,+ 0x983e5152, 0xa831c66d, 0xb00327c8, 0xbf597fc7, 0xc6e00bf3, 0xd5a79147, 0x06ca6351, 0x14292967,+ 0x27b70a85, 0x2e1b2138, 0x4d2c6dfc, 0x53380d13, 0x650a7354, 0x766a0abb, 0x81c2c92e, 0x92722c85,+ 0xa2bfe8a1, 0xa81a664b, 0xc24b8b70, 0xc76c51a3, 0xd192e819, 0xd6990624, 0xf40e3585, 0x106aa070,+ 0x19a4c116, 0x1e376c08, 0x2748774c, 0x34b0bcb5, 0x391c0cb3, 0x4ed8aa4a, 0x5b9cca4f, 0x682e6ff3,+ 0x748f82ee, 0x78a5636f, 0x84c87814, 0x8cc70208, 0x90befffa, 0xa4506ceb, 0xbef9a3f7, 0xc67178f2,+ ];+ let mut h: [u32; 8] = [0x6a09e667, 0xbb67ae85, 0x3c6ef372, 0xa54ff53a, 0x510e527f, 0x9b05688c, 0x1f83d9ab, 0x5be0cd19];+ let mut msg = data.to_vec();+ let bit_len = (data.len() as u64).wrapping_mul(8);+ msg.push(0x80);+ while msg.len() % 64 != 56 {+ msg.push(0);+ }+ msg.extend_from_slice(&bit_len.to_be_bytes());+ for chunk in msg.chunks(64) {+ let mut w = [0u32; 64];+ for i in 0..16 {+ w[i] = u32::from_be_bytes([chunk[i * 4], chunk[i * 4 + 1], chunk[i * 4 + 2], chunk[i * 4 + 3]]);+ }+ for i in 16..64 {+ let s0 = w[i - 15].rotate_right(7) ^ w[i - 15].rotate_right(18) ^ (w[i - 15] >> 3);+ let s1 = w[i - 2].rotate_right(17) ^ w[i - 2].rotate_right(19) ^ (w[i - 2] >> 10);+ w[i] = w[i - 16].wrapping_add(s0).wrapping_add(w[i - 7]).wrapping_add(s1);+ }+ let (mut a, mut b, mut c, mut d, mut e, mut f, mut g, mut hh) = (h[0], h[1], h[2], h[3], h[4], h[5], h[6], h[7]);+ for i in 0..64 {+ let s1 = e.rotate_right(6) ^ e.rotate_right(11) ^ e.rotate_right(25);+ let ch = (e & f) ^ ((!e) & g);+ let t1 = hh.wrapping_add(s1).wrapping_add(ch).wrapping_add(K[i]).wrapping_add(w[i]);+ let s0 = a.rotate_right(2) ^ a.rotate_right(13) ^ a.rotate_right(22);+ let maj = (a & b) ^ (a & c) ^ (b & c);+ let t2 = s0.wrapping_add(maj);+ hh = g; g = f; f = e; e = d.wrapping_add(t1);+ d = c; c = b; b = a; a = t1.wrapping_add(t2);+ }+ for (i, v) in [a, b, c, d, e, f, g, hh].iter().enumerate() {+ h[i] = h[i].wrapping_add(*v);+ }+ }+ h.iter().map(|x| format!("{x:08x}")).collect()+}++#[cfg(test)]+mod tests {+ use super::sha256_hex;++ #[test]+ fn sha256_matches_reference_vectors() {+ assert_eq!(sha256_hex(b""), "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855");+ assert_eq!(sha256_hex(b"abc"), "ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad");+ // > 55 bytes: forces a second padding block+ assert_eq!(+ sha256_hex(b"abcdbcdecdefdefgefghfghighijhijkijkljklmklmnlmnomnopnopq"),+ "248d6a61d20638b8e5c026930c3e6039a33ce45964ff2167f6ecedd419db06c1"+ );+ }+}++#[cfg(test)]+mod interop_tests {+ use super::{agent_base, sha256_hex};++ /// The Python bridge names the generation `AdomAgent_<sha256(template)[..10]>`. Both bridges+ /// MUST agree or they would each install their own generation and fight over the engine.+ #[test]+ fn generation_digest_matches_python() {+ let raw = match std::fs::read_to_string("../src/agent/AdomAgent.pas.tmpl") {+ Ok(r) => r,+ Err(_) => return, // template not present in this checkout+ };+ let full = sha256_hex(raw.as_bytes());+ assert_eq!(agent_base(&raw), format!("AdomAgent_{}", &full[..10]));+ println!("digest = {}", &full[..10]);+ }+}
bridge-rs/src/main.rs+3@@ -3,7 +3,10 @@ //! POST /command {command, args} -> verb envelope. Everything desktop-shaped goes to ab. mod ab; mod agent;+mod dialogs;+mod dump; mod health;+mod lifecycle; mod verbs; use std::time::Instant;
bridge-rs/src/verbs.rs+134−1@@ -1,7 +1,7 @@ //! Verb dispatch. v0 = the Phase 1 introspection surface as thin views over the existing agent //! commands (zero template changes), plus status/readiness. Every verb returns the envelope the //! Python bridge returns, so the CLI and callers see no difference.-use crate::{agent, health};+use crate::{agent, dialogs, dump, health, lifecycle}; use serde_json::{json, Value}; use std::time::Duration; @@ -9,8 +9,84 @@ const KNOWN: &[&str] = &[ "readiness", "status", "agent_status", "eval", "run_command", "open_document", "list_designators", "component_data", "component_pins", "list_nets", "net_connections", "layers", "copper_layers", "read_rules", "set_layer_visibility", "footprint_primitives", "schematic_data",+ "agent_start", "agent_stop", "agent_recover", "launch", "close", "check_dialogs", "dismiss_dialogs",+ "dump_board", "board_summary", "rules_by_kind",+ "describe", "uninstall", ]; +/// One `describe` entry. ab's "Verbs" tab renders these verbatim.+fn verb_doc(name: &str, summary: &str, input: Value, output: Value, timeout: u64, hint: &str, related: &[&str], pitfalls: &[&str], example: Value) -> Value {+ json!({"name": format!("altium_{name}"), "summary": summary, "input": input, "output": output,+ "timeoutSeconds": timeout, "statusVerb": Value::Null, "longRunning": false,+ "hint": hint, "example": example,+ "related": related.iter().map(|r| format!("altium_{r}")).collect::<Vec<_>>(),+ "pitfalls": pitfalls})+}++fn describe() -> Value {+ let board = json!({"success": "bool", "data": "object - the agent's payload", "roundtripMs": "int"});+ json!({"success": true, "bridge": "altium", "engine": "rust", "verbs": [+ verb_doc("status", "Is Altium running, licensed, and is the resident agent alive?", json!({}),+ json!({"running": "bool", "licensed": "bool", "licenceState": "string", "agentAlive": "bool"}), 15,+ "Cheapest health call - answers from ab's window list plus the agent heartbeat; never touches the script engine.",+ &["readiness", "agent_status"], &["'running' can be true while 'licensed' is false - a trial lease can be stranded by a crash."], json!({})),+ verb_doc("agent_status", "Heartbeat age and workspace of the resident DelphiScript agent.", json!({}),+ json!({"alive": "bool", "responsive": "bool", "workspace": "string"}), 15,+ "alive=false does NOT mean the script engine is free - a halted script still occupies it.",+ &["status"], &["Never fire a script lane just because alive=false; that raises 'Another script executing now' on the user's screen."], json!({})),+ verb_doc("eval", "Run DelphiScript STATEMENTS inside Altium (Begin ... End;).", json!({"text": "string - statements only, no Var block", "timeout": "int seconds"}),+ json!({"success": "bool", "note": "string"}), 30,+ "Single quotes only; no Var block. Capture output by writing a file, not by returning a value.",+ &["run_command"], &["An undeclared identifier HALTS the engine with a modal that Try/Except cannot catch.", "ShowMessage blocks the loop behind a modal - never use it."], json!({"text": "Begin PCBServer.GetCurrentPCBBoard.ViewManager_FullUpdate; End;"})),+ verb_doc("run_command", "Run an Altium process (Server:Command) through the agent.", json!({"command": "string - e.g. PCB:Zoom", "params": "string or object"}),+ json!({"success": "bool"}), 60, "Params are pipe-joined key=value pairs.", &["eval"], &["Some processes open a modal dialog and block the loop until it is answered."], json!({"command": "PCB:Zoom", "params": {"Action": "All"}})),+ verb_doc("open_document", "Open a document and make it current.", json!({"path": "string - absolute .PcbDoc/.SchDoc path"}),+ json!({"success": "bool"}), 60, "Board verbs need a .PcbDoc current; schematic iteration needs a LEAF sheet.", &["list_designators", "schematic_data"], &["A hierarchical parent sheet holds only sheet symbols - iterate its children instead."], json!({"path": "C:\\pcb\\Bluetooth_Sentinel.PcbDoc"})),+ verb_doc("list_designators", "Every component designator on the current board.", json!({}), board.clone(), 90, "The cheapest board read - use it to confirm the right .PcbDoc is current.", &["component_data", "component_pins"], &["Needs a .PcbDoc current, not a schematic."], json!({})),+ verb_doc("component_data", "Designator, comment, footprint, layer and XY for components.", json!({"designator": "string - optional filter", "limit": "int (default 500)"}), board.clone(), 90, "Omit 'designator' for the whole board.", &["list_designators"], &[], json!({"designator": "U1"})),+ verb_doc("component_pins", "Pads of one placed component with net names.", json!({"designator": "string - required"}), board.clone(), 90, "Pad-level view; for tracks/arcs/texts use footprint_primitives.", &["footprint_primitives"], &[], json!({"designator": "U1"})),+ verb_doc("list_nets", "Every net with its pad count.", json!({}), board.clone(), 90, "Tallied in the agent; sorted by name.", &["net_connections"], &[], json!({})),+ verb_doc("net_connections", "Per-net pad list and bounding span.", json!({"net": "string - optional filter", "limit": "int (default 4000)"}), board.clone(), 120, "Span is minx|miny|maxx|maxy in board units.", &["list_nets"], &["A whole-board dump on a dense board can exceed the default timeout - filter by net."], json!({"net": "GND"})),+ verb_doc("layers", "All layers with visibility state.", json!({}), board.clone(), 90, "Includes non-copper layers; copper_layers is the copper-only view.", &["copper_layers", "set_layer_visibility"], &[], json!({})),+ verb_doc("copper_layers", "Copper layers only (top, mid 1-30, planes 1-16, bottom).", json!({}), board.clone(), 90, "Use the returned layerId with set_layer_visibility.", &["layers"], &[], json!({})),+ verb_doc("read_rules", "Design rules: kind, priority, scopes, enabled state.", json!({}), board.clone(), 90, "Read-only; rule authoring is not in this surface yet.", &["layers"], &[], json!({})),+ verb_doc("set_layer_visibility", "Show or hide one layer and refresh the view.", json!({"layerId": "string - from altium_layers", "visible": "bool"}), board.clone(), 60, "Refreshes the layer tabs and the board view.", &["layers", "copper_layers"], &["Changes what the USER sees - restore visibility when done."], json!({"layerId": "eTopLayer", "visible": true})),+ verb_doc("footprint_primitives", "Tracks, arcs, pads and texts of one placed component.", json!({"designator": "string - required"}), board.clone(), 120, "The geometry behind a placed footprint.", &["component_pins"], &[], json!({"designator": "U1"})),+ verb_doc("schematic_data", "Components and pins of the current schematic sheet.", json!({"designator": "string - optional", "limit": "int (default 500)", "pins": "bool (default true)"}), board, 120, "Needs a LEAF sheet current.", &["open_document"], &["A hierarchical parent sheet returns sheet symbols, not parts."], json!({})),+ verb_doc("describe", "This list - the bridge's verbs and schemas for ab's Verbs tab.", json!({}), json!({"verbs": "[{name, summary, input, output, ...}]"}), 5, "Static; safe to call any time.", &["status"], &[], json!({})),+ verb_doc("uninstall", "Uninstall cascade - ab calls this first during bridge_uninstall.", json!({"cascade": "bool - set by ab"}), json!({"success": "bool", "output": "string"}), 30,+ "Stops the resident agent and removes the agent workspace; leaves the user's design files alone.",+ &["agent_status"], &["Never deletes anything outside the bridge cache."], json!({"cascade": true})),+ ]})+}++/// ab calls this FIRST on bridge_uninstall so the bridge cleans up its own artifacts.+/// Only the agent workspace inside the bridge cache is touched - never a user design file.+fn uninstall() -> Value {+ let ws = agent::workspace_dir();+ let mut removed: Vec<String> = Vec::new();+ let mut note = String::new();+ if agent::is_alive() {+ // ask the loop to exit on its own; never fire a script lane during uninstall+ if std::fs::write(ws.join("stop"), b"1").is_ok() {+ note.push_str("stop file dropped for the resident agent; ");+ }+ std::thread::sleep(Duration::from_millis(1500));+ }+ if let Ok(entries) = std::fs::read_dir(&ws) {+ for e in entries.flatten() {+ let name = e.file_name().to_string_lossy().to_string();+ let disposable = name.starts_with("response_") || name.starts_with("AdomAgent_")+ || matches!(name.as_str(), "hb.txt" | "request.txt" | "request.payload" | "agent.json" | "stop" | "stage.txt");+ if disposable && std::fs::remove_file(e.path()).is_ok() {+ removed.push(name);+ }+ }+ }+ json!({"success": true, "removed": removed.len(), "workspace": ws.to_string_lossy(),+ "output": format!("{note}altium-bridge cleaned {} agent workspace files. Altium itself and all design files are untouched.", removed.len())})+}+ pub fn names() -> Vec<String> { KNOWN.iter().map(|v| format!("altium_{v}")).collect() }@@ -107,10 +183,67 @@ pub fn dispatch(command: &str, args: &Value, _forwarded: &[(String, String)]) -> None => json!({"success": false, "error": "designator is required (a placed component, e.g. U1)"}), Some(d) => board_read("footprint_primitives", vec![("designator", d)], 120), },+ "describe" => describe(),+ "uninstall" => uninstall(), "schematic_data" => { let pins = if args.get("pins").and_then(|b| b.as_bool()) == Some(false) { "0" } else { "1" }; board_read("schematic_data", vec![("designator", s(args, "designator").unwrap_or_default()), ("limit", s(args, "limit").unwrap_or("500".into())), ("pins", pins.into())], 120) }+ // ---- lifecycle (ported 2026-08-31; every desktop action goes through ab) ----+ "agent_start" => {+ if let Some(g) = need_licensed() { return g; }+ lifecycle::start(Duration::from_secs(timeout.max(45)))+ }+ "agent_stop" => lifecycle::stop(),+ "agent_recover" => {+ let why = s(args, "reason").unwrap_or_else(|| "caller asked".into());+ lifecycle::recover(&why)+ }+ "launch" => lifecycle::launch(Duration::from_secs(timeout.max(180))),+ "close" => lifecycle::close(+ args.get("force").and_then(|b| b.as_bool()).unwrap_or(false),+ Duration::from_secs(timeout.max(20)),+ ),+ "check_dialogs" => {+ let d = dialogs::scan();+ json!({"success": true, "dialogsDetected": d.len(), "dialogs": d,+ "_hint": if d.is_empty() { "clean" } else { "a modal FREEZES the resident loop — clear it before blaming the agent" }})+ }+ "dismiss_dialogs" => {+ let button = s(args, "button").unwrap_or_else(|| "OK".into());+ let titles: Option<Vec<String>> = args.get("titles").and_then(|a| a.as_array())+ .map(|a| a.iter().filter_map(|x| x.as_str().map(str::to_string)).collect());+ dialogs::dismiss(&button, titles.as_deref())+ }+ // ---- dump primitives: ONE agent round trip, views shaped here ----+ "dump_board" | "board_summary" | "rules_by_kind" => {+ if let Some(g) = need_licensed() { return g; }+ let sections = s(args, "sections").unwrap_or_else(|| "components,nets,layers,copper,rules".into());+ let r = agent::call("dump_board", &[("sections", sections), ("limit", s(args, "limit").unwrap_or("2000".into()))],+ None, Duration::from_secs(timeout.max(120)));+ let data = r.get("data").cloned().unwrap_or(Value::Null);+ if data.get("ok").and_then(|b| b.as_bool()) != Some(true) {+ return r;+ }+ if args.get("raw").and_then(|b| b.as_bool()) == Some(true) {+ // the unshaped agent payload — this is what gets recorded as a test fixture+ return json!({"success": true, "raw": data, "roundtripMs": r.get("roundtripMs")});+ }+ let d = dump::BoardDump::new(data);+ // NB: match `cmd` (prefix-stripped), not `command` — "altium_board_summary" would+ // otherwise fall through to the raw-dump arm.+ match cmd {+ "board_summary" => json!({"success": true, "summary": d.summary(), "roundtripMs": r.get("roundtripMs"),+ "_hint": "One agent round trip; every view below is derived in the bridge."}),+ "rules_by_kind" => json!({"success": true, "board": d.board_path(), "rulesByKind": d.rules_by_kind(),+ "roundtripMs": r.get("roundtripMs")}),+ _ => json!({"success": true, "board": d.board_path(), "summary": d.summary(),+ "designators": d.designators(), "nets": d.nets(), "layers": d.layers(),+ "copperLayers": d.copper_layers(), "rules": d.rules(),+ "roundtripMs": r.get("roundtripMs"),+ "_hint": "The raw dump behind every introspection view — record it as a test fixture with `> fixtures/dump_board.json`."})+ }+ } _ => json!({"success": false, "error": format!("Unknown command: {command}"), "_hint": format!("Rust bridge v0 serves: {}", names().join(", "))}), }
bridge.json+1−1@@ -2,7 +2,7 @@ "manifest_version": 1, "name": "altium", "displayName": "Altium Designer",- "version": "0.1.14",+ "version": "0.1.15", "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",
package.json+1−1@@ -1,6 +1,6 @@ { "slug": "altium-bridge",- "version": "0.1.14",+ "version": "0.1.15", "type": "app", "description": "Skills for your container so your AI knows how to drive the Altium Designer bridge (altium_* verbs). The bridge runtime itself is the release zip; Adom Bridge loads that.", "tags": [
skills/altium-bridge-dev/SKILL.md+22@@ -180,3 +180,25 @@ fine for Windows-only Altium. Set `killImageName` to the exe basename. Fork target: `adom/hello-rust-bridge` v1.3.1 — the full contract in ~290 lines, including the `describe` verb (ab's Verbs tab renders it verbatim) and the `uninstall` cascade verb.++## Dump primitives: the shape the Rust bridge is built on (2026-09-01)++`dump_board` (agent) returns components + nets + layers + copper + rules in ONE round trip. It is+composed in DelphiScript from the EXISTING per-verb readers — deliberately: no new Altium API+surface means it cannot introduce an undeclared identifier, which is the one mistake that halts the+engine behind a modal on the user's screen.++The bridge then derives every view (`src/dump.rs`). Two rules that came out of doing it:++1. **Record the RAW agent payload as a fixture, not the bridge's own response.** `altium_dump_board+ {raw: true}` returns the unshaped payload; that is what `fixtures/dump_board.json` holds, so the+ tests exercise the shaping rather than re-reading its output.+2. **Never invent a label for an ordinal.** Altium emits `TRuleKind` as a number. The mapping in+ `rule_kind_name()` was READ OFF a live default rule set (every default rule is named after its+ own kind — 31 ordinals, with the five `Fanout_*` defaults sharing ordinal 49, which is why+ grouping by ordinal beats grouping by name). Unknown ordinals render as `kind <n>`, and a test+ fails loudly rather than silently mislabelling.++Known gap: the agent enumerates every copper layer Altium CAN address (48: top + 30 mid + 16 planes++ bottom), not the stack in use — there is no "in stack" flag in the dump yet, so the summary calls+it `copperLayersAddressable`. Do NOT report that number to a user as the board's layer count.
src/BRIDGE_VERSION+1−1@@ -1 +1 @@-0.1.14+0.1.15
src/agent/AdomAgent.pas.tmpl+27−1@@ -3605,6 +3605,31 @@ Begin + '}'; End; +{ ONE round trip for the whole board. Composed from the EXISTING readers on purpose: no new Altium+ API surface, so it cannot introduce an undeclared identifier (which halts the engine behind a+ modal). The bridge shapes these into typed views; recorded dumps are the unit-test fixtures. }+Function CmdDumpBoard(Lines : TStringList) : String;+Var+ Board : IPCB_Board;+ Sects, S : String;+Begin+ Board := PCBServer.GetCurrentPCBBoard;+ If Board = Nil Then+ Begin+ Result := '{' + JRaw('ok','false') + ',' + JStr('error','no current PCB board (open a .PcbDoc first)') + '}';+ Exit;+ End;+ Sects := Param(Lines, 'sections');+ If Sects = '' Then Sects := 'components,nets,layers,copper,rules';+ S := '';+ If Pos('components', Sects) > 0 Then S := S + ',' + JRaw('components', CmdComponentData(Lines));+ If Pos('nets', Sects) > 0 Then S := S + ',' + JRaw('nets', CmdListNets(Lines));+ If Pos('layers', Sects) > 0 Then S := S + ',' + JRaw('layers', CmdLayers(Lines));+ If Pos('copper', Sects) > 0 Then S := S + ',' + JRaw('copper', CmdCopperLayers(Lines));+ If Pos('rules', Sects) > 0 Then S := S + ',' + JRaw('rules', CmdReadRules(Lines));+ Result := '{' + JRaw('ok','true') + ',' + JStr('board', Board.FileName) + ',' + JStr('sections', Sects) + S + '}';+End;+ Function Dispatch(Cmd : String; Lines : TStringList; Payload : String) : String; Begin If Cmd = 'ping' Then Result := CmdPing(Lines)@@ -3613,6 +3638,7 @@ Begin Else If Cmd = 'open' Then Result := CmdOpen(Lines) Else If Cmd = 'eval' Then Result := CmdEval(Lines, Payload) Else If Cmd = 'io_bench' Then Result := CmdIoBench(Lines)+ Else If Cmd = 'dump_board' Then Result := CmdDumpBoard(Lines) Else If Cmd = 'lib_list' Then Result := CmdLibList(Lines) Else If Cmd = 'lib_select' Then Result := CmdLibSelect(Lines) Else If Cmd = 'compile' Then Result := CmdCompile(Lines)@@ -3663,7 +3689,7 @@ Begin gRunning := False; Result := '{' + JRaw('ok', 'true') + ',' + JStr('note', 'agent loop stopping') + '}'; End- Else Result := '{' + JRaw('ok', 'false') + ',' + JStr('error', 'unknown command: ' + Cmd) + ',' + JStr('known', 'ping,state,run_process,open,eval,lib_list,lib_select,compile,violations,outjob,create_footprint,create_symbol,install_library,compile_intlib,extract_intlib,set_params,add_body,stop,list_designators,component_data,component_pins,list_nets,layers,net_connections,schematic_data,copper_layers,read_rules,set_layer_visibility,footprint_primitives,io_bench') + '}';+ Else Result := '{' + JRaw('ok', 'false') + ',' + JStr('error', 'unknown command: ' + Cmd) + ',' + JStr('known', 'ping,state,run_process,open,eval,lib_list,lib_select,compile,violations,outjob,create_footprint,create_symbol,install_library,compile_intlib,extract_intlib,set_params,add_body,stop,list_designators,component_data,component_pins,list_nets,layers,net_connections,schematic_data,copper_layers,read_rules,set_layer_visibility,footprint_primitives,io_bench,dump_board') + '}'; End; { ------------------------------------------------------------------ loop --- }