← Commit history
CHANGELOG.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"+}\ No newline at end of file
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 --- }