← Commit history

0.2.0: the bridge is Rust end to end — all 112 verbs dispatch from altium-bridge.exe (spawn.kind exe); Python runtime no longer ships. Instance layer, background proof capture, standing reaper, taskbar signals, measured-progress store (history-compatible), seat rules, surfaces, library authoring, import_part, outputs incl. the DRC modal dance, review_board's 14 checks fixture-tested, rcs catalog, a365 OAuth/PKCE. Verb lint reads the Rust dispatch; version drift refuses the build.

John Lauer ·4fb2c470cf ·1mo ago ·parent 565648e
26 files changed +6265−453
CHANGELOG.md+12
@@ -1,5 +1,17 @@ # Changelog +## 0.2.0 — 2026-09-02+- **The bridge is Rust, end to end.** John: *"let's finish getting everything into the rust code. i don't like that we're half rust half python now."* All 112 verbs now dispatch from `bridge-rs` (`altium-bridge.exe`, `spawn.kind: "exe"` per ab's SDK); the Python runtime no longer ships. What crossed over, at behavioural parity with 0.1.41:+  - the **instance layer** (`instance:<pid|key|word>`, `ambiguous_instance` refusal, per-instance agent workspaces `agent/<key>`, instance-aware licence gate incl. the Develop absent-tail rule);+  - **background proof capture** (restore-minimized-into-background → capture → hand the foreground back; sub-400px slivers refused as `proof_unavailable`);+  - the **standing dialog reaper** (catalog safe-actions only, lone-OK acknowledgement rule, never a save prompt, `dialog_log` ring) and the in-flight `-R` modal watcher;+  - **taskbar signals** (driving badge, measured-estimate progress bar, 5-minute idle auto-clear) and the **measured-progress store** — same on-disk paths/JSON as the Python bridge, so each machine's history survives the engine swap;+  - the **seat rules** (force-close refuses without `acceptSeatLoss`, seat hints on every licence-shaped failure, UIA-first licence page driving via `UseLicenseButton`);+  - all **surfaces, library authoring, `import_part`** (zero-model attach still fails loudly), `install_library_bundle` (hardened unzip in Rust), **outputs** (compile/ERC, OutJobs, STEP via the extension, the DRC modal dance), **review_board** (all 14 checks as pure functions, now fixture-tested), **cross_check**, the **rcs command catalog**, and the **Altium 365 OAuth/GraphQL lane** (PKCE per RFC 7636, verified against its test vector).+- The verb lint now reads the RUST dispatch (`verbs.rs` KNOWN) against `bridge.json`, and the release also refuses on version drift across `bridge.json` / `BRIDGE_VERSION` / `package.json` / `main.rs`. `cargo test` (38 tests, incl. the agent-generation digest interop test that keeps Rust and the DelphiScript template in lockstep) runs before every build.+- Two Rust-side views that existed but were never declared — `altium_board_summary`, `altium_rules_by_kind` — are now in `bridge.json` (declared-vs-dispatchable is exactly what the lint exists for).+- Still intentionally not Rust: the DelphiScript agent template (it runs INSIDE Altium; that lane is Pascal by nature) and the container-side converters (`tools/kicad2altium.py`, `tools/symbol_modes.py`), which never ship in the bridge zip.+ ## 0.1.41 — 2026-09-01 - **The tab favicon actually reaches the tab now.** 0.1.40 shipped the correct line-mark `favicon.svg`, but the dashboard's icon route aliased EVERY icon path — `/favicon.svg` included — to `dock-altium.svg`, which is a 60 KB SVG wrapping an embedded PNG raster. Hydrogen's webview reads the page's favicon after load (hydrogen-tab-icons) and renders nothing for that blob, which is precisely the icon-less tab John reported twice. The favicon route now serves `ui/favicon.svg` (the monochrome #e6edf3 line mark, same file as the header icon); `/dock-altium.svg` keeps serving the dock card art. - **`install.sh` now installs `favicon.svg`.** Its asset list was explicit, so 0.1.40's new file never landed in `~/.local/share/adom/altium-bridge/` — the pkg carried it and the installer dropped it.
adom-bridge-altium-manifest.json+4−4
@@ -1,9 +1,9 @@ {   "name": "altium",-  "version": "0.1.41",-  "url": "https://wiki.adom.inc/download/adom/altium-bridge/0.1.41/adom-bridge-altium-v0.1.41.zip",-  "sha256": "92e588a89f54f329f0f9cc38aa99c8595189e2db2121fc43fe9ed733097018f7",-  "size": 135933,+  "version": "0.2.0",+  "url": "https://wiki.adom.inc/download/adom/altium-bridge/0.2.0/adom-bridge-altium-v0.2.0.zip",+  "sha256": "2c425b77f908edd849829b2a36153f9d9a8e17363eed7e1728e0a70cb93ce992",+  "size": 1403462,   "verbPrefixes": [     "altium_"   ],
bridge-rs/Cargo.lockadded+740
@@ -0,0 +1,740 @@+# This file is automatically @generated by Cargo.+# It is not intended for manual editing.+version = 4++[[package]]+name = "adler2"+version = "2.0.1"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa"++[[package]]+name = "altium-bridge"+version = "0.2.0"+dependencies = [+ "getrandom",+ "serde",+ "serde_json",+ "tiny_http",+ "ureq",+ "zip",+]++[[package]]+name = "ascii"+version = "1.1.0"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "d92bec98840b8f03a5ff5413de5293bfcd8bf96467cf5452609f939ec6f5de16"++[[package]]+name = "base64"+version = "0.22.1"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6"++[[package]]+name = "byteorder"+version = "1.5.0"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b"++[[package]]+name = "cc"+version = "1.4.4"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "0ad534f4357a5264cce5019c989cf66a4f0dc4e0d1b1d15f8aacec0ff7360273"+dependencies = [+ "find-msvc-tools",+ "shlex",+]++[[package]]+name = "cfg-if"+version = "1.0.4"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801"++[[package]]+name = "chunked_transfer"+version = "1.5.0"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "6e4de3bc4ea267985becf712dc6d9eed8b04c953b3fcfb339ebc87acd9804901"++[[package]]+name = "crc32fast"+version = "1.5.1"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "8498c871161e1742aaa9d52551b2d6ebdd4c3d45a3be423e3728f33b955be550"+dependencies = [+ "cfg-if",+]++[[package]]+name = "crossbeam-utils"+version = "0.8.22"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "61803da095bee82a81bb1a452ecc25d3b2f1416d1897eb86430c6159ef717c17"++[[package]]+name = "displaydoc"+version = "0.2.7"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "c6232dd377dcc64799954cbd3a9bb882e9cdc1308ccd87b1c098f1fb2eaf82a8"+dependencies = [+ "proc-macro2",+ "quote",+ "syn 3.0.4",+]++[[package]]+name = "find-msvc-tools"+version = "0.1.11"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "d45db016d36b838f563236e9193d0ee6ce38f3f68b6c94e914b4929c96bbb890"++[[package]]+name = "flate2"+version = "1.1.10"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "6e634e2e0ebac1ee034020da1ca582e17ffe4e0f5e985823721e168928136dcb"+dependencies = [+ "crc32fast",+ "miniz_oxide",+]++[[package]]+name = "form_urlencoded"+version = "1.2.2"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "cb4cb245038516f5f85277875cdaa4f7d2c9a0fa0468de06ed190163b1581fcf"+dependencies = [+ "percent-encoding",+]++[[package]]+name = "getrandom"+version = "0.2.17"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0"+dependencies = [+ "cfg-if",+ "libc",+ "wasi",+]++[[package]]+name = "httpdate"+version = "1.0.3"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9"++[[package]]+name = "icu_collections"+version = "2.3.0"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "fa68d21081c4a05d5a901a1c62add574c77048b6a1c67be3b50ce0b60d4ca513"+dependencies = [+ "displaydoc",+ "potential_utf",+ "utf8_iter",+ "yoke",+ "zerofrom",+ "zerovec",+]++[[package]]+name = "icu_locale_core"+version = "2.3.0"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "d56e28588da92eee5c3201a6eff33fabdd49b62269c8938d4ff050ce4d900deb"+dependencies = [+ "displaydoc",+ "litemap",+ "tinystr",+ "writeable",+ "zerovec",+]++[[package]]+name = "icu_normalizer"+version = "2.3.0"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "12f9cf5f235641ed274641dd81c3f28d870e276763d0797aeeab72317b1c646f"+dependencies = [+ "icu_collections",+ "icu_normalizer_data",+ "icu_properties",+ "icu_provider",+ "smallvec",+ "zerovec",+]++[[package]]+name = "icu_normalizer_data"+version = "2.3.0"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "1563da1ed3e0b3bf3d74c9b85917ac9c56464d2f57242270c09c9e752f8021a0"++[[package]]+name = "icu_properties"+version = "2.3.0"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "7e7ca276ad3145661a65914e6daf131ca5120cd3dcee8f8f3214b8875184a148"+dependencies = [+ "displaydoc",+ "icu_collections",+ "icu_locale_core",+ "icu_properties_data",+ "icu_provider",+ "zerotrie",+ "zerovec",+]++[[package]]+name = "icu_properties_data"+version = "2.3.0"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "e590f038c1464a96894fd6d10127e90a8be4509f56ff7ecef851b15cee0b7caa"++[[package]]+name = "icu_provider"+version = "2.3.1"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "d27bbb9d3abbefac45d55f647c9de1d44aafcd1186eb91879afef17c396c3e73"+dependencies = [+ "displaydoc",+ "icu_locale_core",+ "writeable",+ "yoke",+ "zerofrom",+ "zerotrie",+ "zerovec",+]++[[package]]+name = "idna"+version = "1.1.0"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "3b0875f23caa03898994f6ddc501886a45c7d3d62d04d2d90788d47be1b1e4de"+dependencies = [+ "idna_adapter",+ "smallvec",+ "utf8_iter",+]++[[package]]+name = "idna_adapter"+version = "1.2.2"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "cb68373c0d6620ef8105e855e7745e18b0d00d3bdb07fb532e434244cdb9a714"+dependencies = [+ "icu_normalizer",+ "icu_properties",+]++[[package]]+name = "itoa"+version = "1.0.18"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682"++[[package]]+name = "libc"+version = "0.2.189"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "3eaf3ede3fee6db1a4c2ee091bf8a8b4dccdc6d17f656fb07896ee72867612f2"++[[package]]+name = "litemap"+version = "0.8.3"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "47d9d19d1d6efa0109d2f65ff4c85cddd50bd572e5a00127ab10987290bcefae"++[[package]]+name = "log"+version = "0.4.34"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "f9f8bd3e56ce4dfc153cf470fffbfa98c7620958b312ca5c3a4b8d5181fd13c6"++[[package]]+name = "memchr"+version = "2.8.3"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98"++[[package]]+name = "miniz_oxide"+version = "0.9.1"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "b63fbc4a50860e98e7b2aa7804ded1db5cbc3aff9193adaff57a6931bf7c4b4c"+dependencies = [+ "adler2",+ "simd-adler32",+]++[[package]]+name = "once_cell"+version = "1.21.4"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50"++[[package]]+name = "percent-encoding"+version = "2.3.2"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220"++[[package]]+name = "potential_utf"+version = "0.1.6"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "d83eb9bc6d8e5cf568e7a1101d60ee05e81ed50ea106026f3d18deeb046d7661"+dependencies = [+ "zerovec",+]++[[package]]+name = "proc-macro2"+version = "1.0.107"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "985e7ec9bb745e6ce6535b544d84d6cd6f7ad8bd711c398938ae983b91a766d9"+dependencies = [+ "unicode-ident",+]++[[package]]+name = "quote"+version = "1.0.47"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "1fbf4db142a473a8d80c26bbf18454ed458bf8d26c8219c331daecfdbd079001"+dependencies = [+ "proc-macro2",+]++[[package]]+name = "ring"+version = "0.17.14"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "a4689e6c2294d81e88dc6261c768b63bc4fcdb852be6d1352498b114f61383b7"+dependencies = [+ "cc",+ "cfg-if",+ "getrandom",+ "libc",+ "untrusted",+ "windows-sys",+]++[[package]]+name = "rustls"+version = "0.23.43"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "0283386ce02abc0151e1761d08802dfe86c173b0b494af5cbc086574e453da06"+dependencies = [+ "log",+ "once_cell",+ "ring",+ "rustls-pki-types",+ "rustls-webpki",+ "subtle",+ "zeroize",+]++[[package]]+name = "rustls-pki-types"+version = "1.15.1"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "2f4925028c7eb5d1fcdaf196971378ed9d2c1c4efc7dc5d011256f76c99c0a96"+dependencies = [+ "zeroize",+]++[[package]]+name = "rustls-webpki"+version = "0.103.15"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "f3c3cf1d8b1e7d4927e2d154c3fcb02979afb9939629c62cd9048d4f07b60ac2"+dependencies = [+ "ring",+ "rustls-pki-types",+ "untrusted",+]++[[package]]+name = "serde"+version = "1.0.229"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "4148590afebada386688f18773da617792bf2ef03ffc1e4cbd2b1d45b023e0ba"+dependencies = [+ "serde_core",+ "serde_derive",+]++[[package]]+name = "serde_core"+version = "1.0.229"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "67dca2c9c51e58a4791a4b1ed58308b39c64224d349a935ab5039aa360942a48"+dependencies = [+ "serde_derive",+]++[[package]]+name = "serde_derive"+version = "1.0.229"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348"+dependencies = [+ "proc-macro2",+ "quote",+ "syn 3.0.4",+]++[[package]]+name = "serde_json"+version = "1.0.151"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "c841b55ecdae098c80dcae9cf767f6f8a0c2cdb3416bbef72181df4d0fe73f14"+dependencies = [+ "itoa",+ "memchr",+ "serde",+ "serde_core",+ "zmij",+]++[[package]]+name = "shlex"+version = "2.0.1"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba"++[[package]]+name = "simd-adler32"+version = "0.3.10"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "3a219298ac11a56ea9a6d2120044824d6f01aeb034955e7af7bc16858527deea"++[[package]]+name = "smallvec"+version = "1.15.2"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "8ed6a63f02c8539c91a8685a86f4099661ba3da017932f6ebbea6de3f0fa7c90"++[[package]]+name = "stable_deref_trait"+version = "1.2.1"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596"++[[package]]+name = "subtle"+version = "2.6.1"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292"++[[package]]+name = "syn"+version = "2.0.119"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "872831b642d1a07999a962a351ed35b955ea2cfc8f3862091e2a240a84f17297"+dependencies = [+ "proc-macro2",+ "quote",+ "unicode-ident",+]++[[package]]+name = "syn"+version = "3.0.4"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "e6275cddf4610d1775e6d1fe9469b2e77d0f39fd98fb7450901b821e0c53649f"+dependencies = [+ "proc-macro2",+ "quote",+ "unicode-ident",+]++[[package]]+name = "synstructure"+version = "0.13.2"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "728a70f3dbaf5bab7f0c4b1ac8d7ae5ea60a4b5549c8a5914361c99147a709d2"+dependencies = [+ "proc-macro2",+ "quote",+ "syn 2.0.119",+]++[[package]]+name = "tiny_http"+version = "0.12.0"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "389915df6413a2e74fb181895f933386023c71110878cd0825588928e64cdc82"+dependencies = [+ "ascii",+ "chunked_transfer",+ "httpdate",+ "log",+]++[[package]]+name = "tinystr"+version = "0.8.4"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "b1e27c91459209c2986af3dcf603a5a74a4368754ce37414f59acc971167f643"+dependencies = [+ "displaydoc",+ "zerovec",+]++[[package]]+name = "unicode-ident"+version = "1.0.24"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75"++[[package]]+name = "untrusted"+version = "0.9.0"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1"++[[package]]+name = "ureq"+version = "2.12.1"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "02d1a66277ed75f640d608235660df48c8e3c19f3b4edb6a263315626cc3c01d"+dependencies = [+ "base64",+ "log",+ "once_cell",+ "rustls",+ "rustls-pki-types",+ "serde",+ "serde_json",+ "url",+ "webpki-roots 0.26.11",+]++[[package]]+name = "url"+version = "2.5.8"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "ff67a8a4397373c3ef660812acab3268222035010ab8680ec4215f38ba3d0eed"+dependencies = [+ "form_urlencoded",+ "idna",+ "percent-encoding",+ "serde",+]++[[package]]+name = "utf8_iter"+version = "1.0.4"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be"++[[package]]+name = "wasi"+version = "0.11.1+wasi-snapshot-preview1"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b"++[[package]]+name = "webpki-roots"+version = "0.26.11"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "521bc38abb08001b01866da9f51eb7c5d647a19260e00054a8c7fd5f9e57f7a9"+dependencies = [+ "webpki-roots 1.0.9",+]++[[package]]+name = "webpki-roots"+version = "1.0.9"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "7dcd9d09a39985f5344844e66b0c530a33843579125f23e21e9f0f220850f22a"+dependencies = [+ "rustls-pki-types",+]++[[package]]+name = "windows-sys"+version = "0.52.0"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d"+dependencies = [+ "windows-targets",+]++[[package]]+name = "windows-targets"+version = "0.52.6"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973"+dependencies = [+ "windows_aarch64_gnullvm",+ "windows_aarch64_msvc",+ "windows_i686_gnu",+ "windows_i686_gnullvm",+ "windows_i686_msvc",+ "windows_x86_64_gnu",+ "windows_x86_64_gnullvm",+ "windows_x86_64_msvc",+]++[[package]]+name = "windows_aarch64_gnullvm"+version = "0.52.6"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3"++[[package]]+name = "windows_aarch64_msvc"+version = "0.52.6"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469"++[[package]]+name = "windows_i686_gnu"+version = "0.52.6"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b"++[[package]]+name = "windows_i686_gnullvm"+version = "0.52.6"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66"++[[package]]+name = "windows_i686_msvc"+version = "0.52.6"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66"++[[package]]+name = "windows_x86_64_gnu"+version = "0.52.6"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78"++[[package]]+name = "windows_x86_64_gnullvm"+version = "0.52.6"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d"++[[package]]+name = "windows_x86_64_msvc"+version = "0.52.6"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec"++[[package]]+name = "writeable"+version = "0.6.4"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "3ad82d2a33cdc9674dc7465672f271e096168fcdbe0f799d9e6db8c5892679dc"++[[package]]+name = "yoke"+version = "0.8.3"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "709fe23a0424b6a435d82152b1bd3fdfb0833487d5fa90d05d42762a9891fef5"+dependencies = [+ "stable_deref_trait",+ "yoke-derive",+ "zerofrom",+]++[[package]]+name = "yoke-derive"+version = "0.8.2"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "de844c262c8848816172cef550288e7dc6c7b7814b4ee56b3e1553f275f1858e"+dependencies = [+ "proc-macro2",+ "quote",+ "syn 2.0.119",+ "synstructure",+]++[[package]]+name = "zerofrom"+version = "0.1.8"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "0ec05a11813ea801ff6d75110ad09cd0824ddba17dfe17128ea0d5f68e6c5272"+dependencies = [+ "zerofrom-derive",+]++[[package]]+name = "zerofrom-derive"+version = "0.1.7"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "11532158c46691caf0f2593ea8358fed6bbf68a0315e80aae9bd41fbade684a1"+dependencies = [+ "proc-macro2",+ "quote",+ "syn 2.0.119",+ "synstructure",+]++[[package]]+name = "zeroize"+version = "1.9.0"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "e13c156562582aa81c60cb29407084cdb54c4164760106ab78e6c5b0858cf64e"++[[package]]+name = "zerotrie"+version = "0.2.5"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "4ea269c3bd32f0a32c321907a2ae912ba6f4649bb0fc764a15627e99a7095a3f"+dependencies = [+ "displaydoc",+ "yoke",+ "zerofrom",+]++[[package]]+name = "zerovec"+version = "0.11.8"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "bb0464e17806c1d976d5cba29399c7f08e516e279e2ba493f63123b5fca67dd8"+dependencies = [+ "yoke",+ "zerofrom",+ "zerovec-derive",+]++[[package]]+name = "zerovec-derive"+version = "0.11.6"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "34df6fc39dbd26ddc9c10e6a2984476e13acce22e64e4487636ef494369225da"+dependencies = [+ "proc-macro2",+ "quote",+ "syn 3.0.4",+]++[[package]]+name = "zip"+version = "0.6.6"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "760394e246e4c28189f19d488c058bf16f564016aefac5d32bb1f3b51d5e9261"+dependencies = [+ "byteorder",+ "crc32fast",+ "crossbeam-utils",+ "flate2",+]++[[package]]+name = "zmij"+version = "1.0.23"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b"
bridge-rs/Cargo.toml+6−1
@@ -9,7 +9,12 @@ license = "MIT" tiny_http = "0.12" serde = { version = "1", features = ["derive"] } serde_json = "1"-ureq = { version = "2", default-features = false, features = ["json"] }+# tls: the a365 lane speaks HTTPS to auth.altium.com / the workspace; loopback ab calls are unaffected+ureq = { version = "2", default-features = false, features = ["json", "tls"] }+# PKCE verifier entropy (a365 OAuth) — never roll your own randomness for a secret+getrandom = "0.2"+# install_library_bundle unpacks staged zips; deflate only (pure Rust), no bzip2 C dependency+zip = { version = "0.6", default-features = false, features = ["deflate"] }  [profile.release] opt-level = "z"
bridge-rs/src/a365.rsadded+592
@@ -0,0 +1,592 @@+//! Altium 365: OAuth/PKCE (the public-client ActionWait flow from Altium's+//! `AltiumDeveloper/a365-auth` spec) and API primitives (GraphQL, Files Service upload).+//! Deliberately no Altium Designer dependency: the cloud lane works while X2.exe is closed.+//! Tokens live in the bridge cache (a365.json) and NEVER travel in verb responses.+use crate::agent;+use serde_json::{json, Value};+use std::sync::Mutex;+use std::time::Duration;++const AUTH_BASE: &str = "https://auth.altium.com";+const ACTIONWAIT_ENDPOINT: &str = "https://actionwait.altium.com/await";++fn token_endpoint() -> String { format!("{AUTH_BASE}/connect/token") }+fn revocation_endpoint() -> String { format!("{AUTH_BASE}/connect/revocation") }+fn redirect_uri() -> String { format!("{AUTH_BASE}/api/AuthComplete") }++// ---- config store ------------------------------------------------------------++fn cfg_path() -> std::path::PathBuf {+    agent::cache_dir().join("a365.json")+}++fn load() -> Value {+    std::fs::read_to_string(cfg_path()).ok()+        .and_then(|s| serde_json::from_str(&s).ok())+        .unwrap_or_else(|| json!({}))+}++/// Atomic local-only persistence; tokens never travel in verb responses.+fn save(cfg: &Value) {+    let p = cfg_path();+    let _ = std::fs::create_dir_all(p.parent().unwrap_or(std::path::Path::new(".")));+    let tmp = p.with_extension("tmp");+    if std::fs::write(&tmp, cfg.to_string()).is_ok() {+        let _ = std::fs::rename(&tmp, &p);+    }+}++fn access_token(cfg: &Value) -> Option<String> {+    cfg.get("token").or_else(|| cfg.get("accessToken")).and_then(|t| t.as_str()).map(str::to_string)+}++// ---- crypto / encoding helpers -----------------------------------------------++fn b64url(raw: &[u8]) -> String {+    const T: &[u8; 64] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_";+    let mut out = String::with_capacity(raw.len().div_ceil(3) * 4);+    for chunk in raw.chunks(3) {+        let b = [chunk[0], *chunk.get(1).unwrap_or(&0), *chunk.get(2).unwrap_or(&0)];+        let n = ((b[0] as u32) << 16) | ((b[1] as u32) << 8) | b[2] as u32;+        out.push(T[(n >> 18) as usize & 63] as char);+        out.push(T[(n >> 12) as usize & 63] as char);+        if chunk.len() > 1 { out.push(T[(n >> 6) as usize & 63] as char); }+        if chunk.len() > 2 { out.push(T[n as usize & 63] as char); }+    }+    out+}++fn b64url_decode(s: &str) -> Option<Vec<u8>> {+    let val = |c: u8| -> Option<u32> {+        Some(match c {+            b'A'..=b'Z' => (c - b'A') as u32,+            b'a'..=b'z' => (c - b'a' + 26) as u32,+            b'0'..=b'9' => (c - b'0' + 52) as u32,+            b'-' | b'+' => 62,+            b'_' | b'/' => 63,+            _ => return None,+        })+    };+    let bytes: Vec<u8> = s.bytes().filter(|b| *b != b'=').collect();+    let mut out = Vec::with_capacity(bytes.len() * 3 / 4);+    for chunk in bytes.chunks(4) {+        let mut n: u32 = 0;+        for (i, b) in chunk.iter().enumerate() {+            n |= val(*b)? << (18 - 6 * i);+        }+        out.push((n >> 16) as u8);+        if chunk.len() > 2 { out.push((n >> 8) as u8); }+        if chunk.len() > 3 { out.push(n as u8); }+    }+    Some(out)+}++fn sha256_raw(data: &[u8]) -> Vec<u8> {+    let hex = crate::lifecycle::sha256_hex(data);+    (0..hex.len()).step_by(2).map(|i| u8::from_str_radix(&hex[i..i + 2], 16).unwrap_or(0)).collect()+}++fn random_bytes(n: usize) -> Result<Vec<u8>, String> {+    let mut buf = vec![0u8; n];+    getrandom::getrandom(&mut buf).map_err(|e| format!("no OS entropy: {e}"))?;+    Ok(buf)+}++fn uuid4() -> Result<String, String> {+    let b = random_bytes(16)?;+    Ok(format!(+        "{:02x}{:02x}{:02x}{:02x}-{:02x}{:02x}-4{:x}{:02x}-{:x}{:x}{:02x}-{:02x}{:02x}{:02x}{:02x}{:02x}{:02x}",+        b[0], b[1], b[2], b[3], b[4], b[5], b[6] & 0x0f, b[7], 8 + (b[8] & 3), b[8] & 0x0f, b[9],+        b[10], b[11], b[12], b[13], b[14], b[15]+    ))+}++fn urlencode(s: &str) -> String {+    s.bytes().map(|b| match b {+        b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'_' | b'.' | b'~' => (b as char).to_string(),+        b' ' => "+".into(),+        _ => format!("%{b:02X}"),+    }).collect()+}++/// Non-sensitive JWT metadata for display only; never for authorization.+fn unverified_claim_summary(access_token: &str) -> Value {+    let part = match access_token.split('.').nth(1) {+        Some(p) => p,+        None => return json!({}),+    };+    let claims: Value = match b64url_decode(part).and_then(|b| serde_json::from_slice(&b).ok()) {+        Some(c) => c,+        None => return json!({}),+    };+    let mut out = serde_json::Map::new();+    for k in ["iss", "client_id", "workspaceId", "scope", "exp"] {+        if let Some(v) = claims.get(k) {+            out.insert(k.into(), v.clone());+        }+    }+    Value::Object(out)+}++// ---- HTTP helpers ------------------------------------------------------------++/// (status, parsed JSON body). Network failure -> Err(message).+fn request_json(req: ureq::Request, body: RequestBody) -> Result<(u16, Value), String> {+    let resp = match body {+        RequestBody::Form(fields) => {+            let pairs: Vec<(&str, &str)> = fields.iter().map(|(k, v)| (*k, v.as_str())).collect();+            req.send_form(&pairs)+        }+        RequestBody::Json(v) => req.send_json(v),+        RequestBody::Bytes(b) => req.send_bytes(&b),+    };+    let (status, text) = match resp {+        Ok(r) => (r.status(), r.into_string().unwrap_or_default()),+        Err(ureq::Error::Status(code, r)) => (code, r.into_string().unwrap_or_default()),+        Err(e) => return Err(format!("Altium request failed: {e}")),+    };+    let data = if text.is_empty() { json!({}) } else {+        serde_json::from_str(&text).unwrap_or_else(|_| json!({"_raw": text}))+    };+    Ok((status, data))+}++enum RequestBody {+    Form(Vec<(&'static str, String)>),+    Json(Value),+    Bytes(Vec<u8>),+}++// ---- OAuth state -------------------------------------------------------------++static OAUTH: Mutex<Option<Value>> = Mutex::new(None);+static OAUTH_CANCELLED: std::sync::atomic::AtomicBool = std::sync::atomic::AtomicBool::new(false);++fn oauth_state() -> Value {+    OAUTH.lock().ok().and_then(|g| g.clone()).unwrap_or_else(|| json!({"status": "idle"}))+}++fn set_oauth(v: Value) {+    if let Ok(mut g) = OAUTH.lock() {+        *g = Some(v);+    }+}++// ---- verbs -------------------------------------------------------------------++/// Store the workspace domain + a Bearer token (created in the workspace web UI under+/// Admin → Developer on plans that expose it).+pub fn configure(args: &Value) -> Value {+    let mut cfg = load();+    for k in ["workspace", "token"] {+        if let Some(v) = args.get(k).and_then(|x| x.as_str()) {+            cfg[k] = json!(v);+        }+    }+    if cfg.get("workspace").and_then(|w| w.as_str()).unwrap_or("").is_empty() {+        return json!({"success": false, "error": "workspace is required, e.g. 'john-john-3.365.altium.com'"});+    }+    save(&cfg);+    json!({"success": true, "workspace": cfg.get("workspace"), "hasToken": cfg.get("token").map(|t| !t.is_null()).unwrap_or(false),+        "_hint": "altium_a365_status to verify; altium_a365_query {query} to run GraphQL."})+}++pub fn status(_args: &Value) -> Value {+    let cfg = load();+    let ws = cfg.get("workspace").and_then(|w| w.as_str()).map(str::to_string);+    let mut out = json!({"success": true, "configured": ws.is_some(), "workspace": ws,+        "hasToken": access_token(&cfg).is_some(),+        "hasRefreshToken": cfg.get("refreshToken").map(|t| !t.is_null()).unwrap_or(false),+        "clientConfigured": cfg.get("clientId").map(|t| !t.is_null()).unwrap_or(false),+        "endpoint": cfg.get("workspace").and_then(|w| w.as_str()).map(|w| format!("https://{w}/svc/napi/gateway/graphql/"))});+    if access_token(&cfg).is_none() {+        out["_hint"] = json!("No access token. Preferred path: register an Altium public OAuth client, then altium_a365_oauth_begin {clientId, workspace}; open authorizeUrl only after the verb returns. Manual Admin → Developer tokens remain supported by altium_a365_configure.");+        return out;+    }+    let r = query(&json!({"query": "{ desProjects(first: 1) { totalCount } }"}));+    out["tokenLive"] = json!(r.get("success").and_then(|b| b.as_bool()).unwrap_or(false));+    out["probe"] = r.get("data").or_else(|| r.get("error")).cloned().unwrap_or(Value::Null);+    out+}++pub fn query(args: &Value) -> Value {+    let cfg = load();+    let ws = cfg.get("workspace").and_then(|w| w.as_str()).unwrap_or("").to_string();+    let token = access_token(&cfg);+    if ws.is_empty() || token.is_none() {+        return json!({"success": false, "errorCode": "a365_not_configured",+            "_hint": "altium_a365_oauth_begin {clientId, workspace} or altium_a365_configure {workspace, token}"});+    }+    let q = match args.get("query").and_then(|x| x.as_str()) {+        Some(q) if !q.is_empty() => q.to_string(),+        _ => return json!({"success": false, "error": "query (GraphQL) is required",+            "_hint": "e.g. { desProjects(first:10){ nodes { name } } }"}),+    };+    let host = ws.trim_start_matches("https://").trim_start_matches("http://").trim_end_matches('/');+    let timeout = args.get("timeout").and_then(|t| t.as_u64()).unwrap_or(60);+    let req = ureq::post(&format!("https://{host}/svc/napi/gateway/graphql/"))+        .timeout(Duration::from_secs(timeout))+        .set("Authorization", &format!("Bearer {}", token.unwrap()));+    let body = json!({"query": q, "variables": args.get("variables").cloned().unwrap_or(json!({}))});+    match request_json(req, RequestBody::Json(body)) {+        Err(e) => json!({"success": false, "errorCode": "a365_network_error", "error": e,+            "_hint": "Check altium_a365_status; refresh or sign in again if the token expired."}),+        Ok((s, _)) if s != 200 => json!({"success": false, "errorCode": "a365_graphql_http",+            "error": format!("GraphQL returned HTTP {s}."),+            "_hint": "Check altium_a365_status; refresh or sign in again if the token expired."}),+        Ok((_, data)) => json!({"success": data.get("errors").is_none(),+            "data": data.get("data"), "errors": data.get("errors"),+            "_hint": "Explore the schema at https://<workspace>/api/voyager."}),+    }+}++/// Start ActionWait BEFORE returning the browser URL (Altium SPEC section 4.4).+pub fn oauth_begin(args: &Value) -> Value {+    let cfg = load();+    let client_id = args.get("clientId").or_else(|| cfg.get("clientId")).and_then(|x| x.as_str()).unwrap_or("").trim().to_string();+    let workspace = args.get("workspace").or_else(|| cfg.get("workspace")).and_then(|x| x.as_str()).unwrap_or("").to_string();+    if workspace.is_empty() {+        return json!({"success": false, "errorCode": "a365_workspace_required",+            "error": "workspace is required, e.g. john-john-3.365.altium.com"});+    }+    if client_id.is_empty() {+        return json!({"success": false, "errorCode": "a365_client_id_required",+            "error": "An Altium-issued OAuth client ID is required.",+            "_hint": "Altium OAuth clients are issued through Developer Center enrollment; do not reuse a first-party client ID."});+    }+    if oauth_state().get("status").and_then(|s| s.as_str()) == Some("waiting") {+        return json!({"success": false, "errorCode": "a365_oauth_in_progress",+            "_hint": "altium_a365_oauth_status; cancel the old flow before starting another."});+    }+    let select_workspace = args.get("selectWorkspace").and_then(|s| s.as_str()).unwrap_or("strict").to_string();+    if !matches!(select_workspace.as_str(), "none" | "strict" | "optional") {+        return json!({"success": false, "errorCode": "a365_bad_workspace_selection",+            "error": "selectWorkspace must be none, strict, or optional."});+    }+    let verifier = match random_bytes(32) {+        Ok(b) => b64url(&b),+        Err(e) => return json!({"success": false, "errorCode": "a365_entropy", "error": e}),+    };+    let challenge = b64url(&sha256_raw(verifier.as_bytes()));+    let wait_token = match uuid4() {+        Ok(u) => u,+        Err(e) => return json!({"success": false, "errorCode": "a365_entropy", "error": e}),+    };+    let mut params = vec![+        ("client_id", client_id.clone()),+        ("response_type", "code".to_string()),+        ("scope", "openid profile offline_access".to_string()),+        ("redirect_uri", redirect_uri()),+        ("code_challenge", challenge),+        ("code_challenge_method", "S256".to_string()),+        ("state", wait_token.clone()),+    ];+    if select_workspace != "none" {+        params.push(("selectWorkspace", select_workspace));+    }+    let qs = params.iter().map(|(k, v)| format!("{k}={}", urlencode(v))).collect::<Vec<_>>().join("&");+    let authorize_url = format!("{AUTH_BASE}/connect/authorize?{qs}");+    let timeout = args.get("timeout").and_then(|t| t.as_u64()).unwrap_or(300);+    OAUTH_CANCELLED.store(false, std::sync::atomic::Ordering::SeqCst);+    set_oauth(json!({"status": "waiting", "workspace": workspace,+        "startedAt": crate::support::now() as i64, "expiresInSec": timeout}));+    let (cid, ws, wt, ver) = (client_id, workspace, wait_token, verifier);+    let _ = std::thread::Builder::new().name("altium-a365-oauth".into()).spawn(move || {+        oauth_worker(&cid, &ws, &wt, &ver, timeout);+    });+    json!({"success": true, "status": "waiting", "workspace": oauth_state().get("workspace"),+        "authorizeUrl": authorize_url,+        "_hint": "ActionWait is listening now. Open authorizeUrl in the user's existing browser, then poll altium_a365_oauth_status. Tokens never appear in verb output."})+}++fn oauth_worker(client_id: &str, workspace: &str, wait_token: &str, verifier: &str, overall_timeout: u64) {+    // Long-poll ActionWait for the one-time code. HTTP 408 = reconnect; 410 = terminal.+    let deadline = std::time::Instant::now() + Duration::from_secs(overall_timeout);+    let code = loop {+        if OAUTH_CANCELLED.load(std::sync::atomic::Ordering::SeqCst) {+            set_oauth(json!({"status": "cancelled"}));+            return;+        }+        if std::time::Instant::now() >= deadline {+            set_oauth(json!({"status": "error", "errorCode": "a365_oauth_timeout", "error": "Timed out waiting for Altium sign-in."}));+            return;+        }+        let req = ureq::post(ACTIONWAIT_ENDPOINT).timeout(Duration::from_secs(75));+        match request_json(req, RequestBody::Json(json!({"token": wait_token}))) {+            Err(e) => {+                set_oauth(json!({"status": "error", "errorCode": "a365_network_error", "error": e}));+                return;+            }+            Ok((408, _)) => continue,+            Ok((410, _)) => {+                set_oauth(json!({"status": "error", "errorCode": "a365_actionwait_gone", "error": "ActionWait token was consumed or superseded."}));+                return;+            }+            Ok((200, data)) => {+                let result = data.get("data").cloned().unwrap_or(Value::Null);+                let code = result.get("code").and_then(|c| c.as_str()).unwrap_or("").to_string();+                let state = result.get("state").and_then(|s| s.as_str()).unwrap_or("").to_string();+                if code.is_empty() || state.is_empty() {+                    set_oauth(json!({"status": "error", "errorCode": "a365_actionwait_shape", "error": "ActionWait response omitted code or state."}));+                    return;+                }+                if state != wait_token {+                    set_oauth(json!({"status": "error", "errorCode": "a365_state_mismatch", "error": "OAuth state mismatch; authorization was rejected."}));+                    return;+                }+                break code;+            }+            Ok((s, _)) => {+                set_oauth(json!({"status": "error", "errorCode": "a365_actionwait_http", "error": format!("ActionWait returned HTTP {s}.")}));+                return;+            }+        }+    };+    // exchange the code+    let req = ureq::post(&token_endpoint()).timeout(Duration::from_secs(60));+    let fields = vec![+        ("grant_type", "authorization_code".to_string()),+        ("code", code),+        ("code_verifier", verifier.to_string()),+        ("redirect_uri", redirect_uri()),+        ("client_id", client_id.to_string()),+    ];+    let tokens = match request_json(req, RequestBody::Form(fields)) {+        Ok((200, data)) if data.get("access_token").is_some() => data,+        Ok((s, data)) => {+            let oe = data.get("error").and_then(|e| e.as_str()).unwrap_or("").to_string();+            set_oauth(json!({"status": "error", "errorCode": "a365_token_exchange_failed",+                "error": format!("Token exchange failed ({}).", if oe.is_empty() { s.to_string() } else { oe })}));+            return;+        }+        Err(e) => {+            set_oauth(json!({"status": "error", "errorCode": "a365_network_error", "error": e}));+            return;+        }+    };+    let at = tokens.get("access_token").and_then(|t| t.as_str()).unwrap_or("").to_string();+    let mut cfg = load();+    cfg["workspace"] = json!(workspace);+    cfg["clientId"] = json!(client_id);+    cfg["token"] = json!(at);+    cfg["accessToken"] = json!(at);+    if let Some(rt) = tokens.get("refresh_token").and_then(|t| t.as_str()) {+        cfg["refreshToken"] = json!(rt);+    }+    cfg["tokenEndpoint"] = json!(token_endpoint());+    cfg["expiresAt"] = json!(crate::support::now() as i64 + tokens.get("expires_in").and_then(|e| e.as_i64()).unwrap_or(0));+    save(&cfg);+    set_oauth(json!({"status": "authorized", "workspace": workspace,+        "claims": unverified_claim_summary(&at)}));+}++pub fn oauth_status(_args: &Value) -> Value {+    let mut state = oauth_state();+    let status = state.get("status").and_then(|s| s.as_str()).unwrap_or("idle").to_string();+    state["success"] = json!(status != "error");+    if status == "waiting" {+        state["_hint"] = json!("Complete the Altium consent page in the browser; ActionWait is already listening.");+    } else if status == "authorized" {+        state["_hint"] = json!("OAuth is live. altium_a365_library_overview proves the workspace-scoped token.");+    }+    state+}++pub fn oauth_cancel(_args: &Value) -> Value {+    if oauth_state().get("status").and_then(|s| s.as_str()) != Some("waiting") {+        return json!({"success": true, "alreadyStopped": true, "status": oauth_state().get("status").cloned().unwrap_or(json!("idle"))});+    }+    OAUTH_CANCELLED.store(true, std::sync::atomic::Ordering::SeqCst);+    json!({"success": true, "status": "cancelling", "_hint": "Poll altium_a365_oauth_status."})+}++pub fn refresh(_args: &Value) -> Value {+    let mut cfg = load();+    let client_id = cfg.get("clientId").and_then(|c| c.as_str()).unwrap_or("").to_string();+    let refresh_token = cfg.get("refreshToken").and_then(|c| c.as_str()).unwrap_or("").to_string();+    if client_id.is_empty() || refresh_token.is_empty() {+        return json!({"success": false, "errorCode": "a365_refresh_not_configured",+            "_hint": "Complete altium_a365_oauth_begin first with offline_access."});+    }+    let endpoint = cfg.get("tokenEndpoint").and_then(|t| t.as_str()).map(str::to_string).unwrap_or_else(token_endpoint);+    let req = ureq::post(&endpoint).timeout(Duration::from_secs(60));+    let fields = vec![+        ("grant_type", "refresh_token".to_string()),+        ("refresh_token", refresh_token),+        ("client_id", client_id),+    ];+    let tokens = match request_json(req, RequestBody::Form(fields)) {+        Ok((200, data)) if data.get("access_token").is_some() => data,+        Ok((s, data)) => {+            let oe = data.get("error").and_then(|e| e.as_str()).unwrap_or("").to_string();+            return json!({"success": false, "errorCode": "a365_refresh_failed",+                "error": format!("Token refresh failed ({}).", if oe.is_empty() { s.to_string() } else { oe })});+        }+        Err(e) => return json!({"success": false, "errorCode": "a365_network_error", "error": e}),+    };+    let at = tokens.get("access_token").and_then(|t| t.as_str()).unwrap_or("").to_string();+    cfg["token"] = json!(at);+    cfg["accessToken"] = json!(at);+    if let Some(rt) = tokens.get("refresh_token").and_then(|t| t.as_str()) {+        cfg["refreshToken"] = json!(rt);+    }+    cfg["expiresAt"] = json!(crate::support::now() as i64 + tokens.get("expires_in").and_then(|e| e.as_i64()).unwrap_or(0));+    save(&cfg);+    json!({"success": true, "workspace": cfg.get("workspace"),+        "claims": unverified_claim_summary(&at),+        "_hint": "Access token refreshed; rotated refresh token (if any) was stored atomically."})+}++/// Revoke the refresh grant and remove all locally cached OAuth tokens.+pub fn logout(_args: &Value) -> Value {+    let mut cfg = load();+    let refresh_token = cfg.get("refreshToken").and_then(|c| c.as_str()).unwrap_or("").to_string();+    let client_id = cfg.get("clientId").and_then(|c| c.as_str()).unwrap_or("").to_string();+    let mut revoked = false;+    if !refresh_token.is_empty() && !client_id.is_empty() {+        let endpoint = cfg.get("revocationEndpoint").and_then(|t| t.as_str()).map(str::to_string).unwrap_or_else(revocation_endpoint);+        let req = ureq::post(&endpoint).timeout(Duration::from_secs(60));+        let fields = vec![+            ("token", refresh_token),+            ("token_type_hint", "refresh_token".to_string()),+            ("client_id", client_id),+        ];+        match request_json(req, RequestBody::Form(fields)) {+            Ok((200, _)) => revoked = true,+            Ok((s, _)) => return json!({"success": false, "errorCode": "a365_revoke_failed",+                "error": format!("Token revocation returned HTTP {s}."),+                "_hint": "The local grant was retained because remote revocation did not succeed."}),+            Err(e) => return json!({"success": false, "errorCode": "a365_network_error", "error": e,+                "_hint": "The local grant was retained because remote revocation did not succeed."}),+        }+    }+    if let Some(o) = cfg.as_object_mut() {+        for k in ["token", "accessToken", "refreshToken", "expiresAt"] {+            o.remove(k);+        }+    }+    save(&cfg);+    json!({"success": true, "revoked": revoked, "workspace": cfg.get("workspace"),+        "_hint": "OAuth tokens were removed from the bridge cache."})+}++/// Upload an opaque mutation input through the documented Files Service.+pub fn files_upload(args: &Value) -> Value {+    let cfg = load();+    let ws = cfg.get("workspace").and_then(|w| w.as_str()).unwrap_or("").to_string();+    let token = access_token(&cfg);+    if ws.is_empty() || token.is_none() {+        return json!({"success": false, "errorCode": "a365_not_configured",+            "_hint": "Complete altium_a365_oauth_begin first."});+    }+    let file_path = match args.get("filePath").and_then(|f| f.as_str()) {+        Some(f) if !f.is_empty() => f.to_string(),+        _ => return json!({"success": false, "errorCode": "a365_file_required", "error": "filePath is required"}),+    };+    let max_bytes = args.get("maxBytes").and_then(|m| m.as_u64()).unwrap_or(100 * 1024 * 1024);+    let meta = match std::fs::metadata(&file_path) {+        Ok(m) if m.is_file() => m,+        _ => return json!({"success": false, "errorCode": "a365_file_not_found",+            "error": format!("Upload file does not exist: {file_path}")}),+    };+    if meta.len() > max_bytes {+        return json!({"success": false, "errorCode": "a365_file_too_large",+            "error": format!("Upload is {} bytes; limit is {max_bytes} bytes.", meta.len())});+    }+    let content = match std::fs::read(&file_path) {+        Ok(c) => c,+        Err(e) => return json!({"success": false, "errorCode": "a365_file_not_found", "error": format!("{e}")}),+    };+    let fname = std::path::Path::new(&file_path).file_name()+        .map(|n| n.to_string_lossy().replace('"', "_"))+        .unwrap_or_else(|| "upload".into());+    let boundary = format!("----adom-a365-{}", b64url(&random_bytes(16).unwrap_or_default()).to_lowercase());+    let mut body = format!(+        "--{boundary}\r\nContent-Disposition: form-data; name=\"file\"; filename=\"{fname}\"\r\nContent-Type: application/octet-stream\r\n\r\n"+    ).into_bytes();+    body.extend_from_slice(&content);+    body.extend_from_slice(format!("\r\n--{boundary}--\r\n").as_bytes());+    let host = ws.trim_start_matches("https://").trim_start_matches("http://").trim_end_matches('/');+    let timeout = args.get("timeout").and_then(|t| t.as_u64()).unwrap_or(120);+    let req = ureq::post(&format!("https://{host}/svc/napi/files/File/Upload"))+        .timeout(Duration::from_secs(timeout))+        .set("Content-Type", &format!("multipart/form-data; boundary={boundary}"))+        .set("Authorization", &format!("Bearer {}", token.unwrap()));+    match request_json(req, RequestBody::Bytes(body)) {+        Err(e) => json!({"success": false, "errorCode": "a365_network_error", "error": e}),+        Ok((200, data)) => {+            let file_id = data.get("_raw").and_then(|r| r.as_str())+                .map(|s| s.trim().trim_matches('"').to_string())+                .or_else(|| data.as_str().map(str::to_string))+                .unwrap_or_else(|| data.to_string().trim_matches('"').to_string());+            if file_id.is_empty() || file_id == "{}" {+                return json!({"success": false, "errorCode": "a365_file_upload_failed", "error": "Files Service returned an empty id."});+            }+            json!({"success": true, "fileId": file_id, "expiresAfterHoursInactive": 24,+                "_hint": "Temporary opaque file ID. Pass it to the matching library mutation; do not parse or persist it."})+        }+        Ok((s, _)) => json!({"success": false, "errorCode": "a365_file_upload_failed",+            "error": format!("Files Service returned HTTP {s}.")}),+    }+}++pub fn library_overview(_args: &Value) -> Value {+    query(&json!({"query": "query GetLibraryOverview { desLibrary { name description updatedAt components { nodes { id name } } footprints { totalCount } symbols { totalCount } } }"}))+}++pub fn library_schema(args: &Value) -> Value {+    let q = "query LibrarySchema {\n  __schema {\n    queryType { fields { name description } }\n    mutationType { fields { name description args { name type { kind name ofType { kind name } } } } }\n  }\n}";+    let result = query(&json!({"query": q, "timeout": args.get("timeout").and_then(|t| t.as_u64()).unwrap_or(60)}));+    if result.get("success").and_then(|b| b.as_bool()) != Some(true) {+        return result;+    }+    let schema = result.get("data").and_then(|d| d.get("__schema")).cloned().unwrap_or(json!({}));+    let pattern = ["lib", "component", "symbol", "footprint", "part", "datasheet"];+    let relevant = |fields: Option<&Value>| -> Vec<Value> {+        fields.and_then(|f| f.as_array()).map(|a| {+            a.iter().filter(|f| {+                let name = f.get("name").and_then(|n| n.as_str()).unwrap_or("").to_lowercase();+                pattern.iter().any(|p| name.contains(p))+            }).cloned().collect()+        }).unwrap_or_default()+    };+    json!({"success": true,+        "queries": relevant(schema.get("queryType").and_then(|t| t.get("fields"))),+        "mutations": relevant(schema.get("mutationType").and_then(|t| t.get("fields"))),+        "_hint": "Read-only schema map. Choose a mutation only after its input types and rollback/delete mutation are understood."})+}++#[cfg(test)]+mod tests {+    use super::*;++    #[test]+    fn b64url_roundtrip_and_jwt_claims() {+        assert_eq!(b64url(b"foobar"), "Zm9vYmFy");+        assert_eq!(b64url_decode("Zm9vYmFy").unwrap(), b"foobar");+        // urlsafe chars+        let raw: Vec<u8> = vec![0xfb, 0xff, 0xbe];+        let enc = b64url(&raw);+        assert!(!enc.contains('+') && !enc.contains('/'));+        assert_eq!(b64url_decode(&enc).unwrap(), raw);+        // a fake JWT: header.payload.sig with payload {"iss":"x","exp":1}+        let payload = b64url(br#"{"iss":"x","exp":1,"secret":"never"}"#);+        let claims = unverified_claim_summary(&format!("h.{payload}.s"));+        assert_eq!(claims["iss"], json!("x"));+        assert!(claims.get("secret").is_none(), "only allow-listed claims may surface");+    }++    #[test]+    fn pkce_challenge_matches_reference() {+        // RFC 7636 appendix B vector+        let verifier = "dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk";+        let challenge = b64url(&sha256_raw(verifier.as_bytes()));+        assert_eq!(challenge, "E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM");+    }++    #[test]+    fn urlencode_is_form_safe() {+        assert_eq!(urlencode("a b+c/d"), "a+b%2Bc%2Fd");+        assert_eq!(urlencode("openid profile offline_access"), "openid+profile+offline_access");+    }+}
bridge-rs/src/agent.rs+113−11
@@ -3,21 +3,99 @@ //!   request.payload  optional multi-line body (statements for eval, specs, ...) //!   response_<rid>.json  the agent's reply; `data.ok` decides success //!   hb.txt  heartbeat the loop refreshes every tick+//!+//! The workspace is PER INSTANCE (`agent/<key>/`): two Altiums polling one inbox means whichever+//! notices first eats the other's request and answers it from the wrong board (measured+//! 2026-09-01 running Designer + Designer Develop for two AI threads). No selected instance =+//! the legacy single-instance folder, so one-Altium users see no change.+use crate::instance; use serde_json::{json, Value}; use std::path::PathBuf; use std::time::{Duration, Instant, SystemTime}; -pub fn workspace_dir() -> PathBuf {+/// Two different questions, two thresholds (measured 2026-08-29):+/// responsive = hb < 3 s (idle-polling right now); alive = hb < 60 s (the loop exists but may be+/// INSIDE a long RunProcess and cannot heartbeat until it returns). Treating "busy" as "dead" and+/// starting a second loop raises "Another script executing now" on the user's screen.+pub const ALIVE_SEC: u64 = 60;+pub const RESPONSIVE_SEC: u64 = 3;++pub fn cache_dir() -> PathBuf {     let local = std::env::var("LOCALAPPDATA").unwrap_or_else(|_| r"C:\Users\Public".into());-    PathBuf::from(local).join("Adom Bridge").join("bridges-cache").join("altium").join("agent")+    PathBuf::from(local).join("Adom Bridge").join("bridges-cache").join("altium")+}++fn agent_root() -> PathBuf {+    cache_dir().join("agent")+}++pub fn workspace_dir() -> PathBuf {+    let root = agent_root();+    let d = match instance::selected().and_then(|i| i.get("key").and_then(|k| k.as_str()).map(str::to_string)) {+        Some(key) => root.join(key),+        None => root,+    };+    let _ = std::fs::create_dir_all(&d);+    d+}++fn hb_age(ws: &std::path::Path) -> Option<Duration> {+    std::fs::metadata(ws.join("hb.txt"))+        .and_then(|m| m.modified())+        .ok()+        .and_then(|t| SystemTime::now().duration_since(t).ok()) }  pub fn is_alive() -> bool {-    let hb = workspace_dir().join("hb.txt");-    match std::fs::metadata(&hb).and_then(|m| m.modified()) {-        Ok(t) => SystemTime::now().duration_since(t).map(|d| d < Duration::from_secs(8)).unwrap_or(false),-        Err(_) => false,+    hb_age(&workspace_dir()).map(|d| d < Duration::from_secs(ALIVE_SEC)).unwrap_or(false)+}++pub fn is_responsive() -> bool {+    hb_age(&workspace_dir()).map(|d| d < Duration::from_secs(RESPONSIVE_SEC)).unwrap_or(false)+}++/// Heartbeat parsed: first line = tick, then k=v (gen=, phase counters...).+pub fn heartbeat() -> Option<Value> {+    let ws = workspace_dir();+    let raw = std::fs::read_to_string(ws.join("hb.txt")).ok()?;+    let age = hb_age(&ws)?;+    let mut out = json!({"ageSec": (age.as_millis() as f64) / 1000.0});+    let mut lines = raw.lines();+    if let Some(first) = lines.next() {+        if let Ok(t) = first.trim().parse::<u64>() {+            out["tick"] = json!(t);+        }     }+    for l in lines {+        if let Some((k, v)) = l.split_once('=') {+            out[k.trim()] = json!(v.trim());+        }+    }+    Some(out)+}++/// Is ANY agent loop alive in ANY workspace (legacy root or any per-instance subfolder)?+/// A `-R` script lane must refuse if so: Altium raises "Another script executing now" for the+/// second script REGARDLESS of which workspace the first one polls. Returns the workspace path+/// of the live loop so the refusal can name it.+pub fn any_agent_alive() -> Option<(PathBuf, f64)> {+    let root = agent_root();+    let mut candidates = vec![root.clone()];+    if let Ok(rd) = std::fs::read_dir(&root) {+        for e in rd.flatten() {+            if e.path().is_dir() {+                candidates.push(e.path());+            }+        }+    }+    for ws in candidates {+        if let Some(age) = hb_age(&ws) {+            if age < Duration::from_secs(ALIVE_SEC) {+                return Some((ws, (age.as_millis() as f64) / 1000.0));+            }+        }+    }+    None }  fn rid() -> String {@@ -27,7 +105,16 @@ fn rid() -> String {  pub fn call(command: &str, params: &[(&str, String)], payload: Option<&str>, timeout: Duration) -> Value {     if !is_alive() {-        return json!({"success": false, "errorCode": "agent_not_running", "_hint": "altium_agent_start (the Python bridge bootstraps the agent; the Rust bridge reuses a running one)"});+        // parity with the Python bridge: bootstrap on demand, so a cold box needs no ceremony+        let started = crate::lifecycle::start(Duration::from_secs(45));+        if started.get("success").and_then(|b| b.as_bool()) != Some(true) {+            let mut out = started;+            if let Some(o) = out.as_object_mut() {+                o.entry("errorCode").or_insert(json!("agent_not_running"));+                o.insert("_hint".into(), json!("the resident agent could not be bootstrapped — see this reply's fields; altium_agent_recover if the engine is halted behind a modal"));+            }+            return out;+        }     }     let ws = workspace_dir();     let id = rid();@@ -41,8 +128,9 @@ pub fn call(command: &str, params: &[(&str, String)], payload: Option<&str>, tim     let inbox = ws.join("request.txt");     let t0 = Instant::now();     while inbox.exists() && t0.elapsed() < timeout {-        std::thread::sleep(Duration::from_millis(20));+        std::thread::sleep(Duration::from_millis(5));     }+    let t_inbox = t0.elapsed().as_millis() as u64;     if inbox.exists() {         return json!({"success": false, "errorCode": "agent_busy", "error": "previous request still in the inbox"});     }@@ -59,9 +147,13 @@ pub fn call(command: &str, params: &[(&str, String)], payload: Option<&str>, tim     if let Err(e) = std::fs::rename(&tmp, &inbox) {         return json!({"success": false, "error": format!("cannot post request: {e}")});     }+    let t_post = t0.elapsed().as_millis() as u64;     let resp = ws.join(format!("response_{id}.json"));+    let mut t_seen: Option<u64> = None;+    let mut parse_retries = 0u32;     while t0.elapsed() < timeout {         if resp.exists() {+            if t_seen.is_none() { t_seen = Some(t0.elapsed().as_millis() as u64); }             if let Ok(raw) = std::fs::read_to_string(&resp) {                 if let Ok(mut v) = serde_json::from_str::<Value>(&raw) {                     let _ = std::fs::remove_file(&resp);@@ -69,15 +161,25 @@ pub fn call(command: &str, params: &[(&str, String)], payload: Option<&str>, tim                     if let Some(o) = v.as_object_mut() {                         o.insert("success".into(), json!(ok));                         o.insert("roundtripMs".into(), json!(t0.elapsed().as_millis() as u64));+                        o.insert("_timing".into(), json!({"inboxWaitMs": t_inbox, "postedAtMs": t_post, "responseSeenAtMs": t_seen, "parseRetries": parse_retries, "totalMs": t0.elapsed().as_millis() as u64}));                     }                     return v;                 }             }-            std::thread::sleep(Duration::from_millis(50));+            parse_retries += 1;+            std::thread::sleep(Duration::from_millis(5));             continue;         }-        std::thread::sleep(Duration::from_millis(10));+        std::thread::sleep(Duration::from_millis(2));     }-    json!({"success": false, "errorCode": "timed_out", "error": format!("agent did not answer within {}s", timeout.as_secs()),+    json!({"success": false, "errorCode": "agent_timeout", "error": format!("agent did not answer within {}s", timeout.as_secs()),            "agentAlive": is_alive(), "_hint": "a slow Altium process, or the engine halted on a modal — altium_check_dialogs / altium_agent_recover"}) }++/// A call that must NOT bootstrap the agent (status probes). Answers agent_not_running instead.+pub fn call_no_autostart(command: &str, params: &[(&str, String)], timeout: Duration) -> Value {+    if !is_alive() {+        return json!({"success": false, "errorCode": "agent_not_running"});+    }+    call(command, params, None, timeout)+}
bridge-rs/src/dialogs.rs+253−77
@@ -1,94 +1,158 @@-//! 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;+//! Modal detection, classification and the STANDING REAPER — ported from the Python bridge's+//! `dialogs.py`. A dialog on Altium's main process FREEZES the resident script loop (the engine is+//! single-threaded), so "the agent stopped answering" and "a modal is up" are the same event.+//!+//! Scoped by PID via the instance snapshot, never by title: a title match once swept up four+//! "RemoteApp security warning" windows that had nothing to do with Altium (2026-09-01) — and+//! pressing buttons in someone else's application is exactly what this bridge must never do.+//! When an instance is selected, the sweep is scoped to it: with two Altiums running, an unscoped+//! sweep lets one AI thread see — and clear — a modal belonging to the other thread's Altium.+use crate::{ab, instance}; use serde_json::{json, Value};+use std::sync::atomic::{AtomicBool, Ordering};+use std::sync::Mutex;+use std::time::Duration; -/// Dialog window classes, from the Python bridge's measured list. NOTE `#32770` is the generic-/// Win32 dialog class — half the desktop uses it — so class alone is NEVER enough to decide a-/// window is Altium's. See `scan()`. const DIALOG_CLASSES: [&str; 5] = ["TMessageForm", "#32770", "TForm", "TMessageDialog", "TForm_RunScriptParameters"]; -/// Every dialog owned by an Altium process.-///-/// Scoped by PID, not by title. The first cut of this matched window titles containing "Error" /-/// "Warning" and immediately picked up four **"RemoteApp security warning"** windows that had-/// nothing to do with Altium (2026-09-01, on John's desktop) — which `dismiss_dialogs` would then-/// have clicked OK on. Pressing buttons in someone else's application is exactly the kind of thing-/// this bridge must never do, so: ask ab which pids are Altium (`process_list`), enumerate only-/// those windows (`desktop_enum_windows {pids}`), and let class + "not the main window" decide.-pub fn scan() -> Vec<Value> {-    scan_checked().unwrap_or_default()-}+/// title/body substring -> (category, safe default action or "" = none, meaning).+/// A safeAction of "" means the reaper must NEVER touch it (save prompts carry decisions).+const CATALOG: &[(&str, &str, &str, &str)] = &[+    ("another script executing", "script_busy", "OK",+     "A -R script request arrived while the agent loop (or another script) was resident. Dismiss; route work through the agent."),+    ("continue execution?", "script_runtime_error", "Cancel",+     "A script hit a runtime error. Cancel stops it cleanly; OK continues past the error."),+    ("undeclared identifier", "script_compile_error", "OK",+     "The script failed to compile; the engine is not halted after OK."),+    ("unknown identifier", "script_compile_error", "OK",+     "Compile error in RunScriptText text (statements only, no Var)."),+    ("select item to run", "run_script_picker", "Cancel",+     "Altium could not resolve the -R RunScript target (unit did not compile, or wrong ProcName) and opened its Run Script picker. Cancel it, fix the script."),+    ("another instance of", "second_instance_busy", "OK",+     "A helper X2 could not hand its -R request to the running instance because that instance is busy (a modal is open)."),+    ("do you want to save", "save_prompt", "",+     "Unsaved changes on close. Decide: Yes saves, No discards. Never default this."),+    ("save changes", "save_prompt", "", "Unsaved changes. Decide explicitly."),+    ("could not start vault explorer", "altium_extension_broken", "OK",+     "Altium's own VaultExplorer extension fails to load at startup. NOT caused by the bridge. Informational — dismissing it costs nothing, and the standing reaper does so automatically."),+    ("file type not recognised", "unsupported_document", "OK",+     "Altium was handed a path it does not open. From the bridge that means a bad open call (the bridge refuses unsupported extensions up front); at STARTUP it means Altium is restoring a document from the last session that it can no longer open. Safe to dismiss."),+    ("file type not recognized", "unsupported_document", "OK", "Same as 'not recognised' (US spelling)."),+    ("license maximum usage reached", "licence_seat_stranded", "OK",+     "The seat is held by an instance that never released it — almost always an Altium that was KILLED rather than closed. The licence page will still show Seats 0/1, which looks free and is not. FIX: close every other Altium POLITELY (altium_close, never process_kill), then altium_license {action:'use_license'}. Waiting does not clear it."),+    ("license renewal failed", "licence_seat_stranded", "OK",+     "Altium could not renew the On-Demand lease. Same cause and fix as 'maximum usage reached': a killed instance is still holding the seat. This dialog RE-FIRES on a timer until the seat is reclaimed, so clearing it is not the fix."),+    ("cannot focus a disabled or invisible window", "altium_internal", "Send and Close",+     "Altium's own crash-report dialog, usually raised when a window's state changed while it was focusing a document view. Its only button SENDS a report to Altium — try the title-bar X first if the user's data matters."),+    ("unsaved changes", "save_prompt", "",+     "Names the document(s) at risk — READ IT. On a shared machine the document may belong to ANOTHER AI thread; Save preserves their work, Don't Save destroys it. Never default this."),+    ("license", "licence", "", "Licence prompt — read it; usually informational."),+    ("trial", "licence", "", "Trial notice."),+]; -/// `Err(reason)` when we could not LOOK — which must never be reported to a user as "clean".-/// (2026-09-01: this called `process_list {nameContains}`, which ab rejects as bad_args. The error-/// yielded an empty pid list, the empty list yielded an empty dialog list, and `check_dialogs`-/// cheerfully answered "clean" while a Warning sat on John's screen. A failed look and an empty-/// screen must not share a return value.)-pub fn scan_checked() -> Result<Vec<Value>, String> {-    let pl = ab::call("process_list", json!({"nameFilter": "X2"}), &[], 15);-    if pl.get("success").and_then(|b| b.as_bool()) == Some(false) {-        return Err(format!("process_list failed: {}", pl.get("error").and_then(|e| e.as_str()).unwrap_or("unknown")));+pub fn classify(title: &str, body: &str) -> (String, Option<String>, String) {+    let hay = format!("{title} {body}").to_lowercase();+    for (needle, cat, action, meaning) in CATALOG {+        if hay.contains(needle) {+            let act = if action.is_empty() { None } else { Some(action.to_string()) };+            return (cat.to_string(), act, meaning.to_string());+        }     }-    let procs = pl.get("processes").and_then(|a| a.as_array()).cloned().unwrap_or_default();-    let pids: Vec<u64> = procs.iter().filter_map(|p| p.get("pid").and_then(|v| v.as_u64())).collect();-    if pids.is_empty() {-        return Ok(Vec::new()); // Altium genuinely is not running+    ("unknown".into(), None, "Not in the catalog — read the text and decide; never blind-dismiss.".into())+}++/// The painted message via UIA (Altium's TMessageForm has no Static children; WM_GETTEXT sees+/// only button labels, which is why classification must go through ab's desktop_ui_text).+fn dialog_text(hwnd: i64) -> String {+    let r = ab::call("desktop_ui_text", json!({"hwnd": hwnd}), &[], 10);+    match r.get("text").or_else(|| r.get("texts")) {+        Some(Value::String(s)) => s.clone(),+        Some(Value::Array(a)) => a.iter().filter_map(|x| x.as_str()).collect::<Vec<_>>().join(" "),+        _ => String::new(),     }-    let r = ab::call("desktop_enum_windows", json!({"pids": pids}), &[], 20);-    if r.get("success").and_then(|b| b.as_bool()) == Some(false) {-        return Err(format!("desktop_enum_windows failed: {}", r.get("error").and_then(|e| e.as_str()).unwrap_or("unknown")));+}++fn buttons(hwnd: i64) -> Vec<String> {+    let r = ab::call("desktop_find_controls", json!({"hwnd": hwnd, "role": "button"}), &[], 10);+    r.get("controls")+        .and_then(|a| a.as_array())+        .map(|a| {+            a.iter()+                .filter_map(|c| c.get("text").or_else(|| c.get("name")).and_then(|t| t.as_str()))+                .map(|t| t.replace('&', ""))+                .collect()+        })+        .unwrap_or_default()+}++/// Every dialog owned by an Altium process — classified, instance-scoped when one is selected.+pub fn scan() -> Vec<Value> {+    scan_scoped(false)+}++/// `main_pid_only` keeps only dialogs owned by the selected/first MAIN instance's process —+/// the ones that freeze the agent (helper X2 dialogs are flagged, not blocking).+pub fn scan_scoped(main_pid_only: bool) -> Vec<Value> {+    scan_impl(main_pid_only, instance::selected().and_then(|i| i.get("pid").and_then(|p| p.as_u64())))+}++fn scan_impl(main_pid_only: bool, sel_pid: Option<u64>) -> Vec<Value> {+    let mut wins = instance::altium_windows();+    if let Some(pid) = sel_pid {+        wins.retain(|w| w.get("pid").and_then(|p| p.as_u64()) == Some(pid));     }-    let wins = r.get("windows").and_then(|a| a.as_array()).cloned().unwrap_or_default();+    let main = wins.iter().find(|w| w.get("kind").and_then(|k| k.as_str()) == Some("main")).cloned();+    let main_pid = main.as_ref().and_then(|m| m.get("pid").and_then(|p| p.as_u64()));+    let main_disabled = main+        .as_ref()+        .map(|m| m.get("enabled").and_then(|b| b.as_bool()) == Some(false))+        .unwrap_or(false);     let mut out = Vec::new();     for w in wins {-        let title = w.get("title").and_then(|s| s.as_str()).unwrap_or("").to_string();-        let class = w.get("className").and_then(|s| s.as_str()).unwrap_or("").to_string();+        if w.get("kind").and_then(|k| k.as_str()) == Some("main") {+            continue;+        }+        let class = w.get("className").and_then(|s| s.as_str()).unwrap_or("");+        let kind = w.get("kind").and_then(|k| k.as_str()).unwrap_or("other");+        let pid = w.get("pid").and_then(|p| p.as_u64());+        // Any enabled top-level window while the MAIN window is disabled IS the modal, whatever+        // its class (measured: TForm_RunScriptParameters "Select Item To Run").+        let modal_by_state = main_disabled+            && w.get("enabled").and_then(|b| b.as_bool()).unwrap_or(true)+            && pid == main_pid+            && !matches!(class, "TClientBarControl" | "TThemedNotificationPopup" | "TAnimatedNotificationControl");+        if !DIALOG_CLASSES.iter().any(|c| c.eq_ignore_ascii_case(class))+            && !matches!(kind, "dialog" | "script_error_modal")+            && !modal_by_state+        {+            continue;+        }         let hwnd = match w.get("hwnd").and_then(|h| h.as_i64()) {             Some(h) => h,             None => continue,         };-        let visible = w.get("visible").and_then(|b| b.as_bool()).unwrap_or(true);-        if !visible || title.is_empty() || is_main_window(&title) {-            continue;-        }-        if !DIALOG_CLASSES.iter().any(|c| c.eq_ignore_ascii_case(&class)) {+        let title = w.get("title").and_then(|s| s.as_str()).unwrap_or("").to_string();+        let btns = buttons(hwnd);+        let body = dialog_text(hwnd);+        let (category, safe_action, meaning) = classify(&title, &body);+        let owned_by_main = main_pid.is_some() && pid == main_pid;+        if main_pid_only && !owned_by_main {             continue;         }-        out.push(json!({"hwnd": hwnd, "title": title, "className": class,-                        "pid": w.get("pid").cloned().unwrap_or(Value::Null),-                        "text": dialog_text(hwnd), "buttons": buttons(hwnd)}));+        out.push(json!({"hwnd": hwnd, "pid": pid, "title": title, "className": class,+                        "body": body, "buttons": btns,+                        "category": category, "safeAction": safe_action, "meaning": meaning,+                        "ownedByMainInstance": owned_by_main,+                        "rect": w.get("rect").cloned().unwrap_or(Value::Null)}));     }-    Ok(out)-}--/// Windows that merely CONTAIN the app name are the app itself, never a modal.-fn is_main_window(title: &str) -> bool {-    crate::health::parse_title(title).is_some()+    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 a dialog through UIA (no focus steal, no cursor).+pub fn press(hwnd: i64, button: &str) -> Value {+    let r = ab::call("desktop_ui_click", json!({"hwnd": hwnd, "name": button, "role": "button"}), &[], 15);+    let ok = r.get("success").and_then(|b| b.as_bool()).unwrap_or(false);+    json!({"success": ok, "clicked": button, "hwnd": hwnd, "via": "uia", "ab": r}) }  /// Press a named button on every detected dialog. `only_titles` narrows it (e.g. just "Error").@@ -102,14 +166,126 @@ pub fn dismiss(button: &str, only_titles: Option<&[String]>) -> Value {                 continue;             }         }+        if let Some(hwnd) = d.get("hwnd").and_then(|h| h.as_i64()) {+            let r = press(hwnd, button);+            pressed.push(json!({"title": title, "hwnd": hwnd, "button": button,+                                "ok": r.get("success").cloned().unwrap_or(json!(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."})+}++// ---- the standing reaper (John, 2026-09-01: "always pre-empt these from ever showing up") ----+//+// Runs continuously from bridge start and presses the catalog's SAFE action the moment a known+// nuisance appears — no verb needs to be in flight (Altium's own startup warnings appear when+// nothing else would ever catch them). HARD RULES: only catalog-safe actions — never `unknown`,+// never a save prompt, never anything naming a document; one press per hwnd; everything logged.++static REAPED: Mutex<Vec<Value>> = Mutex::new(Vec::new());+static REAPED_HWNDS: Mutex<Vec<i64>> = Mutex::new(Vec::new());+static REAPER_RUNNING: AtomicBool = AtomicBool::new(false);++pub fn reaped_log(limit: usize) -> Vec<Value> {+    REAPED+        .lock()+        .map(|g| g.iter().rev().take(limit).rev().cloned().collect())+        .unwrap_or_default()+}++pub fn reap_once() -> usize {+    let mut n = 0;+    // The reaper is a background service: it sweeps EVERY Altium pid, deliberately ignoring the+    // per-verb instance selection (mutating that global from this thread would race a mid-flight+    // verb). Catalog-safe actions carry no decisions, so acting on another thread's nuisance+    // dialog helps rather than harms.+    let dialogs = scan_impl(false, None);+    for d in dialogs {+        let mut act = d.get("safeAction").and_then(|a| a.as_str()).map(str::to_string);+        let mut cat = d.get("category").and_then(|c| c.as_str()).unwrap_or("unknown").to_string();+        // Altium PAINTS its dialog messages: when even UIA text is empty, classification cannot+        // see them. What CAN be trusted is the button set: a dialog whose ONLY button is OK+        // carries NO DECISION — dismissing it can acknowledge, never choose. Every dialog that+        // asks something has >= 2 buttons.+        let labels: Vec<String> = d+            .get("buttons")+            .and_then(|b| b.as_array())+            .map(|a| a.iter().filter_map(|x| x.as_str()).map(|s| s.trim().to_lowercase()).collect())+            .unwrap_or_default();+        let lone_ok = labels == ["ok"];+        if act.is_none() && lone_ok {+            act = Some("OK".into());+            cat = "single_ok_acknowledgement".into();+        }+        let act = match act {+            Some(a) if cat != "unknown" && cat != "save_prompt" => a,+            _ => continue,+        };+        if cat != "single_ok_acknowledgement" && labels.len() > 1 && !labels.contains(&act.to_lowercase()) {+            continue; // the catalog's action is not even on this dialog — do not improvise+        }         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)}));+        if REAPED_HWNDS.lock().map(|g| g.contains(&hwnd)).unwrap_or(true) {+            continue;+        }+        let ok = press(hwnd, &act).get("success").and_then(|b| b.as_bool()).unwrap_or(false);+        if let Ok(mut g) = REAPED_HWNDS.lock() {+            g.push(hwnd);+            let len = g.len();+            if len > 500 {+                g.drain(..len - 500);+            }+        }+        if let Ok(mut g) = REAPED.lock() {+            g.push(json!({"at": std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).map(|x| x.as_secs()).unwrap_or(0),+                          "title": d.get("title").cloned().unwrap_or(Value::Null), "category": cat,+                          "pressed": act, "ok": ok,+                          "note": "pre-empted by the standing reaper — the user never had to deal with it"}));+            let len = g.len();+            if len > 200 {+                g.drain(..len - 200);+            }+        }+        n += 1;+    }+    n+}++/// Start the standing reaper. Idempotent.+pub fn start_reaper() -> bool {+    if REAPER_RUNNING.swap(true, Ordering::SeqCst) {+        return false;+    }+    std::thread::Builder::new()+        .name("altium-dialog-reaper".into())+        .spawn(|| loop {+            let _ = std::panic::catch_unwind(|| reap_once());+            std::thread::sleep(Duration::from_millis(1500));+        })+        .is_ok()+}++#[cfg(test)]+mod tests {+    use super::classify;++    #[test]+    fn catalog_classification() {+        let (cat, act, _) = classify("Warning", "Could not start Vault Explorer because ...");+        assert_eq!(cat, "altium_extension_broken");+        assert_eq!(act.as_deref(), Some("OK"));+        let (cat, act, _) = classify("Confirm", "Do you want to save changes to Board1.PcbDoc?");+        assert_eq!(cat, "save_prompt");+        assert!(act.is_none(), "a save prompt must never carry a safe action");+        let (cat, act, _) = classify("Information", "License renewal failed. ...");+        assert_eq!(cat, "licence_seat_stranded");+        assert_eq!(act.as_deref(), Some("OK"));+        let (cat, act, _) = classify("Something", "never seen before");+        assert_eq!(cat, "unknown");+        assert!(act.is_none());     }-    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/health.rs+6−39
@@ -1,5 +1,5 @@ //! /health — Altium detection through ab's window enumeration (no Win32 in the bridge).-use crate::{ab, agent, BRIDGE_VERSION};+use crate::{agent, BRIDGE_VERSION}; use serde_json::{json, Value};  /// Parse "<doc> - Altium Designer[ Develop][ (26.9.1)][ - Trial]" (issue #2: edition without parens).@@ -33,53 +33,20 @@ pub fn parse_title(t: &str) -> Option<Value> {     Some(json!({"edition": edition, "adVersion": version, "licenceState": lic, "document": doc})) } -static CACHE: std::sync::Mutex<Option<(std::time::Instant, Value)>> = std::sync::Mutex::new(None);--/// Licence/window state via ab, cached 5 s: an ab round trip costs ~400-800 ms and every verb gates on it.+/// Licence/window state — the instance layer's view (instance-aware, Develop absent-tail rule,+/// snapshot-cached). Kept here as the name every verb historically gates on. pub fn licence_state() -> Value {-    if let Ok(g) = CACHE.lock() {-        if let Some((t, v)) = g.as_ref() {-            if t.elapsed() < std::time::Duration::from_secs(5) { return v.clone(); }-        }+    let v = crate::instance::licence_state();+    if v.get("running").and_then(|b| b.as_bool()) == Some(false) && crate::instance::ab_unreachable() {+        return json!({"running": false, "abUnreachable": true});     }-    let v = licence_state_uncached();-    if let Ok(mut g) = CACHE.lock() { *g = Some((std::time::Instant::now(), v.clone())); }     v } -fn licence_state_uncached() -> Value {-    let r = ab::call("desktop_find_window", json!({"titleContains": "Altium Designer"}), &[], 8);-    let wins: Vec<Value> = r.get("windows").and_then(|m| m.as_array()).cloned()-        .or_else(|| r.get("matches").and_then(|m| m.as_array()).cloned())-        .or_else(|| r.get("best").filter(|b| b.is_object()).map(|b| vec![b.clone()]))-        .unwrap_or_default();-    for w in wins {-        if let Some(t) = w.get("title").and_then(|t| t.as_str()) {-            if let Some(mut p) = parse_title(t) {-                let lic = p.get("licenceState").and_then(|l| l.as_str()).map(|s| s.to_string());-                let licensed = lic.as_deref().map(|l| l != "No License");-                if let Some(o) = p.as_object_mut() {-                    o.insert("running".into(), json!(true));-                    o.insert("hwnd".into(), w.get("hwnd").cloned().unwrap_or(Value::Null));-                    o.insert("licensed".into(), json!(licensed));-                    o.insert("state".into(), json!(lic));-                }-                return p;-            }-        }-    }-    let ab_down = r.get("errorCode").and_then(|c| c.as_str()) == Some("ab_unreachable");-    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/instance.rsadded+363
@@ -0,0 +1,363 @@+//! More than one Altium can run at once (Designer beside Designer Develop, often driven by two+//! different AI threads). Everything here treats "the Altium" as a CHOICE, not a given — ported+//! from the Python bridge's `altium_detect.py`, where three single-instance assumptions were each+//! a live bug within an hour of running two Altiums (hardcoded exe path, first-main-window licence+//! gate, unscoped dialog sweep).+//!+//! All window/process facts come from ab (`process_list`, `desktop_enum_windows`); the bridge+//! re-implements no Win32.+use crate::ab;+use crate::health::parse_title;+use serde_json::{json, Value};+use std::sync::Mutex;+use std::time::{Duration, Instant};++/// The instance the CURRENT verb acts on. Set once per dispatch (like Python's+/// `agent.use_instance`); background services must not rely on it.+static SELECTED: Mutex<Option<Value>> = Mutex::new(None);++pub fn select(inst: Option<Value>) {+    if let Ok(mut g) = SELECTED.lock() {+        *g = inst;+    }+}++pub fn selected() -> Option<Value> {+    SELECTED.lock().ok().and_then(|g| g.clone())+}++/// Stable per-instance key for the agent workspace folder: the install folder name ("AD26",+/// "ADDevelop") survives a restart, which a pid does not. Same-install twins fall back to pid.+pub fn key_for(exe: Option<&str>, pid: u64) -> String {+    if let Some(e) = exe {+        let norm = e.replace('/', "\\");+        let parts: Vec<&str> = norm.split('\\').filter(|q| !q.is_empty()).collect();+        if parts.len() >= 2 {+            return parts[parts.len() - 2].to_string();+        }+    }+    format!("pid{pid}")+}++/// Executable path out of ab's reported command line (never a constant: Develop and Designer+/// live in different install folders, and a -R helper must target the SELECTED instance's image).+pub fn exe_from_command_line(cl: &str) -> Option<String> {+    let cl = cl.trim();+    if cl.is_empty() {+        return None;+    }+    if let Some(rest) = cl.strip_prefix('"') {+        return rest.split('"').next().map(str::to_string);+    }+    cl.split(' ').next().map(str::to_string)+}++// ---- cached window snapshot --------------------------------------------------+// Two ab round trips cost ~1 s and half a dozen helpers want the same answer inside one verb.++struct Snapshot {+    at: Instant,+    procs: Vec<Value>,+    windows: Vec<Value>,+    ab_unreachable: bool,+}++static SNAP: Mutex<Option<Snapshot>> = Mutex::new(None);+const SNAP_TTL: Duration = Duration::from_secs(2);++/// Was ab reachable on the freshest snapshot? (/health must tell "Altium not running" apart+/// from "cannot even ask".)+pub fn ab_unreachable() -> bool {+    let _ = snapshot();+    SNAP.lock().ok().and_then(|g| g.as_ref().map(|s| s.ab_unreachable)).unwrap_or(false)+}++fn snapshot() -> (Vec<Value>, Vec<Value>) {+    if let Ok(g) = SNAP.lock() {+        if let Some(s) = g.as_ref() {+            if s.at.elapsed() < SNAP_TTL {+                return (s.procs.clone(), s.windows.clone());+            }+        }+    }+    let pl = ab::call("process_list", json!({"nameFilter": "X2"}), &[], 15);+    let ab_down = pl.get("errorCode").and_then(|c| c.as_str()) == Some("ab_unreachable");+    let procs = pl.get("processes").and_then(|a| a.as_array()).cloned().unwrap_or_default();+    let pids: Vec<u64> = procs.iter().filter_map(|p| p.get("pid").and_then(|v| v.as_u64())).collect();+    let windows = if pids.is_empty() {+        Vec::new()+    } else {+        ab::call("desktop_enum_windows", json!({"pids": pids}), &[], 20)+            .get("windows")+            .and_then(|a| a.as_array())+            .cloned()+            .unwrap_or_default()+    };+    if let Ok(mut g) = SNAP.lock() {+        *g = Some(Snapshot { at: Instant::now(), procs: procs.clone(), windows: windows.clone(), ab_unreachable: ab_down });+    }+    (procs, windows)+}++pub fn invalidate_snapshot() {+    if let Ok(mut g) = SNAP.lock() {+        *g = None;+    }+}++/// Normalise ab's rect (object or [l,t,r,b] array) to {left,top,width,height}.+pub fn norm_rect(w: &Value) -> Value {+    match w.get("rect") {+        Some(Value::Array(a)) if a.len() == 4 => {+            let g = |i: usize| a[i].as_i64().unwrap_or(0);+            json!({"left": g(0), "top": g(1), "width": g(2) - g(0), "height": g(3) - g(1)})+        }+        Some(Value::Object(o)) => {+            if o.contains_key("width") {+                Value::Object(o.clone())+            } else {+                let g = |k: &str| o.get(k).and_then(|v| v.as_i64()).unwrap_or(0);+                json!({"left": g("left"), "top": g("top"),+                       "width": g("right") - g("left"), "height": g("bottom") - g("top")})+            }+        }+        _ => Value::Null,+    }+}++/// Every visible top-level window owned by an X2.exe, classified like the Python bridge's+/// `altium_windows()`: kind = main | script_error_modal | dialog | other, mains first.+pub fn altium_windows() -> Vec<Value> {+    let (_, wins) = snapshot();+    let mut out: Vec<Value> = Vec::new();+    for w in wins {+        let visible = w.get("visible").and_then(|b| b.as_bool()).unwrap_or(true);+        let title = w.get("title").and_then(|s| s.as_str()).unwrap_or("").to_string();+        let class = w.get("className").and_then(|s| s.as_str()).unwrap_or("").to_string();+        let rect = norm_rect(&w);+        let tiny = rect.get("width").and_then(|v| v.as_i64()).unwrap_or(999) < 20+            && rect.get("height").and_then(|v| v.as_i64()).unwrap_or(999) < 20;+        if !visible || tiny {+            continue;+        }+        let mut row = json!({+            "hwnd": w.get("hwnd").cloned().unwrap_or(Value::Null),+            "pid": w.get("pid").cloned().unwrap_or(Value::Null),+            "title": title, "className": class, "visible": visible,+            "enabled": w.get("enabled").cloned().unwrap_or(json!(true)),+            "rect": rect,+        });+        let kind = if let Some(p) = parse_title(&row["title"].as_str().unwrap_or("").to_string()) {+            for k in ["edition", "adVersion", "document"] {+                row[k] = p.get(k).cloned().unwrap_or(Value::Null);+            }+            row["licenceState"] = p.get("licenceState").cloned().unwrap_or(Value::Null);+            "main"+        } else if row["title"] == json!("X2") {+            "script_error_modal"+        } else if matches!(row["className"].as_str(), Some("TMessageForm") | Some("#32770")) {+            "dialog"+        } else {+            "other"+        };+        row["kind"] = json!(kind);+        out.push(row);+    }+    let order = |k: &str| match k {+        "main" => 0,+        "script_error_modal" => 1,+        "dialog" => 2,+        _ => 3,+    };+    out.sort_by_key(|w| order(w.get("kind").and_then(|k| k.as_str()).unwrap_or("other")));+    out+}++/// Licence read from a main window's parsed title, with the two measured quirks:+/// "Viewer" counts as unlicensed, and Develop with NO tail is LICENSED (Develop only writes a+/// tail — "- Viewer" — when it does not hold the seat; 2026-09-01, ADD imported five parts while+/// the naive read reported it unlicensed).+fn licence_of(win: &Value) -> (Option<bool>, Option<String>) {+    let st = win.get("licenceState").and_then(|s| s.as_str()).map(str::to_string);+    match st {+        Some(s) => {+            let l = s.to_lowercase();+            (Some(l != "no license" && l != "no licence" && l != "viewer"), Some(s))+        }+        None if win.get("edition").and_then(|e| e.as_str()) == Some("Develop") => {+            (Some(true), Some("Licensed (Develop reports no licence tail)".into()))+        }+        None => (None, None),+    }+}++/// Every running Altium, each with the identity a caller needs to choose between them.+pub fn instances() -> Vec<Value> {+    let (procs, _) = snapshot();+    let exe_of = |pid: u64| -> Option<String> {+        procs+            .iter()+            .find(|p| p.get("pid").and_then(|v| v.as_u64()) == Some(pid))+            .and_then(|p| p.get("commandLine").and_then(|c| c.as_str()))+            .and_then(exe_from_command_line)+    };+    let mut out = Vec::new();+    let mut seen = std::collections::BTreeSet::new();+    for w in altium_windows() {+        if w.get("kind").and_then(|k| k.as_str()) != Some("main") {+            continue;+        }+        let pid = match w.get("pid").and_then(|p| p.as_u64()) {+            Some(p) if !seen.contains(&p) => p,+            _ => continue,+        };+        seen.insert(pid);+        let exe = exe_of(pid);+        let (licensed, st) = licence_of(&w);+        out.push(json!({+            "pid": pid,+            "key": key_for(exe.as_deref(), pid),+            "edition": w.get("edition").and_then(|e| e.as_str()).unwrap_or("Designer"),+            "adVersion": w.get("adVersion").cloned().unwrap_or(Value::Null),+            "licenceState": st, "licensed": licensed,+            "exe": exe, "hwnd": w.get("hwnd").cloned().unwrap_or(Value::Null),+            "document": w.get("document").cloned().unwrap_or(Value::Null),+            "title": w.get("title").cloned().unwrap_or(Value::Null),+        }));+    }+    out.sort_by_key(|i| i.get("pid").and_then(|p| p.as_u64()).unwrap_or(0));+    out+}++/// Pick the instance a verb should act on.+/// Ok(inst) — act on it. Err(refusal) — return the refusal verbatim (ambiguity must STOP here,+/// or one thread's verb lands in another thread's Altium; not-running is Err too, with its code).+pub fn resolve(selector: Option<&Value>) -> Result<Value, Value> {+    let found = instances();+    if found.is_empty() {+        return Err(json!({"success": false, "error": "no Altium is running",+                          "errorCode": "altium_not_running", "instances": []}));+    }+    let sel = match selector {+        None | Some(Value::Null) => String::new(),+        Some(Value::String(s)) => s.trim().to_lowercase(),+        Some(v) => v.to_string().trim_matches('"').to_lowercase(),+    };+    if sel.is_empty() || sel == "auto" {+        if found.len() == 1 {+            return Ok(found[0].clone());+        }+        return Err(json!({"success": false,+            "error": format!("{} Altium instances are running — say which one", found.len()),+            "errorCode": "ambiguous_instance", "instances": found,+            "_hint": "pass instance: a pid, a key (e.g. 'ADDevelop'), or a word from the edition/title (e.g. 'develop')"}));+    }+    for i in &found {+        let pid = i.get("pid").and_then(|p| p.as_u64()).map(|p| p.to_string()).unwrap_or_default();+        let key = i.get("key").and_then(|k| k.as_str()).unwrap_or("").to_lowercase();+        if sel == pid || sel == key {+            return Ok(i.clone());+        }+    }+    for i in &found {+        let hay = format!(+            "{} {} {}",+            i.get("edition").and_then(|e| e.as_str()).unwrap_or(""),+            i.get("key").and_then(|k| k.as_str()).unwrap_or(""),+            i.get("title").and_then(|t| t.as_str()).unwrap_or("")+        )+        .to_lowercase();+        if hay.contains(&sel) {+            return Ok(i.clone());+        }+    }+    Err(json!({"success": false, "error": format!("no running Altium matches instance={sel:?}"),+               "errorCode": "instance_not_found", "instances": found}))+}++/// The main window of the SELECTED instance, or the only one. Instance-awareness has to reach+/// this far down: `licence_state()` is built on it and every `_need_licensed` gate on that.+pub fn main_window() -> Option<Value> {+    let mains: Vec<Value> = altium_windows()+        .into_iter()+        .filter(|w| w.get("kind").and_then(|k| k.as_str()) == Some("main"))+        .collect();+    if mains.is_empty() {+        return None;+    }+    if let Some(sel) = selected() {+        if let Some(pid) = sel.get("pid").and_then(|p| p.as_u64()) {+            for w in &mains {+                if w.get("pid").and_then(|p| p.as_u64()) == Some(pid) {+                    return Some(w.clone());+                }+            }+        }+    }+    Some(mains[0].clone())+}++/// Parsed from the selected instance's main window title (the cheapest truthful source).+pub fn licence_state() -> Value {+    let mw = match main_window() {+        Some(w) => w,+        None => return json!({"running": false, "licensed": null, "state": null}),+    };+    let (licensed, st) = licence_of(&mw);+    json!({"running": true, "licensed": licensed, "state": st,+           "adVersion": mw.get("adVersion").cloned().unwrap_or(Value::Null),+           "edition": mw.get("edition").cloned().unwrap_or(Value::Null),+           "document": mw.get("document").cloned().unwrap_or(Value::Null),+           "hwnd": mw.get("hwnd").cloned().unwrap_or(Value::Null),+           "pid": mw.get("pid").cloned().unwrap_or(Value::Null)})+}++#[cfg(test)]+mod tests {+    use super::*;++    #[test]+    fn key_from_install_folder_survives_restart() {+        assert_eq!(key_for(Some(r"C:\Program Files\Altium\AD26\X2.EXE"), 1), "AD26");+        assert_eq!(key_for(Some(r"C:\Program Files\Altium\ADDevelop\X2.EXE"), 2), "ADDevelop");+        assert_eq!(key_for(Some("C:/Program Files/Altium/AD26/X2.EXE"), 3), "AD26");+        assert_eq!(key_for(None, 4242), "pid4242");+    }++    #[test]+    fn exe_out_of_quoted_and_bare_command_lines() {+        assert_eq!(+            exe_from_command_line(r#""C:\Program Files\Altium\AD26\X2.EXE" -restart"#).as_deref(),+            Some(r"C:\Program Files\Altium\AD26\X2.EXE")+        );+        assert_eq!(exe_from_command_line(r"C:\X2.EXE -x").as_deref(), Some(r"C:\X2.EXE"));+        assert_eq!(exe_from_command_line(""), None);+    }++    #[test]+    fn develop_absent_tail_means_licensed() {+        let w = json!({"edition": "Develop", "licenceState": null});+        let (lic, st) = licence_of(&w);+        assert_eq!(lic, Some(true));+        assert!(st.unwrap().contains("Develop"));+        // ...but "- Viewer" on Develop means NOT licensed+        let w = json!({"edition": "Develop", "licenceState": "Viewer"});+        assert_eq!(licence_of(&w).0, Some(false));+        // Designer with no tail stays unknown+        let w = json!({"edition": null, "licenceState": null});+        assert_eq!(licence_of(&w).0, None);+        // Viewer / No License are unlicensed; anything else licensed+        assert_eq!(licence_of(&json!({"licenceState": "No License"})).0, Some(false));+        assert_eq!(licence_of(&json!({"licenceState": "Trial"})).0, Some(true));+    }++    #[test]+    fn rect_array_and_object_forms_normalise() {+        let a = json!({"rect": [10, 20, 110, 220]});+        assert_eq!(norm_rect(&a), json!({"left": 10, "top": 20, "width": 100, "height": 200}));+        let o = json!({"rect": {"left": 1, "top": 2, "right": 11, "bottom": 22}});+        assert_eq!(norm_rect(&o), json!({"left": 1, "top": 2, "width": 10, "height": 20}));+        let w = json!({"rect": {"left": 1, "top": 2, "width": 3, "height": 4}});+        assert_eq!(norm_rect(&w)["width"], json!(3));+    }+}
bridge-rs/src/library.rsadded+615
@@ -0,0 +1,615 @@+//! Library authoring — thin views over the agent's library commands, plus the two fat lanes:+//! `import_part` (one call imports a whole part: footprint spec, STEP body, symbol modes, models,+//! params, save) and `install_library_bundle` (the no-AI web-control lane: staged zip →+//! Installed Libraries, hardened for untrusted zips).+use crate::support::{self, s, s_or, timeout_of};+use crate::{agent, shot};+use serde_json::{json, Value};+use std::time::Duration;++/// `_lib_call`: gate, optionally open a library first, then one agent command.+fn lib_call(args: &Value, cmd: &str, params: Vec<(&str, String)>, open_path: Option<&str>, tdef: u64) -> Value {+    if let Some(g) = support::need_licensed() {+        return g;+    }+    if let Some(p) = open_path {+        let r = support::agent_open(p, 60);+        if r.get("success").and_then(|b| b.as_bool()) != Some(true) {+            return r;+        }+    }+    agent::call(cmd, &params, None, timeout_of(args, tdef))+}++/// `_sch_lib_verb`: gate, open args.libraryPath if given (best effort), one agent command.+fn sch_lib_verb(args: &Value, cmd: &str, params: Vec<(&str, String)>, tdef: u64) -> Value {+    if let Some(g) = support::need_licensed() {+        return g;+    }+    if let Some(p) = s(args, "libraryPath") {+        support::agent_open(&p, 60);+    }+    agent::call(cmd, &params, None, timeout_of(args, tdef))+}++fn with_shot(mut r: Value) -> Value {+    if r.get("success").and_then(|b| b.as_bool()) == Some(true) {+        r["shot"] = shot::shot_of_main();+    } else {+        r["shot"] = Value::Null;+    }+    r["windowEtiquette"] = json!("background");+    r+}++pub fn create_footprint(args: &Value) -> Value {+    if let Some(g) = support::need_licensed() {+        return g;+    }+    for k in ["name", "libraryPath"] {+        if s(args, k).unwrap_or_default().is_empty() {+            return json!({"success": false, "error": format!("{k} is required"),+                "_hint": "pads: 'des,x_mil,y_mil,w_mil,h_mil,rect|round,hole_mil;...'"});+        }+    }+    let r = agent::call("create_footprint",+        &[("name", s_or(args, "name", "")), ("libraryPath", s_or(args, "libraryPath", "")),+          ("description", s_or(args, "description", "")), ("pads", s_or(args, "pads", ""))],+        None, timeout_of(args, 90));+    let mut r = with_shot(r);+    if r.get("_hint").is_none() {+        r["_hint"] = json!("Footprint registered in the (new or opened) PcbLib and made current; the library is NOT saved yet — altium_save {filePath} or altium_run_command Client:Save... shot.image shows it.");+    }+    r+}++pub fn create_symbol(args: &Value) -> Value {+    if let Some(g) = support::need_licensed() {+        return g;+    }+    for k in ["name", "libraryPath"] {+        if s(args, k).unwrap_or_default().is_empty() {+            return json!({"success": false, "error": format!("{k} is required"),+                "_hint": "pins: 'des,name,left|right|top|bottom,offset_mil;...'"});+        }+    }+    let r = agent::call("create_symbol",+        &[("name", s_or(args, "name", "")), ("libraryPath", s_or(args, "libraryPath", "")),+          ("description", s_or(args, "description", "")), ("designator", s_or(args, "designator", "U")),+          ("body_w_mil", s_or(args, "bodyWidthMil", "800")), ("body_h_mil", s_or(args, "bodyHeightMil", "600")),+          ("pins", s_or(args, "pins", ""))],+        None, timeout_of(args, 90));+    let mut r = with_shot(r);+    if r.get("_hint").is_none() {+        r["_hint"] = json!("Symbol added to the SchLib and made current (library unsaved). shot.image shows it.");+    }+    r+}++pub fn build_footprint(args: &Value) -> Value {+    if let Some(g) = support::need_licensed() {+        return g;+    }+    if s(args, "libraryPath").unwrap_or_default().is_empty() || s(args, "spec").unwrap_or_default().is_empty() {+        return json!({"success": false, "error": "libraryPath (.PcbLib) and spec (Windows path to a kicad2altium spec) are required"});+    }+    with_shot(agent::call("build_footprint",+        &[("libraryPath", s_or(args, "libraryPath", "")), ("spec", s_or(args, "spec", ""))],+        None, timeout_of(args, 240)))+}++pub fn build_symbol_modes(args: &Value) -> Value {+    if let Some(g) = support::need_licensed() {+        return g;+    }+    if s(args, "libraryPath").unwrap_or_default().is_empty() || s(args, "spec").unwrap_or_default().is_empty() {+        return json!({"success": false, "error": "libraryPath (.SchLib) and spec (Windows path to a symbol_modes spec) are required"});+    }+    with_shot(agent::call("build_symbol_modes",+        &[("libraryPath", s_or(args, "libraryPath", "")), ("spec", s_or(args, "spec", ""))],+        None, timeout_of(args, 240)))+}++pub fn add_body(args: &Value) -> Value {+    let lib = match s(args, "libraryPath") {+        Some(l) if !l.is_empty() => l,+        _ => return json!({"success": false, "error": "libraryPath (.PcbLib) is required",+            "_hint": "step:'C:/…/part.step' for a real model, else bwMil/bhMil/hMil/soMil for an extruded box"}),+    };+    let mut r = lib_call(args, "add_body",+        vec![("name", s_or(args, "footprintName", "")), ("step", s_or(args, "step", "")),+             ("bw_mil", s_or(args, "bwMil", "100")), ("bh_mil", s_or(args, "bhMil", "100")),+             ("h_mil", s_or(args, "hMil", "40")), ("so_mil", s_or(args, "soMil", "0"))],+        Some(&lib), 90);+    if r.get("success").and_then(|b| b.as_bool()) == Some(true) {+        // canvas-added bodies land 50000 mil from the pads in the stored frame; realign+        let n = agent::call("normalize_bodies", &[("name", s_or(args, "footprintName", ""))], None, Duration::from_secs(90));+        r["normalized"] = n.get("data").and_then(|d| d.get("shifts")).cloned().unwrap_or(Value::Null);+        r["shot"] = shot::shot_of_main();+    } else {+        r["shot"] = Value::Null;+    }+    r+}++pub fn normalize_bodies(args: &Value) -> Value {+    sch_lib_verb(args, "normalize_bodies", vec![("name", s_or(args, "footprintName", ""))], 120)+}++pub fn set_params(args: &Value) -> Value {+    let lib = match s(args, "libraryPath") {+        Some(l) if !l.is_empty() => l,+        _ => return json!({"success": false, "error": "libraryPath (.SchLib) is required",+            "_hint": "params:'LCSC Part #=C123;Manufacturer=TI' footprint:'SOT-23' comment:'10k'"}),+    };+    let params = match args.get("params") {+        Some(Value::Object(m)) => m.iter()+            .map(|(k, v)| format!("{k}={}", v.as_str().map(str::to_string).unwrap_or(v.to_string())))+            .collect::<Vec<_>>().join(";"),+        Some(Value::String(p)) => p.clone(),+        _ => String::new(),+    };+    let mut r = lib_call(args, "set_params",+        vec![("name", s_or(args, "symbolName", "")), ("params", params),+             ("footprint", s_or(args, "footprint", "")), ("comment", s_or(args, "comment", ""))],+        Some(&lib), 90);+    if r.get("_hint").is_none() {+        r["_hint"] = json!("Parameters set on the symbol (unsaved) — altium_save {filePath: libraryPath}. LCSC Part # / Manufacturer Part Number are the JLC/BOM keys.");+    }+    r+}++pub fn set_visibility(args: &Value) -> Value {+    let vis = if args.get("visible").and_then(|b| b.as_bool()).unwrap_or(true) { "1" } else { "0" };+    sch_lib_verb(args, "set_visibility",+        vec![("name", s_or(args, "symbolName", "")), ("param", s_or(args, "param", "")), ("visible", vis.into())], 90)+}++pub fn read_symbol(args: &Value) -> Value {+    sch_lib_verb(args, "read_symbol", vec![("name", s_or(args, "symbolName", ""))], 90)+}++pub fn clone_symbol(args: &Value) -> Value {+    sch_lib_verb(args, "clone_symbol", vec![("from", s_or(args, "from", "")), ("to", s_or(args, "to", ""))], 90)+}++pub fn rename_symbol(args: &Value) -> Value {+    sch_lib_verb(args, "rename_symbol", vec![("from", s_or(args, "from", "")), ("to", s_or(args, "to", ""))], 90)+}++pub fn rename_footprint(args: &Value) -> Value {+    sch_lib_verb(args, "rename_footprint", vec![("from", s_or(args, "from", "")), ("to", s_or(args, "to", ""))], 90)+}++pub fn remove_symbol(args: &Value) -> Value {+    sch_lib_verb(args, "remove_symbol", vec![("name", s_or(args, "symbolName", ""))], 60)+}++pub fn remove_footprint(args: &Value) -> Value {+    sch_lib_verb(args, "remove_footprint", vec![("name", s_or(args, "footprintName", ""))], 90)+}++pub fn copy_footprint(args: &Value) -> Value {+    sch_lib_verb(args, "copy_footprint", vec![("from", s_or(args, "from", "")), ("to", s_or(args, "to", ""))], 90)+}++pub fn cleanup_libs(args: &Value) -> Value {+    sch_lib_verb(args, "cleanup_libs", vec![], 90)+}++pub fn xcopy_footprint(args: &Value) -> Value {+    if let Some(g) = support::need_licensed() {+        return g;+    }+    if let Some(p) = s(args, "toLib") {+        support::agent_open(&p, 60);+    }+    let to = s(args, "to").or_else(|| s(args, "from")).unwrap_or_default();+    agent::call("xcopy_footprint",+        &[("fromLib", s_or(args, "fromLib", "")), ("from", s_or(args, "from", "")), ("to", to)],+        None, timeout_of(args, 120))+}++pub fn swap_footprint(args: &Value) -> Value {+    if let Some(g) = support::need_licensed() {+        return g;+    }+    if let Some(p) = s(args, "pcbPath") {+        support::agent_open(&p, 60);+    }+    let mut r = agent::call("swap_footprint",+        &[("footprint", s_or(args, "footprintName", "")), ("assigns", s_or(args, "assigns", "")),+          ("libraryPath", s_or(args, "libraryPath", ""))],+        None, timeout_of(args, 240));+    r["windowEtiquette"] = json!("background");+    r+}++/// The editor paints its OWN mode: Comp.DisplayMode changes data only (measured 2026-08-30,+/// frames identical). Drive the editor's process: Mode0, then Next × mode.+pub fn symbol_mode(args: &Value) -> Value {+    if let Some(g) = support::need_licensed() {+        return g;+    }+    if let Some(p) = s(args, "libraryPath") {+        support::agent_open(&p, 60);+    }+    let mode = args.get("mode").and_then(|v| v.as_u64()).unwrap_or(0);+    agent::call("run_process", &[("process", "Sch:ToggleComponentDisplayMode".into()), ("params", "Action=Mode0".into())], None, Duration::from_secs(30));+    for _ in 0..mode {+        agent::call("run_process", &[("process", "Sch:ToggleComponentDisplayMode".into()), ("params", "Action=Next".into())], None, Duration::from_secs(30));+    }+    let r = agent::call("symbol_mode", &[("mode", mode.to_string())], None, timeout_of(args, 60));+    agent::call("run_process", &[("process", "Sch:Zoom".into()), ("params", "Action=All".into())], None, Duration::from_secs(30));+    std::thread::sleep(Duration::from_millis(800));+    with_shot(r)+}++pub fn install_library(args: &Value) -> Value {+    let lib = match s(args, "libraryPath") {+        Some(l) if !l.is_empty() => l,+        _ => return json!({"success": false, "error": "libraryPath is required (.IntLib/.SchLib/.PcbLib/.DbLib)"}),+    };+    let mut r = lib_call(args, "install_library", vec![("path", lib)], None, 90);+    if r.get("_hint").is_none() {+        r["_hint"] = json!("Registered in Altium's Installed Libraries (IntegratedLibraryManager.InstallLibrary).");+    }+    r+}++pub fn compile_intlib(args: &Value) -> Value {+    let p = match s(args, "libpkgPath") {+        Some(l) if !l.is_empty() => l,+        _ => return json!({"success": false, "error": "libpkgPath (.LibPkg) is required; output = destination dir"}),+    };+    lib_call(args, "compile_intlib", vec![("path", p), ("output", s_or(args, "output", ""))], None, 300)+}++pub fn extract_intlib(args: &Value) -> Value {+    let p = match s(args, "intlibPath") {+        Some(l) if !l.is_empty() => l,+        _ => return json!({"success": false, "error": "intlibPath (.IntLib) is required"}),+    };+    let mut r = lib_call(args, "extract_intlib", vec![("path", p)], None, 300);+    if r.get("_hint").is_none() {+        r["_hint"] = json!("sources[] are the editable .SchLib/.PcbLib Altium extracted next to the IntLib (now open in the workspace).");+    }+    r+}++pub fn place_on_sch(args: &Value) -> Value {+    if let Some(g) = support::need_licensed() {+        return g;+    }+    let lib = match s(args, "libraryPath") {+        Some(l) if !l.is_empty() => l,+        _ => return json!({"success": false, "error": "libraryPath (.SchLib) required"}),+    };+    support::agent_open(&lib, 60);+    with_shot(agent::call("place_on_sch",+        &[("sch", s_or(args, "schPath", "")), ("libref", s_or(args, "symbolName", "CH340G"))],+        None, timeout_of(args, 90)))+}++pub fn place_on_pcb(args: &Value) -> Value {+    if let Some(g) = support::need_licensed() {+        return g;+    }+    let lib = match s(args, "libraryPath") {+        Some(l) if !l.is_empty() => l,+        _ => return json!({"success": false, "error": "libraryPath (.PcbLib) required"}),+    };+    support::agent_open(&lib, 60);+    with_shot(agent::call("place_on_pcb",+        &[("pcb", s_or(args, "pcbPath", "")), ("footprint", s_or(args, "footprintName", ""))],+        None, timeout_of(args, 90)))+}++pub fn test_board(args: &Value) -> Value {+    if let Some(g) = support::need_licensed() {+        return g;+    }+    if let Some(p) = s(args, "libraryPath") {+        support::agent_open(&p, 60);+    }+    let mut r = agent::call("test_board",+        &[("footprint", s_or(args, "footprintName", "")), ("wMm", s_or(args, "wMm", "15")),+          ("hMm", s_or(args, "hMm", "15")), ("pcb", s_or(args, "pcbPath", "")),+          ("assigns", s_or(args, "assigns", "")), ("stubs", s_or(args, "stubs", ""))],+        None, timeout_of(args, 240));+    r["windowEtiquette"] = json!("background");+    r+}++/// Sanitise a value for the agent's model spec: models split on ';', fields on '|' — an unescaped+/// separator in a DESCRIPTION silently truncates the spec.+fn spec_safe(v: &str) -> String {+    v.replace(';', ",").replace('|', "/")+}++fn params_spec(params: &Value) -> String {+    match params {+        Value::Object(m) => m.iter()+            .map(|(k, v)| format!("{k}={}", v.as_str().map(str::to_string).unwrap_or(v.to_string()).replace(';', ",")))+            .collect::<Vec<_>>().join(";"),+        _ => String::new(),+    }+}++/// Attach N PCB footprint models (Altium's footprint-variant mechanism) + parameters to a SchLib+/// component. NOT idempotent — each call APPENDS another implementation (known Altium defect);+/// repair = remove_symbol + re-import.+pub fn add_models(args: &Value) -> Value {+    if let Some(g) = support::need_licensed() {+        return g;+    }+    let lib = s(args, "libraryPath").unwrap_or_default();+    let name = s(args, "symbolName").unwrap_or_default();+    if lib.is_empty() || name.is_empty() {+        return json!({"success": false, "error": "libraryPath (.SchLib) and symbolName are required"});+    }+    let models: Vec<Value> = args.get("models").and_then(|m| m.as_array()).cloned().unwrap_or_default();+    let spec = models.iter().map(|m| format!("{}|{}|{}",+        m.get("name").and_then(|v| v.as_str()).unwrap_or(""),+        m.get("pcblib").and_then(|v| v.as_str()).unwrap_or(""),+        spec_safe(m.get("description").and_then(|v| v.as_str()).unwrap_or(""))))+        .collect::<Vec<_>>().join(";");+    let pspec = params_spec(args.get("params").unwrap_or(&Value::Null));+    let mut r = agent::call("add_models",+        &[("libraryPath", lib), ("name", name), ("models", spec), ("params", pspec),+          ("comment", s_or(args, "comment", "")), ("description", s_or(args, "description", ""))],+        None, timeout_of(args, 120));+    if r.get("success").and_then(|b| b.as_bool()) == Some(true) {+        r["shot"] = shot::shot_of_main();+    } else {+        r["shot"] = Value::Null;+    }+    r["_aiHint"] = json!("add_models APPENDS an implementation each call — calling it twice leaves duplicate footprint models on one symbol. Repair = altium_remove_symbol + re-import. read_symbol does not report models (models:null is a reporting gap, not a missing link).");+    r+}++/// Barrett's fat verb, on the fast lane: one call imports a whole part.+/// Args: schLib, pcbLib, symbolSpec (symbol_modes spec path) OR pins, footprintSpec+/// (kicad2altium spec path), step, params{}, comment, description.+pub fn import_part(args: &Value) -> Value {+    if let Some(g) = support::need_licensed() {+        return g;+    }+    let mut steps: Vec<Value> = Vec::new();+    let mut fp_name: Option<String> = None;+    let pcb_lib = s(args, "pcbLib").unwrap_or_default();+    let sch_lib = s(args, "schLib").unwrap_or_default();+    if let Some(spec) = s(args, "footprintSpec").filter(|v| !v.is_empty()) {+        let r = agent::call("build_footprint", &[("libraryPath", pcb_lib.clone()), ("spec", spec)], None, Duration::from_secs(240));+        if r.get("success").and_then(|b| b.as_bool()) != Some(true) {+            let mut out = r;+            out["success"] = json!(false);+            out["failedAt"] = json!("build_footprint");+            return out;+        }+        fp_name = r.get("data").and_then(|d| d.get("footprint")).and_then(|f| f.as_str()).map(str::to_string);+        steps.push(json!({"build_footprint": fp_name}));+    }+    if let (Some(step), Some(fp)) = (s(args, "step").filter(|v| !v.is_empty()), fp_name.clone()) {+        let r = agent::call("add_body",+            &[("libraryPath", pcb_lib.clone()), ("name", fp.clone()), ("step", step),+              ("bw_mil", "100".into()), ("bh_mil", "100".into()), ("h_mil", "40".into()), ("so_mil", "0".into())],+            None, Duration::from_secs(180));+        steps.push(json!({"add_body": r.get("success").and_then(|b| b.as_bool()).unwrap_or(false)}));+        agent::call("normalize_bodies", &[("name", fp)], None, Duration::from_secs(90));+    }+    let mut sym_name = s(args, "symbolName");+    if let Some(spec) = s(args, "symbolSpec").filter(|v| !v.is_empty()) {+        support::agent_open(&sch_lib, 60);+        let r = agent::call("build_symbol_modes", &[("libraryPath", sch_lib.clone()), ("spec", spec)], None, Duration::from_secs(240));+        if r.get("success").and_then(|b| b.as_bool()) != Some(true) {+            let mut out = r;+            out["success"] = json!(false);+            out["failedAt"] = json!("build_symbol_modes");+            out["steps"] = json!(steps);+            return out;+        }+        sym_name = r.get("data").and_then(|d| d.get("libref")).and_then(|f| f.as_str()).map(str::to_string);+        steps.push(json!({"build_symbol_modes": sym_name}));+    }+    if let (Some(sym), Some(fp)) = (sym_name.clone(), fp_name.clone()) {+        let desc_raw = s_or(args, "description", "");+        let desc: String = spec_safe(&desc_raw).chars().take(80).collect();+        let r = agent::call("add_models",+            &[("libraryPath", sch_lib.clone()), ("name", sym.clone()),+              ("models", format!("{fp}|{pcb_lib}|{desc}")),+              ("params", params_spec(args.get("params").unwrap_or(&Value::Null))),+              ("comment", s(args, "comment").unwrap_or_else(|| sym.clone())),+              ("description", desc_raw)],+            None, Duration::from_secs(120));+        let d = r.get("data").cloned().unwrap_or(json!({}));+        let ok = r.get("success").and_then(|b| b.as_bool()) == Some(true);+        steps.push(json!({"add_models": ok, "models": d.get("models"), "params": d.get("params")}));+        let models_n = d.get("models").and_then(|m| m.as_i64()).unwrap_or(0);+        if ok && models_n == 0 {+            // attaching ZERO models is a silent failure: the symbol places with no footprint+            let mut out = r;+            out["success"] = json!(false);+            out["failedAt"] = json!("add_models");+            out["steps"] = json!(steps);+            out["error"] = json!("add_models attached 0 footprint models — the symbol would place with no footprint. Check the model spec separators (';' between models, '|' between name|pcblib|description).");+            return out;+        }+    }+    for lib in [&pcb_lib, &sch_lib] {+        if !lib.is_empty() {+            support::agent_open(lib, 60);+            agent::call("run_process",+                &[("process", "WorkspaceManager:SaveObject".into()), ("params", "ObjectKind=FocusedDocument".into())],+                None, Duration::from_secs(60));+        }+    }+    json!({"success": true, "symbol": sym_name, "footprint": fp_name, "steps": steps,+        "_hint": "One-call part import (Barrett's import_part on the resident lane). Verify with altium_read_symbol + altium_pcb_report."})+}++// ---- install_library_bundle: the no-AI web-control lane ----------------------++fn sanitise_lib_name(name: &str) -> String {+    name.chars().map(|c| if c.is_ascii_alphanumeric() || "_.-".contains(c) { c } else { '_' }).collect()+}++fn valid_lib_name(name: &str) -> bool {+    let mut chars = name.chars();+    match chars.next() {+        Some(c) if c.is_ascii_alphanumeric() => {}+        _ => return false,+    }+    name.len() <= 64 && name.chars().all(|c| c.is_ascii_alphanumeric() || " ._-".contains(c))+}++/// Unpack a staged zip (<Lib>.SchLib + <Lib>.PcbLib [+ .IntLib], regardless of a wrapping+/// folder), install into the user's Installed Libraries, optionally return a proof frame.+/// Hardened for untrusted zips: size caps, zip-slip refusal.+pub fn install_library_bundle(args: &Value) -> Value {+    if let Some(g) = support::need_licensed() {+        return g;+    }+    let zp = match s(args, "zipPath") {+        Some(z) if !z.is_empty() => z,+        _ => return json!({"success": false, "error": "zipPath is required (stage the bundle with write_file / write_file_chunk first)"}),+    };+    let name = s_or(args, "libraryName", "");+    if !name.is_empty() && !valid_lib_name(&name) {+        return json!({"success": false, "error": "libraryName must match ^[A-Za-z0-9][A-Za-z0-9 ._-]{0,63}$"});+    }+    let meta = match std::fs::metadata(&zp) {+        Ok(m) if m.is_file() => m,+        _ => return json!({"success": false, "failedAt": "unpack", "error": format!("staged zip not found: {zp}")}),+    };+    if meta.len() > 268_435_456 {+        return json!({"success": false, "failedAt": "unpack", "error": "zip over 256MB"});+    }+    let public = std::env::var("PUBLIC").unwrap_or_else(|_| r"C:\Users\Public".into());+    let stem = std::path::Path::new(&zp).file_stem().map(|s| s.to_string_lossy().to_string()).unwrap_or_default();+    let dest = std::path::Path::new(&public).join("AdomAltiumLibs")+        .join(sanitise_lib_name(if name.is_empty() { &stem } else { &name }));+    let _ = std::fs::remove_dir_all(&dest);+    if let Err(e) = std::fs::create_dir_all(&dest) {+        return json!({"success": false, "failedAt": "unpack", "error": format!("cannot create {}: {e}", dest.display())});+    }+    let file = match std::fs::File::open(&zp) {+        Ok(f) => f,+        Err(e) => return json!({"success": false, "failedAt": "unpack", "error": format!("{e}")}),+    };+    let mut z = match zip::ZipArchive::new(file) {+        Ok(z) => z,+        Err(_) => return json!({"success": false, "failedAt": "unpack", "error": "not a valid zip"}),+    };+    let mut total: u64 = 0;+    for i in 0..z.len() {+        let mut entry = match z.by_index(i) {+            Ok(e) => e,+            Err(_) => return json!({"success": false, "failedAt": "unpack", "error": "not a valid zip"}),+        };+        total += entry.size();+        if total > 536_870_912 {+            return json!({"success": false, "failedAt": "unpack", "error": "unpacked over 512MB"});+        }+        let raw = entry.name().replace('\\', "/");+        let parts: Vec<&str> = raw.split('/').collect();+        if raw.starts_with('/') || parts.iter().any(|p| *p == "..") || (raw.len() > 1 && raw.as_bytes()[1] == b':') {+            return json!({"success": false, "failedAt": "unpack", "error": format!("zip slip: {raw}")});+        }+        let target = parts.iter().fold(dest.clone(), |acc, p| acc.join(p));+        if entry.is_dir() {+            let _ = std::fs::create_dir_all(&target);+            continue;+        }+        if let Some(parent) = target.parent() {+            let _ = std::fs::create_dir_all(parent);+        }+        match std::fs::File::create(&target) {+            Ok(mut out) => {+                if std::io::copy(&mut entry, &mut out).is_err() {+                    return json!({"success": false, "failedAt": "unpack", "error": format!("cannot write {raw}")});+                }+            }+            Err(e) => return json!({"success": false, "failedAt": "unpack", "error": format!("cannot write {raw}: {e}")}),+        }+    }+    let mut schlibs = Vec::new();+    let mut pcblibs = Vec::new();+    let mut intlibs = Vec::new();+    let mut stack = vec![dest.clone()];+    while let Some(d) = stack.pop() {+        if let Ok(rd) = std::fs::read_dir(&d) {+            for e in rd.flatten() {+                let p = e.path();+                if p.is_dir() {+                    stack.push(p);+                    continue;+                }+                let lower = p.to_string_lossy().to_lowercase();+                if lower.ends_with(".schlib") { schlibs.push(p); }+                else if lower.ends_with(".pcblib") { pcblibs.push(p); }+                else if lower.ends_with(".intlib") { intlibs.push(p); }+            }+        }+    }+    if schlibs.is_empty() && pcblibs.is_empty() && intlibs.is_empty() {+        return json!({"success": false, "failedAt": "unpack", "error": "bundle holds no .SchLib/.PcbLib/.IntLib"});+    }+    let to_install: Vec<&std::path::PathBuf> = if !intlibs.is_empty() {+        intlibs.iter().collect()+    } else {+        schlibs.iter().chain(pcblibs.iter()).collect()+    };+    let mut installed = Vec::new();+    for lib in to_install {+        let winpath = lib.to_string_lossy().replace('/', "\\");+        let ir = agent::call("install_library", &[("path", winpath)], None, Duration::from_secs(120));+        installed.push(json!({"lib": lib.file_name().map(|n| n.to_string_lossy().to_string()),+                              "ok": ir.get("success").and_then(|b| b.as_bool()).unwrap_or(false)}));+    }+    let st = agent::call("state", &[], None, Duration::from_secs(30));+    let running = st.get("windows").map(|w| !w.is_null()).unwrap_or(false)+        || st.get("data").and_then(|d| d.get("windows")).map(|w| !w.is_null()).unwrap_or(false);+    let mut out = json!({"success": true,+        "library": if name.is_empty() { stem } else { name },+        "symbols": schlibs.len(), "footprints": pcblibs.len(), "intlibs": intlibs.len(),+        "installed": installed, "altiumRunning": running, "restartNeeded": running,+        "_hint": "Registered in Installed Libraries. A running Altium predates the install and needs a restart to see the new library — the page/AI tells the user; the verb never closes their Altium."});+    if args.get("capture").and_then(|b| b.as_bool()) == Some(true) {+        if !schlibs.is_empty() && !running {+            agent::call("show_library", &[("path", schlibs[0].to_string_lossy().replace('/', "\\"))], None, Duration::from_secs(90));+            let frame = shot::shot_of_main();+            if let Some(img) = frame.get("image").and_then(|i| i.as_str()) {+                out["thumbnailDataUrl"] = json!(format!("data:image/png;base64,{img}"));+                out["shot"] = frame.clone();+            }+            if args.get("closeEditor").and_then(|b| b.as_bool()) == Some(true) {+                agent::call("run_process", &[("process", "Client:CloseDocument".into()), ("params", "".into())], None, Duration::from_secs(30));+            }+        } else {+            out["proofSkipped"] = json!("Altium was already running; its library tables predate this install.");+        }+    }+    out+}++#[cfg(test)]+mod tests {+    use super::*;++    #[test]+    fn model_spec_separators_are_sanitised() {+        assert_eq!(spec_safe("a;b|c"), "a,b/c");+        assert_eq!(params_spec(&json!({"MPN": "X;1", "Val": "10k"})), "MPN=X,1;Val=10k");+    }++    #[test]+    fn library_name_validation_matches_python() {+        assert!(valid_lib_name("UTA Longhorn Racing"));+        assert!(valid_lib_name("Lib_1.2-x"));+        assert!(!valid_lib_name(" leading-space"));+        assert!(!valid_lib_name("bad/slash"));+        assert!(!valid_lib_name(""));+        assert_eq!(sanitise_lib_name("a b/c"), "a_b_c");+    }+}
bridge-rs/src/license.rsadded+263
@@ -0,0 +1,263 @@+//! Licence lifecycle. The License Management page is a WebView2 whose DOM IS in the a11y tree,+//! so the quiet lane works (John: "you keep turning on the cursor" → UIA FIRST, no cursor, no+//! foreground); the measured pixel table is only the fallback. The seat rules live in+//! `support.rs` — one On-Demand seat serves every Altium app on the machine.+use crate::support::{self, s_or};+use crate::{ab, agent, instance, shot};+use serde_json::{json, Value};+use std::time::Duration;++/// image-space on a 1400x840 safe capture of a maximised main window (measured 2026-08-29)+fn page_coords(action: &str) -> Option<(i64, i64)> {+    Some(match action {+        "refresh" => (1288, 178),+        "use_license" => (1252, 388),+        "start_trial" => (788, 607),+        "sign_out" => (386, 179),+        _ => return None,+    })+}++/// The SAME actions by accessible name / automationId — the quiet lane (measured 2026-09-01).+fn page_name(action: &str) -> &str {+    match action {+        "refresh" => "Refresh",+        "use_license" => "Use License",+        "start_trial" => "Start Trial",+        "sign_out" => "Sign Out",+        other => other,+    }+}++fn page_automation_id(action: &str) -> Option<&'static str> {+    match action {+        "use_license" => Some("UseLicenseButton"),+        _ => None,+    }+}++/// UIA first (quiet), image-space click only if UIA cannot reach the control.+fn click_by_name_then_pixels(hwnd: i64, name: &str, automation_id: Option<&str>, pixel: impl FnOnce() -> Value) -> Value {+    let mut args = json!({"hwnd": hwnd, "name": name, "role": "button"});+    if let Some(id) = automation_id {+        args["automationId"] = json!(id);+    }+    let r = ab::call("desktop_ui_click", args, &[], 30);+    if r.get("success").and_then(|b| b.as_bool()) == Some(true) {+        let mut out = r;+        out["quiet"] = json!(true);+        out["windowEtiquette"] = json!("background");+        out["lane"] = json!("uia");+        return out;+    }+    let mut out = pixel();+    out["lane"] = json!("image_click");+    out["windowEtiquette"] = json!("foreground");+    out["_uiaError"] = r.get("error").or_else(|| r.get("_hint")).cloned().unwrap_or(Value::Null);+    out+}++/// Drive the License Management page. Every call returns a capture so the caller can see the+/// state and click anything else itself.+pub fn license(args: &Value) -> Value {+    let lic = instance::licence_state();+    if lic.get("running").and_then(|b| b.as_bool()) != Some(true) {+        return json!({"success": false, "errorCode": "altium_not_running", "_hint": "altium_launch first."});+    }+    let hwnd = lic.get("hwnd").and_then(|h| h.as_i64()).unwrap_or(0);+    let action = s_or(args, "action", "show");+    if agent::is_alive() {+        agent::call_no_autostart("run_process", &[("process", "Client:LicenseSubscriptionNotification".into()), ("params", "".into())], Duration::from_secs(15));+        // bring the License Management view up via its process launcher (background)+        agent::call_no_autostart("run_process", &[("process", "Client:MyAccount".into()), ("params", "".into())], Duration::from_secs(20));+    }+    std::thread::sleep(Duration::from_secs(1));+    let win = ab::call("desktop_screenshot_window", json!({"hwnd": hwnd}), &[], 20);+    let mut frame = shot::shot_of_main();+    let shot_id = win.get("coordMap").and_then(|c| c.get("shotId")).or_else(|| win.get("shotId")).cloned();+    if frame.is_object() {+        frame["shotId"] = shot_id.clone().unwrap_or(Value::Null);+        frame["coordMap"] = win.get("coordMap").cloned().unwrap_or(Value::Null);+    }+    let mut out = json!({"success": true, "licenceState": instance::licence_state().get("state"),+        "shot": frame, "windowEtiquette": "background"});+    if let Some((x, y)) = page_coords(&action) {+        let sid = shot_id.clone();+        let r = click_by_name_then_pixels(hwnd, page_name(&action), page_automation_id(&action), move || {+            match sid {+                Some(id) if !id.is_null() =>+                    ab::call("desktop_click", json!({"space": "image", "shotId": id, "x": x, "y": y, "hwnd": hwnd}), &[], 20),+                _ => json!({"success": false, "error": "no shotId for the pixel fallback"}),+            }+        });+        out["windowEtiquette"] = r.get("windowEtiquette").cloned().unwrap_or(json!("foreground"));+        out["clicked"] = json!({"action": action, "lane": r.get("lane"), "result": r});+        std::thread::sleep(Duration::from_secs(2));+        instance::invalidate_snapshot();+        out["licenceStateAfter"] = instance::licence_state().get("state").cloned().unwrap_or(Value::Null);+        out["shotAfter"] = ab::call("desktop_screenshot_window", json!({"hwnd": hwnd}), &[], 20);+    }+    out["_hint"] = json!("actions: show | refresh | use_license | start_trial | sign_out. Coordinates were measured on a maximised window; verify against shot.image before trusting a click. start_trial opens the browser workspace-creation flow (name, terms, Activate) — see the altium-install skill.");+    out+}++/// Plain-language answer to 'what Altium licence am I on?'.+pub fn license_status(_args: &Value) -> Value {+    let lic = instance::licence_state();+    if lic.get("running").and_then(|b| b.as_bool()) != Some(true) {+        return json!({"success": true, "running": false, "licensed": null,+            "summary": "Altium is not running. altium_launch (or altium_ensure_licensed) first.",+            "_hint": "altium_launch first."});+    }+    let state = lic.get("state").and_then(|st| st.as_str()).unwrap_or("Unknown").to_string();+    let licensed = lic.get("licensed").and_then(|b| b.as_bool());+    let page = license(&json!({"action": "show"}));+    let mut seat_stuck = Value::Null;+    let summary = if licensed == Some(true) {+        format!("Licensed and in use: {state}.")+    } else if matches!(state.to_lowercase().as_str(), "no license" | "no licence") {+        seat_stuck = json!("possible");+        "Signed in but NOT currently using a licence. If the License Management page shows 'no seats available', the single seat is still leased by a prior/crashed session — it frees on its own, or Sign Out + Sign In forces the server to release it, then Use license. altium_ensure_licensed automates the recovery.".to_string()+    } else {+        format!("Licence state: {state}.")+    };+    json!({"success": true, "running": true, "licensed": licensed, "licenceState": state,+        "seatStuck": seat_stuck, "summary": summary, "shot": page.get("shot"),+        "_hint": "Structured state from the window title; read shot.image for the product row (product, Trial/Full, days left, seat n/n, licence code). Trial info lives ONLY on the page."})+}++/// One call to get the user usable: launch if needed, claim a seat, or name the human step.+pub fn ensure_licensed(args: &Value) -> Value {+    let mut steps: Vec<Value> = Vec::new();+    let mut lic = instance::licence_state();+    if lic.get("running").and_then(|b| b.as_bool()) != Some(true) {+        let wait = args.get("launchWaitSec").and_then(|v| v.as_u64()).unwrap_or(240);+        let lr = crate::lifecycle::launch(Duration::from_secs(wait));+        steps.push(json!({"launch": lr.get("success"), "detail": lr.get("error")}));+        instance::invalidate_snapshot();+        lic = instance::licence_state();+    }+    if lic.get("licensed").and_then(|b| b.as_bool()) == Some(true) {+        return json!({"success": true, "alreadyLicensed": true, "licenceState": lic.get("state"),+            "steps": steps, "summary": format!("Already licensed: {:?}.", lic.get("state").and_then(|st| st.as_str()).unwrap_or("?"))});+    }+    // try to use an available seat+    let used = license(&json!({"action": "use_license"}));+    steps.push(json!({"use_license": used.get("licenceStateAfter").or_else(|| used.get("licenceState"))}));+    std::thread::sleep(Duration::from_secs(2));+    instance::invalidate_snapshot();+    let lic = instance::licence_state();+    if lic.get("licensed").and_then(|b| b.as_bool()) == Some(true) {+        return json!({"success": true, "licenceState": lic.get("state"), "steps": steps,+            "summary": format!("Acquired a seat: {:?}.", lic.get("state").and_then(|st| st.as_str()).unwrap_or("?"))});+    }+    // no seat: it is leased elsewhere (crash/other machine)+    if args.get("allowSignInReset").and_then(|b| b.as_bool()) == Some(true) {+        let so = license(&json!({"action": "sign_out"}));+        steps.push(json!({"sign_out": so.get("licenceStateAfter")}));+        return json!({"success": false, "errorCode": "signed_out_for_reset", "steps": steps,+            "summary": "No seat was free, so I signed you out. Sign back in (browser flow) and the server will hand the seat back; then altium_ensure_licensed again.",+            "needsHuman": "browser sign-in", "shot": so.get("shotAfter")});+    }+    if args.get("allowTrial").and_then(|b| b.as_bool()) == Some(true) {+        let tr = license(&json!({"action": "start_trial"}));+        steps.push(json!({"start_trial": "opened the trial/workspace flow in the browser"}));+        return json!({"success": false, "errorCode": "trial_needs_browser", "steps": steps,+            "summary": "Started the free-trial flow — finish it in the browser (name the workspace, accept terms, Activate), then altium_ensure_licensed again.",+            "needsHuman": "browser trial activation", "shot": tr.get("shot")});+    }+    let st = license_status(&json!({}));+    support::seat_hints(+        json!({"success": false, "errorCode": "no_seat_available", "steps": steps,+            "summary": "A licence exists but its single seat is in use (a prior or crashed session still holds it). Retry shortly, or call again with allowSignInReset:true to Sign Out + back in, which forces the server to release it.",+            "seatStuck": true, "shot": st.get("shot")}),+        instance::licence_state().get("state").and_then(|x| x.as_str()),+        instance::instances().len())+}++/// Onboarding: nothing → working trial. Returns the ONE next action; human steps are named,+/// not faked (the elevated installer's EULA click sits behind the UIPI wall).+pub fn setup_trial(_args: &Value) -> Value {+    let inst = support::first_install();+    if inst.is_none() {+        return json!({"success": false, "stage": "install",+            "summary": "Altium Designer is not installed. Use the altium-install skill: it downloads AltiumDesignerSetup, verifies the signature and drives the wizard — but the installer self-elevates, so a non-elevated bridge cannot click past the EULA (UIPI wall); the user clicks Next there.",+            "needsHuman": "installer EULA click", "nextAction": "run the altium-install skill"});+    }+    let lic = instance::licence_state();+    if lic.get("running").and_then(|b| b.as_bool()) != Some(true) {+        return json!({"success": true, "stage": "launch", "installed": true,+            "summary": "Altium is installed but not running. Launching it next.",+            "nextAction": "altium_launch (or altium_ensure_licensed)"});+    }+    let ss = signin_status(&json!({}));+    let signed_in = ss.get("account").map(|v| !v.is_null()).unwrap_or(false)+        || ss.get("user").map(|v| !v.is_null()).unwrap_or(false)+        || ss.get("signedIn").and_then(|b| b.as_bool()).unwrap_or(false);+    if !signed_in && lic.get("licensed").and_then(|b| b.as_bool()) == Some(false) {+        return json!({"success": false, "stage": "signin", "installed": true, "running": true,+            "summary": "Altium is open but no account is signed in. Sign in at the account menu (browser flow) with a free Altium account, then start the trial.",+            "needsHuman": "browser sign-in", "nextAction": "sign in, then altium_setup_trial again"});+    }+    if lic.get("licensed").and_then(|b| b.as_bool()) == Some(true) {+        return json!({"success": true, "stage": "done", "licenceState": lic.get("state"),+            "summary": format!("Already set up and licensed: {:?}.", lic.get("state").and_then(|st| st.as_str()).unwrap_or("?"))});+    }+    // signed in, not licensed -> start the trial+    ensure_licensed(&json!({"allowTrial": true}))+}++/// Account + workspace connection as Altium itself reports it.+pub fn signin_status(_args: &Value) -> Value {+    let lic = instance::licence_state();+    let mut out = json!({"success": true, "running": lic.get("running"),+        "licensed": lic.get("licensed"), "licenceState": lic.get("state")});+    if lic.get("running").and_then(|b| b.as_bool()) != Some(true) {+        out["_hint"] = json!("altium_launch first.");+        return out;+    }+    if agent::is_alive() {+        let st = agent::call_no_autostart("run_process", &[("process", "Client:CurrentUser".into()), ("params", "".into())], Duration::from_secs(15));+        out["currentUserProcess"] = st.get("data").cloned().unwrap_or(Value::Null);+    }+    // A whole-window UIA tree takes >45 s on this box (even scoped), so ask one exact question+    // instead: is there a menu item literally named "Not Connected"?+    let hwnd = lic.get("hwnd").cloned().unwrap_or(Value::Null);+    let r = ab::call("desktop_find_control", json!({"hwnd": hwnd, "name": "Not Connected", "role": "menuitem"}), &[], 25);+    let found = r.get("success").and_then(|b| b.as_bool()).unwrap_or(false)+        && (r.get("best").map(|b| !b.is_null()).unwrap_or(false) || r.get("control").map(|c| !c.is_null()).unwrap_or(false));+    out["workspaceMenu"] = json!(if found { "Not Connected" } else { "(connected: the menu shows the workspace name)" });+    out["workspaceConnected"] = json!(!found);+    out["workspace"] = Value::Null;+    out["_hint"] = json!("Signed-in account is on the License Management page (altium_license {action:'show'}). 'Not Connected' = no Altium 365 workspace attached: altium_connect_workspace opens the picker.");+    out+}++/// Open Altium's workspace picker (the 'Not Connected' menu). Its list is a custom control with+/// no UIA children, so the verb returns a screenshot with coordMap for the caller to click.+pub fn connect_workspace(_args: &Value) -> Value {+    let lic = instance::licence_state();+    if lic.get("running").and_then(|b| b.as_bool()) != Some(true) {+        return json!({"success": false, "errorCode": "altium_not_running"});+    }+    let hwnd = lic.get("hwnd").cloned().unwrap_or(Value::Null);+    ab::call("desktop_ui_expand", json!({"hwnd": hwnd, "name": "Not Connected", "role": "menuitem"}), &[], 25);+    let mut picker: Option<Value> = None;+    for _ in 0..8 {+        std::thread::sleep(Duration::from_millis(400));+        instance::invalidate_snapshot();+        picker = instance::altium_windows().into_iter()+            .find(|w| w.get("className").and_then(|c| c.as_str()) == Some("TWorkspacesForm"));+        if picker.is_some() {+            break;+        }+    }+    let picker = match picker {+        Some(p) => p,+        None => return json!({"success": false, "errorCode": "picker_not_open",+            "_hint": "Altium may already be connected (menu text is the workspace name) — altium_signin_status."}),+    };+    let shot = ab::call("desktop_screenshot_window", json!({"hwnd": picker.get("hwnd")}), &[], 20);+    json!({"success": true, "picker": picker, "shot": shot, "windowEtiquette": "background",+        "_hint": "The picker lists candidate workspaces; click the wanted row with ab desktop_click {space:'image', shotId, x, y}. It is a popup — it closes on focus loss."})+}
bridge-rs/src/lifecycle.rs+121−47
@@ -117,19 +117,25 @@ fn resident_generation() -> Option<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+    // The SELECTED instance's own image first — Develop and Designer live in different install+    // folders, and the launcher hardcoding AD26 sent a Develop-targeted -R lane to Designer+    // (live bug, 2026-09-01).+    if let Some(exe) = crate::instance::selected().and_then(|i| i.get("exe").and_then(|e| e.as_str()).map(str::to_string)) {+        return Some(exe);+    }+    if let Some(exe) = crate::instance::instances().first().and_then(|i| i.get("exe").and_then(|e| e.as_str()).map(str::to_string)) {+        return Some(exe);+    }     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!({"nameFilter": "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)))+        .and_then(|a| a.iter().find_map(|p| p.get("commandLine").and_then(|s| s.as_str()).and_then(crate::instance::exe_from_command_line))) }  /// Spawn the X2 helper. Process spawning is NOT desktop-shaped, and ab's direct API refuses@@ -279,41 +285,141 @@ fn kill_stray_helpers() -> Vec<u64> {  // ---- 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() {+pub fn x2_exe_pub() -> Option<String> {+    x2_exe()+}++pub fn spawn_helper_pub(exe: &str, arg: &str) -> Result<std::process::Child, String> {+    spawn_helper(exe, arg)+}++/// The -R lane against a CALLER-SUPPLIED .PrjScr (altium_run_script). Same one-shot rules.+pub fn run_script_lane_in(proc_name: &str, prjscr: Option<&str>, timeout: Duration) -> Value {+    match prjscr {+        Some(p) => run_script_lane_impl(proc_name, p.to_string(), timeout),+        None => run_script_lane(proc_name, timeout),+    }+}++/// -R RunScriptText: STATEMENTS ONLY (Begin ... End;). Refused while any agent is resident.+pub fn run_script_text_lane(text: &str, timeout: Duration) -> Value {+    if let Some((ws, age)) = agent::any_agent_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"});+            "liveWorkspace": ws.to_string_lossy(), "heartbeatAgeSec": age,+            "error": "a resident agent loop is already running - a second script would raise Altium's 'Another script executing now' modal on the user's screen",+            "_hint": "Use altium_eval (goes through the agent) while the agent is up; altium_agent_stop first if you truly need the text lane."});     }     let exe = match x2_exe() {         Some(e) => e,         None => return json!({"success": false, "errorCode": "host_app_not_installed", "hostApp": "Altium Designer"}),     };+    if text.contains('"') {+        return json!({"success": false, "error": "RunScriptText text cannot contain double quotes (the -R arg is double-quoted); use single quotes for strings."});+    }+    let arg = format!("-RScriptingSystem:RunScriptText(Text=\"{text}\")");+    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()});+    }+    let _ = child.kill();+    let _ = child.wait();+    let reaped = reap_busy_notice(Duration::from_secs(4));+    json!({"success": false, "errorCode": "instance_busy", "busyNoticeReaped": reaped,+           "_hint": "altium_check_dialogs, then retry."})+}++/// 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 {     let prj = match prjscr_path() {         Some(p) => p.to_string_lossy().to_string(),         None => return json!({"success": false, "errorCode": "template_missing"}),     };+    run_script_lane_impl(proc_name, prj, timeout)+}++fn run_script_lane_impl(proc_name: &str, prj: String, timeout: Duration) -> Value {+    // ANY live loop refuses the lane, not just this instance's: Altium raises "Another script+    // executing now" for the second script regardless of which workspace the first one polls+    // (an upgraded bridge leaves the OLD loop heartbeating under the legacy path).+    if let Some((ws, age)) = agent::any_agent_alive() {+        return json!({"success": false, "errorCode": "agent_resident",+            "error": "an Adom agent loop is resident; a second script would raise Altium's 'Another script executing now' modal",+            "liveWorkspace": ws.to_string_lossy(), "heartbeatAgeSec": age,+            "_hint": "send the work through the agent (eval / run_command), or stop the loop polling liveWorkspace first (altium_agent_stop, with instance: if it is another instance's)"});+    }+    let exe = match x2_exe() {+        Some(e) => e,+        None => return json!({"success": false, "errorCode": "host_app_not_installed", "hostApp": "Altium Designer"}),+    };     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}")}),     };+    // PRE-EMPT: watch for OUR OWN modals for as long as the helper is in flight and press OK the+    // instant one appears — the user should never have to find it. Anything not ours (a save+    // prompt, a licence question) is left strictly alone.+    let (watcher, cleared) = start_modal_watcher();     let t0 = Instant::now();     if wait_helper(&mut child, timeout) {-        return json!({"success": true, "helperSec": t0.elapsed().as_secs_f64(), "arg": arg});+        watcher.store(true, std::sync::atomic::Ordering::Relaxed);+        let mut out = json!({"success": true, "helperSec": t0.elapsed().as_secs_f64(), "arg": arg});+        let c = cleared.lock().map(|g| g.clone()).unwrap_or_default();+        if !c.is_empty() {+            out["modalsPreempted"] = json!(c);+        }+        return out;     }     let _ = child.kill();     let _ = child.wait();+    watcher.store(true, std::sync::atomic::Ordering::Relaxed);     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,+           "modalsPreempted": cleared.lock().map(|g| g.clone()).unwrap_or_default(),            "_hint": "ONE SHOT: do not retry this lane. agent_recover frees a halted engine first."}) } +/// Modals this bridge CAUSES. Only these are auto-cleared while an operation is in flight;+/// anything else (a save prompt, a licence question) belongs to the user.+const OUR_MODALS: [&str; 4] = ["another script executing", "another instance of",+    "is busy at the moment", "scripting system is not able"];++fn start_modal_watcher() -> (std::sync::Arc<std::sync::atomic::AtomicBool>, std::sync::Arc<std::sync::Mutex<Vec<Value>>>) {+    let stop = std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false));+    let cleared = std::sync::Arc::new(std::sync::Mutex::new(Vec::new()));+    let (s2, c2) = (stop.clone(), cleared.clone());+    let _ = std::thread::Builder::new().name("altium-lane-modal-watcher".into()).spawn(move || {+        while !s2.load(std::sync::atomic::Ordering::Relaxed) {+            for d in crate::dialogs::scan() {+                let blob = format!("{} {}",+                    d.get("title").and_then(|t| t.as_str()).unwrap_or(""),+                    d.get("body").and_then(|b| b.as_str()).unwrap_or("")).to_lowercase();+                if !OUR_MODALS.iter().any(|k| blob.contains(k)) {+                    continue;+                }+                if let Some(hw) = d.get("hwnd").and_then(|h| h.as_i64()) {+                    ab::call("desktop_ui_click", json!({"hwnd": hw, "name": "OK", "role": "button"}), &[], 8);+                    if let Ok(mut g) = c2.lock() {+                        g.push(json!({"title": d.get("title"),+                            "body": d.get("body").and_then(|b| b.as_str()).map(|b| b.chars().take(120).collect::<String>()),+                            "clearedInFlight": true}));+                    }+                }+            }+            std::thread::sleep(Duration::from_millis(400));+        }+    });+    (stop, cleared)+}+ // ---- start / stop / recover --------------------------------------------------  pub fn start(wait: Duration) -> Value {@@ -461,44 +567,12 @@ pub fn launch(wait: Duration) -> Value {            "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())})-}+// NOTE: application close lives in misc::close (the seat-guarded verb); the old lifecycle-level+// close was removed when it landed — one close path, one seat rule.  // ---- sha256 (no external crate) ---------------------------------------------- -fn sha256_hex(data: &[u8]) -> String {+pub 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,
bridge-rs/src/main.rs+20−3
@@ -1,18 +1,30 @@ //! altium-bridge (Rust) — spawned by ab with `--port <n>`, binds ADOM_BIND_HOST. //! GET /health|/status -> {status, led, summary, tooltip, bridgeVersion, licence} //! POST /command {command, args} -> verb envelope. Everything desktop-shaped goes to ab.+mod a365; mod ab; mod agent;-mod dialogs; mod board;+mod dialogs; mod health;+mod instance;+mod library;+mod license; mod lifecycle;+mod misc;+mod outputs;+mod progress;+mod review;+mod shot;+mod support;+mod surfaces;+mod taskbar; mod verbs;  use std::time::Instant; use tiny_http::{Header, Method, Response, Server}; -pub const BRIDGE_VERSION: &str = "0.2.0-rs";+pub const BRIDGE_VERSION: &str = "0.2.0";  fn json_response(status: u16, body: &serde_json::Value) -> Response<std::io::Cursor<Vec<u8>>> {     let data = serde_json::to_vec(body).unwrap_or_else(|_| b"{}".to_vec());@@ -33,6 +45,11 @@ fn main() {     let server = Server::http(format!("{host}:{port}")).expect("bind");     let bound = server.server_addr();     println!("[altium] bridge {BRIDGE_VERSION} listening on {bound}");+    // Standing services, started separately so one failing cannot take the other down:+    // the dialog reaper pre-empts Altium's nuisance modals (John: "always pre-empt these from+    // ever showing up"), the idle watcher clears the taskbar overlay after 5 quiet minutes.+    dialogs::start_reaper();+    taskbar::start_idle_watcher();     for mut req in server.incoming_requests() {         let t0 = Instant::now();         let path = req.url().to_string();@@ -54,7 +71,7 @@ fn main() {                     Ok(v) => {                         let command = v.get("command").and_then(|c| c.as_str()).unwrap_or("").to_string();                         let vargs = v.get("args").cloned().unwrap_or_else(|| serde_json::json!({}));-                        let mut out = verbs::dispatch(&command, &vargs, &forwarded);+                        let mut out = verbs::handle(&command, &vargs, &forwarded);                         if let Some(o) = out.as_object_mut() {                             o.insert("durationMs".into(), serde_json::json!(t0.elapsed().as_millis() as u64));                             o.entry("bridgeVersion").or_insert(serde_json::json!(BRIDGE_VERSION));
bridge-rs/src/misc.rsadded+441
@@ -0,0 +1,441 @@+//! Odds and ends: readiness/status, instances, dialog_log, state, diagnostics, the process-+//! launcher catalog (list_commands), launch/close with the seat guard, uninstall.+use crate::support::{self, s, s_or};+use crate::{ab, agent, dialogs, instance, lifecycle, progress, taskbar};+use serde_json::{json, Value};+use std::time::Duration;++pub fn readiness(_args: &Value) -> Value {+    let inst = support::first_install();+    let lic = instance::licence_state();+    let alive = agent::is_alive();+    let running = lic.get("running").and_then(|b| b.as_bool()).unwrap_or(false);+    let licensed = lic.get("licensed").and_then(|b| b.as_bool());+    let ready = inst.is_some() && running && licensed == Some(true) && alive;+    let hint = if inst.is_none() {+        "Not installed — see altium_launch's hint to install.".to_string()+    } else if !running {+        "Altium is installed but not running: altium_launch (cold start ~60s, measured).".to_string()+    } else if licensed == Some(false) {+        format!("Altium is running but the title says {:?} — scripting is refused without a licence. altium_license '{{}}' drives the trial/On-Demand flow.",+            lic.get("state").and_then(|st| st.as_str()).unwrap_or("?"))+    } else if !alive {+        "Licensed and running; the agent loop is not resident — altium_agent_start (≈8s), then drive.".to_string()+    } else {+        "Ready. altium_state for the picture, altium_eval / altium_run_command to act.".to_string()+    };+    json!({"success": true, "hostApp": support::HOST_APP, "installed": inst.is_some(), "install": inst,+        "running": running, "licensed": licensed, "licenceState": lic.get("state"),+        "adVersion": lic.get("adVersion"), "agentAlive": alive, "ready": ready,+        "engine": "rust", "_hint": hint})+}++pub fn status(args: &Value) -> Value {+    let mut r = readiness(args);+    r["windows"] = json!(instance::altium_windows());+    r["dialogs"] = json!(dialogs::scan());+    r["reapedDialogs"] = json!(dialogs::reaped_log(5));+    r+}++pub fn diagnostics(_args: &Value) -> Value {+    let env: serde_json::Map<String, Value> = ["LOCALAPPDATA", "APPDATA", "USERPROFILE", "ProgramFiles", "ADOM_BIND_HOST", "ADOM_DIRECT_API_URL"]+        .iter()+        .map(|k| (k.to_string(), std::env::var(k).map(Value::from).unwrap_or(Value::Null)))+        .collect();+    json!({"success": true, "engine": "rust", "env": env,+        "installs": support::detect_installs(),+        "windows": instance::altium_windows(),+        "agent": agent_status(&json!({})),+        "cacheDir": agent::cache_dir().to_string_lossy()})+}++pub fn agent_status(_args: &Value) -> Value {+    let alive = agent::is_alive();+    let mut out = json!({"success": true, "alive": alive, "responsive": agent::is_responsive(),+        "heartbeat": agent::heartbeat(), "workspace": agent::workspace_dir().to_string_lossy(),+        "engine": "rust"});+    if alive && !agent::is_responsive() {+        out["_hint"] = json!("Loop is alive but busy inside a long Altium process (no heartbeat while a RunProcess runs). Wait; do not start a second agent.");+    }+    out+}++/// Every running Altium, and how to address it. Each instance gets its own agent workspace+/// (`bridges-cache/altium/agent/<key>`), so two resident loops never share an inbox.+pub fn instances_verb(_args: &Value) -> Value {+    let root = agent::cache_dir().join("agent");+    let found: Vec<Value> = instance::instances().into_iter().map(|mut i| {+        let key = i.get("key").and_then(|k| k.as_str()).unwrap_or("").to_string();+        let ws = root.join(&key);+        let alive = std::fs::metadata(ws.join("hb.txt")).and_then(|m| m.modified()).ok()+            .and_then(|t| std::time::SystemTime::now().duration_since(t).ok())+            .map(|d| d < Duration::from_secs(agent::ALIVE_SEC))+            .unwrap_or(false);+        i["agentAlive"] = json!(alive);+        i["agentWorkspace"] = json!(ws.to_string_lossy());+        i+    }).collect();+    let n = found.len();+    let out = json!({"success": true, "count": n, "instances": found,+        "_hint": if n > 1 {+            "Pass instance:<pid|key|word> to any verb to target one. With several running, verbs without it fail with errorCode ambiguous_instance."+        } else {+            "One Altium running — `instance` is optional."+        }});+    support::seat_hints(out, None, n)+}++/// What the standing dialog reaper has pre-empted.+pub fn dialog_log(args: &Value) -> Value {+    let limit = args.get("limit").and_then(|l| l.as_u64()).unwrap_or(25) as usize;+    json!({"success": true, "reaped": dialogs::reaped_log(limit), "running": true,+        "_hint": "Anything NOT listed here that reached the user is either a save prompt (never auto-answered, by design) or a dialog missing from the catalog — report it so it can be added with a safe action."})+}++/// Screenshot ARRAY of every Altium window + logical state from the agent.+pub fn state(args: &Value) -> Value {+    let wins = instance::altium_windows();+    let mut shots: Vec<Value> = Vec::new();+    if args.get("screenshots").and_then(|b| b.as_bool()).unwrap_or(true) {+        for w in &wins {+            let kind = w.get("kind").and_then(|k| k.as_str()).unwrap_or("");+            if !matches!(kind, "main" | "dialog" | "script_error_modal") {+                continue;+            }+            if let Some(hwnd) = w.get("hwnd").and_then(|h| h.as_i64()) {+                let r = ab::call("desktop_screenshot_base64", json!({"hwnd": hwnd}), &[], 15);+                if let Some(img) = r.get("image").or_else(|| r.get("base64")).and_then(|i| i.as_str()) {+                    shots.push(json!({"title": w.get("title"), "hwnd": hwnd, "kind": kind,+                        "image": img, "width": r.get("width"), "height": r.get("height")}));+                }+            }+        }+    }+    let logical = if agent::is_alive() {+        agent::call_no_autostart("state", &[], Duration::from_secs(15))+    } else {+        Value::Null+    };+    json!({"success": true, "licence": instance::licence_state(), "windows": wins,+        "dialogs": dialogs::scan(), "shots": shots, "logical": logical, "focusEvents": [],+        "agentAlive": agent::is_alive(),+        "drivingBadge": {"active": taskbar::badge_active()},+        "_hint": "shots[] are base64 PNGs of every Altium window (render inline). logical.data.documents lists open docs (agent required)."})+}++pub fn progress_verb(_args: &Value) -> Value {+    json!({"success": true, "active": progress::live(false, ""), "history": progress::history(),+        "verbTimes": progress::verb_report(40)})+}++pub fn uninstall_verb(args: &Value) -> Value {+    let mut removed: Vec<String> = Vec::new();+    if agent::is_alive() {+        lifecycle::stop();+    }+    let d = agent::cache_dir();+    if d.exists() && args.get("dryRun").and_then(|b| b.as_bool()) != Some(true) {+        let _ = std::fs::remove_dir_all(&d);+        removed.push(d.to_string_lossy().to_string());+    }+    json!({"success": true, "removed": removed, "dryRun": args.get("dryRun").and_then(|b| b.as_bool()).unwrap_or(false),+        "note": "Altium Designer itself and the user's designs are never touched."})+}++// ---- launch / close (seat-aware) ---------------------------------------------++pub fn launch(args: &Value) -> Value {+    let inst = match support::first_install() {+        Some(i) => i,+        None => return support::not_installed(),+    };+    let lic = instance::licence_state();+    if lic.get("running").and_then(|b| b.as_bool()) == Some(true) {+        return json!({"success": true, "alreadyRunning": true, "licence": lic,+            "windowEtiquette": "background", "_hint": "Already running — nothing launched."});+    }+    let t0 = support::now();+    progress::begin("cold_start", "cold", "launching Altium Designer", "bridge");+    let exe = inst.get("exe").and_then(|e| e.as_str()).unwrap_or("").to_string();+    if let Err(e) = lifecycle::spawn_helper_pub(&exe, "") {+        progress::end("cold_start", "bridge");+        return json!({"success": false, "error": format!("launch failed: {e}")});+    }+    let wait = args.get("waitSec").and_then(|v| v.as_f64()).unwrap_or(180.0);+    while support::now() - t0 < wait {+        instance::invalidate_snapshot();+        let lic = instance::licence_state();+        if lic.get("running").and_then(|b| b.as_bool()) == Some(true) {+            progress::record("cold_start", support::now() - t0, "cold");+            progress::end("cold_start", "bridge");+            return json!({"success": true, "startedInSec": support::elapsed_round(t0), "licence": lic,+                "windowEtiquette": "foreground",+                "_hint": "Altium's own launch foregrounds itself (the one legitimate foreground). Next: altium_readiness; if unlicensed, altium_license."});+        }+        std::thread::sleep(Duration::from_secs(1));+    }+    progress::end("cold_start", "bridge");+    json!({"success": false, "errorCode": "launch_timeout", "error": format!("no Altium main window after {wait}s")})+}++fn strand_refusal(extra: Value) -> Value {+    let lic = instance::licence_state();+    let mut out = json!({"success": false, "errorCode": "would_strand_licence_seat",+        "licenceState": lic.get("state"),+        "error": "force:true kills Altium and STRANDS its licence seat — no other Altium will be able to claim it afterwards, and the licence page will report it as free while refusing every claim.",+        "_hint": "Try altium_close with no force first: a polite WM_CLOSE releases the seat. If it reports blocked_by_dialog, READ the dialog (an Unsaved Changes prompt names the documents — on a shared machine one may belong to another AI thread) and answer it. Only if you truly must kill: repeat with acceptSeatLoss:true."});+    if let (Some(o), Some(e)) = (out.as_object_mut(), extra.as_object()) {+        for (k, v) in e {+            o.insert(k.clone(), v.clone());+        }+    }+    support::seat_hints(out, lic.get("state").and_then(|st| st.as_str()), instance::instances().len())+}++fn kill_instance_pids() -> Vec<u64> {+    let mut killed = Vec::new();+    for i in instance::instances() {+        if let Some(pid) = i.get("pid").and_then(|p| p.as_u64()) {+            let r = ab::call("process_kill", json!({"pid": pid, "reason": "forced altium_close with acceptSeatLoss"}), &[], 20);+            if r.get("success").and_then(|b| b.as_bool()).unwrap_or(false) {+                killed.push(pid);+            }+        }+    }+    killed+}++pub fn close(args: &Value) -> Value {+    let lic = instance::licence_state();+    if lic.get("running").and_then(|b| b.as_bool()) != Some(true) {+        return json!({"success": true, "wasRunning": false});+    }+    let force = args.get("force").and_then(|b| b.as_bool()).unwrap_or(false);+    let accept = args.get("acceptSeatLoss").and_then(|b| b.as_bool()).unwrap_or(false);+    // The INTENT to force is the risk, not whether a dialog happens to block: force:true KILLS+    // the process, which strands the On-Demand seat. Refuse up front.+    if force && !accept && lic.get("licensed").and_then(|b| b.as_bool()) != Some(false) {+        return strand_refusal(json!({}));+    }+    if agent::is_alive() {+        lifecycle::stop();+    }+    if let Some(h) = lic.get("hwnd").and_then(|h| h.as_i64()) {+        ab::call("desktop_set_window_state", json!({"hwnd": h, "state": "close", "reason": "polite close of Altium Designer (releases the licence seat)"}), &[], 20);+    }+    let t0 = support::now();+    let wait = args.get("waitSec").and_then(|v| v.as_f64()).unwrap_or(20.0);+    while support::now() - t0 < wait {+        instance::invalidate_snapshot();+        if instance::instances().is_empty() {+            return json!({"success": true, "closedInSec": support::elapsed_round(t0)});+        }+        let mut dl = dialogs::scan_scoped(true);+        if !dl.is_empty() {+            if force {+                if !accept {+                    return strand_refusal(json!({"dialogs": dl}));+                }+                let killed = kill_instance_pids();+                return json!({"success": true, "forced": true, "killedPids": killed,+                    "dismissedDialogs": dl.iter().map(|d| d.get("title").cloned().unwrap_or(Value::Null)).collect::<Vec<_>>(),+                    "_seatWarning": format!("The licence seat is now STRANDED. {}", support::SEAT_STRANDED_FIX),+                    "_hint": "force:true kills even when a dialog blocks the polite close (e.g. the 'script stopped on a breakpoint' Error that refuses WM_CLOSE)."});+            }+            crate::shot::attach_dialog_shots(&mut dl);+            return json!({"success": false, "errorCode": "close_blocked_by_dialog", "dialogs": dl,+                "_hint": "Altium asked something on close (save?). Read dialogs[] and answer via altium_dismiss_dialogs {hwnd,button}, or pass force:true (with acceptSeatLoss:true — a kill strands the seat)."});+        }+        std::thread::sleep(Duration::from_millis(500));+    }+    if force && accept {+        let killed = kill_instance_pids();+        return json!({"success": true, "forced": true, "killedPids": killed,+            "_seatWarning": format!("The licence seat is now STRANDED. {}", support::SEAT_STRANDED_FIX)});+    }+    json!({"success": false, "errorCode": "close_timeout",+        "_hint": "still running — a modal may be blocking WM_CLOSE (altium_check_dialogs); force:true + acceptSeatLoss:true taskkills and strands the seat"})+}++// ---- the process-launcher catalog (list_commands) ----------------------------+//+// Every menu/toolbar action in Altium is a "process launcher" line in a System/*.rcs table:+//   PL PLPCB:Undo   Command='PCB:Undo'  Caption='&Undo'  Shortcut1='Ctrl+Z' ... End+// AD 26.9.1 ships 47 .rcs files -> 3,205 launchers -> 1,109 distinct commands.++static RCS_CACHE: std::sync::Mutex<Option<(std::time::Instant, String, Value)>> = std::sync::Mutex::new(None);++/// Parse `Key='value'` attributes ('' = escaped quote) off a launcher line.+fn parse_attrs(rest: &str) -> Vec<(String, String)> {+    let bytes = rest.as_bytes();+    let mut out = Vec::new();+    let mut i = 0;+    while i < bytes.len() {+        // find identifier start+        while i < bytes.len() && !bytes[i].is_ascii_alphanumeric() && bytes[i] != b'_' {+            i += 1;+        }+        let ks = i;+        while i < bytes.len() && (bytes[i].is_ascii_alphanumeric() || bytes[i] == b'_') {+            i += 1;+        }+        if i >= bytes.len() || ks == i {+            break;+        }+        if bytes[i] != b'=' || i + 1 >= bytes.len() || bytes[i + 1] != b'\'' {+            continue;+        }+        let key = rest[ks..i].to_string();+        i += 2; // past ='+        let vs = i;+        loop {+            if i >= bytes.len() {+                return out; // unterminated — drop the tail+            }+            if bytes[i] == b'\'' {+                if i + 1 < bytes.len() && bytes[i + 1] == b'\'' {+                    i += 2; // escaped ''+                    continue;+                }+                break;+            }+            i += 1;+        }+        out.push((key, rest[vs..i].replace("''", "'")));+        i += 1;+    }+    out+}++fn parse_rcs_dir(system_dir: &str) -> Value {+    if let Ok(g) = RCS_CACHE.lock() {+        if let Some((t, dir, v)) = g.as_ref() {+            if dir == system_dir && t.elapsed() < Duration::from_secs(3600) {+                return v.clone();+            }+        }+    }+    let mut commands: std::collections::BTreeMap<String, Value> = std::collections::BTreeMap::new();+    let mut files = 0;+    let mut launchers = 0;+    if let Ok(rd) = std::fs::read_dir(system_dir) {+        let mut paths: Vec<_> = rd.flatten().map(|e| e.path())+            .filter(|p| p.extension().map(|e| e.to_string_lossy().to_lowercase() == "rcs").unwrap_or(false))+            .collect();+        paths.sort();+        for rcs in paths {+            files += 1;+            let Ok(bytes) = std::fs::read(&rcs) else { continue };+            let text: String = bytes.iter().map(|b| *b as char).collect(); // latin-1+            let fname = rcs.file_name().map(|n| n.to_string_lossy().to_string()).unwrap_or_default();+            for line in text.lines() {+                let t = line.trim_start();+                if t.starts_with("//") || !t.starts_with("PL") {+                    continue;+                }+                let Some(rest) = t.trim_end().strip_suffix("End") else { continue };+                // PL<word> <id> <attrs>+                let mut it = rest.splitn(3, char::is_whitespace).filter(|q| !q.is_empty());+                let (Some(_pl), Some(_id), Some(attrs_str)) = (it.next(), it.next(), it.next()) else { continue };+                launchers += 1;+                let attrs = parse_attrs(attrs_str);+                let cmd = attrs.iter().find(|(k, _)| k == "Command").map(|(_, v)| v.clone()).unwrap_or_default();+                if cmd.is_empty() {+                    continue;+                }+                let server = cmd.split(':').next().unwrap_or("").to_string();+                let e = commands.entry(cmd.clone()).or_insert_with(|| json!({+                    "command": cmd, "server": server, "captions": [], "shortcuts": [],+                    "params": [], "descriptions": [], "sources": []}));+                let push_unique = |e: &mut Value, list: &str, v: String| {+                    if v.is_empty() {+                        return;+                    }+                    let arr = e[list].as_array_mut().unwrap();+                    if !arr.iter().any(|x| x.as_str() == Some(v.as_str())) {+                        arr.push(json!(v));+                    }+                };+                for (k, list) in [("Caption", "captions"), ("Description", "descriptions"), ("Params", "params")] {+                    let mut v = attrs.iter().find(|(a, _)| a == k).map(|(_, v)| v.clone()).unwrap_or_default();+                    if k == "Caption" {+                        v = v.replace('&', "");+                    }+                    push_unique(e, list, v);+                }+                for k in ["Shortcut1", "Shortcut2"] {+                    let v = attrs.iter().find(|(a, _)| a == k).map(|(_, v)| v.clone()).unwrap_or_default();+                    push_unique(e, "shortcuts", v);+                }+                push_unique(e, "sources", fname.clone());+            }+        }+    }+    let mut servers: std::collections::BTreeMap<String, u64> = std::collections::BTreeMap::new();+    for c in commands.values() {+        *servers.entry(c.get("server").and_then(|st| st.as_str()).unwrap_or("").to_string()).or_default() += 1;+    }+    let data = json!({"systemDir": system_dir, "files": files, "launchers": launchers,+        "distinctCommands": commands.len(), "servers": servers,+        "commands": commands.into_iter().collect::<serde_json::Map<_, _>>()});+    if let Ok(mut g) = RCS_CACHE.lock() {+        *g = Some((std::time::Instant::now(), system_dir.to_string(), data.clone()));+    }+    data+}++pub fn list_commands(args: &Value) -> Value {+    let inst = match support::first_install() {+        Some(i) => i,+        None => return support::not_installed(),+    };+    let system_dir = inst.get("system_dir").and_then(|d| d.as_str()).unwrap_or("").to_string();+    let data = parse_rcs_dir(&system_dir);+    let q = s_or(args, "query", "").to_lowercase();+    let server = s(args, "server");+    let limit = args.get("limit").and_then(|l| l.as_u64()).unwrap_or(50) as usize;+    let mut hits = Vec::new();+    if let Some(cmds) = data.get("commands").and_then(|c| c.as_object()) {+        for (cmd, e) in cmds {+            if let Some(srv) = &server {+                if e.get("server").and_then(|st| st.as_str()).map(|x| !x.eq_ignore_ascii_case(srv)).unwrap_or(true) {+                    continue;+                }+            }+            if !q.is_empty() {+                let hay = format!("{cmd} {} {} {}",+                    e["captions"].to_string(), e["descriptions"].to_string(), e["params"].to_string()).to_lowercase();+                if !hay.contains(&q) {+                    continue;+                }+            }+            let mut hit = serde_json::Map::new();+            for k in ["command", "server", "captions", "shortcuts", "params", "descriptions"] {+                hit.insert(k.into(), e.get(k).cloned().unwrap_or(Value::Null));+            }+            hits.push(Value::Object(hit));+            if hits.len() >= limit {+                break;+            }+        }+    }+    json!({"success": true, "query": q, "server": server, "count": hits.len(),+        "total": data.get("distinctCommands"), "servers": data.get("servers"), "hits": hits,+        "_hint": "Run one with altium_run_command {command:'PCB:Zoom', params:'Action=All'} (goes through the agent). Params are Name=Value|Name=Value."})+}++#[cfg(test)]+mod tests {+    use super::parse_attrs;++    #[test]+    fn rcs_attr_parser_handles_escapes() {+        let attrs = parse_attrs("Command='PCB:Undo'  Caption='&Undo'  Shortcut1='Ctrl+Z' Description='It''s undo' Params=''");+        let get = |k: &str| attrs.iter().find(|(a, _)| a == k).map(|(_, v)| v.clone());+        assert_eq!(get("Command").as_deref(), Some("PCB:Undo"));+        assert_eq!(get("Caption").as_deref(), Some("&Undo"));+        assert_eq!(get("Description").as_deref(), Some("It's undo"));+        assert_eq!(get("Params").as_deref(), Some(""));+    }+}
bridge-rs/src/outputs.rsadded+354
@@ -0,0 +1,354 @@+//! Outputs and project ops: compile/ERC, OutJobs (gerber/BOM), STEP export via Barrett's+//! AdomBridge extension, the DRC modal dance, and the -R script lanes.+use crate::support::{self, s, s_or, timeout_of};+use crate::{ab, agent, dialogs, instance, lifecycle, shot};+use serde_json::{json, Value};+use std::time::Duration;++fn ensure_project(args: &Value) -> (String, Value) {+    let path = s(args, "projectPath").or_else(|| s(args, "filePath")).unwrap_or_else(support::demo_project);+    let r = agent::call("open", &[("path", path.clone())], None, timeout_of(args, 60));+    (path, r)+}++pub fn compile(args: &Value) -> Value {+    if let Some(g) = support::need_licensed() {+        return g;+    }+    let t0 = support::now();+    let (path, r) = ensure_project(args);+    if r.get("success").and_then(|b| b.as_bool()) != Some(true) {+        return r;+    }+    let c = agent::call("compile",+        &[("objectKind", s_or(args, "objectKind", "Project")), ("limit", s_or(args, "limit", "200"))],+        None, timeout_of(args, 120));+    if c.get("success").and_then(|b| b.as_bool()) != Some(true) {+        return c;+    }+    let data = c.get("data").cloned().unwrap_or(json!({}));+    let msgs = data.get("messages").and_then(|m| m.as_array()).cloned().unwrap_or_default();+    let mut by_class: std::collections::BTreeMap<String, u64> = std::collections::BTreeMap::new();+    for m in &msgs {+        let k = m.get("class").and_then(|c| c.as_str()).unwrap_or("?").to_string();+        *by_class.entry(k).or_default() += 1;+    }+    json!({"success": true, "project": path, "messageCount": data.get("messageCount"),+        "byClass": by_class, "messages": msgs, "elapsedSec": support::elapsed_round(t0),+        "windowEtiquette": "background",+        "_hint": "messages[] is Altium's Messages panel after Compile (ERC + connectivity). Zero messages = clean."})+}++pub fn run_outjob(args: &Value) -> Value {+    if let Some(g) = support::need_licensed() {+        return g;+    }+    let t0 = support::now();+    let outjob = s(args, "outjobPath").or_else(|| s(args, "filePath")).unwrap_or_else(support::demo_outjob_fab);+    let medium = s_or(args, "medium", "Folder Structure");+    let proj = s(args, "projectPath")+        .or_else(|| if outjob.starts_with(support::EXAMPLES_DIR) { Some(support::demo_project()) } else { None });+    if let Some(p) = &proj {+        let r = support::agent_open(p, 60);+        if r.get("success").and_then(|b| b.as_bool()) != Some(true) {+            return r;+        }+    }+    let o = agent::call("outjob",+        &[("path", outjob.clone()), ("medium", medium.clone()), ("objectKind", s_or(args, "objectKind", "OutputBatch"))],+        None, timeout_of(args, 300));+    let mut dl = dialogs::scan_scoped(true);+    shot::attach_dialog_shots(&mut dl);+    // what the OutJob wrote under "Project Outputs for <project>"+    let base = std::path::Path::new(proj.as_deref().unwrap_or(&outjob));+    let stem = base.file_stem().map(|q| q.to_string_lossy().to_string()).unwrap_or_default();+    let pdir = base.parent().map(|p| p.join(format!("Project Outputs for {stem}")));+    let (out_dir, files) = match pdir {+        Some(d) if d.exists() => {+            let mut files = Vec::new();+            let mut stack = vec![d.clone()];+            while let Some(q) = stack.pop() {+                if let Ok(rd) = std::fs::read_dir(&q) {+                    for e in rd.flatten() {+                        let p = e.path();+                        if p.is_dir() {+                            stack.push(p);+                        } else if let Ok(rel) = p.strip_prefix(&d) {+                            files.push(rel.to_string_lossy().to_string());+                        }+                    }+                }+            }+            files.sort();+            files.truncate(60);+            (Some(d.to_string_lossy().to_string()), files)+        }+        _ => (None, Vec::new()),+    };+    let ok = o.get("success").and_then(|b| b.as_bool()).unwrap_or(false) && dl.is_empty();+    json!({"success": ok, "outjob": outjob, "medium": medium, "agent": o.get("data"),+        "outputDir": out_dir, "files": files, "dialogs": dl,+        "elapsedSec": support::elapsed_round(t0), "windowEtiquette": "background",+        "_hint": "files[] is what the OutJob wrote under 'Project Outputs for <project>'. A dialog means a medium needed a decision (printer/PDF path) — read dialogs[].shot."})+}++pub fn export_gerber(args: &Value) -> Value {+    let mut a = args.clone();+    if a.get("outjobPath").is_none() {+        a["outjobPath"] = json!(support::demo_outjob_fab());+    }+    if a.get("medium").is_none() {+        a["medium"] = json!("Folder Structure");+    }+    let mut r = run_outjob(&a);+    r["surface"] = json!("gerber");+    r+}++pub fn export_bom(args: &Value) -> Value {+    let mut a = args.clone();+    if a.get("outjobPath").is_none() {+        a["outjobPath"] = json!(support::demo_outjob_asm());+    }+    if a.get("medium").is_none() {+        a["medium"] = json!("Folder Structure");+    }+    let mut r = run_outjob(&a);+    r["surface"] = json!("bom");+    r+}++// ---- the AdomBridge .NET extension (Barrett's) — parameterized processes -----++/// SPACE-FREE: Altium's -R parser splits on whitespace.+const EXT_REQ_DIR: &str = r"C:\Users\Public\AdomAltium";++pub const EXT_STATUS_NOTE: &str = "MEASURED 2026-08-29 on AD 26.9.1: the AdomBridge server never loads into the RUNNING instance — `X2 -RAdomBridge:*` helpers wait on Altium's startup mutex ~65-90 s and then host the server themselves, and RunProcess('AdomBridge:Ping') from inside the agent is silently ignored (unknown process). It also coincided with a 'Could not start Vault Explorer' warning at startup, so it was unregistered again. Keep the verbs; they fail loudly until the extension is rebuilt/verified for AD 26.";++fn extension_installed() -> bool {+    let base = std::path::Path::new(r"C:\ProgramData\Altium");+    let Ok(rd) = std::fs::read_dir(base) else { return false };+    for e in rd.flatten() {+        let name = e.file_name().to_string_lossy().to_string();+        if name.starts_with("Altium Designer")+            && e.path().join("Extensions").join("AdomBridge").join("AdomBridge.dll").exists()+        {+            return true;+        }+    }+    false+}++/// Extension directory shipped beside the exe (release packs `extension/` next to it).+fn ext_dir() -> std::path::PathBuf {+    std::env::current_exe()+        .ok()+        .and_then(|p| p.parent().map(|d| d.join("extension")))+        .unwrap_or_else(|| std::path::PathBuf::from("extension"))+}++/// X2.EXE -RAdomBridge:<Process>(Req=<file>) with the parameters in a request file at a+/// space-free path. The extension writes its result file; only the filesystem is proof.+fn ext_dispatch(process: &str, req_lines: &[String], result_file: &str, timeout: Duration) -> Value {+    if !extension_installed() {+        return json!({"success": false, "errorCode": "extension_not_installed",+            "_hint": "altium_install_extension (Altium closed), restart Altium, retry."});+    }+    let exe = match lifecycle::x2_exe_pub() {+        Some(e) => e,+        None => return support::not_installed(),+    };+    let _ = std::fs::create_dir_all(EXT_REQ_DIR);+    let req = format!("{EXT_REQ_DIR}\\{}_req.txt", process.to_lowercase());+    let res = format!("{EXT_REQ_DIR}\\{result_file}");+    let _ = std::fs::remove_file(&res);+    if let Err(e) = std::fs::write(&req, format!("{}\n", req_lines.join("\n"))) {+        return json!({"success": false, "error": format!("cannot write request file: {e}")});+    }+    let mut child = match lifecycle::spawn_helper_pub(&exe, &format!("-RAdomBridge:{process}(Req={req})")) {+        Ok(c) => c,+        Err(e) => return json!({"success": false, "error": format!("could not launch X2 helper: {e}")}),+    };+    let t0 = std::time::Instant::now();+    while t0.elapsed() < timeout {+        if std::path::Path::new(&res).exists() {+            std::thread::sleep(Duration::from_millis(300));+            let body = std::fs::read_to_string(&res).unwrap_or_default();+            return json!({"success": true, "result": body, "elapsedSec": (t0.elapsed().as_secs_f64() * 10.0).round() / 10.0});+        }+        std::thread::sleep(Duration::from_millis(500));+    }+    let _ = child.kill();+    let _ = child.wait();+    json!({"success": false, "errorCode": "extension_timeout",+        "elapsedSec": (t0.elapsed().as_secs_f64() * 10.0).round() / 10.0,+        "_hint": format!("No result file. {EXT_STATUS_NOTE}")})+}++/// Headless STEP with real holes/copper/models — Barrett's AdomBridge:ExportStep.+pub fn export_step(args: &Value) -> Value {+    if let Some(g) = support::need_licensed() {+        return g;+    }+    let pcb = s(args, "filePath").unwrap_or_else(support::demo_pcb);+    let out = s(args, "output").unwrap_or_else(|| {+        let stem = pcb.rsplit_once('.').map(|(a, _)| a.to_string()).unwrap_or_else(|| pcb.clone());+        format!("{stem}.step")+    });+    let lines = vec![+        format!("PcbDoc={pcb}"),+        format!("Out={out}"),+        format!("Holes={}", s_or(args, "holes", "All")),+        format!("PadHoles={}", if args.get("padHoles").and_then(|b| b.as_bool()).unwrap_or(true) { "true" } else { "false" }),+        format!("Copper={}", s_or(args, "copper", "None")),+        format!("Models={}", s_or(args, "models", "Step")),+        format!("Folded={}", if args.get("folded").and_then(|b| b.as_bool()).unwrap_or(false) { "true" } else { "false" }),+    ];+    let mut r = ext_dispatch("ExportStep", &lines, "adombridge_exportstep.txt", timeout_of(args, 300));+    let exists = std::path::Path::new(&out).exists();+    r["output"] = json!(out);+    r["outputExists"] = json!(exists);+    r["outputBytes"] = json!(if exists { std::fs::metadata(&out).map(|m| m.len()).unwrap_or(0) } else { 0 });+    if r.get("success").and_then(|b| b.as_bool()) == Some(true) && !exists {+        r["success"] = json!(false);+        r["errorCode"] = json!("step_not_written");+    }+    if r.get("_hint").is_none() {+        r["_hint"] = json!("Copper=All bakes traces as solids (30x bigger file); Holes=All gives real drilled geometry.");+    }+    r+}++pub fn import_kicad(args: &Value) -> Value {+    let file = match s(args, "filePath") {+        Some(f) if !f.is_empty() => f,+        _ => return json!({"success": false, "error": "filePath (.kicad_sym / .kicad_sch / .kicad_pcb) is required"}),+    };+    let lines = vec![format!("Mode={}", s_or(args, "mode", "Import")), format!("File={file}")];+    let mut r = ext_dispatch("ImportKiCad", &lines, "adombridge_importkicad.txt", timeout_of(args, 300));+    if r.get("_hint").is_none() {+        r["_hint"] = json!("Output lands in 'Imported <stem>' beside the source. Footprints are NOT imported by Altium's KiCad importer — link one with altium_set_params {footprint}.");+    }+    r+}++pub fn install_extension(args: &Value) -> Value {+    let installer = ext_dir().join("Install-AdomBridge.ps1");+    if !installer.exists() {+        return json!({"success": false, "error": format!("installer missing at {}", installer.display())});+    }+    let remove = args.get("remove").and_then(|b| b.as_bool()).unwrap_or(false);+    if !instance::instances().is_empty() && !remove {+        return json!({"success": false, "errorCode": "altium_running",+            "error": "Altium must be CLOSED to install/update the extension (it holds AdomBridge.dll open).",+            "_hint": "altium_close, then altium_install_extension, then altium_launch. Put extension/REMOVE-AdomBridge.cmd on the Desktop first — a failed extension load stops Altium starting."});+    }+    let mut cmd = std::process::Command::new("powershell");+    cmd.args(["-NoProfile", "-ExecutionPolicy", "Bypass", "-File"])+        .arg(&installer)+        .arg("-Source")+        .arg(ext_dir().join("AdomBridge"));+    if remove {+        cmd.arg("-Remove");+    }+    #[cfg(windows)]+    {+        use std::os::windows::process::CommandExt;+        cmd.creation_flags(0x0800_0000); // CREATE_NO_WINDOW+    }+    match cmd.output() {+        Ok(p) => {+            let stdout = String::from_utf8_lossy(&p.stdout).to_string();+            let stderr = String::from_utf8_lossy(&p.stderr).to_string();+            let ok = p.status.success() && !stderr.to_lowercase().contains("denied");+            json!({"success": ok, "removed": remove,+                "output": stdout.chars().rev().take(1200).collect::<String>().chars().rev().collect::<String>(),+                "stderr": stderr.chars().rev().take(400).collect::<String>().chars().rev().collect::<String>(),+                "_hint": "Start Altium — extensions load at startup. Then altium_export_step / altium_import_kicad."})+        }+        Err(e) => json!({"success": false, "error": format!("powershell failed to start: {e}")}),+    }+}++pub fn extension_ping(_args: &Value) -> Value {+    let mut r = ext_dispatch("Ping", &["Msg=hello".to_string()], "adombridge_ping.txt", Duration::from_secs(30));+    r["installedOnDisk"] = json!(extension_installed());+    r+}++// ---- DRC: the modal dance ----------------------------------------------------++/// PCB:DesignRuleCheck opens a MODAL — the agent blocks inside RunProcess until it closes.+/// The bridge drives the dialog's 'Run Design Rule Check' via ab UIA in the background, then+/// reads the violations off the board through the agent.+pub fn run_drc(args: &Value) -> Value {+    if let Some(g) = support::need_licensed() {+        return g;+    }+    let t0 = support::now();+    let path = s(args, "filePath").unwrap_or_else(support::demo_pcb);+    let r = support::agent_open(&path, 60);+    if r.get("success").and_then(|b| b.as_bool()) != Some(true) {+        return r;+    }+    // fire the process WITHOUT waiting for the agent (the modal freezes the loop)+    agent::call("run_process", &[("process", "PCB:DesignRuleCheck".into()), ("params", "".into())], None, Duration::from_secs(6));+    let mut dlg: Option<Value> = None;+    for _ in 0..30 {+        instance::invalidate_snapshot();+        let hit = instance::altium_windows().into_iter().find(|w| {+            w.get("kind").and_then(|k| k.as_str()) != Some("main")+                && w.get("title").and_then(|t| t.as_str()).map(|t| t.to_lowercase().contains("rule")).unwrap_or(false)+        });+        if hit.is_some() {+            dlg = hit;+            break;+        }+        std::thread::sleep(Duration::from_millis(500));+    }+    let mut steps = vec![json!({"dialog": dlg.as_ref().and_then(|d| d.get("title")).cloned(),+                                "hwnd": dlg.as_ref().and_then(|d| d.get("hwnd")).cloned()})];+    let dlg = match dlg {+        Some(d) => d,+        None => return json!({"success": false, "errorCode": "drc_dialog_not_found", "steps": steps,+            "_hint": "PCB:DesignRuleCheck did not open its dialog — is a PCB the focused document? altium_window_info."}),+    };+    let hwnd = dlg.get("hwnd").and_then(|h| h.as_i64()).unwrap_or(0);+    let click = ab::call("desktop_ui_click", json!({"hwnd": hwnd, "contains": "Run Design Rule Check", "role": "button"}), &[], 30);+    steps.push(json!({"uiClick": "Run Design Rule Check", "result": click}));+    // wait for the agent to come back (the modal closed) and DRC to finish+    let wait = timeout_of(args, 180);+    let t1 = std::time::Instant::now();+    while t1.elapsed() < wait {+        if agent::is_responsive() {+            break;+        }+        std::thread::sleep(Duration::from_millis(500));+    }+    // the DRC report (HTML) opens as a new document and takes focus — put the board back first+    support::agent_open(&path, 60);+    let v = agent::call("violations", &[("limit", s_or(args, "limit", "200"))], None, Duration::from_secs(60));+    let data = v.get("data").cloned().unwrap_or(json!({}));+    steps.push(json!({"violations": {"success": v.get("success"),+        "error": data.get("error").or_else(|| v.get("error")), "ms": v.get("roundtripMs")}}));+    let mut dl = dialogs::scan_scoped(true);+    shot::attach_dialog_shots(&mut dl);+    let ok = v.get("success").and_then(|b| b.as_bool()).unwrap_or(false) && dl.is_empty();+    json!({"success": ok, "board": path, "violationCount": data.get("violationCount"),+        "violations": data.get("violations"), "steps": steps, "dialogs": dl,+        "elapsedSec": support::elapsed_round(t0), "windowEtiquette": "background",+        "_hint": "violations[] are the DRC markers on the board (rule, description, location). A DRC report document may also have opened — altium_state shows it."})+}++/// One-shot: run ProcName in a .PrjScr through the -R lane (agent must NOT be resident).+pub fn run_script(args: &Value) -> Value {+    if let Some(g) = support::need_licensed() {+        return g;+    }+    let prj = match s(args, "prjscr") {+        Some(p) if !p.is_empty() => p,+        _ => return json!({"success": false, "error": "prjscr (absolute path to a .PrjScr on this machine) is required"}),+    };+    let proc = s_or(args, "proc", "Main");+    lifecycle::run_script_lane_in(&proc, Some(&prj), timeout_of(args, 60))+}
bridge-rs/src/progress.rsadded+385
@@ -0,0 +1,385 @@+//! The MEASURED-progress contract (wiki issue #32), ported from `progress.py`. John's rule:+//! every slow verb reports measured progress, never "can take a couple of minutes". Estimates come+//! from THIS machine's own history — the store paths and JSON shapes are byte-compatible with the+//! Python bridge's, so a machine's measured history survives the engine swap.+use serde_json::{json, Value};+use std::collections::BTreeMap;+use std::path::PathBuf;+use std::sync::Mutex;+use std::time::{SystemTime, UNIX_EPOCH};++pub const PREFIX: &str = "altium";+const KEEP: usize = 10; // rolling window per key (fusion parity)+const RUNLOG_MAX_BYTES: u64 = 4 * 1024 * 1024;++static LOCK: Mutex<()> = Mutex::new(());++/// Shipped defaults, used ONLY to seed a machine with no history of its own — each a measured+/// figure from this fleet, not a guess.+fn default_seconds(phase: &str) -> Option<f64> {+    Some(match phase {+        "cold_start" => 60.0,+        "warm_start" => 1.0,+        "agent_start" => 8.0,+        "show_symbol" => 12.0,+        "show_footprint" => 20.0,+        "show_3d_chip" => 25.0,+        "show_library" => 12.0,+        "show_schematic" => 15.0,+        "show_2d_board" => 15.0,+        "show_3d_board" => 30.0,+        "show_project" => 10.0,+        "install_plugin" => 6.0,+        "run_outjob" => 60.0,+        "export" => 8.0,+        "demo" => 210.0,+        _ => return None,+    })+}++fn store_dir() -> PathBuf {+    let base = std::env::var("USERPROFILE")+        .or_else(|_| std::env::var("HOME"))+        .unwrap_or_else(|_| ".".into());+    let d = PathBuf::from(base).join(".adom");+    let _ = std::fs::create_dir_all(&d);+    d+}++fn timings_path() -> PathBuf {+    store_dir().join("altium-bridge-timings.json")+}++fn runlog_path() -> PathBuf {+    store_dir().join("altium-verb-times.jsonl")+}++fn load() -> BTreeMap<String, Vec<f64>> {+    std::fs::read_to_string(timings_path())+        .ok()+        .and_then(|s| serde_json::from_str::<BTreeMap<String, Vec<f64>>>(&s).ok())+        .unwrap_or_default()+}++fn save(data: &BTreeMap<String, Vec<f64>>) {+    if let Ok(s) = serde_json::to_string_pretty(data) {+        let _ = std::fs::write(timings_path(), s);+    }+}++fn key(phase: &str, start_kind: &str) -> String {+    format!("{phase}:{}", if start_kind.is_empty() { "warm" } else { start_kind })+}++fn pct(samples: &[f64], q: f64) -> f64 {+    if samples.is_empty() {+        return 0.0;+    }+    let mut s = samples.to_vec();+    s.sort_by(|a, b| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal));+    let i = ((q * (s.len() as f64 - 1.0)).round() as usize).min(s.len() - 1);+    (s[i] * 100.0).round() / 100.0+}++/// Add one real measurement. This is what makes confidence "measured".+pub fn record(phase: &str, seconds: f64, start_kind: &str) {+    if !(0.0..=7200.0).contains(&seconds) {+        return;+    }+    let _g = LOCK.lock();+    let mut data = load();+    let k = key(phase, start_kind);+    let samples = data.entry(k).or_default();+    samples.push((seconds * 100.0).round() / 100.0);+    let len = samples.len();+    if len > KEEP {+        samples.drain(..len - KEEP);+    }+    save(&data);+}++/// (estimatedSec, confidence) for this phase ON THIS MACHINE.+/// 3+ samples -> p50 "measured"; 1-2 -> blend with the shipped seed, "typical";+/// a bare seed -> "typical"; nothing at all -> (0, "unknown") = indeterminate marquee, never a+/// guessed number wearing a measurement's clothes.+pub fn estimate(phase: &str, start_kind: &str) -> (f64, String) {+    let data = load();+    let samples = data.get(&key(phase, start_kind)).cloned().unwrap_or_default();+    if samples.len() >= 3 {+        return (pct(&samples, 0.5), "measured".into());+    }+    let default = default_seconds(phase);+    if !samples.is_empty() {+        let base = default.unwrap_or(*samples.last().unwrap());+        let avg = samples.iter().sum::<f64>() / samples.len() as f64;+        return ((((avg + base) / 2.0) * 100.0).round() / 100.0, "typical".into());+    }+    if let Some(d) = default {+        return (d, "typical".into());+    }+    (0.0, "unknown".into())+}++/// Everything we know, for describe/status: p50 + p90 + sample counts.+pub fn history() -> Value {+    let mut out = serde_json::Map::new();+    for (k, samples) in load() {+        if samples.is_empty() {+            continue;+        }+        let (phase, kind) = k.split_once(':').unwrap_or((k.as_str(), "warm"));+        let entry = out.entry(phase.to_string()).or_insert_with(|| json!({}));+        entry[kind] = json!({"samples": samples.len(), "p50": pct(&samples, 0.5), "p90": pct(&samples, 0.9)});+    }+    Value::Object(out)+}++fn now_secs() -> f64 {+    SystemTime::now().duration_since(UNIX_EPOCH).map(|d| d.as_secs_f64()).unwrap_or(0.0)+}++/// Build the progress block in the shape ab consumes verbatim. Formulas adopted from fusion's+/// timings.py (John: copy the method, do not redesign): 0->80 across the estimate, 80->95+/// asymptotic when overdue, the two meeting at elapsed == estimate so percent never walks+/// backwards at the crossover.+pub fn block(phase: &str, step_label: &str, started_at: f64, percent: Option<i64>,+             start_kind: &str, blocked_by: Option<&str>, done: bool) -> Value {+    let elapsed = (now_secs() - started_at).max(0.0);+    let (est0, mut confidence) = estimate(phase, start_kind);+    let mut est = est0;+    let overdue = est0 > 0.0 && elapsed > est0;+    if overdue {+        est = (elapsed * 1.25 * 10.0).round() / 10.0;+        if confidence == "measured" {+            confidence = "typical".into();+        }+        if elapsed > 2.0 * est0 {+            confidence = "unknown".into();+        }+    }+    let mut percent = percent;+    if done {+        percent = Some(100);+        est = (elapsed * 100.0).round() / 100.0;+    } else if percent.is_none() {+        percent = if est0 <= 0.0 {+            None+        } else if !overdue {+            Some((80.0 * elapsed / est0) as i64)+        } else {+            let over = elapsed - est0;+            Some(80 + (15.0 * (over / (over + est0))) as i64)+        };+    }+    let eta = percent.map(|_| ((est - elapsed).max(0.0) * 10.0).round() / 10.0);+    let mut step_label = step_label.to_string();+    if !done && est0 > 0.0 && overdue && confidence == "unknown" {+        step_label = format!("{step_label} (taking far longer than this machine ever has)");+    }+    if let Some(b) = blocked_by {+        step_label = format!("waiting for you: {b}");+    }+    let mut out = json!({+        "phase": format!("{PREFIX}.{phase}"),+        "stepLabel": step_label,+        "percent": percent,+        "elapsedSec": (elapsed * 10.0).round() / 10.0,+        "estimatedSec": (est * 10.0).round() / 10.0,+        "etaSec": eta,+        "confidence": confidence,+        "startKind": start_kind,+    });+    if !done && est0 > 0.0 {+        out["overdue"] = json!(overdue);+    }+    if let Some(b) = blocked_by {+        out["blockedBy"] = json!(b);+    }+    if done {+        out["measured"] = json!({"phase": format!("{PREFIX}.{phase}"), "seconds": (elapsed * 100.0).round() / 100.0, "startKind": start_kind});+    }+    out+}++// ---- live, in-flight progress (in-memory on purpose: a stale frame surviving a restart lies) --++struct Live {+    started_at: f64,+    start_kind: String,+    step_label: String,+    percent: Option<i64>,+    blocked_by: Option<String>,+    seq: u64,+}++static LIVE: Mutex<Vec<((String, String), Live)>> = Mutex::new(Vec::new());+static LIVE_SEQ: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0);++pub fn begin(phase: &str, start_kind: &str, step_label: &str, caller: &str) {+    let seq = LIVE_SEQ.fetch_add(1, std::sync::atomic::Ordering::Relaxed) + 1;+    if let Ok(mut g) = LIVE.lock() {+        g.retain(|(k, _)| !(k.0 == caller && k.1 == phase));+        g.push(((caller.to_string(), phase.to_string()),+                Live { started_at: now_secs(), start_kind: start_kind.into(),+                       step_label: step_label.into(), percent: None, blocked_by: None, seq }));+    }+}++/// Advance the visible sub-step of an in-flight phase — the live streaming contract. Kept as a+/// public API for verbs that report sub-steps; not every verb does yet.+#[allow(dead_code)]+pub fn step(phase: &str, step_label: &str, percent: Option<i64>, blocked_by: Option<&str>, caller: &str) {+    if let Ok(mut g) = LIVE.lock() {+        let hit = g.iter_mut().find(|(k, _)| k.0 == caller && k.1 == phase).map(|(_, v)| v);+        let hit = match hit {+            Some(v) => Some(v),+            None => {+                // fall back to the only in-flight entry for this phase; never guess among several+                let mut it = g.iter_mut().filter(|(k, _)| k.1 == phase);+                match (it.next(), it.next()) {+                    (Some((_, v)), None) => Some(v),+                    _ => None,+                }+            }+        };+        if let Some(v) = hit {+            v.step_label = step_label.into();+            v.blocked_by = blocked_by.map(str::to_string);+            if let Some(p) = percent {+                // never let a bar go backwards: a bar that retreats reads as broken+                v.percent = Some(p.max(v.percent.unwrap_or(0)));+            }+        }+    }+}++pub fn end(phase: &str, caller: &str) {+    if let Ok(mut g) = LIVE.lock() {+        g.retain(|(k, _)| !(k.0 == caller && k.1 == phase));+    }+}++/// Every phase currently in flight, as full progress blocks (percent/ETA recomputed at read time;+/// a step's explicit percent is a monotonic FLOOR, so the bar always advances).+pub fn live(mine_only: bool, asker: &str) -> Vec<Value> {+    let snapshot: Vec<(String, String, f64, String, String, Option<i64>, Option<String>, u64)> =+        LIVE.lock()+            .map(|g| {+                g.iter()+                    .map(|((who, phase), v)| {+                        (who.clone(), phase.clone(), v.started_at, v.start_kind.clone(),+                         v.step_label.clone(), v.percent, v.blocked_by.clone(), v.seq)+                    })+                    .collect()+            })+            .unwrap_or_default();+    let mut out = Vec::new();+    for (who, phase, started_at, start_kind, step_label, floor, blocked_by, seq) in snapshot {+        if mine_only && who != asker {+            continue;+        }+        let mut b = block(&phase, &step_label, started_at, None, &start_kind, blocked_by.as_deref(), false);+        if let Some(f) = floor {+            let derived = b.get("percent").and_then(|p| p.as_i64());+            b["percent"] = json!(derived.map(|d| d.max(f)).unwrap_or(f));+        }+        b["seq"] = json!(seq);+        b["caller"] = json!(who);+        out.push(b);+    }+    out+}++// ---- per-verb run times (fusion parity) --------------------------------------++/// Append one verb run to the historical log AND the rolling window. Called at the dispatch+/// chokepoint for EVERY verb. Never fails loudly.+pub fn log_run(verb: &str, seconds: f64, ok: bool) {+    if !(0.0..=7200.0).contains(&seconds) {+        return; // a clock jump is not a sample+    }+    record(&format!("verb:{verb}"), seconds, "warm");+    let p = runlog_path();+    if let Ok(m) = std::fs::metadata(&p) {+        if m.len() > RUNLOG_MAX_BYTES {+            let _ = std::fs::rename(&p, p.with_extension("jsonl.1"));+        }+    }+    let line = json!({"at": now_secs() as i64, "verb": verb, "seconds": (seconds * 100.0).round() / 100.0, "ok": ok, "note": ""});+    use std::io::Write;+    if let Ok(mut f) = std::fs::OpenOptions::new().create(true).append(true).open(&p) {+        let _ = writeln!(f, "{line}");+    }+}++/// Aggregate the historical log: per verb count / p50 / p90 / max / failures, p90 descending —+/// p90, not p50, is the column for hunting slow paths.+pub fn verb_report(limit: usize) -> Value {+    let raw = match std::fs::read_to_string(runlog_path()) {+        Ok(s) => s,+        Err(_) => return json!({}),+    };+    let mut runs: BTreeMap<String, (Vec<f64>, u64)> = BTreeMap::new();+    for line in raw.lines() {+        if let Ok(r) = serde_json::from_str::<Value>(line) {+            let verb = r.get("verb").and_then(|v| v.as_str()).unwrap_or("?").to_string();+            let e = runs.entry(verb).or_default();+            e.0.push(r.get("seconds").and_then(|s| s.as_f64()).unwrap_or(0.0));+            if r.get("ok").and_then(|b| b.as_bool()) != Some(true) {+                e.1 += 1;+            }+        }+    }+    let mut rows: Vec<(String, Value, f64)> = runs+        .into_iter()+        .map(|(verb, (secs, fails))| {+            let p90 = pct(&secs, 0.9);+            let row = json!({"count": secs.len(), "p50": pct(&secs, 0.5), "p90": p90,+                             "max": secs.iter().cloned().fold(f64::NAN, f64::max), "failures": fails});+            (verb, row, p90)+        })+        .collect();+    rows.sort_by(|a, b| b.2.partial_cmp(&a.2).unwrap_or(std::cmp::Ordering::Equal));+    let mut out = serde_json::Map::new();+    for (verb, row, _) in rows.into_iter().take(limit) {+        out.insert(verb, row);+    }+    Value::Object(out)+}++#[cfg(test)]+mod tests {+    use super::*;++    #[test]+    fn percentile_and_estimate_ladder() {+        assert_eq!(pct(&[3.0, 1.0, 2.0], 0.5), 2.0);+        assert_eq!(pct(&[], 0.5), 0.0);+        // a phase with a seed but no samples is "typical", never "unknown"+        let (e, c) = estimate("show_symbol_never_recorded_xyz", "warm");+        assert_eq!((e, c.as_str()), (0.0, "unknown"));+    }++    #[test]+    fn block_percent_meets_at_crossover() {+        // with no estimate the bar is indeterminate, never 0%+        let b = block("no_such_phase_zz", "working", now_secs() - 5.0, None, "warm", None, false);+        assert!(b["percent"].is_null());+        // done carries the measured actuals+        let b = block("no_such_phase_zz", "done", now_secs() - 5.0, None, "warm", None, true);+        assert_eq!(b["percent"], json!(100));+        assert!(b["measured"]["seconds"].as_f64().unwrap() >= 4.9);+    }++    #[test]+    fn live_floor_is_monotonic() {+        begin("t_phase", "warm", "starting", "tester");+        step("t_phase", "half way", Some(50), None, "tester");+        step("t_phase", "later", Some(30), None, "tester"); // lower explicit % must not retreat+        let l = live(true, "tester");+        assert_eq!(l.len(), 1);+        assert!(l[0]["percent"].as_i64().unwrap() >= 50);+        end("t_phase", "tester");+        assert!(live(true, "tester").is_empty());+    }+}
bridge-rs/src/review.rsadded+589
@@ -0,0 +1,589 @@+//! Design review: 14 checks, each a PURE FUNCTION over data the bridge already reads — adding+//! one costs no new Altium API surface (a wrong identifier in the agent halts the script engine+//! behind a modal on the user's screen). Checks needing geometry Altium has not been asked for+//! (outline polygon, silkscreen overlap, ratsnest) are deliberately absent rather than guessed;+//! `notImplemented` in the reply names them so nobody mistakes silence for a clean board.+use crate::board::natural_cmp;+use crate::support::{self, timeout_of};+use crate::agent;+use serde_json::{json, Value};++const PLACEHOLDER_VALUES: [&str; 12] = ["comment", "value", "*", "?", "n/a", "na", "tbd", "xxx",+    "component", "designator", "part", "unknown"];++/// Adom house rule (John, standing): Mouser is the PREFERRED vendor, and every component carries+/// Mouser + DigiKey + LCSC part numbers.+const VENDOR_PATTERNS: [(&str, &[&str]); 3] = [+    ("Mouser", &["mouser"]),+    ("DigiKey", &["digikey", "digi-key", "digi key"]),+    ("LCSC", &["lcsc", "jlcpcb"]),+];++/// Designator prefixes that legitimately carry no vendor part number.+const NON_PURCHASED_PREFIXES: [&str; 8] = ["TP", "FID", "MH", "H", "LOGO", "NET", "PWR", "GND"];++/// Rules whose absence a fabricator will notice before you do: (kind ordinal, name).+const CRITICAL_RULE_KINDS: [(i64, &str); 4] = [(0, "Clearance"), (2, "Width"), (42, "HoleSize"), (15, "ShortCircuit")];++pub const NOT_IMPLEMENTED: [(&str, &str); 6] = [+    ("pads_near_board_edge", "needs per-PAD geometry; components_near_board_edge measures component origins instead, which is a weaker claim"),+    ("outline_shape_checks", "the outline read carries a BOUNDING BOX, not the polygon — a part inside the box but outside an L-shaped or slotted outline is not caught"),+    ("unrouted_connections", "needs the ratsnest"),+    ("silkscreen_over_pad", "needs per-layer primitive geometry"),+    ("via_antennas", "needs via/track topology"),+    ("missing_decoupling", "needs net-to-part topology"),+];++// ---- data access helpers -----------------------------------------------------++fn components(src: &Value) -> Vec<Value> {+    src.get("components").and_then(|c| c.get("components")).and_then(|a| a.as_array()).cloned().unwrap_or_default()+}++fn str_of<'a>(v: &'a Value, k: &str) -> &'a str {+    v.get(k).and_then(|x| x.as_str()).unwrap_or("")+}++fn finding(check: &str, severity: &str, key: (&str, &str), message: String) -> Value {+    let mut f = json!({"check": check, "severity": severity, "message": message});+    if !key.1.is_empty() {+        f[key.0] = json!(key.1);+    }+    f+}++fn is_purchased(designator: &str) -> bool {+    let prefix: String = designator.to_uppercase().chars().filter(|c| c.is_ascii_alphabetic()).collect();+    !NON_PURCHASED_PREFIXES.contains(&prefix.as_str())+}++fn params_of(comp: &Value) -> Vec<(String, String)> {+    comp.get("params").and_then(|p| p.as_array()).map(|a| {+        a.iter().filter_map(|p| {+            let name = p.get("name").and_then(|n| n.as_str())?.trim().to_lowercase();+            let value = p.get("value").and_then(|v| v.as_str()).unwrap_or("").trim().to_string();+            if name.is_empty() { None } else { Some((name, value)) }+        }).collect()+    }).unwrap_or_default()+}++// ---- the checks --------------------------------------------------------------++/// error — a component still carrying Altium's default text instead of a real value/MPN.+fn chk_placeholder_values(board: &Value, _sheet: Option<&Value>) -> Vec<Value> {+    components(board).iter().filter_map(|c| {+        let com = str_of(c, "comment").trim().to_string();+        if PLACEHOLDER_VALUES.contains(&com.to_lowercase().as_str()) {+            Some(finding("placeholder_values", "error", ("designator", str_of(c, "designator")),+                format!("{} has placeholder Comment {com:?} — it will reach the BOM as-is.", str_of(c, "designator"))))+        } else { None }+    }).collect()+}++/// warning — a net with exactly one pad connects to nothing.+fn chk_single_pin_nets(board: &Value, _sheet: Option<&Value>) -> Vec<Value> {+    board.get("nets").and_then(|n| n.get("nets")).and_then(|a| a.as_array()).map(|nets| {+        nets.iter().filter_map(|n| {+            if n.get("pads").and_then(|p| p.as_i64()) == Some(1) {+                let name = str_of(n, "name");+                Some(finding("single_pin_nets", "warning", ("net", name),+                    format!("Net {name:?} has a single pad — it connects to nothing.")))+            } else { None }+        }).collect()+    }).unwrap_or_default()+}++/// info — a mechanical layer that is enabled but carries nothing.+fn chk_unused_mechanical_layers(board: &Value, _sheet: Option<&Value>) -> Vec<Value> {+    let occupied: std::collections::BTreeSet<String> = board.get("occupancy").and_then(|o| o.get("occupancy"))+        .and_then(|a| a.as_array())+        .map(|a| a.iter().filter_map(|l| l.get("layerId").and_then(|i| i.as_str()).map(str::to_string)).collect())+        .unwrap_or_default();+    board.get("layers").and_then(|l| l.get("layers")).and_then(|a| a.as_array()).map(|layers| {+        layers.iter().filter_map(|l| {+            if str_of(l, "kind") != "mechanical" || l.get("displayed").and_then(|d| d.as_bool()) != Some(true) {+                return None;+            }+            if occupied.contains(str_of(l, "id")) {+                return None;+            }+            let name = str_of(l, "name");+            Some(finding("unused_mechanical_layers", "info", ("layer", name),+                format!("Mechanical layer {name:?} is enabled but carries no primitives.")))+        }).collect()+    }).unwrap_or_default()+}++/// info — a placed component whose origin is off the placement grid (5 mil default).+fn chk_off_grid_components(board: &Value, _sheet: Option<&Value>) -> Vec<Value> {+    const GRID_MIL: f64 = 5.0;+    components(board).iter().filter_map(|c| {+        let x = c.get("xMil").and_then(|v| v.as_f64())?;+        let y = c.get("yMil").and_then(|v| v.as_f64())?;+        if (x.round() as i64) % (GRID_MIL as i64) != 0 || (y.round() as i64) % (GRID_MIL as i64) != 0 {+            Some(finding("off_grid_components", "info", ("designator", str_of(c, "designator")),+                format!("{} sits at ({x}, {y}) mil — off the {GRID_MIL} mil grid.", str_of(c, "designator"))))+        } else { None }+    }).collect()+}++/// error — the same designator twice on one sheet/board.+fn chk_duplicate_designators(board: &Value, sheet: Option<&Value>) -> Vec<Value> {+    let mut out = Vec::new();+    let sources: Vec<(&Value, &str)> = sheet.map(|sv| (sv, "sheet")).into_iter()+        .chain(std::iter::once((board, "board")))+        .collect();+    for (src, label) in sources {+        if src.is_null() {+            continue;+        }+        let mut seen: std::collections::BTreeMap<String, u32> = std::collections::BTreeMap::new();+        for c in components(src) {+            let d = str_of(&c, "designator").to_string();+            if !d.is_empty() {+                *seen.entry(d).or_default() += 1;+            }+        }+        for (d, n) in seen {+            if n > 1 {+                out.push(finding("duplicate_designators", "error", ("designator", &d),+                    format!("Designator {d} occurs {n} times on the {label}.")));+            }+        }+    }+    out+}++/// The board extent, or None. Only source == "boardOutline" is trustworthy for off-board claims.+fn outline_box(board: &Value) -> Option<(f64, f64, f64, f64)> {+    let o = board.get("outline")?;+    if o.get("ok").and_then(|b| b.as_bool()) != Some(true) || str_of(o, "source") != "boardOutline" {+        return None;+    }+    Some((+        o.get("leftMil")?.as_f64()?,+        o.get("rightMil")?.as_f64()?,+        o.get("bottomMil")?.as_f64()?,+        o.get("topMil")?.as_f64()?,+    ))+}++/// error — a placed component outside the board extent (a BOUNDING BOX test, and it says so).+fn chk_components_outside_board(board: &Value, _sheet: Option<&Value>) -> Vec<Value> {+    let Some((l, r, b, t)) = outline_box(board) else { return Vec::new() };+    components(board).iter().filter_map(|c| {+        let x = c.get("xMil").and_then(|v| v.as_f64())?;+        let y = c.get("yMil").and_then(|v| v.as_f64())?;+        if x < l || x > r || y < b || y > t {+            Some(finding("components_outside_board_outline", "error", ("designator", str_of(c, "designator")),+                format!("{} sits at ({x}, {y}) mil, outside the board bounding box ({l}..{r}, {b}..{t} mil).", str_of(c, "designator"))))+        } else { None }+    }).collect()+}++/// warning — a component ORIGIN within 20 mil of the board edge (origins, not pads — named for+/// what it measures).+fn chk_components_near_board_edge(board: &Value, _sheet: Option<&Value>) -> Vec<Value> {+    const MARGIN_MIL: f64 = 20.0;+    let Some((l, r, b, t)) = outline_box(board) else { return Vec::new() };+    components(board).iter().filter_map(|c| {+        let x = c.get("xMil").and_then(|v| v.as_f64())?;+        let y = c.get("yMil").and_then(|v| v.as_f64())?;+        if x < l || x > r || y < b || y > t {+            return None; // already reported as outside+        }+        let d = (x - l).min(r - x).min(y - b).min(t - y);+        if d < MARGIN_MIL {+            Some(finding("components_near_board_edge", "warning", ("designator", str_of(c, "designator")),+                format!("{} origin is {} mil from the board edge (under the {MARGIN_MIL} mil margin).", str_of(c, "designator"), d as i64)))+        } else { None }+    }).collect()+}++/// warning — a purchased component missing Mouser / DigiKey / LCSC part numbers (Mouser first:+/// it is the preferred vendor). Test points/fiducials/mounting holes are exempt.+fn chk_vendor_part_numbers(_board: &Value, sheet: Option<&Value>) -> Vec<Value> {+    let Some(sheet) = sheet else { return Vec::new() };+    components(sheet).iter().filter_map(|c| {+        let des = str_of(c, "designator");+        if !is_purchased(des) {+            return None;+        }+        let blob = params_of(c).iter().map(|(k, v)| format!("{k} {v}")).collect::<Vec<_>>().join(" ");+        let missing: Vec<&str> = VENDOR_PATTERNS.iter()+            .filter(|(_, keys)| !keys.iter().any(|k| blob.contains(k)))+            .map(|(vendor, _)| *vendor)+            .collect();+        if missing.is_empty() {+            return None;+        }+        let mut f = finding("vendor_part_numbers", "warning", ("designator", des),+            format!("{des} has no {} part number (house rule: Mouser preferred, all three required).", missing.join(" / ")));+        f["missing"] = json!(missing);+        Some(f)+    }).collect()+}++/// info — a purchased component with no datasheet link.+fn chk_missing_datasheets(_board: &Value, sheet: Option<&Value>) -> Vec<Value> {+    let Some(sheet) = sheet else { return Vec::new() };+    components(sheet).iter().filter_map(|c| {+        let des = str_of(c, "designator");+        if !is_purchased(des) {+            return None;+        }+        let has = params_of(c).iter().any(|(k, _)| {+            matches!(k.as_str(), "datasheet" | "helpurl" | "datasheet link" | "datasheeturl") || k.contains("datasheet")+        });+        if has {+            None+        } else {+            Some(finding("missing_datasheets", "info", ("designator", des),+                format!("{des} has no Datasheet parameter.")))+        }+    }).collect()+}++/// error — a schematic component still carrying default text in Comment (caught at the source,+/// where the BOM is actually generated from).+fn chk_schematic_placeholders(_board: &Value, sheet: Option<&Value>) -> Vec<Value> {+    let Some(sheet) = sheet else { return Vec::new() };+    components(sheet).iter().filter_map(|c| {+        let com = str_of(c, "comment").trim().to_string();+        if PLACEHOLDER_VALUES.contains(&com.to_lowercase().as_str()) {+            Some(finding("schematic_placeholder_values", "error", ("designator", str_of(c, "designator")),+                format!("{} on the sheet has placeholder Comment {com:?}.", str_of(c, "designator"))))+        } else { None }+    }).collect()+}++fn rules(board: &Value) -> Vec<Value> {+    board.get("rules").and_then(|r| r.get("rules")).and_then(|a| a.as_array()).cloned().unwrap_or_default()+}++/// warning/error — a design rule that exists but is switched OFF. A disabled Clearance or+/// ShortCircuit rule is worse than a missing one: the board LOOKS ruled and DRC passes.+fn chk_disabled_design_rules(board: &Value, _sheet: Option<&Value>) -> Vec<Value> {+    rules(board).iter().filter_map(|r| {+        if r.get("enabled").and_then(|e| e.as_bool()) != Some(false) {+            return None;+        }+        let kind = r.get("kind").and_then(|k| k.as_i64()).unwrap_or(-1);+        let kind_name = crate::board::rule_kind_name(kind);+        let sev = if CRITICAL_RULE_KINDS.iter().any(|(k, _)| *k == kind) { "error" } else { "warning" };+        let name = str_of(r, "name");+        Some(finding("disabled_design_rules", sev, ("rule", name),+            format!("Design rule {name:?} ({kind_name}) is DISABLED — DRC will pass without ever applying it.")))+    }).collect()+}++/// error — no ENABLED rule of a kind a fabricator relies on.+fn chk_missing_critical_rules(board: &Value, _sheet: Option<&Value>) -> Vec<Value> {+    let all = rules(board);+    if all.is_empty() {+        return Vec::new(); // no rule data at all is not the same as no rules+    }+    let have: std::collections::BTreeSet<i64> = all.iter()+        .filter(|r| r.get("enabled").and_then(|e| e.as_bool()) != Some(false))+        .filter_map(|r| r.get("kind").and_then(|k| k.as_i64()))+        .collect();+    CRITICAL_RULE_KINDS.iter().filter_map(|(kind, name)| {+        if have.contains(kind) {+            None+        } else {+            Some(finding("missing_critical_rules", "error", ("rule", name),+                format!("No enabled {name} rule on this board.")))+        }+    }).collect()+}++/// info — two enabled rules of the same kind sharing a priority (Altium resolves ties by+/// internal order, which is not readable off the board).+fn chk_duplicate_rule_priority(board: &Value, _sheet: Option<&Value>) -> Vec<Value> {+    let mut seen: std::collections::BTreeMap<(String, String), Vec<String>> = std::collections::BTreeMap::new();+    for r in rules(board) {+        if r.get("enabled").and_then(|e| e.as_bool()) == Some(false) {+            continue;+        }+        let key = (r.get("kind").map(|k| k.to_string()).unwrap_or_default(),+                   r.get("priority").map(|p| p.to_string()).unwrap_or_default());+        seen.entry(key).or_default().push(str_of(&r, "name").to_string());+    }+    seen.into_iter().filter_map(|((kind, prio), mut names)| {+        if names.len() < 2 {+            return None;+        }+        names.sort();+        let kind_name = kind.parse::<i64>().map(crate::board::rule_kind_name).unwrap_or(kind.clone());+        Some(finding("duplicate_rule_priority", "info", ("rule", &names.join(", ")),+            format!("{} enabled {kind_name} rules share priority {prio} ({}) — which one wins is not readable from the board.", names.len(), names.join(", "))))+    }).collect()+}++/// warning — a placed component whose footprint PATTERN is empty (no library identity).+fn chk_components_without_footprint(board: &Value, _sheet: Option<&Value>) -> Vec<Value> {+    components(board).iter().filter_map(|c| {+        if str_of(c, "pattern").trim().is_empty() {+            Some(finding("components_without_footprint", "warning", ("designator", str_of(c, "designator")),+                format!("{} has no footprint pattern name — it cannot be matched back to a library.", str_of(c, "designator"))))+        } else { None }+    }).collect()+}++// (id, fn). The ID is what appears in a finding's `check` field, so `checks:["..."]` filters on+// the name the caller actually sees — deriving it from a function name was a live trap.+type Check = fn(&Value, Option<&Value>) -> Vec<Value>;+pub const BOARD_CHECKS: [(&str, Check); 14] = [+    ("placeholder_values", chk_placeholder_values),+    ("single_pin_nets", chk_single_pin_nets),+    ("unused_mechanical_layers", chk_unused_mechanical_layers),+    ("off_grid_components", chk_off_grid_components),+    ("duplicate_designators", chk_duplicate_designators),+    ("components_outside_board_outline", chk_components_outside_board),+    ("components_near_board_edge", chk_components_near_board_edge),+    // schematic-side: need includeSchematic:true, and quietly return nothing without it+    ("vendor_part_numbers", chk_vendor_part_numbers),+    ("missing_datasheets", chk_missing_datasheets),+    ("schematic_placeholder_values", chk_schematic_placeholders),+    // design-rule hygiene, from the rules the board read already carries+    ("disabled_design_rules", chk_disabled_design_rules),+    ("missing_critical_rules", chk_missing_critical_rules),+    ("duplicate_rule_priority", chk_duplicate_rule_priority),+    ("components_without_footprint", chk_components_without_footprint),+];++const SHEET_CHECKS: [&str; 4] = ["vendor_part_numbers", "missing_datasheets",+    "schematic_placeholder_values", "duplicate_designators"];++/// Run the checks over already-read data. Pure — this is what the fixture tests exercise.+pub fn run_checks(board: &Value, sheet: Option<&Value>, wanted: Option<&Vec<String>>) -> (Vec<Value>, Vec<String>, Value) {+    let have_board = board.is_object() && !board.as_object().map(|o| o.is_empty()).unwrap_or(true);+    let mut findings = Vec::new();+    let mut ran = Vec::new();+    let mut skipped = serde_json::Map::new();+    for (name, f) in BOARD_CHECKS.iter() {+        if let Some(w) = wanted {+            if !w.iter().any(|x| x == name) {+                continue;+            }+        }+        let needs_sheet = SHEET_CHECKS.contains(name);+        if !have_board && !needs_sheet {+            skipped.insert(name.to_string(), json!("no board read"));+            continue;+        }+        if needs_sheet && sheet.is_none() && *name != "duplicate_designators" {+            skipped.insert(name.to_string(), json!("no schematic read — pass includeSchematic:true or sheet:<path>"));+            continue;+        }+        ran.push(name.to_string());+        findings.extend(f(board, sheet));+    }+    let sev_order = |s: &str| match s { "error" => 0, "warning" => 1, "info" => 2, _ => 9 };+    findings.sort_by(|a, b| {+        let sa = sev_order(a.get("severity").and_then(|s| s.as_str()).unwrap_or(""));+        let sb = sev_order(b.get("severity").and_then(|s| s.as_str()).unwrap_or(""));+        sa.cmp(&sb)+            .then_with(|| str_of(a, "check").cmp(str_of(b, "check")))+            .then_with(|| {+                let ka = [str_of(a, "designator"), str_of(a, "net"), str_of(a, "layer")].concat();+                let kb = [str_of(b, "designator"), str_of(b, "net"), str_of(b, "layer")].concat();+                natural_cmp(&ka, &kb)+            })+    });+    (findings, ran, Value::Object(skipped))+}++/// The verb: one agent round trip per document; all analysis in the bridge. Returns PARTIAL+/// results rather than nothing — a review that fails wholesale because one document was not+/// current is a review nobody runs.+pub fn review_board(args: &Value) -> Value {+    if let Some(g) = support::need_licensed() {+        return g;+    }+    let timeout = timeout_of(args, 90);+    let open = |path: &str| -> Option<Value> {+        if let Some(bad) = support::validate_openable(path) {+            return Some(bad);+        }+        let o = agent::call("open", &[("path", path.to_string())], None, timeout);+        let d = o.get("data").cloned().unwrap_or(json!({}));+        if d.get("ok").and_then(|b| b.as_bool()) == Some(false) {+            return Some(json!({"error": d.get("error")}));+        }+        None+    };+    let mut board_data = json!({});+    let mut sheet_data: Option<Value> = None;+    let mut board_err: Option<String> = None;+    let mut sheet_err: Option<String> = None;+    if let Some(bp) = crate::support::s(args, "board") {+        if let Some(e) = open(&bp) {+            let mut out = e;+            out["success"] = json!(false);+            out["stage"] = json!("board");+            return out;+        }+    }+    let r = agent::call("read_board",+        &[("sections", "components,nets,layers,copper,rules,occupancy,outline".into()),+          ("limit", crate::support::s_or(args, "limit", "2000"))],+        None, timeout);+    let bd = r.get("data").cloned().unwrap_or(json!({}));+    if bd.get("ok").and_then(|b| b.as_bool()) == Some(true) {+        board_data = bd;+    } else {+        board_err = Some(bd.get("error").and_then(|e| e.as_str()).unwrap_or("board read failed").to_string());+    }+    let want_sheet = args.get("includeSchematic").and_then(|b| b.as_bool()) == Some(true) || args.get("sheet").is_some();+    if want_sheet {+        if let Some(sp) = crate::support::s(args, "sheet") {+            if let Some(e) = open(&sp) {+                sheet_err = e.get("error").and_then(|x| x.as_str()).map(str::to_string).or(Some("could not open sheet".into()));+            }+        }+        if sheet_err.is_none() {+            let sr = agent::call("read_schematic", &[("sections", "components".into()), ("params", "1".into())], None, timeout);+            let sd = sr.get("data").cloned().unwrap_or(json!({}));+            if sd.get("ok").and_then(|b| b.as_bool()) == Some(true) {+                sheet_data = Some(sd);+            } else {+                sheet_err = Some(sd.get("error").and_then(|e| e.as_str()).unwrap_or("schematic read failed").to_string());+            }+        }+    }+    let have_board = board_err.is_none();+    if !have_board && sheet_data.is_none() {+        return json!({"success": false, "boardError": board_err, "schematicError": sheet_err,+            "_hint": "Nothing could be read. Pass board:<path> and/or sheet:<path> — Altium holds only ONE document current, so the review activates each in turn."});+    }+    let wanted: Option<Vec<String>> = args.get("checks").and_then(|c| c.as_array())+        .map(|a| a.iter().filter_map(|x| x.as_str().map(str::to_string)).collect());+    let unknown: Vec<String> = wanted.as_ref().map(|w| {+        w.iter().filter(|x| !BOARD_CHECKS.iter().any(|(n, _)| n == &x.as_str())).cloned().collect()+    }).unwrap_or_default();+    let board_for_checks = if have_board { board_data.clone() } else { json!({}) };+    let (findings, ran, mut skipped) = run_checks(&board_for_checks, sheet_data.as_ref(), wanted.as_ref());+    if let (Some(err), Some(obj)) = (&board_err, skipped.as_object_mut()) {+        for (_, v) in obj.iter_mut() {+            if v == &json!("no board read") {+                *v = json!(format!("no board read ({err})"));+            }+        }+    }+    let mut counts: std::collections::BTreeMap<String, u64> = std::collections::BTreeMap::new();+    for f in &findings {+        *counts.entry(f.get("severity").and_then(|s| s.as_str()).unwrap_or("?").to_string()).or_default() += 1;+    }+    let mut out = json!({"success": true, "board": board_data.get("board"),+        "sheet": sheet_data.as_ref().and_then(|s| s.get("document")),+        "findings": findings, "counts": counts, "checksRun": ran,+        "availableChecks": BOARD_CHECKS.iter().map(|(n, _)| *n).collect::<Vec<_>>(),+        "notImplemented": NOT_IMPLEMENTED.iter().cloned().collect::<std::collections::BTreeMap<_, _>>(),+        "_hint": "A clean result means the checks that RAN found nothing. `skipped` lists checks with no data; `notImplemented` lists what this review cannot see yet."});+    if skipped.as_object().map(|o| !o.is_empty()).unwrap_or(false) {+        out["skipped"] = skipped;+    }+    if !unknown.is_empty() {+        out["unknownChecks"] = json!(unknown);+    }+    if let Some(e) = board_err {+        out["boardError"] = json!(e);+    }+    if let Some(e) = sheet_err {+        out["schematicError"] = json!(e);+    }+    out+}++#[cfg(test)]+mod tests {+    use super::*;++    fn fixture_board() -> Value {+        let raw = std::fs::read_to_string("fixtures/read_board.json").expect("fixture");+        let v: Value = serde_json::from_str(&raw).expect("fixture json");+        v.get("raw").cloned().unwrap_or(v)+    }++    #[test]+    fn checks_run_clean_over_the_recorded_board() {+        let board = fixture_board();+        let (findings, ran, skipped) = run_checks(&board, None, None);+        // board-side checks all ran; sheet-side were skipped (no sheet given)+        assert!(ran.iter().any(|r| r == "placeholder_values"));+        assert!(skipped.get("vendor_part_numbers").is_some());+        // findings on a real board are plausible, but every one must carry the visible fields+        for f in &findings {+            assert!(f.get("check").is_some() && f.get("severity").is_some() && f.get("message").is_some());+        }+    }++    #[test]+    fn placeholder_and_duplicate_detection() {+        let board = json!({"components": {"components": [+            {"designator": "R1", "comment": "Comment"},+            {"designator": "R1", "comment": "10k"},+            {"designator": "U2", "comment": "STM32"},+        ]}});+        let (findings, _, _) = run_checks(&board, None, Some(&vec!["placeholder_values".into(), "duplicate_designators".into()]));+        assert!(findings.iter().any(|f| f["check"] == json!("placeholder_values") && f["designator"] == json!("R1")));+        assert!(findings.iter().any(|f| f["check"] == json!("duplicate_designators")));+        // errors sort before warnings before info+        let sevs: Vec<&str> = findings.iter().map(|f| f["severity"].as_str().unwrap()).collect();+        let mut sorted = sevs.clone();+        sorted.sort_by_key(|s| match *s { "error" => 0, "warning" => 1, _ => 2 });+        assert_eq!(sevs, sorted);+    }++    #[test]+    fn vendor_rule_needs_the_sheet_and_exempts_non_purchased() {+        let sheet = json!({"components": {"components": [+            {"designator": "U1", "params": [{"name": "Mouser Part #", "value": "123"}]},+            {"designator": "TP1", "params": []},+        ]}});+        let (findings, _, _) = run_checks(&json!({}), Some(&sheet), Some(&vec!["vendor_part_numbers".into()]));+        // U1 misses DigiKey + LCSC; TP1 is exempt+        assert_eq!(findings.len(), 1);+        assert_eq!(findings[0]["designator"], json!("U1"));+        let missing = findings[0]["missing"].as_array().unwrap();+        assert_eq!(missing.len(), 2);+        // Mouser preferred: the message names what is MISSING, and Mouser is present here+        assert!(!findings[0]["message"].as_str().unwrap().contains("no Mouser"));+    }++    #[test]+    fn outline_bounding_box_checks_refuse_weak_sources() {+        let board = json!({+            "outline": {"ok": true, "source": "boardBounds", "leftMil": 0, "rightMil": 1000, "bottomMil": 0, "topMil": 1000},+            "components": {"components": [{"designator": "U9", "xMil": 5000, "yMil": 5000}]}+        });+        // boardBounds is everything on the board incl. strays — a much weaker claim; no findings+        let (findings, _, _) = run_checks(&board, None, Some(&vec!["components_outside_board_outline".into()]));+        assert!(findings.is_empty());+        let board = json!({+            "outline": {"ok": true, "source": "boardOutline", "leftMil": 0, "rightMil": 1000, "bottomMil": 0, "topMil": 1000},+            "components": {"components": [{"designator": "U9", "xMil": 5000, "yMil": 5000},+                                            {"designator": "U8", "xMil": 995, "yMil": 500}]}+        });+        let (findings, _, _) = run_checks(&board, None, None);+        assert!(findings.iter().any(|f| f["check"] == json!("components_outside_board_outline") && f["designator"] == json!("U9")));+        assert!(findings.iter().any(|f| f["check"] == json!("components_near_board_edge") && f["designator"] == json!("U8")));+    }++    #[test]+    fn rule_hygiene_checks() {+        let board = json!({"components": {"components": []}, "rules": {"rules": [+            {"name": "Clearance", "kind": 0, "priority": 1, "enabled": false},+            {"name": "Width1", "kind": 2, "priority": 1, "enabled": true},+            {"name": "Width2", "kind": 2, "priority": 1, "enabled": true},+        ]}});+        let (findings, _, _) = run_checks(&board, None, None);+        // disabled critical rule = error; missing HoleSize + ShortCircuit + Clearance(enabled) = errors+        assert!(findings.iter().any(|f| f["check"] == json!("disabled_design_rules") && f["severity"] == json!("error")));+        let missing: Vec<&str> = findings.iter().filter(|f| f["check"] == json!("missing_critical_rules"))+            .map(|f| f["rule"].as_str().unwrap()).collect();+        assert!(missing.contains(&"Clearance") && missing.contains(&"HoleSize") && missing.contains(&"ShortCircuit"));+        assert!(findings.iter().any(|f| f["check"] == json!("duplicate_rule_priority")));+    }+}
bridge-rs/src/shot.rsadded+107
@@ -0,0 +1,107 @@+//! Background proof capture. John's etiquette (2026-09-01): never park an Altium window+//! off-screen — a user reaching for Altium must find it where they left it. If it is minimized,+//! restore it INTO THE BACKGROUND (bottom of the z-order), capture, and hand the user's+//! foreground straight back (~0.6 s end to end). A capture under MIN_PROOF_WIDTH px is a+//! minimized-window sliver, not evidence — refused as proofUnavailable rather than returned.+use crate::{ab, instance};+use serde_json::{json, Value};++/// Anything narrower is a minimized-window sliver, not a screenshot.+pub const MIN_PROOF_WIDTH: i64 = 400;++fn is_minimized(rect: &Value) -> bool {+    if !rect.is_object() {+        return false;+    }+    let g = |k: &str| rect.get(k).and_then(|v| v.as_i64()).unwrap_or(0);+    g("left") <= -30000 || g("top") <= -30000 || (g("width") < MIN_PROOF_WIDTH && g("height") < 200)+}++/// Restore a minimized window and push it BEHIND the user's windows, then hand the foreground+/// straight back. Returns what it had to do, so a caller can report honestly whether the user's+/// screen was touched.+pub fn make_capturable(hwnd: i64) -> Value {+    let mut out = json!({"restored": false, "sentToBack": false, "foregroundReturned": false});+    let before = ab::call("desktop_get_foreground", json!({}), &[], 8).get("hwnd").and_then(|h| h.as_i64());+    let rect = instance::altium_windows()+        .into_iter()+        .find(|w| w.get("hwnd").and_then(|h| h.as_i64()) == Some(hwnd))+        .map(|w| w.get("rect").cloned().unwrap_or(Value::Null))+        .unwrap_or(Value::Null);+    if is_minimized(&rect) {+        ab::call("desktop_set_window_state", json!({"hwnd": hwnd, "state": "restore",+            "reason": "restore a minimized Altium into the background for a proof capture"}), &[], 10);+        out["restored"] = json!(true);+        instance::invalidate_snapshot();+    }+    ab::call("desktop_set_window_state", json!({"hwnd": hwnd, "state": "bottom",+        "reason": "keep Altium behind the user's windows"}), &[], 10);+    out["sentToBack"] = json!(true);+    let after = ab::call("desktop_get_foreground", json!({}), &[], 8).get("hwnd").and_then(|h| h.as_i64());+    if let Some(b) = before {+        if after != Some(b) {+            ab::call("desktop_raise_window", json!({"hwnd": b,+                "reason": "hand the user's window back after restoring Altium into the background"}), &[], 10);+            out["foregroundReturned"] = json!(true);+        }+        out["userForeground"] = json!(b);+    }+    out+}++/// A capture of the selected Altium's main window, or Null — never a sliver-as-proof.+pub fn shot_of_main() -> Value {+    let mw = match instance::main_window() {+        Some(w) => w,+        None => return Value::Null,+    };+    let hwnd = match mw.get("hwnd").and_then(|h| h.as_i64()) {+        Some(h) => h,+        None => return Value::Null,+    };+    let title = mw.get("title").cloned().unwrap_or(Value::Null);+    let prepared = make_capturable(hwnd);+    let r = ab::call("desktop_screenshot_base64", json!({"hwnd": hwnd}), &[], 20);+    let image = r.get("image").or_else(|| r.get("base64")).and_then(|i| i.as_str()).map(str::to_string);+    match image {+        Some(img) => {+            let w = r.get("width").and_then(|v| v.as_i64());+            let h = r.get("height").and_then(|v| v.as_i64());+            if let Some(wpx) = w {+                if wpx < MIN_PROOF_WIDTH {+                    return json!({"title": title, "hwnd": hwnd, "proofUnavailable": true,+                        "reason": format!("the Altium window is minimized (captured {}x{}) — Windows does not render a minimized window, so there is no image to prove anything", wpx, h.unwrap_or(0)),+                        "prepared": prepared});+                }+            }+            json!({"title": title, "hwnd": hwnd, "image": img, "width": w, "height": h, "prepared": prepared})+        }+        None => Value::Null,+    }+}++/// Altium PAINTS its dialog text (no Static child, not in WM_GETTEXT) — the only way to READ a+/// dialog is to look at it. Attach a base64 PNG per dialog row.+pub fn attach_dialog_shots(dialogs: &mut [Value]) {+    for d in dialogs.iter_mut() {+        if let Some(hwnd) = d.get("hwnd").and_then(|h| h.as_i64()) {+            let r = ab::call("desktop_screenshot_base64", json!({"hwnd": hwnd}), &[], 10);+            if let Some(img) = r.get("image").or_else(|| r.get("base64")).and_then(|i| i.as_str()) {+                d["shot"] = json!(img);+            }+        }+    }+}++#[cfg(test)]+mod tests {+    use super::*;++    #[test]+    fn minimized_detection() {+        assert!(is_minimized(&json!({"left": -32000, "top": -32000, "width": 160, "height": 28})));+        assert!(is_minimized(&json!({"left": 100, "top": 100, "width": 237, "height": 39})));+        assert!(!is_minimized(&json!({"left": 0, "top": 0, "width": 1920, "height": 1080})));+        assert!(!is_minimized(&Value::Null));+    }+}
bridge-rs/src/support.rsadded+207
@@ -0,0 +1,207 @@+//! Shared verb plumbing: the licence gate, seat hints, openable-document validation, install+//! detection, demo constants and small arg helpers. Ported from `server.py`'s top half.+use crate::{agent, dialogs, instance, shot};+use serde_json::{json, Value};+use std::time::Duration;++// ---- arg helpers (every verb speaks loosely-typed JSON) ----------------------++pub fn s(args: &Value, k: &str) -> Option<String> {+    args.get(k).map(|v| match v {+        Value::String(x) => x.clone(),+        Value::Bool(b) => if *b { "1".into() } else { "0".into() },+        other => other.to_string(),+    })+}++pub fn s_or(args: &Value, k: &str, default: &str) -> String {+    s(args, k).unwrap_or_else(|| default.to_string())+}++pub fn f_or(args: &Value, k: &str, default: f64) -> f64 {+    args.get(k)+        .and_then(|v| v.as_f64().or_else(|| v.as_str().and_then(|x| x.parse().ok())))+        .unwrap_or(default)+}++pub fn timeout_of(args: &Value, default: u64) -> Duration {+    Duration::from_secs_f64(f_or(args, "timeout", default as f64).max(1.0))+}++pub fn now() -> f64 {+    std::time::SystemTime::now()+        .duration_since(std::time::UNIX_EPOCH)+        .map(|d| d.as_secs_f64())+        .unwrap_or(0.0)+}++pub fn elapsed_round(t0: f64) -> f64 {+    ((now() - t0) * 10.0).round() / 10.0+}++// ---- licence-seat hints (John, 2026-09-01: "ai hints are way more valuable ... the AI is bad+// at reading skills") ----------------------------------------------------------++pub const SEAT_RULE: &str = "One On-Demand seat serves EVERY Altium app on this machine — Altium Designer and Altium Designer Develop share it. Only one can be licensed at a time.";++pub const SEAT_STRANDED_FIX: &str = "If a claim fails while the licence page shows Seats 0/1 (looks free, is not), the seat is STRANDED by an Altium that was KILLED instead of closed. Fix: altium_close on every other instance (polite WM_CLOSE releases the lease), then altium_license {action:'use_license'}. Waiting does not clear it, and Refresh is a link the UIA layer cannot invoke.";++pub const SEAT_SWAP_HOWTO: &str = "To move the seat between apps: altium_close the one holding it (answer any Unsaved Changes prompt deliberately — on a shared machine the document may belong to another AI thread), then LAUNCH the other app; it claims the free seat silently at startup. No clicking required.";++/// Attach the seat guidance to any reply where the licence is the thing about to bite.+pub fn seat_hints(mut out: Value, licence_state: Option<&str>, instance_count: usize) -> Value {+    let unlicensed = matches!(+        licence_state.map(|s| s.to_lowercase()).as_deref(),+        Some("no license") | Some("no licence") | Some("viewer")+    );+    if unlicensed {+        out["_licenceHint"] = json!(format!("{SEAT_RULE} {SEAT_STRANDED_FIX}"));+    }+    if instance_count > 1 {+        out["_seatHint"] = json!(format!(+            "{SEAT_RULE} {SEAT_SWAP_HOWTO} NOTE: Altium Designer Develop writes NO licence tail in \+             its title when it HOLDS the seat (only '- Viewer' when it does not), so a blank \+             licenceState on Develop does not mean unlicensed — try the verb."+        ));+    }+    out+}++/// The gate every driving verb passes: running, licensed, and no modal freezing the engine.+/// None = clear to proceed; Some(refusal) = return it verbatim.+pub fn need_licensed() -> Option<Value> {+    let lic = instance::licence_state();+    if lic.get("running").and_then(|b| b.as_bool()) != Some(true) {+        return Some(json!({"success": false, "errorCode": "altium_not_running", "_hint": "altium_launch first."}));+    }+    if lic.get("licensed").and_then(|b| b.as_bool()) == Some(false) {+        let n = instance::instances().len();+        let state = lic.get("state").and_then(|s| s.as_str()).map(str::to_string);+        return Some(seat_hints(+            json!({"success": false, "errorCode": "not_licensed", "licenceState": state,+                   "_hint": "Altium refuses scripts without a licence. altium_license {\"action\":\"use_license\"} claims a free seat."}),+            state.as_deref(),+            n,+        ));+    }+    let mut dl = dialogs::scan_scoped(true);+    if !dl.is_empty() {+        shot::attach_dialog_shots(&mut dl);+        return Some(json!({"success": false, "errorCode": "blocked_by_dialog", "dialogs": dl,+            "_hint": "A modal on the main window freezes scripting. Read dialogs[].shot (base64 PNG — the text is painted, not exposed), altium_dismiss_dialogs {hwnd, button}, retry. If it is a script error, altium_agent_recover."}));+    }+    None+}++// ---- openable-document validation --------------------------------------------+// Validated in the BRIDGE so an unsupported path is a clean error instead of Altium's+// "File type not recognised." modal on the user's screen (John: pre-empt, don't clean up).++pub const OPENABLE: &[&str] = &[".pcbdoc", ".schdoc", ".pcblib", ".schlib", ".outjob", ".pcb3dlib",+    ".prjpcb", ".prjscr", ".prjmbd", ".prjhar", ".libpkg", ".txt", ".pas", ".rep"];++/// ab gives a verb 60 s. A cold file open can exceed that; blowing the budget looks like a+/// failure while Altium is still working — wait a little under it and hand back a poll instead.+pub const OPEN_BUDGET_SEC: f64 = 45.0;++pub fn validate_openable(path: &str) -> Option<Value> {+    if path.is_empty() {+        return Some(json!({"success": false, "error": "path is required"}));+    }+    let ext = path.rsplit('.').next().map(|e| format!(".{}", e.to_lowercase())).unwrap_or_default();+    if !path.contains('.') || !OPENABLE.contains(&ext.as_str()) {+        return Some(json!({"success": false, "errorCode": "unsupported_document",+            "error": format!("Altium does not open '{}' — asking it to would raise a 'File type not recognised' modal on the user's screen", if ext.is_empty() { path } else { &ext }),+            "supported": OPENABLE}));+    }+    None+}++// ---- installs ----------------------------------------------------------------++pub const HOST_APP: &str = "Altium Designer";++/// Every Altium install on the box, newest major first.+pub fn detect_installs() -> Vec<Value> {+    let mut out: Vec<(i64, Value)> = Vec::new();+    for pf in ["C:\\Program Files", "C:\\Program Files (x86)"] {+        let altium = std::path::Path::new(pf).join("Altium");+        let Ok(rd) = std::fs::read_dir(&altium) else { continue };+        for e in rd.flatten() {+            let name = e.file_name().to_string_lossy().to_string();+            if !name.to_uppercase().starts_with("AD") {+                continue;+            }+            let exe = e.path().join("X2.EXE");+            if !exe.exists() {+                continue;+            }+            let major: i64 = name.chars().filter(|c| c.is_ascii_digit()).collect::<String>().parse().unwrap_or(0);+            out.push((major, json!({+                "installed": true,+                "base_dir": e.path().to_string_lossy(),+                "exe": exe.to_string_lossy(),+                "system_dir": e.path().join("System").to_string_lossy(),+                "major": major.to_string(),+            })));+        }+    }+    out.sort_by_key(|(m, _)| -m);+    out.into_iter().map(|(_, v)| v).collect()+}++pub fn first_install() -> Option<Value> {+    detect_installs().into_iter().next()+}++pub fn not_installed() -> Value {+    json!({"success": false, "errorCode": "host_app_not_installed", "hostApp": HOST_APP,+           "error": "Altium Designer is not installed on this machine.",+           "_hint": "Altium is a licensed host app (no silent installer). Offer to run the official AltiumDesignerSetup.exe for the user — the altium-install skill has the full drive."})+}++// ---- demo / example constants ------------------------------------------------++pub const EXAMPLES_DIR: &str = r"C:\Users\Public\Documents\Altium\AD26\Examples";++pub fn demo_project() -> String { format!(r"{EXAMPLES_DIR}\Bluetooth Sentinel\Bluetooth_Sentinel.PrjPcb") }+pub fn demo_pcb() -> String { format!(r"{EXAMPLES_DIR}\Bluetooth Sentinel\Bluetooth_Sentinel.PcbDoc") }+pub fn demo_sch() -> String { format!(r"{EXAMPLES_DIR}\Bluetooth Sentinel\Bluetooth_Sentinel.SchDoc") }+pub fn demo_schlib() -> String { format!(r"{EXAMPLES_DIR}\SpiritLevel-SL1\Libraries\SpiritLevel_2E.SchLib") }+pub fn demo_pcblib() -> String { format!(r"{EXAMPLES_DIR}\SpiritLevel-SL1\Libraries\Spirit_Level_Project.PcbLib") }+pub fn demo_outjob_fab() -> String { format!(r"{EXAMPLES_DIR}\Bluetooth Sentinel\Fabrication.OutJob") }+pub fn demo_outjob_asm() -> String { format!(r"{EXAMPLES_DIR}\Bluetooth Sentinel\Assembly.OutJob") }++/// A real 3-pin SOT in the SpiritLevel PcbLib; the first entry ("NT") is a 2-pad test point.+pub const DEMO_FOOTPRINT: &str = "SO-G3";++// ---- agent-call convenience --------------------------------------------------++/// The dominant Python idiom `agent.call("open", {"path": p})` with best-effort semantics.+pub fn agent_open(path: &str, timeout_s: u64) -> Value {+    agent::call("open", &[("path", path.to_string())], None, Duration::from_secs(timeout_s))+}++#[cfg(test)]+mod tests {+    use super::*;++    #[test]+    fn openable_refuses_unknown_extensions_cleanly() {+        assert!(validate_openable(r"C:\x\board.PcbDoc").is_none());+        assert!(validate_openable(r"C:\x\part.step").is_some());+        let r = validate_openable(r"C:\x\noext").unwrap();+        assert_eq!(r["errorCode"], json!("unsupported_document"));+        assert!(validate_openable("").is_some());+    }++    #[test]+    fn seat_hints_attach_on_the_right_conditions() {+        let out = seat_hints(json!({}), Some("Viewer"), 2);+        assert!(out.get("_licenceHint").is_some());+        assert!(out.get("_seatHint").is_some());+        let out = seat_hints(json!({}), Some("Trial"), 1);+        assert!(out.get("_licenceHint").is_none());+        assert!(out.get("_seatHint").is_none());+    }+}
bridge-rs/src/surfaces.rsadded+334
@@ -0,0 +1,334 @@+//! The show_* surfaces: open a document/library member through the agent, run follow-up+//! processes, capture a BACKGROUND proof frame. A surface verb exists to produce PROOF — if the+//! capture failed, saying success:true would hand the caller (and the wiki front-end) a confident+//! nothing, so a missing image is `proof_unavailable`, not success.+use crate::support::{self, s, s_or, timeout_of};+use crate::{agent, dialogs, instance, shot};+use serde_json::{json, Value};+use std::time::Duration;++/// Open a document through the agent, run follow-up processes, return the frame.+pub fn open_then(path: &str, commands: &[(&str, &str)], timeout: Duration, label: &str) -> Value {+    if let Some(g) = support::need_licensed() {+        return g;+    }+    let t0 = support::now();+    let mut steps: Vec<Value> = Vec::new();+    let r = agent::call("open", &[("path", path.to_string())], None, timeout);+    steps.push(json!({"open": path, "result": r.get("data"), "ms": r.get("roundtripMs")}));+    if r.get("success").and_then(|b| b.as_bool()) != Some(true) {+        let mut out = r;+        out["steps"] = json!(steps);+        return out;+    }+    for (cmd, params) in commands {+        let rr = agent::call("run_process", &[("process", cmd.to_string()), ("params", params.to_string())], None, timeout);+        steps.push(json!({"process": cmd, "params": params, "ok": rr.get("success"), "ms": rr.get("roundtripMs")}));+    }+    std::thread::sleep(Duration::from_millis(600));+    let dl = dialogs::scan_scoped(true);+    let frame = shot::shot_of_main();+    let mw = instance::main_window().unwrap_or(json!({}));+    let ok = dl.is_empty();+    json!({"success": ok, "surface": label, "path": path, "steps": steps,+           "title": mw.get("title"), "document": mw.get("document"),+           "dialogsDetected": dl.len(), "dialogs": dl, "shot": frame,+           "windowEtiquette": "background", "elapsedSec": support::elapsed_round(t0),+           "_hint": if ok {+               format!("{label} is on screen (title: {:?}). shot.image is the frame; altium_state for every window.", mw.get("title").and_then(|t| t.as_str()).unwrap_or(""))+           } else {+               "A dialog came up — read dialogs[] before doing anything else.".into()+           }})+}++/// Open a library, select a member, run view processes, capture proof.+pub fn lib_surface(args: &Value, kind: &str, default_path: &str, label: &str, after: &[(&str, &str)]) -> Value {+    if let Some(g) = support::need_licensed() {+        return g;+    }+    let t0 = support::now();+    let path = s(args, "libraryPath").or_else(|| s(args, "filePath")).unwrap_or_else(|| default_path.to_string());+    let name = s(args, "symbolName").or_else(|| s(args, "footprintName")).or_else(|| s(args, "name")).unwrap_or_default();+    let timeout = timeout_of(args, 60);+    let mut steps: Vec<Value> = Vec::new();+    let r = agent::call("open", &[("path", path.clone())], None, timeout);+    steps.push(json!({"open": path, "result": r.get("data"), "ms": r.get("roundtripMs")}));+    if r.get("success").and_then(|b| b.as_bool()) != Some(true) {+        let mut out = r;+        out["steps"] = json!(steps);+        return out;+    }+    let sel = agent::call("lib_select", &[("kind", kind.to_string()), ("name", name.clone())], None, timeout);+    steps.push(json!({"lib_select": if name.is_empty() { "(first)" } else { &name }, "result": sel.get("data"), "ms": sel.get("roundtripMs")}));+    if sel.get("success").and_then(|b| b.as_bool()) != Some(true) {+        let lst = agent::call("lib_list", &[("kind", kind.to_string()), ("limit", "50".into())], None, Duration::from_secs(30));+        let mut out = sel;+        out["steps"] = json!(steps);+        out["available"] = lst.get("data").and_then(|d| d.get("names")).cloned().unwrap_or(Value::Null);+        out["_hint"] = json!("Component not found in that library — pick one of available[] (altium_list_library shows all).");+        return out;+    }+    for (cmd, params) in after {+        let rr = agent::call("run_process", &[("process", cmd.to_string()), ("params", params.to_string())], None, Duration::from_secs(60));+        steps.push(json!({"process": cmd, "params": params, "ok": rr.get("success"), "ms": rr.get("roundtripMs")}));+    }+    std::thread::sleep(Duration::from_millis(600));+    let mut dl = dialogs::scan_scoped(true);+    shot::attach_dialog_shots(&mut dl);+    let frame = shot::shot_of_main();+    let mw = instance::main_window().unwrap_or(json!({}));+    let no_proof = frame.get("proofUnavailable").and_then(|b| b.as_bool()) == Some(true);+    let selected = sel.get("data").and_then(|d| d.get("selected")).cloned().unwrap_or(Value::Null);+    let mut out = json!({"success": dl.is_empty() && !no_proof, "surface": label, "path": path,+        "selected": selected,+        "steps": steps, "title": mw.get("title"), "dialogsDetected": dl.len(), "dialogs": dl, "shot": frame,+        "windowEtiquette": "background", "elapsedSec": support::elapsed_round(t0),+        "_hint": format!("{label}: {:?} in {:?}. shot.image is the frame.",+            out_str(&out_sel(&sel)), mw.get("document").and_then(|d| d.as_str()).unwrap_or(""))});+    if no_proof {+        out["errorCode"] = json!("proof_unavailable");+        out["error"] = out["shot"].get("reason").cloned().unwrap_or(Value::Null);+        out["_hint"] = json!("The surface was opened and selected correctly — only the CAPTURE failed, because the Altium window is minimized. The bridge restores it into the background automatically; if it is still minimized, something re-minimized it mid-call.");+    }+    out+}++fn out_sel(sel: &Value) -> Value {+    sel.get("data").and_then(|d| d.get("selected")).cloned().unwrap_or(Value::Null)+}++fn out_str(v: &Value) -> String {+    v.as_str().map(str::to_string).unwrap_or_else(|| v.to_string())+}++// ---- the eight surfaces ------------------------------------------------------++pub fn show_project(args: &Value) -> Value {+    let p = s(args, "filePath").unwrap_or_else(support::demo_project);+    open_then(&p, &[], timeout_of(args, 60), "project")+}++pub fn show_schematic(args: &Value) -> Value {+    let p = s(args, "filePath").unwrap_or_else(support::demo_sch);+    open_then(&p, &[("Sch:Zoom", "Action=All")], timeout_of(args, 60), "schematic")+}++pub fn show_2d_board(args: &Value) -> Value {+    let p = s(args, "filePath").unwrap_or_else(support::demo_pcb);+    open_then(&p, &[("PCB:SwitchTo2D", ""), ("PCB:Zoom", "Action=All")], timeout_of(args, 120), "2d_board")+}++pub fn show_3d_board(args: &Value) -> Value {+    let p = s(args, "filePath").unwrap_or_else(support::demo_pcb);+    open_then(&p, &[("PCB:SwitchTo3D", ""), ("PCB:Zoom", "Action=All")], timeout_of(args, 120), "3d_board")+}++pub fn show_symbol(args: &Value) -> Value {+    lib_surface(args, "sch", &support::demo_schlib(), "symbol", &[])+}++fn with_demo_footprint(args: &Value) -> Value {+    let mut a = args.clone();+    let custom = args.get("libraryPath").is_some() || args.get("filePath").is_some();+    if a.get("footprintName").is_none() {+        a["footprintName"] = json!(if custom { "" } else { support::DEMO_FOOTPRINT });+    }+    a+}++pub fn show_footprint(args: &Value) -> Value {+    let a = with_demo_footprint(args);+    lib_surface(&a, "pcb", &support::demo_pcblib(), "footprint", &[("PCB:SwitchTo2D", ""), ("PCB:Zoom", "Action=All")])+}++pub fn show_3d_chip(args: &Value) -> Value {+    let a = with_demo_footprint(args);+    lib_surface(&a, "pcb", &support::demo_pcblib(), "3d_chip", &[("PCB:SwitchTo3D", ""), ("PCB:Zoom", "Action=All")])+}++fn lib_kind_and_path(args: &Value) -> (String, String) {+    let path = s(args, "libraryPath").or_else(|| s(args, "filePath")).unwrap_or_else(support::demo_schlib);+    let kind = if path.to_lowercase().ends_with("pcblib") { "pcb" } else { "sch" };+    (kind.into(), path)+}++pub fn show_library(args: &Value) -> Value {+    let (kind, path) = lib_kind_and_path(args);+    let mut a = args.clone();+    a["libraryPath"] = json!(path);+    a["name"] = json!("");+    let mut r = lib_surface(&a, &kind, &path, "library", &[]);+    if r.get("success").and_then(|b| b.as_bool()) == Some(true) {+        let lst = agent::call("lib_list", &[("kind", kind), ("limit", "200".into())], None, Duration::from_secs(30));+        r["components"] = lst.get("data").and_then(|d| d.get("names")).cloned().unwrap_or(Value::Null);+        r["componentCount"] = lst.get("data").and_then(|d| d.get("count")).cloned().unwrap_or(Value::Null);+    }+    r+}++pub fn list_library(args: &Value) -> Value {+    let (kind, path) = lib_kind_and_path(args);+    if let Some(g) = support::need_licensed() {+        return g;+    }+    let r = support::agent_open(&path, 60);+    if r.get("success").and_then(|b| b.as_bool()) != Some(true) {+        return r;+    }+    let limit = s_or(args, "limit", "500");+    let mut lst = agent::call("lib_list", &[("kind", kind.clone()), ("limit", limit)], None, Duration::from_secs(60));+    lst["path"] = json!(path);+    lst["kind"] = json!(kind);+    lst+}++// ---- window / document verbs -------------------------------------------------++pub fn window_info(_args: &Value) -> Value {+    let wins = instance::altium_windows();+    let dl = dialogs::scan();+    let has_modal = dl.iter().any(|d| d.get("ownedByMainInstance").and_then(|b| b.as_bool()) == Some(true));+    let mut out = json!({"success": true, "windows": wins, "dialogs": dl, "hasModalDialogs": has_modal});+    if has_modal {+        out["_hint"] = json!("A dialog is open on Altium's main window: the agent loop and every -R request are frozen behind it. Read dialogs[].body + category, then altium_dismiss_dialogs {hwnd, button} with the work-preserving choice.");+    }+    out+}++/// Make an ALREADY-OPEN document current — the quiet lane. The AGENT is the real quiet lane+/// (a whole-window UIA search takes >100 s on the doc bar): one in-process call, no cursor,+/// no OS foreground; Altium ACTIVATES an already-open document rather than re-reading it.+pub fn activate_document(args: &Value) -> Value {+    if let Some(g) = support::need_licensed() {+        return g;+    }+    let path = match s(args, "path").or_else(|| s(args, "document")).or_else(|| s(args, "name")) {+        Some(p) => p,+        None => return json!({"success": false, "error": "path is required (the document's full path)",+            "_hint": "altium_window_info lists what is open; board verbs need a .PcbDoc current."}),+    };+    if let Some(bad) = support::validate_openable(&path) {+        return bad;+    }+    let wait = timeout_of(args, 60).as_secs_f64().min(support::OPEN_BUDGET_SEC);+    let mut r = agent::call("open", &[("path", path.clone())], None, Duration::from_secs_f64(wait));+    if r.get("errorCode").and_then(|c| c.as_str()) == Some("agent_timeout") {+        return json!({"success": true, "status": "in_progress", "path": path, "lane": "agent",+            "windowEtiquette": "background", "waitedSec": wait,+            "_hint": "Still opening — poll altium_status until the document is current."});+    }+    r["lane"] = json!("agent");+    r["windowEtiquette"] = json!("background");+    if r.get("_hint").is_none() {+        r["_hint"] = json!("The document is now current. An already-open document is activated, not reloaded.");+    }+    r+}++pub fn open_document(args: &Value) -> Value {+    if let Some(g) = support::need_licensed() {+        return g;+    }+    let path = match s(args, "filePath").or_else(|| s(args, "path")) {+        Some(p) => p,+        None => return json!({"success": false, "error": "filePath is required (absolute Windows path)"}),+    };+    if let Some(bad) = support::validate_openable(&path) {+        return bad;+    }+    let wait = timeout_of(args, 60).as_secs_f64().min(support::OPEN_BUDGET_SEC);+    let mut r = agent::call("open", &[("path", path.clone())], None, Duration::from_secs_f64(wait));+    if r.get("errorCode").and_then(|c| c.as_str()) == Some("agent_timeout") {+        // Altium is still opening it — that is not a failure, and ab's 60 s budget must not turn+        // it into one. Report progress and let the caller poll.+        return json!({"success": true, "status": "in_progress", "path": path, "waitedSec": wait,+            "roundtripMs": r.get("roundtripMs"),+            "_hint": "Altium is still opening the document. Poll altium_status (or altium_window_info) until it is current; the agent answers again once the open completes."});+    }+    r["windowEtiquette"] = json!("background");+    r+}++/// Save the focused document (or the one at filePath) — WorkspaceManager:SaveObject.+pub fn save(args: &Value) -> Value {+    if let Some(g) = support::need_licensed() {+        return g;+    }+    if let Some(p) = s(args, "filePath") {+        support::agent_open(&p, 60);+    }+    // pre-empts "a command is currently active" (save-a-copy Warning)+    agent::call("tx_clear", &[], None, Duration::from_secs(30));+    let mut r = agent::call("run_process",+        &[("process", "WorkspaceManager:SaveObject".into()), ("params", "ObjectKind=FocusedDocument".into())],+        None, Duration::from_secs(60));+    r["_hint"] = json!("Saved the focused document. A 'Save As' dialog means the document never had a path — altium_check_dialogs.");+    r+}++pub fn board_shape(args: &Value) -> Value {+    if let Some(g) = support::need_licensed() {+        return g;+    }+    if let Some(p) = s(args, "pcbPath") {+        support::agent_open(&p, 60);+    }+    agent::call("board_shape",+        &[("x0Mm", s_or(args, "x0Mm", "0")), ("y0Mm", s_or(args, "y0Mm", "0")),+          ("wMm", s_or(args, "wMm", "15")), ("hMm", s_or(args, "hMm", "15"))],+        None, timeout_of(args, 120))+}++pub fn pcb_report(args: &Value) -> Value {+    if let Some(g) = support::need_licensed() {+        return g;+    }+    if let Some(p) = s(args, "path") {+        support::agent_open(&p, 60);+    }+    agent::call("pcb_report",+        &[("name", s_or(args, "name", "")), ("want", s_or(args, "want", ""))],+        None, timeout_of(args, 60))+}++// ---- demo tour ---------------------------------------------------------------++const DEMO_BEATS: &[(&str, &str)] = &[+    ("symbol", "A schematic symbol from the SpiritLevel example library, in the Symbol editor."),+    ("footprint", "Its land pattern in the footprint editor: pads, silkscreen, courtyard."),+    ("3d_chip", "The same footprint rendered in 3D."),+    ("schematic", "The Bluetooth Sentinel top-level schematic — six sheets wired by ports."),+    ("2d_board", "The layout in 2D: a two-board rigid-flex, four copper layers."),+    ("3d_board", "The populated board in 3D."),+];++/// Same shape as kicad_demo/fusion_demo: one beat per call, `done` when finished.+pub fn demo(args: &Value) -> Value {+    let stage = args.get("stage").and_then(|v| v.as_u64()).unwrap_or(0) as usize;+    if stage >= DEMO_BEATS.len() {+        return json!({"success": true, "done": true, "stage": stage,+            "narrate": "That is the whole tour: symbol, footprint, 3D part, schematic, 2D and 3D board — all driven through the resident agent while Altium stayed in the background.",+            "_hint": "Tour complete."});+    }+    let (label, narrate) = DEMO_BEATS[stage];+    let beat_args = json!({"timeout": 120});+    let r = match label {+        "symbol" => show_symbol(&beat_args),+        "footprint" => show_footprint(&beat_args),+        "3d_chip" => show_3d_chip(&beat_args),+        "schematic" => show_schematic(&beat_args),+        "2d_board" => show_2d_board(&beat_args),+        _ => show_3d_board(&beat_args),+    };+    let ok = r.get("success").and_then(|b| b.as_bool()) == Some(true);+    let shots = match r.get("shot").and_then(|s| s.get("image")) {+        Some(img) => json!([{"label": label, "image": img}]),+        None => json!([]),+    };+    json!({"success": ok, "done": false, "stage": stage, "label": label, "narrate": narrate,+        "screenshots": shots, "steps": r.get("steps"), "dialogs": r.get("dialogs"),+        "failedSteps": if ok { json!([]) } else { json!([label]) },+        "nextCall": {"verb": "altium_demo", "args": {"stage": stage + 1}},+        "_hint": format!("Beat {}/{} ({label}). Narrate, show the screenshot, then call altium_demo {{stage:{}}}.",+                         stage + 1, DEMO_BEATS.len(), stage + 1)})+}
bridge-rs/src/taskbar.rsadded+216
@@ -0,0 +1,216 @@+//! "Adom is driving Altium" on the Windows taskbar — badge overlay + progress bar, both painted+//! by ab's generic `desktop_taskbar` verb (never an in-process ITaskbarList3 call: that returns+//! S_OK cross-process and the shell silently ignores it; pup, fusion and kicad each paid for it).+//! No AUMID stamping — a per-window AppUserModelID poisons the shell's icon cache (kicad's lesson).+//!+//! John's rules: progress rides the taskbar icon, never toasts; the overlay auto-clears after+//! 5 minutes without a driving verb (a permanent overlay stops meaning "an AI is driving now").+use crate::{ab, instance};+use serde_json::{json, Value};+use std::collections::{BTreeMap, BTreeSet};+use std::sync::atomic::{AtomicBool, AtomicI64, Ordering};+use std::sync::Mutex;+use std::time::Duration;++static BADGED: Mutex<BTreeSet<i64>> = Mutex::new(BTreeSet::new());+static LAST_DRIVING: AtomicI64 = AtomicI64::new(0);+static IDLE_WATCHER: AtomicBool = AtomicBool::new(false);+static PROGRESS_STOP: Mutex<BTreeMap<String, std::sync::Arc<AtomicBool>>> = Mutex::new(BTreeMap::new());++pub const IDLE_CLEAR_SEC: i64 = 300;++fn now() -> i64 {+    std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).map(|d| d.as_secs() as i64).unwrap_or(0)+}++fn badge_b64() -> String {+    static B64: Mutex<Option<String>> = Mutex::new(None);+    if let Ok(g) = B64.lock() {+        if let Some(s) = g.as_ref() {+            return s.clone();+        }+    }+    let p = std::env::current_exe()+        .ok()+        .and_then(|e| e.parent().map(|d| d.join("resources").join("adom-overlay-32.png")));+    let s = p.and_then(|p| std::fs::read(p).ok()).map(|b| b64encode(&b)).unwrap_or_default();+    if let Ok(mut g) = B64.lock() {+        *g = Some(s.clone());+    }+    s+}++fn taskbar(args: Value) -> bool {+    let r = ab::call("desktop_taskbar", args, &[], 15);+    r.get("success").and_then(|b| b.as_bool()).unwrap_or(false)+        || r.to_string().contains("Taskbar updated")+}++fn main_hwnd() -> Option<i64> {+    instance::main_window().and_then(|w| w.get("hwnd").and_then(|h| h.as_i64()))+}++// ---- badge -------------------------------------------------------------------++pub fn set_driving_badge() -> bool {+    let hwnd = match main_hwnd() {+        Some(h) => h,+        None => return false,+    };+    if BADGED.lock().map(|g| g.contains(&hwnd)).unwrap_or(false) {+        return true;+    }+    let icon = badge_b64();+    if icon.is_empty() {+        return false; // badge asset missing beside the exe — not worth failing a verb over+    }+    let ok = taskbar(json!({"hwnd": hwnd, "overlay": {"icon": icon, "tooltip": "Adom is driving Altium Designer"}}));+    if ok {+        if let Ok(mut g) = BADGED.lock() {+            g.insert(hwnd);+        }+    }+    ok+}++pub fn clear_driving_badge() -> bool {+    let targets: Vec<i64> = BADGED.lock().map(|g| g.iter().cloned().collect()).unwrap_or_default();+    let mut ok = true;+    for h in targets {+        if taskbar(json!({"hwnd": h, "overlay": {"badge": "none"}})) {+            if let Ok(mut g) = BADGED.lock() {+                g.remove(&h);+            }+        } else {+            ok = false;+        }+    }+    ok+}++pub fn badge_active() -> bool {+    BADGED.lock().map(|g| !g.is_empty()).unwrap_or(false)+}++/// Called by dispatch on every driving verb; feeds the idle clock.+pub fn note_driving() {+    LAST_DRIVING.store(now(), Ordering::Relaxed);+}++/// Clear the overlay after IDLE_CLEAR_SEC without a driving verb. Idempotent; the next driving+/// verb re-badges instantly.+pub fn start_idle_watcher() -> bool {+    if IDLE_WATCHER.swap(true, Ordering::SeqCst) {+        return false;+    }+    std::thread::Builder::new()+        .name("altium-badge-idle-clear".into())+        .spawn(|| loop {+            std::thread::sleep(Duration::from_secs(15));+            let last = LAST_DRIVING.load(Ordering::Relaxed);+            if badge_active() && last > 0 && now() - last > IDLE_CLEAR_SEC {+                clear_driving_badge();+            }+        })+        .is_ok()+}++// ---- progress ----------------------------------------------------------------++fn taskbar_progress(state: &str, value: Option<i64>) -> bool {+    let hwnd = match main_hwnd() {+        Some(h) => h,+        None => return false,+    };+    let mut prog = json!({"state": state});+    if let Some(v) = value {+        prog["value"] = json!(v.clamp(0, 100));+    }+    taskbar(json!({"hwnd": hwnd, "progress": prog}))+}++/// Begin a progress bar for `verb`, paced by its measured estimate (marquee when none).+pub fn start_activity_progress(verb: &str, estimate_s: Option<f64>, confidence: &str) {+    let stop = std::sync::Arc::new(AtomicBool::new(false));+    if let Ok(mut g) = PROGRESS_STOP.lock() {+        g.insert(verb.to_string(), stop.clone());+    }+    let est = estimate_s.filter(|e| *e > 0.0 && confidence != "unknown");+    let _ = std::thread::Builder::new().name(format!("altium-progress-{verb}")).spawn(move || {+        let started = std::time::Instant::now();+        match est {+            None => {+                taskbar_progress("indeterminate", None);+                while !stop.load(Ordering::Relaxed) {+                    std::thread::sleep(Duration::from_secs(1));+                }+            }+            Some(e) => {+                let mut fails = 0u32;+                while !stop.load(Ordering::Relaxed) {+                    std::thread::sleep(Duration::from_millis(700));+                    if stop.load(Ordering::Relaxed) {+                        break;+                    }+                    let pct = ((95.0 * started.elapsed().as_secs_f64() / e) as i64).min(95);+                    if taskbar_progress("normal", Some(pct)) {+                        fails = 0;+                    } else {+                        fails += 1; // window may not exist yet during a cold launch — keep trying+                        if fails > 120 {+                            return;+                        }+                    }+                }+            }+        }+    });+}++pub fn end_activity_progress(verb: &str, failed: bool) {+    let stop = PROGRESS_STOP.lock().ok().and_then(|mut g| g.remove(verb));+    if let Some(s) = stop {+        s.store(true, Ordering::Relaxed);+    } else {+        return; // no bar was started for this verb+    }+    if failed {+        taskbar_progress("error", Some(100));+        std::thread::sleep(Duration::from_millis(1200));+    }+    taskbar_progress("none", None);+    if let Some(h) = main_hwnd() {+        // cancel Windows' own orange attention flash (John: "can u not orange flash the taskbar")+        taskbar(json!({"hwnd": h, "flash": "stop"}));+    }+}++// ---- base64 (no external crate) ----------------------------------------------++fn b64encode(data: &[u8]) -> String {+    const T: &[u8; 64] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";+    let mut out = String::with_capacity(data.len().div_ceil(3) * 4);+    for chunk in data.chunks(3) {+        let b = [chunk[0], *chunk.get(1).unwrap_or(&0), *chunk.get(2).unwrap_or(&0)];+        let n = ((b[0] as u32) << 16) | ((b[1] as u32) << 8) | b[2] as u32;+        out.push(T[(n >> 18) as usize & 63] as char);+        out.push(T[(n >> 12) as usize & 63] as char);+        out.push(if chunk.len() > 1 { T[(n >> 6) as usize & 63] as char } else { '=' });+        out.push(if chunk.len() > 2 { T[n as usize & 63] as char } else { '=' });+    }+    out+}++#[cfg(test)]+mod tests {+    use super::b64encode;++    #[test]+    fn base64_reference_vectors() {+        assert_eq!(b64encode(b""), "");+        assert_eq!(b64encode(b"f"), "Zg==");+        assert_eq!(b64encode(b"fo"), "Zm8=");+        assert_eq!(b64encode(b"foo"), "Zm9v");+        assert_eq!(b64encode(b"foobar"), "Zm9vYmFy");+    }+}
bridge-rs/src/verbs.rs+355−159
@@ -1,19 +1,53 @@-//! 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, board, dialogs, health, lifecycle};+//! Verb dispatch — the FULL surface (110 verbs), at parity with the Python bridge it replaces.+//! Every verb returns the envelope the Python bridge returned, so the CLI and callers see no+//! difference; the engine field says "rust".+use crate::support::{self, s};+use crate::{a365, agent, board, dialogs, library, license, lifecycle, misc, outputs, progress, review, surfaces, taskbar}; use serde_json::{json, Value}; use std::time::Duration; -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",-    "read_board", "board_summary", "rules_by_kind", "layers_in_use",-    "describe", "uninstall",+/// Everything this bridge serves. lint_verbs in scripts/release.py checks bridge.json against+/// THIS list — keep it the single source of truth for the dispatch below.+pub const KNOWN: &[&str] = &[+    // health / lifecycle+    "readiness", "status", "diagnostics", "window_info", "state", "check_dialogs",+    "dismiss_dialogs", "launch", "close", "instances", "dialog_log", "uninstall", "describe",+    // agent / REPL+    "agent_start", "agent_stop", "agent_status", "agent_recover",+    "eval", "run_command", "run_script", "list_commands", "open_document", "open_project",+    "activate_document", "progress",+    // board / schematic reads+    "read_board", "board_summary", "rules_by_kind", "layers_in_use", "read_schematic",+    "cross_check", "review_board", "list_designators", "component_data", "component_pins",+    "list_nets", "net_connections", "layers", "copper_layers", "read_rules",+    "set_layer_visibility", "footprint_primitives", "schematic_data",+    // surfaces+    "show_project", "show_schematic", "show_2d_board", "show_3d_board", "show_library",+    "show_symbol", "show_footprint", "show_3d_chip", "list_library", "demo",+    // outputs / project+    "compile", "run_erc", "run_drc", "run_outjob", "export_gerber", "export_bom", "export_step",+    "save", "test_board", "board_shape", "pcb_report", "import_kicad",+    "install_extension", "extension_ping",+    // library authoring+    "create_footprint", "create_symbol", "build_footprint", "build_symbol_modes", "add_body",+    "add_models", "normalize_bodies", "set_params", "set_visibility", "read_symbol",+    "clone_symbol", "rename_symbol", "rename_footprint", "remove_symbol", "remove_footprint",+    "copy_footprint", "xcopy_footprint", "swap_footprint", "symbol_mode", "cleanup_libs",+    "install_library", "install_library_bundle", "compile_intlib", "extract_intlib",+    "place_on_sch", "place_on_pcb", "import_part",+    // licence / account+    "license", "license_status", "ensure_licensed", "setup_trial", "signin", "signin_status",+    "connect_workspace",+    // Altium 365+    "a365_configure", "a365_oauth_begin", "a365_oauth_status", "a365_oauth_cancel",+    "a365_refresh", "a365_logout", "a365_status", "a365_query", "a365_files_upload",+    "a365_library_overview", "a365_library_schema", ]; +pub fn names() -> Vec<String> {+    KNOWN.iter().map(|v| format!("altium_{v}")).collect()+}+ /// 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,@@ -25,90 +59,54 @@ fn verb_doc(name: &str, summary: &str, input: Value, output: Value, timeout: u64  fn describe() -> Value {     let board = json!({"success": "bool", "data": "object - the agent's payload", "roundtripMs": "int"});-    json!({"success": true, "bridge": "altium", "engine": "rust", "verbs": [+    let shot = json!({"success": "bool", "shot": "{image: base64 png, width, height}", "windowEtiquette": "background"});+    json!({"success": true, "bridge": "altium", "engine": "rust",+        "facts": ["one script at a time; a modal freezes everything",+                  "never foreground Altium; proofs are captured in the background",+                  "one On-Demand seat serves every Altium app on the machine; killing Altium strands it",+                  "instance:<pid|key|word> targets one of several running Altiums"],+        "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!({})),+            &["readiness", "agent_status", "instances"], &["'running' can be true while 'licensed' is false - a trial lease can be stranded by a crash."], json!({})),+        verb_doc("instances", "Every running Altium (Designer + Designer Develop can run together).", json!({}),+            json!({"count": "int", "instances": "[{pid, key, edition, licensed, agentAlive, ...}]"}), 15,+            "With several running, other verbs REFUSE without instance:<pid|key|word> - guessing means acting inside another AI thread's Altium.",+            &["status"], &["Develop writes NO licence tail when it HOLDS the seat - blank licenceState there means licensed."], 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 read 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!({})),+            json!({"success": "bool"}), 60, "Params are pipe-joined key=value pairs. altium_list_commands searches the catalog.", &["eval", "list_commands"], &["Some processes open a modal dialog and block the loop until it is answered."], json!({"command": "PCB:Zoom", "params": {"Action": "All"}})),+        verb_doc("read_board", "ONE round trip: components, nets, layers, copper, rules, occupancy, outline.", json!({"sections": "string csv - optional", "limit": "int"}), board.clone(), 120,+            "The primitive every board view derives from; board_summary / rules_by_kind / layers_in_use are shaped from the same read.",+            &["board_summary", "review_board"], &["Needs a .PcbDoc current."], json!({})),+        verb_doc("review_board", "Design-review checks (14) over one board read; schematic checks with includeSchematic.", json!({"board": "path - optional", "sheet": "path - optional", "includeSchematic": "bool", "checks": "[string] - optional filter"}),+            json!({"findings": "[{check, severity, message, ...}]", "counts": "{}", "skipped": "{}", "notImplemented": "{}"}), 120,+            "PARTIAL results by design: skipped says which checks had no data; a clean result means the checks that RAN found nothing.",+            &["read_board", "cross_check"], &["Altium holds ONE current document - the review activates board and sheet in turn."], json!({"includeSchematic": true})),+        verb_doc("import_part", "One call imports a whole part: footprint spec + STEP + symbol modes + models + params + save.", json!({"schLib": "path", "pcbLib": "path", "footprintSpec": "path", "symbolSpec": "path", "step": "path", "params": "{}", "comment": "string", "description": "string"}),+            json!({"symbol": "string", "footprint": "string", "steps": "[]"}), 300,+            "Check the step counts: models:0 fails the call - a symbol with no footprint model places with nothing attached.",+            &["read_symbol", "show_symbol"], &["add_models APPENDS on every call (not idempotent) - repair is remove_symbol + re-import.", "The first part into a NEW library can silently skip models+params if the bridge restarted mid-flight - verify with read_symbol."], json!({})),+        verb_doc("show_symbol", "Open a SchLib member in the Symbol editor and capture a background proof.", json!({"libraryPath": "path", "symbolName": "string"}), shot.clone(), 60,+            "Never foregrounds: a minimized Altium is restored INTO THE BACKGROUND and the user's window handed straight back.",+            &["show_footprint", "show_3d_chip", "show_library"], &["A capture under 400px wide is refused as proof_unavailable - a title-bar sliver is not evidence."], json!({})),+        verb_doc("license", "Drive the License Management page (UIA first - no cursor, no foreground).", json!({"action": "show|refresh|use_license|start_trial|sign_out"}), shot, 60,+            "One On-Demand seat serves EVERY Altium app on this machine. use_license claims a free seat.",+            &["ensure_licensed", "license_status", "close"], &["NEVER kill an Altium holding the seat: the lease strands, the page shows Seats 0/1 while every claim fails. altium_close releases it."], json!({"action": "use_license"})),+        verb_doc("close", "Polite close (releases the licence seat); force needs acceptSeatLoss.", json!({"force": "bool", "acceptSeatLoss": "bool"}), json!({"closedInSec": "float"}), 30,+            "A save prompt on close NAMES the documents - read it; on a shared machine one may belong to another AI thread. Never defaulted.",+            &["license", "launch"], &["force:true without acceptSeatLoss is refused: a kill strands the On-Demand seat."], 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()-}--fn s(args: &Value, k: &str) -> Option<String> {-    args.get(k).map(|v| match v {-        Value::String(x) => x.clone(),-        Value::Bool(b) => if *b { "1".into() } else { "0".into() },-        other => other.to_string(),-    })+    ],+    "allVerbs": names()}) }  fn need_licensed() -> Option<Value> {-    let lic = health::licence_state();-    if !lic.get("running").and_then(|b| b.as_bool()).unwrap_or(false) {-        return Some(json!({"success": false, "errorCode": "altium_not_running", "_hint": "altium_launch first."}));-    }-    if lic.get("licensed").and_then(|b| b.as_bool()) == Some(false) {-        return Some(json!({"success": false, "errorCode": "not_licensed", "licenceState": lic.get("state").cloned(),-                           "_hint": "Altium refuses scripts without a licence. altium_license '{}'."}));-    }-    None+    support::need_licensed() }  fn board_read(cmd: &str, params: Vec<(&str, String)>, timeout: u64) -> Value {@@ -123,29 +121,133 @@ fn board_read(cmd: &str, params: Vec<(&str, String)>, timeout: u64) -> Value {     r } +/// The dispatch chokepoint — instance selection, taskbar badge/progress and the run log wrap+/// EVERY verb here (one wrapper, not per-verb calls someone forgets), exactly like the Python+/// bridge's `dispatch_command`.+pub fn handle(command: &str, args: &Value, forwarded: &[(String, String)]) -> Value {+    let cmd = command.strip_prefix("altium_").unwrap_or(command);+    // Verbs that describe the machine rather than drive ONE Altium are exempt from instance+    // resolution — `instances` itself must work when several run, which is exactly when+    // resolution refuses.+    let instance_exempt = matches!(cmd, "instances" | "describe" | "diagnostics" | "progress" | "state" | "uninstall" | "readiness"+        | "a365_configure" | "a365_oauth_begin" | "a365_oauth_status" | "a365_oauth_cancel"+        | "a365_refresh" | "a365_logout" | "a365_status" | "a365_query" | "a365_files_upload"+        | "a365_library_overview" | "a365_library_schema");+    if instance_exempt {+        crate::instance::select(None);+    } else {+        match crate::instance::resolve(args.get("instance")) {+            Ok(inst) => crate::instance::select(Some(inst)),+            Err(refusal) => {+                let code = refusal.get("errorCode").and_then(|c| c.as_str()).unwrap_or("");+                // not-running stays a per-verb concern (many verbs answer usefully without+                // Altium); AMBIGUITY must stop here, or one thread's verb lands in another+                // thread's Altium.+                if matches!(code, "ambiguous_instance" | "instance_not_found") {+                    return refusal;+                }+                crate::instance::select(None);+            }+        }+    }+    let driving = !matches!(cmd, "readiness" | "status" | "describe" | "diagnostics" | "window_info"+        | "check_dialogs" | "progress" | "agent_status" | "list_commands" | "state" | "instances" | "dialog_log")+        && !cmd.starts_with("a365_");+    let mut bar_started = false;+    if driving {+        taskbar::set_driving_badge();+        taskbar::note_driving(); // feeds the 5-min idle clock that auto-clears the overlay+        let (est, conf) = progress::estimate(cmd, "warm");+        if est >= 3.0 {+            taskbar::start_activity_progress(cmd, Some(est), &conf);+            bar_started = true;+        }+    }+    let t0 = std::time::Instant::now();+    let out = dispatch(command, args, forwarded);+    let ok = out.get("success").and_then(|b| b.as_bool()).unwrap_or(true);+    progress::log_run(cmd, t0.elapsed().as_secs_f64(), ok);+    if bar_started {+        taskbar::end_activity_progress(cmd, !ok);+    }+    out+}+ pub fn dispatch(command: &str, args: &Value, _forwarded: &[(String, String)]) -> Value {     let cmd = command.strip_prefix("altium_").unwrap_or(command);     let timeout = args.get("timeout").and_then(|t| t.as_u64()).unwrap_or(60);     match cmd {-        "status" | "readiness" => {-            let lic = health::licence_state();-            json!({"success": true, "running": lic.get("running"), "licensed": lic.get("licensed"),-                   "licenceState": lic.get("state"), "agentAlive": agent::is_alive(), "engine": "rust"})+        // ---- health / lifecycle ----+        "readiness" => misc::readiness(args),+        "status" => misc::status(args),+        "diagnostics" => misc::diagnostics(args),+        "window_info" => surfaces::window_info(args),+        "state" => misc::state(args),+        "instances" => misc::instances_verb(args),+        "dialog_log" => misc::dialog_log(args),+        "launch" => misc::launch(args),+        "close" => misc::close(args),+        "uninstall" => misc::uninstall_verb(args),+        "describe" => describe(),+        "check_dialogs" => {+            let mut d = dialogs::scan();+            if args.get("screenshots").and_then(|b| b.as_bool()).unwrap_or(true) {+                crate::shot::attach_dialog_shots(&mut d);+            }+            json!({"success": true, "dialogsDetected": d.len(), "dialogs": d,+                   "_hint": if d.is_empty() { "No Altium dialogs open." } else {+                       "ANALYZE before acting: category/safeAction are suggestions; a save prompt is never auto-answered." }})+        }+        "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())+        }+        // ---- agent / REPL ----+        "agent_start" => {+            if let Some(g) = need_licensed() { return g; }+            let t0 = support::now();+            let r = lifecycle::start(Duration::from_secs(args.get("waitSec").and_then(|v| v.as_u64()).unwrap_or(45)));+            progress::record("agent_start", support::now() - t0, "warm");+            let mut r = r;+            if r.get("success").and_then(|b| b.as_bool()) == Some(true) {+                r["_hint"] = json!("Agent resident. altium_eval / altium_run_command / altium_state now go through it (~ms). Do NOT use altium_run_script while it is up.");+            }+            r+        }+        "agent_stop" => lifecycle::stop(),+        "agent_status" => misc::agent_status(args),+        "agent_recover" => {+            agent::call_no_autostart("tx_clear", &[], Duration::from_secs(30));+            let why = s(args, "reason").unwrap_or_else(|| "requested".into());+            lifecycle::recover(&why)         }-        "agent_status" => json!({"success": true, "alive": agent::is_alive(), "responsive": agent::is_alive(),-                                 "workspace": agent::workspace_dir().to_string_lossy(), "engine": "rust"}),         "eval" => {             if let Some(g) = need_licensed() { return g; }-            match s(args, "text").or_else(|| s(args, "code")) {-                None => json!({"success": false, "error": "text is required (DelphiScript STATEMENTS: Begin ... End;)"}),-                Some(t) => agent::call("eval", &[], Some(&t), Duration::from_secs(timeout.max(30))),+            let text = match s(args, "text").or_else(|| s(args, "code")) {+                Some(t) => t,+                None => return json!({"success": false, "error": "text is required (DelphiScript STATEMENTS: Begin ... End;)"}),+            };+            if !agent::is_alive() {+                // not resident → the -R RunScriptText lane is available and simplest+                let mut r = lifecycle::run_script_text_lane(&text, Duration::from_secs(timeout.max(30)));+                r["lane"] = json!("cli_run_script_text");+                if r.get("_hint").is_none() {+                    r["_hint"] = json!("Ran via X2 -RScriptingSystem:RunScriptText (statements only). Errors surface as an 'Error'/'X2' modal — altium_check_dialogs.");+                }+                return r;             }+            let mut r = agent::call("eval", &[], Some(&text), Duration::from_secs(timeout.max(30)));+            r["lane"] = json!("agent");+            r         }         "run_command" => {             if let Some(g) = need_licensed() { return g; }             let c = s(args, "command").or_else(|| s(args, "process")).unwrap_or_default();             if !c.contains(':') {-                return json!({"success": false, "error": "command must be Server:Command, e.g. PCB:Zoom"});+                return json!({"success": false, "error": "command must be Server:Command, e.g. PCB:Zoom",+                              "_hint": "altium_list_commands {query:'zoom'}"});             }             let params = match args.get("params") {                 Some(Value::Object(m)) => m.iter().map(|(k, v)| format!("{k}={}", v.as_str().map(|x| x.to_string()).unwrap_or(v.to_string()))).collect::<Vec<_>>().join("|"),@@ -154,71 +256,15 @@ pub fn dispatch(command: &str, args: &Value, _forwarded: &[(String, String)]) ->             };             agent::call("run_process", &[("process", c), ("params", params)], None, Duration::from_secs(timeout))         }-        "open_document" => {-            if let Some(g) = need_licensed() { return g; }-            match s(args, "path").or_else(|| s(args, "filePath")) {-                None => json!({"success": false, "error": "path is required"}),-                Some(p) => agent::call("open", &[("path", p)], None, Duration::from_secs(timeout)),-            }-        }-        "list_designators" => board_read("list_designators", vec![], 90),-        "component_data" => board_read("component_data", vec![("designator", s(args, "designator").unwrap_or_default()), ("limit", s(args, "limit").unwrap_or("500".into()))], 90),-        "component_pins" => match s(args, "designator") {-            None => json!({"success": false, "error": "designator is required (e.g. U1)"}),-            Some(d) => board_read("component_pins", vec![("designator", d)], 90),-        },-        "list_nets" => board_read("list_nets", vec![], 90),-        "net_connections" => board_read("net_connections", vec![("net", s(args, "net").unwrap_or_default()), ("limit", s(args, "limit").unwrap_or("4000".into()))], 120),-        "layers" => board_read("layers", vec![], 90),-        "copper_layers" => board_read("copper_layers", vec![], 90),-        "read_rules" => board_read("read_rules", vec![], 90),-        "set_layer_visibility" => match s(args, "layerId") {-            None => json!({"success": false, "error": "layerId is required (from altium_layers)"}),-            Some(l) => {-                let shown = args.get("displayed").and_then(|b| b.as_bool()).unwrap_or(true);-                board_read("set_layer_visibility", vec![("layerId", l), ("displayed", if shown { "1".into() } else { "0".into() })], 60)-            }-        },-        "footprint_primitives" => match s(args, "designator") {-            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())-        }+        "run_script" => outputs::run_script(args),+        "list_commands" => misc::list_commands(args),+        "open_document" | "open_project" => surfaces::open_document(args),+        "activate_document" => surfaces::activate_document(args),+        "progress" => misc::progress_verb(args),         // ---- board-read primitive: ONE agent round trip, views shaped here ----         "read_board" | "board_summary" | "rules_by_kind" | "layers_in_use" => {             if let Some(g) = need_licensed() { return g; }-            let sections = s(args, "sections").unwrap_or_else(|| "components,nets,layers,copper,rules,occupancy".into());+            let sections = s(args, "sections").unwrap_or_else(|| "components,nets,layers,copper,rules,occupancy,outline".into());             let r = agent::call("read_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);@@ -230,8 +276,7 @@ pub fn dispatch(command: &str, args: &Value, _forwarded: &[(String, String)]) ->                 return json!({"success": true, "raw": data, "roundtripMs": r.get("roundtripMs")});             }             let d = board::BoardData::new(data);-            // NB: match `cmd` (prefix-stripped), not `command` — "altium_board_summary" would-            // otherwise fall through to the raw-read arm.+            // NB: match `cmd` (prefix-stripped), not `command`.             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."}),@@ -246,10 +291,161 @@ pub fn dispatch(command: &str, args: &Value, _forwarded: &[(String, String)]) ->                             "designators": d.designators(), "nets": d.nets(), "layers": d.layers(),                             "copperLayers": d.copper_layers(), "rules": d.rules(),                             "roundtripMs": r.get("roundtripMs"),-                            "_hint": "The raw read behind every introspection view — record it as a test fixture with `> fixtures/read_board.json`."})+                            "_hint": "The raw read behind every introspection view — record it as a test fixture with `raw:true`."})             }         }+        "read_schematic" => {+            if let Some(g) = need_licensed() { return g; }+            let params = if args.get("params").and_then(|b| b.as_bool()) == Some(false) { "0" } else { "1" };+            let r = agent::call("read_schematic", &[("sections", s(args, "sections").unwrap_or("components".into())),+                                                    ("params", params.into()),+                                                    ("limit", s(args, "limit").unwrap_or("500".into()))],+                                None, Duration::from_secs(timeout.max(90)));+            let data = r.get("data").cloned().unwrap_or(Value::Null);+            if data.get("ok").and_then(|b| b.as_bool()) != Some(true) { return r; }+            let sch = board::SchematicData::new(data);+            json!({"success": true, "document": sch.document(), "count": sch.components().len(),+                   "designators": sch.designators(), "components": sch.components(),+                   "roundtripMs": r.get("roundtripMs"),+                   "_hint": "The CURRENT sheet only — a hierarchical parent holds sheet symbols, not parts."})+        }+        "cross_check" => cross_check(args),+        "review_board" => review::review_board(args),+        // ---- direct board reads ----+        "list_designators" => board_read("list_designators", vec![], 90),+        "component_data" => board_read("component_data", vec![("designator", s(args, "designator").unwrap_or_default()), ("limit", s(args, "limit").unwrap_or("500".into()))], 90),+        "component_pins" => match s(args, "designator") {+            None => json!({"success": false, "error": "designator is required (e.g. U1)"}),+            Some(d) => board_read("component_pins", vec![("designator", d)], 90),+        },+        "list_nets" => board_read("list_nets", vec![], 90),+        "net_connections" => board_read("net_connections", vec![("net", s(args, "net").unwrap_or_default()), ("limit", s(args, "limit").unwrap_or("4000".into()))], 120),+        "layers" => board_read("layers", vec![], 90),+        "copper_layers" => board_read("copper_layers", vec![], 90),+        "read_rules" => board_read("read_rules", vec![], 90),+        "set_layer_visibility" => match s(args, "layerId") {+            None => json!({"success": false, "error": "layerId is required (from altium_layers)"}),+            Some(l) => {+                let shown = args.get("displayed").and_then(|b| b.as_bool()).unwrap_or(true);+                board_read("set_layer_visibility", vec![("layerId", l), ("displayed", if shown { "1".into() } else { "0".into() })], 60)+            }+        },+        "footprint_primitives" => match s(args, "designator") {+            None => json!({"success": false, "error": "designator is required (a placed component, e.g. U1)"}),+            Some(d) => board_read("footprint_primitives", vec![("designator", d)], 120),+        },+        "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)+        }+        // ---- surfaces ----+        "show_project" => surfaces::show_project(args),+        "show_schematic" => surfaces::show_schematic(args),+        "show_2d_board" => surfaces::show_2d_board(args),+        "show_3d_board" => surfaces::show_3d_board(args),+        "show_library" => surfaces::show_library(args),+        "show_symbol" => surfaces::show_symbol(args),+        "show_footprint" => surfaces::show_footprint(args),+        "show_3d_chip" => surfaces::show_3d_chip(args),+        "list_library" => surfaces::list_library(args),+        "demo" => surfaces::demo(args),+        "save" => surfaces::save(args),+        "board_shape" => surfaces::board_shape(args),+        "pcb_report" => surfaces::pcb_report(args),+        // ---- outputs / project ----+        "compile" | "run_erc" => outputs::compile(args),+        "run_drc" => outputs::run_drc(args),+        "run_outjob" => outputs::run_outjob(args),+        "export_gerber" => outputs::export_gerber(args),+        "export_bom" => outputs::export_bom(args),+        "export_step" => outputs::export_step(args),+        "test_board" => library::test_board(args),+        "import_kicad" => outputs::import_kicad(args),+        "install_extension" => outputs::install_extension(args),+        "extension_ping" => outputs::extension_ping(args),+        // ---- library authoring ----+        "create_footprint" => library::create_footprint(args),+        "create_symbol" => library::create_symbol(args),+        "build_footprint" => library::build_footprint(args),+        "build_symbol_modes" => library::build_symbol_modes(args),+        "add_body" => library::add_body(args),+        "add_models" => library::add_models(args),+        "normalize_bodies" => library::normalize_bodies(args),+        "set_params" => library::set_params(args),+        "set_visibility" => library::set_visibility(args),+        "read_symbol" => library::read_symbol(args),+        "clone_symbol" => library::clone_symbol(args),+        "rename_symbol" => library::rename_symbol(args),+        "rename_footprint" => library::rename_footprint(args),+        "remove_symbol" => library::remove_symbol(args),+        "remove_footprint" => library::remove_footprint(args),+        "copy_footprint" => library::copy_footprint(args),+        "xcopy_footprint" => library::xcopy_footprint(args),+        "swap_footprint" => library::swap_footprint(args),+        "symbol_mode" => library::symbol_mode(args),+        "cleanup_libs" => library::cleanup_libs(args),+        "install_library" => library::install_library(args),+        "install_library_bundle" => library::install_library_bundle(args),+        "compile_intlib" => library::compile_intlib(args),+        "extract_intlib" => library::extract_intlib(args),+        "place_on_sch" => library::place_on_sch(args),+        "place_on_pcb" => library::place_on_pcb(args),+        "import_part" => library::import_part(args),+        // ---- licence / account ----+        "license" => license::license(args),+        "license_status" => license::license_status(args),+        "ensure_licensed" => license::ensure_licensed(args),+        "setup_trial" => license::setup_trial(args),+        "signin" | "signin_status" => license::signin_status(args),+        "connect_workspace" => license::connect_workspace(args),+        // ---- Altium 365 ----+        "a365_configure" => a365::configure(args),+        "a365_oauth_begin" => a365::oauth_begin(args),+        "a365_oauth_status" => a365::oauth_status(args),+        "a365_oauth_cancel" => a365::oauth_cancel(args),+        "a365_refresh" => a365::refresh(args),+        "a365_logout" => a365::logout(args),+        "a365_status" => a365::status(args),+        "a365_query" => a365::query(args),+        "a365_files_upload" => a365::files_upload(args),+        "a365_library_overview" => a365::library_overview(args),+        "a365_library_schema" => a365::library_schema(args),         _ => json!({"success": false, "error": format!("Unknown command: {command}"),-                    "_hint": format!("Rust bridge v0 serves: {}", names().join(", "))}),+                    "_hint": format!("Valid: {}", names().join(", "))}),     } }++/// Compare a schematic sheet against a board. Altium CANNOT hold both current at once —+/// `GetCurrentPCBBoard` follows the focused document — so this reads them in TURN: activate the+/// board, read it, activate the sheet, read it.+fn cross_check(args: &Value) -> Value {+    if let Some(g) = need_licensed() { return g; }+    let timeout = Duration::from_secs_f64(support::f_or(args, "timeout", 45.0));+    let read = |kind: &str, path: Option<String>| -> Result<Value, Value> {+        if let Some(p) = &path {+            if let Some(bad) = support::validate_openable(p) {+                return Err(bad);+            }+            let o = agent::call("open", &[("path", p.clone())], None, timeout);+            let d = o.get("data").cloned().unwrap_or(json!({}));+            if d.get("ok").and_then(|b| b.as_bool()) == Some(false) {+                return Err(json!({"success": false, "stage": kind, "error": d.get("error"),+                                  "_hint": format!("could not open {p}")}));+            }+        }+        let cmd = if kind == "board" { "read_board" } else { "read_schematic" };+        let r = agent::call(cmd, &[("sections", "components".to_string())], None, timeout);+        let d = r.get("data").cloned().unwrap_or(json!({}));+        if d.get("ok").and_then(|b| b.as_bool()) != Some(true) {+            return Err(json!({"success": false, "stage": kind, "error": d.get("error"),+                "_hint": format!("No current {}. Pass {kind}:<path>, or open one first — Altium can only hold ONE document current, so cross_check must switch between them.",+                    if kind == "board" { "PCB board" } else { "schematic sheet" })}));+        }+        Ok(d)+    };+    let bd = match read("board", s(args, "board")) { Ok(d) => d, Err(e) => return e };+    let sd = match read("sheet", s(args, "sheet")) { Ok(d) => d, Err(e) => return e };+    let mut out = board::cross_check(&board::SchematicData::new(sd), &board::BoardData::new(bd));+    out["success"] = json!(true);+    out+}
package.json+2−2
@@ -1,6 +1,6 @@ {   "slug": "altium-bridge",-  "version": "0.1.41",+  "version": "0.2.0",   "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": [@@ -115,4 +115,4 @@       "prompt": "generate gerbers and a BOM from my altium project"     }   ]-}+}\ No newline at end of file
scripts/release.py+65−26
@@ -2,62 +2,101 @@ """Build the RUNTIME release zip + manifest for altium-bridge (SDK payload rules: runtime-only, bridge.json at the zip ROOT, no media/docs). +The runtime is the RUST bridge (bridge-rs → altium-bridge.exe, spawn.kind "exe"): the zip carries+the exe, the DelphiScript agent template (the in-process lane — that one stays Pascal by nature),+the taskbar badge asset and the AdomBridge extension payload. No Python ships.+   python3 scripts/release.py            # -> dist/adom-bridge-altium-v<ver>.zip + dist/adom-bridge-altium-manifest.json   python3 scripts/release.py --publish  # + adom-wiki release create/upload + POST manifest to the page -Versions live in ONE place: src/bridge.json (package.json for the skills pkg is bumped in lockstep).+Versions live in ONE place: src/bridge.json (package.json and bridge-rs/src/main.rs BRIDGE_VERSION+are checked against it, so a half-bumped release refuses to build). """-import hashlib, json, os, subprocess, sys, zipfile+import hashlib, json, os, re, subprocess, sys, zipfile from pathlib import Path  ROOT = Path(__file__).resolve().parent.parent SRC = ROOT / "src"+RS = ROOT / "bridge-rs"+TARGET = "x86_64-pc-windows-gnu"+EXE = RS / "target" / TARGET / "release" / "altium-bridge.exe" DIST = ROOT / "dist" PAGE = "adom/altium-bridge" PAGE_SLUG = "altium-bridge" -INCLUDE = ["server.py", "bridge.json", "BRIDGE_VERSION", "caller_identity.py",-           "handlers/__init__.py", "handlers/a365.py", "handlers/ad_client.py", "handlers/progress.py", "handlers/uia.py",-           "handlers/png_stdlib.py", "handlers/altium_detect.py", "handlers/agent.py", "handlers/dialogs.py",-           "handlers/rcs_catalog.py", "handlers/taskbar.py", "agent/AdomAgent.pas.tmpl", "agent/AdomAgent.PrjScr",-           "resources/adom-overlay-32.png", "extension/Install-AdomBridge.ps1", "extension/REMOVE-AdomBridge.cmd",-           "extension/AdomBridge/AdomBridge.dll", "extension/AdomBridge/AdomBridge.ins", "extension/AdomBridge/AdomBridge.rcs",-           "extension/AdomBridge/Help/ServerDlls.txt"]-+# (zip path, source path). bridge.json at the ROOT; everything else beside the exe exactly where+# the exe looks for it (agent/, resources/, extension/ relative to current_exe).+INCLUDE = [+    ("bridge.json", SRC / "bridge.json"),+    ("BRIDGE_VERSION", SRC / "BRIDGE_VERSION"),+    ("altium-bridge.exe", EXE),+    ("agent/AdomAgent.pas.tmpl", SRC / "agent" / "AdomAgent.pas.tmpl"),+    ("agent/AdomAgent.PrjScr", SRC / "agent" / "AdomAgent.PrjScr"),+    ("resources/adom-overlay-32.png", SRC / "resources" / "adom-overlay-32.png"),+    ("extension/Install-AdomBridge.ps1", SRC / "extension" / "Install-AdomBridge.ps1"),+    ("extension/REMOVE-AdomBridge.cmd", SRC / "extension" / "REMOVE-AdomBridge.cmd"),+    ("extension/AdomBridge/AdomBridge.dll", SRC / "extension" / "AdomBridge" / "AdomBridge.dll"),+    ("extension/AdomBridge/AdomBridge.ins", SRC / "extension" / "AdomBridge" / "AdomBridge.ins"),+    ("extension/AdomBridge/AdomBridge.rcs", SRC / "extension" / "AdomBridge" / "AdomBridge.rcs"),+    ("extension/AdomBridge/Help/ServerDlls.txt", SRC / "extension" / "AdomBridge" / "Help" / "ServerDlls.txt"),+]  -def lint_verbs(src_dir):+def lint_verbs():     """bridge.json's verbs[] is a PROMISE ab routes on. Drift breaks it in both directions:     a declared verb with no handler answers "Unknown command" after ab has already routed it, and a     working handler that is not declared is unreachable. Both were live on 2026-09-01 (3 phantom-    verbs, 3 unreachable ones), so the release now refuses rather than shipping it again."""-    import json as _json, re as _re-    src = (src_dir / "server.py").read_text(encoding="utf-8", errors="replace")-    keys = set(_re.findall(r'COMMAND_HANDLERS\["([a-z0-9_]+)"\]', src))-    m = _re.search(r'COMMAND_HANDLERS\s*=\s*\{(.*?)\n\}', src, _re.S)-    keys |= set(_re.findall(r'"([a-z0-9_]+)"\s*:\s*h_', m.group(1) if m else ""))-    declared = {v[len("altium_"):] for v in _json.loads((src_dir / "bridge.json").read_text())["verbs"]}-    phantom, unreachable = sorted(declared - keys), sorted(keys - declared)+    verbs, 3 unreachable ones), so the release refuses rather than shipping it again.+    The dispatch of record is bridge-rs/src/verbs.rs (KNOWN)."""+    rs = (RS / "src" / "verbs.rs").read_text(encoding="utf-8", errors="replace")+    m = re.search(r"pub const KNOWN: &\[&str\] = &\[(.*?)\];", rs, re.S)+    if not m:+        raise SystemExit("cannot find KNOWN in bridge-rs/src/verbs.rs")+    known = set(re.findall(r'"([a-z0-9_]+)"', m.group(1)))+    declared = {v[len("altium_"):] for v in json.loads((SRC / "bridge.json").read_text())["verbs"]}+    phantom, unreachable = sorted(declared - known), sorted(known - declared)     if phantom or unreachable:         raise SystemExit(-            "bridge.json does not match server.py\n"+            "bridge.json does not match bridge-rs/src/verbs.rs\n"             f"  declared but NOT dispatchable (ab routes them, the bridge 404s): {phantom}\n"             f"  dispatchable but NOT declared (unreachable through ab):          {unreachable}")     return len(declared)  +def lint_versions(ver):+    """One version, three stamps — refuse a half-bumped release."""+    stamps = {+        "src/BRIDGE_VERSION": (SRC / "BRIDGE_VERSION").read_text().strip(),+        "package.json": json.loads((ROOT / "package.json").read_text())["version"],+    }+    m = re.search(r'BRIDGE_VERSION: &str = "([^"]+)"', (RS / "src" / "main.rs").read_text())+    stamps["bridge-rs/src/main.rs"] = m.group(1) if m else "?"+    bad = {k: v for k, v in stamps.items() if v != ver}+    if bad:+        raise SystemExit(f"version drift against bridge.json {ver}: {bad}")+++def build_exe():+    """Cross-compile the Windows exe + run the test suite first (a red test never ships)."""+    subprocess.run(["cargo", "test", "--quiet"], cwd=RS, check=True)+    subprocess.run(["cargo", "build", "--release", "--target", TARGET], cwd=RS, check=True)+    if not EXE.exists():+        raise SystemExit(f"exe missing after build: {EXE}")++ def main():-    n = lint_verbs(SRC)-    print(f"verb lint ok: {n} verbs declared and dispatchable")     ver = json.loads((SRC / "bridge.json").read_text())["version"]+    lint_versions(ver)+    n = lint_verbs()+    print(f"verb lint ok: {n} verbs declared and dispatchable (rust)")+    build_exe()     DIST.mkdir(exist_ok=True)     zname = f"adom-bridge-altium-v{ver}.zip"     zpath = DIST / zname     with zipfile.ZipFile(zpath, "w", zipfile.ZIP_DEFLATED) as z:-        for rel in INCLUDE:-            p = SRC / rel+        for rel, p in INCLUDE:             if not p.exists():-                sys.exit(f"missing {rel}")+                sys.exit(f"missing {p}")             z.write(p, rel)     sha = hashlib.sha256(zpath.read_bytes()).hexdigest()     manifest = {@@ -78,7 +117,7 @@ def main():     if "--publish" in sys.argv:         run = lambda *a: subprocess.run(a, check=True)         run("adom-wiki", "release", "create", PAGE, ver, "--title", f"altium-bridge {ver}", "--changelog", "see CHANGELOG.md")-        run("adom-wiki", "release", "upload", PAGE, ver, str(zpath), "--platform", "any")+        run("adom-wiki", "release", "upload", PAGE, ver, str(zpath), "--platform", "windows", "--arch", "x64")         run("adom-wiki", "repo", "push", PAGE)   # manifest + page files         print("published") 
src/BRIDGE_VERSION+1−1
@@ -1 +1 @@-0.1.41+0.2.0
src/bridge.json+101−83
@@ -2,119 +2,137 @@   "manifest_version": 1,   "name": "altium",   "displayName": "Altium Designer",-  "version": "0.1.11",+  "version": "0.2.0",   "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",   "author": "Adom Inc.",   "license": "MIT",   "spawn": {-    "kind": "python",-    "entrypoint": "server.py",+    "kind": "exe",+    "entrypoint": "altium-bridge.exe",     "port": 0,     "persistent": true,     "healthEndpoint": "/health",     "stopMethod": "kill",-    "killImageName": "python.exe"+    "killImageName": "altium-bridge.exe"   },   "verbPrefixes": [     "altium_"   ],   "verbs": [-    "altium_readiness",-    "altium_status",+    "altium_a365_configure",+    "altium_a365_files_upload",+    "altium_a365_library_overview",+    "altium_a365_library_schema",+    "altium_a365_logout",+    "altium_a365_oauth_begin",+    "altium_a365_oauth_cancel",+    "altium_a365_oauth_status",+    "altium_a365_query",+    "altium_a365_refresh",+    "altium_a365_status",+    "altium_activate_document",+    "altium_add_body",+    "altium_add_models",+    "altium_agent_recover",+    "altium_agent_start",+    "altium_agent_status",+    "altium_agent_stop",+    "altium_board_shape",+    "altium_board_summary",+    "altium_build_footprint",+    "altium_build_symbol_modes",+    "altium_check_dialogs",+    "altium_cleanup_libs",+    "altium_clone_symbol",+    "altium_close",+    "altium_compile",+    "altium_compile_intlib",+    "altium_component_data",+    "altium_component_pins",+    "altium_connect_workspace",+    "altium_copper_layers",+    "altium_copy_footprint",+    "altium_create_footprint",+    "altium_create_symbol",+    "altium_cross_check",+    "altium_demo",     "altium_describe",-    "altium_state",-    "altium_progress",     "altium_diagnostics",-    "altium_uninstall",-    "altium_launch",-    "altium_close",-    "altium_window_info",-    "altium_screenshot_all",-    "altium_check_dialogs",+    "altium_dialog_log",     "altium_dismiss_dialogs",-    "altium_agent_start",-    "altium_agent_stop",-    "altium_agent_status",-    "altium_agent_recover",+    "altium_ensure_licensed",     "altium_eval",-    "altium_run_command",-    "altium_run_script",-    "altium_list_commands",-    "altium_open_project",-    "altium_open_document",-    "altium_close_document",-    "altium_save",-    "altium_compile",-    "altium_show_symbol",-    "altium_show_footprint",-    "altium_show_3d_chip",-    "altium_show_library",-    "altium_show_schematic",-    "altium_show_2d_board",-    "altium_show_3d_board",-    "altium_show_project",-    "altium_run_outjob",-    "altium_run_drc",-    "altium_run_erc",+    "altium_export_bom",     "altium_export_gerber",     "altium_export_step",-    "altium_export_pdf",-    "altium_export_bom",-    "altium_create_symbol",-    "altium_create_footprint",-    "altium_list_library",-    "altium_signin",-    "altium_license",-    "altium_a365_status",-    "altium_a365_query",-    "altium_demo",-    "altium_install_library",-    "altium_compile_intlib",-    "altium_extract_intlib",-    "altium_set_params",-    "altium_add_body",-    "altium_install_extension",     "altium_extension_ping",+    "altium_extract_intlib",+    "altium_footprint_primitives",     "altium_import_kicad",-    "altium_a365_configure",-    "altium_signin_status",-    "altium_connect_workspace",-    "altium_build_footprint",-    "altium_build_symbol_modes",-    "altium_symbol_mode",-    "altium_add_models",-    "altium_normalize_bodies",-    "altium_test_board",-    "altium_board_shape",-    "altium_swap_footprint",-    "altium_pcb_report",-    "altium_place_on_sch",-    "altium_place_on_pcb",-    "altium_remove_symbol",-    "altium_clone_symbol",-    "altium_rename_symbol",-    "altium_read_symbol",-    "altium_copy_footprint",-    "altium_rename_footprint",-    "altium_remove_footprint",-    "altium_cleanup_libs",-    "altium_set_visibility",-    "altium_xcopy_footprint",     "altium_import_part",+    "altium_install_extension",+    "altium_install_library",     "altium_install_library_bundle",+    "altium_instances",+    "altium_launch",+    "altium_layers",+    "altium_layers_in_use",+    "altium_license",+    "altium_license_status",+    "altium_list_commands",     "altium_list_designators",-    "altium_component_data",-    "altium_component_pins",+    "altium_list_library",     "altium_list_nets",-    "altium_layers",     "altium_net_connections",-    "altium_schematic_data",-    "altium_copper_layers",+    "altium_normalize_bodies",+    "altium_open_document",+    "altium_open_project",+    "altium_pcb_report",+    "altium_place_on_pcb",+    "altium_place_on_sch",+    "altium_progress",+    "altium_read_board",     "altium_read_rules",+    "altium_read_schematic",+    "altium_read_symbol",+    "altium_readiness",+    "altium_remove_footprint",+    "altium_remove_symbol",+    "altium_rename_footprint",+    "altium_rename_symbol",+    "altium_review_board",+    "altium_rules_by_kind",+    "altium_run_command",+    "altium_run_drc",+    "altium_run_erc",+    "altium_run_outjob",+    "altium_run_script",+    "altium_save",+    "altium_schematic_data",     "altium_set_layer_visibility",-    "altium_footprint_primitives"+    "altium_set_params",+    "altium_set_visibility",+    "altium_setup_trial",+    "altium_show_2d_board",+    "altium_show_3d_board",+    "altium_show_3d_chip",+    "altium_show_footprint",+    "altium_show_library",+    "altium_show_project",+    "altium_show_schematic",+    "altium_show_symbol",+    "altium_signin",+    "altium_signin_status",+    "altium_state",+    "altium_status",+    "altium_swap_footprint",+    "altium_symbol_mode",+    "altium_test_board",+    "altium_uninstall",+    "altium_window_info",+    "altium_xcopy_footprint"   ],   "statusVerb": "altium_status",   "uninstall": "altium_uninstall",