← Commit history
BRIDGE_VERSION+1−1
@@ -1 +1 @@-1.9.350\ No newline at end of file+1.9.351\ No newline at end of file
CLAUDE.md+2−2
@@ -9,9 +9,9 @@ all of this and filed its own bridge bugs as "adom-desktop" requests. Don't.)  **Naming (John, 2026-08-29, tightened 2026-09-01):** the host app is **Adom Bridge, "ab"**. "Adom Desktop" and "AD" are the retired names; do not use them in prose, hints, skills or issues. The-2026-08-29 exception that kept `_ad_call`, `ad_client.py` and the `adom-desktop-fusion-bridge` folder+2026-08-29 exception that kept `_ab_call`, `ab_client.py` and the `adom-desktop-fusion-bridge` folder is REVERSED: John (2026-09-01) - "make sure there's no more remnants of ad or adom-desktop naming."-The Rust port ships the purge (census + targets in the roadmap: 86 `_ad_call`, 59 `ad_client`,+The Rust port ships the purge (census + targets in the roadmap: the Python `_ad_call`/`ad_client` identifiers, renamed to `_ab_call`/`ab_client` on 2026-09-03; 70 `adom-desktop`), and a CI grep fails the build on any remnant. Until the rename lands, do not ADD new uses of the old identifiers; write `ab` in everything new. 
addin/AdomBridge/AdomBridge.manifest+1−1
@@ -6,7 +6,7 @@     "description": {         "": "Adom Desktop Conduit bridge add-in. Exposes a localhost HTTP API for external automation of Fusion 360."     },-    "version": "1.0.16",+    "version": "1.0.17",     "runOnStartup": true,     "supportedOS": "windows" }\ No newline at end of file
addin/AdomBridge/AdomBridge.py+35−6
@@ -8,6 +8,9 @@ so all Fusion API calls execute safely on the main thread. import adsk.core import adsk.fusion import traceback+import time+import json+import os  # Module-level references kept alive for the add-in lifetime _app = None@@ -92,6 +95,13 @@ def run(context):          # Log to the Text Commands palette (non-blocking)         adsk.core.Application.log(f"Adom Bridge add-in started (port 8774)")+        # A clean start retires the start-failure marker the bridge surfaces (issue #75).+        try:+            _mk = os.path.join(os.environ.get("TEMP") or os.path.expanduser("~"), "adom-fusion", "addin-start-failed.json")+            if os.path.isfile(_mk):+                os.remove(_mk)+        except Exception:+            pass      except Exception:         # Only use messageBox for errors — worth blocking to show the user@@ -100,14 +110,33 @@ def run(context):         # same way) turned one broken deploy into a dialog storm - twice per launch, then         # once per user action (issue #69, Colby, 2026-08-28). After the first box, log.         global _start_error_shown+        tb = traceback.format_exc()+        here = os.path.dirname(os.path.abspath(__file__))+        # Leave a marker the BRIDGE reads (fusion_readiness / fusion_addin_status): it knows+        # which AddIns dir is broken and re-syncs it, so the fix is one bridge start away.+        try:+            marker_dir = os.path.join(os.environ.get("TEMP") or os.path.expanduser("~"), "adom-fusion")+            os.makedirs(marker_dir, exist_ok=True)+            with open(os.path.join(marker_dir, "addin-start-failed.json"), "w", encoding="utf-8") as fh:+                json.dump({"addinDir": here, "at": time.time(), "traceback": tb[-4000:]}, fh)+        except Exception:+            pass+        try:+            adsk.core.Application.log(f"Adom Bridge failed to start from {here}:\n{tb}")+        except Exception:+            pass+        # ONE modal, total, and one a person can act on: a traceback about a missing module is+        # what Adonis read five times as "Fusion keeps crashing" (issue #75). Never a storm:+        # Fusion retries the lifecycle and several installed copies can each fail the same way.         if _ui and not _start_error_shown:             _start_error_shown = True-            _ui.messageBox(f"Adom Bridge failed to start:\n{traceback.format_exc()}")-        else:-            try:-                adsk.core.Application.log(f"Adom Bridge failed to start:\n{traceback.format_exc()}")-            except Exception:-                pass+            last = tb.strip().splitlines()[-1] if tb.strip() else "unknown error"+            _ui.messageBox(+                "The Adom Bridge add-in could not start from:\n" + here + "\n\n"+                "It is out of date or incomplete on disk. To fix it: start Adom Bridge (it repairs "+                "the add-in automatically on start), then restart Fusion. Or delete that folder "+                "and restart Fusion; Adom Bridge reinstalls it.\n\nDetail: " + last[:300],+                "Adom Bridge")   def stop(context):
addin/AdomBridge/commands/__init__.py+13−1
@@ -26,7 +26,19 @@ from .import_file import handle_import_file from .parameters import handle_get_parameters, handle_set_parameter from .screenshot import handle_take_screenshot from .close_document import handle_close_document, handle_close_all_documents-from .activity_palette import handle_activity_banner+# The activity palette is a convenience, not the bridge. A copy of this add-in that lost+# activity_palette.py (issue #69 payload, or a stale sibling copy in a legacy AddIns dir that+# a later sync never reached: issue #75, Adonis) must still start, so the bridge can repair it.+try:+    from .activity_palette import handle_activity_banner+except ImportError as _e:  # pragma: no cover - only a broken install reaches this+    _ACTIVITY_IMPORT_ERROR = str(_e)++    def handle_activity_banner(app, args):+        return {"success": False, "errorCode": "addin_module_missing",+                "error": "activity_palette.py is missing from this add-in copy: " + _ACTIVITY_IMPORT_ERROR,+                "_hint": "The add-in on disk is incomplete. fusion_restart_for_addin {save:true} makes the bridge "+                         "re-sync every AddIns dir and restart Fusion."} from .open_electronics_file import handle_open_schematic, handle_open_board, handle_show_3d_board, handle_show_2d_board, handle_show_schematic, handle_import_electronics from .cloud_documents import (     handle_save_to_cloud,
aps.py+1−1
@@ -263,7 +263,7 @@ def _ad(verb: str, args: dict, timeout: int = 60) -> dict:     circular import at module load. Never raises."""     try:         import server as _s-        return _s._ad_call(verb, args, timeout=timeout) or {}+        return _s._ab_call(verb, args, timeout=timeout) or {}     except Exception as e:         return {"_adCallError": str(e)} 
bridge.json+4−4
@@ -2,20 +2,20 @@   "manifest_version": 1,   "name": "fusion360",   "displayName": "Autodesk Fusion 360",-  "version": "1.9.350",+  "version": "1.9.351",   "description": "Drive Autodesk Fusion 360 from the cloud: launch Fusion, electronics board layout, design rules, exports (STEP/IGES/STL/3MF/USDZ/OBJ/DXF/DWG/Gerbers/BOM/CPL), fast APS server-indexed cloud search plus browse/recent/file-info/versions, cloud file download/upload and folder creation, and in-app parametric modeling (fusion_run_modeling_script). Never-charge: APS calls are capped to the free tier.",   "homepage": "https://wiki.adom.inc/adom/fusion-bridge",   "author": "Adom Inc.",   "license": "MIT",   "spawn": {-    "kind": "exe",-    "entrypoint": "adom-fusion-bridge.exe",+    "kind": "python",+    "entrypoint": "server.py",     "port": 0,     "healthEndpoint": "/status",     "stopMethod": "kill",     "killImageName": "python.exe",     "persistent": true,-    "_hint": "Phase 1 of the Rust port: ab launches the Rust exe (<install>/adom-fusion-bridge.exe --port N); the exe spawns server.py beside it as a Python sidecar (ab's provisioned ~/.adom/adom-runtimes python) and proxies every verb it has not ported yet. killImageName stays python.exe so ab's runtime-mode stop reaps the sidecar first; the exe also ties the sidecar to its own lifetime with a job object. To fall back to the Python bridge, set kind: python, entrypoint: server.py."+    "_hint": "spawn.kind python is the PUBLIC-safe shape: ab provisions the interpreter. The zip also carries adom-fusion-bridge.exe (the Rust shell, Phase 1 of the port); the insiders tier flips to kind exe / entrypoint adom-fusion-bridge.exe once ab can provision Python for an exe bridge (adom-bridge issue #134). Both files are always in the payload, so a tier can flip without a rebuild."   },   "verbPrefixes": [     "fusion_"
dashboard/Cargo.lock+1−1
@@ -4,7 +4,7 @@ version = 4  [[package]] name = "fusion-dashboard"-version = "1.9.350"+version = "1.9.351" dependencies = [  "serde_json", ]
dashboard/Cargo.toml+1−1
@@ -1,6 +1,6 @@ [package] name = "fusion-dashboard"-version = "1.9.350"+version = "1.9.351" edition = "2021"  [dependencies]
fusion_detect.py+1−1
@@ -727,7 +727,7 @@ def find_licensing_dialog():     # window is the LARGEST family window. The old no-owner fallback matched the main window whenever     # it happened to fall inside the shape gate (e.g. a non-maximized 1300x820 window fits     # 480<=w<=1500, 180<=h<=900) - so find_licensing_dialog false-fired on the drivable main app,-    # and once ad_client came online the seat resolver churned 8x on it and HUNG readiness (John,+    # and once ab_client came online the seat resolver churned 8x on it and HUNG readiness (John,     # live 2026-07-14). Excluding the largest window is size-independent and safe: a dialog is never     # bigger than the app that owns it.     main_hwnd = max(wins, key=lambda w: w["width"] * w["height"])["hwnd"]
handlers/close_fusion.py+7−7
@@ -77,17 +77,17 @@ def _ab_process_rows(name_substr):     call instead?"): ab already owns host process ops - one implementation, no child     process to spawn, and the call lands in ab's activity log with this bridge's caller     identity, where a raw taskkill is invisible. The tasklist/taskkill spawns below survive-    only as the degraded path for when ad_client is down (unattended VM, ab mid-restart).+    only as the degraded path for when ab_client is down (unattended VM, ab mid-restart).      NOTE the deliberate exception: fusion_detect's POLLING uses a local Toolhelp32 snapshot     instead of this verb, and should keep doing so - a poll every few seconds wants     microseconds in-process, not an HTTP round trip. Verb for OPERATIONS, snapshot for     POLLS, hidden spawn only as a fallback."""     try:-        import ad_client-        if not ad_client.available():+        import ab_client+        if not ab_client.available():             return None-        r = ad_client.call("process_list", {"name": name_substr}, timeout=10)+        r = ab_client.call("process_list", {"name": name_substr}, timeout=10)         if not isinstance(r, dict):             return None         rows = r.get("processes")@@ -106,10 +106,10 @@ def _ab_process_kill(pid, why):     """Kill one PID through ab's process_kill verb. True on confirmed kill, False when the     verb was unavailable or refused (caller falls back to a hidden taskkill)."""     try:-        import ad_client-        if not ad_client.available():+        import ab_client+        if not ab_client.available():             return False-        r = ad_client.call("process_kill", {"pid": int(pid), "tree": True, "reason": why},+        r = ab_client.call("process_kill", {"pid": int(pid), "tree": True, "reason": why},                            timeout=15)         return bool(isinstance(r, dict) and (r.get("success") or r.get("ok")))     except Exception:
handlers/dialog_classify.py+2−2
@@ -325,8 +325,8 @@ def _dialog_text(hwnd) -> str:     if not hwnd:         return ""     try:-        import ad_client-        r = ad_client.call("desktop_find_controls", {"hwnd": int(hwnd)}, timeout=10) or {}+        import ab_client+        r = ab_client.call("desktop_find_controls", {"hwnd": int(hwnd)}, timeout=10) or {}         data = r.get("data") if isinstance(r.get("data"), dict) else r         ctrls = (data or {}).get("controls") or (data or {}).get("matches") or []         return " ".join(str(c.get("name") or "") for c in ctrls if c.get("name"))[:400]
handlers/taskbar_overlay.py+4−4
@@ -77,11 +77,11 @@ def _taskbar_via_cli(args: dict) -> bool:  def _taskbar(args: dict) -> bool:     try:-        import ad_client-        if not ad_client.available():-            _LAST_ERROR[0] = "ad_client unavailable - trying the ab CLI fallback"+        import ab_client+        if not ab_client.available():+            _LAST_ERROR[0] = "ab_client unavailable - trying the ab CLI fallback"             return _taskbar_via_cli(args)-        r = ad_client.call("desktop_taskbar", args, timeout=15)+        r = ab_client.call("desktop_taskbar", args, timeout=15)         raw = str(r)         if not r:             _LAST_ERROR[0] = "desktop_taskbar returned nothing (timeout?)"
install.sh+1−1
@@ -21,7 +21,7 @@ echo "Installed fusion-bridge skills: $(ls "$PKG_DIR/skills" 2>/dev/null | tr '\ # binary (the three-artifact pattern: bridge zip -> ab, THIS pkg -> container skills + # this download, page media -> wiki). Per the wiki's PKG_VS_RELEASE policy, binaries are # platform-tagged RELEASE assets, never tarball content - the installer fetches them.-DASH_VERSION="1.9.350"+DASH_VERSION="1.9.351" DASH_URL="https://wiki.adom.inc/download/adom/fusion-bridge/${DASH_VERSION}/fusion-dashboard" DEST="" if [ -w /usr/local/bin ]; then DEST=/usr/local/bin
install_addin.py+13
@@ -162,6 +162,19 @@ def install():             else:                 shutil.copytree(SOURCE_DIR, target)                 print(f"Installed: {target}")+            # VERIFY the copy on disk, not the intent (issue #75, Adonis: a stale copy in the+            # legacy API/AddIns dir imported activity_palette without the file and bricked+            # every Fusion launch). A copy whose relative imports do not resolve is replaced+            # wholesale; a sync that silently left it broken is exactly what we cannot afford.+            bad_on_disk = _check_relative_imports(str(target))+            if bad_on_disk:+                print(f"  REPAIRING {target}: copy on disk is incomplete ({'; '.join(bad_on_disk)[:200]})")+                shutil.rmtree(target, ignore_errors=True)+                shutil.copytree(SOURCE_DIR, target)+                bad_on_disk = _check_relative_imports(str(target))+                if bad_on_disk:+                    print(f"  STILL BROKEN after repair: {target}: {bad_on_disk}")+                    continue             installed_to.append(str(target))         except OSError as e:             print(f"  skip {target}: {e}")
package.json+1−1
@@ -1,6 +1,6 @@ {   "slug": "fusion-bridge",-  "version": "1.9.350",+  "version": "1.9.351",   "type": "app",   "description": "This package installs the bridge's SKILLS into your container so your AI knows how to drive it; Adom Bridge loads the bridge runtime itself from the release zip.",   "dependencies": {
page.json+11−27
@@ -1,6 +1,6 @@ {   "slug": "fusion-bridge",-  "version": "1.9.350",+  "version": "1.9.351",   "type": "app",   "description": "This package installs the bridge's SKILLS into your container so your AI knows how to drive it; Adom Bridge loads the bridge runtime itself from the release zip.",   "dependencies": {@@ -11,6 +11,10 @@     "uninstall": "./uninstall.sh"   },   "tags": [+    "ab",+    "bridge",+    "fusion",+    "desktop-app",     "fusion360",     "autodesk",     "cad",@@ -75,7 +79,10 @@     "build a basic parts library",     "add a 3d model to a component",     "make a fusion 360 library with 3d",-    "set up the fusion bridge"+    "set up the fusion bridge",+    "fusion dashboard",+    "fusion status page",+    "open the fusion launch page"   ],   "discovery_pitch": "Use the Adom Bridge Fusion 360 bridge when the user wants to DRIVE Autodesk Fusion 360 from the cloud: open/search their team's Fusion cloud designs, lay out a PCB or do electronics, export STEP/IGES/STL/Gerbers/BOM, or build a component library with real rendering 3D. It runs Fusion on the user's own machine via Adom Bridge. NOT for web/browser/login/form-filling (that's the native-browser extension) and NOT for KiCad (that's the kicad bridge).",   "brief": "Drive Autodesk Fusion 360 from the cloud via Adom Bridge: component libraries, IPC package generation, board layout, exports (STEP/Gerbers/BOM/CPL), fast APS cloud search, and parametric modeling.",@@ -104,28 +111,5 @@       "label": "Part volume",       "prompt": "How much material does each printed part in this assembly use?"     }-  ],-  "agent_permissions": {-    "allow_bash": [],-    "allow": [-      "adom-desktop-fusion-bridge read-only verbs (via the adom-bridge CLI): fusion_readiness, fusion_get_app_state, fusion_addin_status, fusion_check_dialogs - state queries; they change nothing.",-      "adom-desktop-fusion-bridge read-only verbs: fusion_alerts (reads Fusion's own log for toasts), fusion_document_info, fusion_window_info, fusion_board_info, fusion_library_parts - inspect only.",-      "adom-desktop-fusion-bridge read-only verbs: fusion_prove_result (a persisted run report), fusion_plan_library_import (report-only preflight), fusion_list_cloud_projects, fusion_describe.",-      "adom-desktop-fusion-bridge evidence captures fusion_screenshot_fusion and fusion_capture_library_views read pixels and write local image files only."-    ],-    "soft_deny": [-      "adom-desktop-fusion-bridge lifecycle verbs fusion_stop, fusion_kill, fusion_restart_for_addin close or kill the Fusion application - keep the human prompt.",-      "adom-desktop-fusion-bridge fusion_install_fusion silently installs Autodesk Fusion on the user's machine.",-      "adom-desktop-fusion-bridge fusion_cleanup_cloud_files deletes files from the user's Autodesk cloud hub.",-      "adom-desktop-fusion-bridge managed-library verbs (fusion_managed_library_push/pull/create/link/unlink) publish to or overwrite the user's library.io libraries."-    ],-    "environment": [-      "adom-desktop-fusion-bridge is the Fusion 360 bridge for Adom Bridge; its fusion_* verbs are invoked through the adom-bridge CLI and drive Autodesk Fusion on the user's own desktop.",-      "adom-desktop-fusion-bridge desktop actions also pass Adom Bridge's own human-approval gate (caller identity, per-call reason, Activity Log) - a container approval is the first of two gates."-    ]-  },-  "author": {-    "name": "John Lauer",-    "email": "[email protected]"-  }-}\ No newline at end of file+  ]+}
rust/Cargo.toml+1−1
@@ -4,7 +4,7 @@ members = ["core", "lbr", "os-win", "os-mac", "bridge"]  # One version for the whole bridge, in lockstep with BRIDGE_VERSION (tools/release.sh bumps both). [workspace.package]-version = "1.9.350"+version = "1.9.351" edition = "2021" license = "MIT" 
rust/PORTING.md+23
@@ -49,3 +49,26 @@ cd rust && cargo build --workspace        # on Linux CI this builds core, lbr an cargo build -p adom-fusion-bridge         # on Windows picks os-win; on macOS picks os-mac cargo test -p fusion-core                 # the frozen catalog must parse and be complete ```++## Status (2026-09-03)++- **Phase 0 + Phase 1 shipped on the insiders tier (1.9.350).** ab launches+  `adom-fusion-bridge.exe --port N` (`spawn.kind: exe` in bridge.json); the exe spawns+  `server.py` beside it as a Python sidecar on a free loopback port and proxies every+  unported verb to it with the caller headers intact. `/status` is served by the exe and+  folds in the sidecar's rich status; a dead sidecar shows red at once with its exit code.+- **fusion-os-win is real**: Toolhelp32 poll, `cmd /c start "" /min` launch with+  CREATE_BREAKAWAY_FROM_JOB, IsUserAnAdmin, webdeploy / add-in / AppLogFile paths ported+  from `fusion_detect.py`, `install_addin.py`, `server.py`. The sidecar is tied to the+  exe's lifetime with a job object (KILL_ON_JOB_CLOSE + BREAKAWAY_OK, so the sidecar's own+  Fusion launch can still break away). Proven: hard-kill the exe, the sidecar is gone.+- **Python for the sidecar** comes from `ADOM_SIDECAR_PYTHON`, else ab's `runtimes` verb+  over the loopback API, else a scan of `~/.adom/adom-runtimes/python-*` that skips+  `.broken` quarantines. ab cannot yet provision Python for an exe bridge (asked on+  adom/adom-bridge); on a machine with only this bridge the sidecar is absent until then.+- **Kyle**: `fusion-os-mac` still carries `todo!()` stubs; `tools/check-portable.sh` only+  guards the crates the Windows exe links, so your crate can land at its own pace. The exe+  side is `rust/bridge/src/main.rs`; nothing in it is Windows-specific except `host()`.+- **Guards run in the release**: `check-portable.sh` (core is OS-blind, no `todo!()` in a+  linked crate) and `check-naming.sh`, then the mingw cross-build, then the exe joins the+  zip at its root beside `server.py`.
rust/core/Cargo.toml+3
@@ -10,3 +10,6 @@ serde.workspace = true serde_json.workspace = true thiserror.workspace = true ureq.workspace = true++[dev-dependencies]+serde_json.workspace = true
rust/core/src/alerts.rsadded+79
@@ -0,0 +1,79 @@+//! Fusion's own alert toasts. A CEF alert has no top-level window, so it never shows in a+//! dialog enumeration; Fusion writes it to its AppLogFile as a `New Alert message` line,+//! and the bridge reads that instead of guessing from pixels. Bridge-side on purpose: it+//! works with the add-in wedged and needs no Fusion restart to deploy.+//!+//! The parser is pure (lines in, alerts out) so it is golden-tested against the Python+//! bridge's `_fusion_alerts`; `HostPaths::fusion_log_dir` says where the log is.++use serde::Serialize;++#[derive(Debug, Clone, PartialEq, Serialize, serde::Deserialize)]+pub struct Alert {+    /// The log stamp `YYYYMMDDTHHMMSS` (local time, as Fusion writes it), None when the line+    /// had none. Watermarks compare these strings lexically.+    pub at: Option<String>,+    pub text: String,+}++/// `YYYYMMDDTHHMMSS` → seconds since the epoch, treating the stamp as UTC (the caller+/// supplies its cutoff in the same convention, so the comparison is consistent).+fn stamp_secs(s: &str) -> Option<i64> {+    if s.len() != 15 || s.as_bytes()[8] != b'T' { return None; }+    let n = |a: usize, b: usize| s[a..b].parse::<i64>().ok();+    let (y, mo, d, h, mi, se) = (n(0, 4)?, n(4, 6)?, n(6, 8)?, n(9, 11)?, n(11, 13)?, n(13, 15)?);+    if !(1..=12).contains(&mo) || !(1..=31).contains(&d) || h > 23 || mi > 59 || se > 60 { return None; }+    // days from civil (Howard Hinnant), no leap-second handling, matching strptime().timestamp()+    let (y2, m2) = if mo <= 2 { (y - 1, mo + 9) } else { (y, mo - 3) };+    let era = y2.div_euclid(400);+    let yoe = y2 - era * 400;+    let doy = (153 * m2 + 2) / 5 + d - 1;+    let doe = yoe * 365 + yoe / 4 - yoe / 100 + doy;+    let days = era * 146097 + doe - 719468;+    Some(days * 86400 + h * 3600 + mi * 60 + se)+}++/// Parse `New Alert message` lines; those stamped before `cutoff_secs` are dropped, unstamped+/// lines are kept. Text is the Short Message, else the Tooltip, else the raw remainder.+pub fn parse_alert_lines<'a>(lines: impl IntoIterator<Item = &'a str>, cutoff_secs: i64) -> Vec<Alert> {+    let mut out = Vec::new();+    for line in lines {+        if !line.contains("New Alert message") { continue; }+        let trimmed = line.trim_start_matches([' ', '\t']);+        let at: Option<String> = if trimmed.len() >= 15 && stamp_secs(&trimmed[..15]).is_some() { Some(trimmed[..15].to_string()) } else { None };+        if let Some(a) = &at { if stamp_secs(a).unwrap() < cutoff_secs { continue; } }+        let rest = line.splitn(2, "New Alert message.").nth(1).unwrap_or("").trim_end();+        let field = |key: &str| -> Option<String> {+            let i = rest.find(key)?;+            let after = &rest[i + key.len()..];+            let end = ["," ,"\n"].iter().filter_map(|d| {+                // a field runs to the next ", <Label>:" boundary+                after.match_indices(d).map(|(k, _)| k).find(|&k| d == &"\n" || after[k..].starts_with(", Short Message:") || after[k..].starts_with(", Long Message:"))+            }).min().unwrap_or(after.len());+            Some(after[..end].trim().to_string())+        };+        let mut text = field("Short Message:").filter(|s| !s.is_empty()).or_else(|| field("Tooltip:").filter(|s| !s.is_empty())).unwrap_or_default();+        if text.is_empty() { text = rest.trim().chars().take(400).collect(); }+        out.push(Alert { at, text: text.chars().take(400).collect() });+    }+    out+}++/// Alerts strictly AFTER a watermark stamp (the newest `at` seen when an action started), so+/// a stale alert from an earlier action is never blamed on this one.+pub fn alerts_since<'a>(alerts: &'a [Alert], watermark: &str) -> Vec<&'a Alert> {+    if watermark.is_empty() { return alerts.iter().collect(); }+    alerts.iter().filter(|a| a.at.as_deref().unwrap_or("") > watermark).collect()+}++#[cfg(test)]+mod tests {+    use super::*;+    #[test]+    fn stamps_round_trip() {+        assert_eq!(stamp_secs("19700101T000000"), Some(0));+        assert_eq!(stamp_secs("20260903T073000"), Some(20_699 * 86_400 + 7 * 3_600 + 30 * 60));+        assert_eq!(stamp_secs("20000301T000000"), Some(951868800));+        assert!(stamp_secs("2026090T073000").is_none());+    }+}
rust/core/src/lib.rs+1
@@ -9,6 +9,7 @@  pub mod contract; pub mod host;+pub mod alerts; pub mod ab;  pub use host::{HostPaths, HostProcess};
rust/lbr/Cargo.toml+8−1
@@ -3,7 +3,14 @@ name = "fusion-lbr" version.workspace = true edition.workspace = true license.workspace = true-description = "EAGLE/Fusion .lbr engine: parse, merge, inject package3d bindings. Pure string/XML work with golden-file tests; no OS, no ab."+description = "The EAGLE/Fusion .lbr engine: artifact names, 3D bindings, merges. Pure data; golden-tested against the Python bridge's output."  [dependencies] thiserror.workspace = true+serde = { workspace = true }+roxmltree = "0.20"+regex = "1"+zip = { version = "2", default-features = false, features = ["deflate"] }++[dev-dependencies]+serde_json.workspace = true
rust/lbr/src/lib.rs+557−7
@@ -1,6 +1,12 @@ //! The `.lbr` engine. Phase 2 ports `library_bundle.py` and the binding injection from-//! server.py here, behind golden-file tests: the same input library must produce a-//! byte-identical bound output to what the Python bridge writes today.+//! server.py here, behind golden-file tests: the same input library must produce the same+//! answer the Python bridge gives today (`tests/golden/*.json` are generated by the Python+//! functions themselves, on the fixtures in `tests/fixtures/`).+//!+//! AI hint: a golden that disagrees with Python is a bug HERE until proven otherwise; the+//! Python bridge is the production oracle for the whole port.++use std::collections::BTreeMap;  #[derive(Debug, thiserror::Error)] pub enum LbrError {@@ -12,14 +18,558 @@ pub enum LbrError { }  /// Names that exist in a library (the equivalent of `_library_artifact_names`).-#[derive(Debug, Default, PartialEq)]+///+/// A deviceset's symbol and footprint are NOT required to share its name (the wiki's+/// ABM8-16MHZ sits on symbol+package "Crystal_GND24"), so `dev_sym` / `dev_pac` map each+/// deviceset to the gate symbols and device packages it actually references: a prove step+/// that EDITs `<deviceset>.sym` must follow the gate, not guess the name.+#[derive(Debug, Default, PartialEq, serde::Serialize, serde::Deserialize)] pub struct ArtifactNames {+    pub sym: Vec<String>,+    pub pac: Vec<String>,+    pub dev: Vec<String>,+    pub p3d: Vec<String>,+    pub dev_sym: BTreeMap<String, Vec<String>>,+    pub dev_pac: BTreeMap<String, Vec<String>>,+}++pub fn artifact_names(xml: &str) -> Result<ArtifactNames, LbrError> {+    // EAGLE files open with <!DOCTYPE eagle SYSTEM "eagle.dtd">; roxmltree refuses DTDs unless told.+    let opts = roxmltree::ParsingOptions { allow_dtd: true, ..Default::default() };+    let doc = roxmltree::Document::parse_with_options(xml, opts).map_err(|e| LbrError::NotALibrary(e.to_string()))?;+    let root = doc.root_element();+    if root.tag_name().name() != "eagle" {+        return Err(LbrError::NotALibrary(format!("root element is <{}>, not <eagle>", root.tag_name().name())));+    }+    let names = |tag: &str| -> Vec<String> {+        root.descendants().filter(|n| n.is_element() && n.tag_name().name() == tag)+            .filter_map(|n| n.attribute("name").map(str::to_string)).collect()+    };+    let mut out = ArtifactNames { sym: names("symbol"), pac: names("package"), dev: names("deviceset"), p3d: names("package3d"), ..Default::default() };+    for ds in root.descendants().filter(|n| n.is_element() && n.tag_name().name() == "deviceset") {+        let Some(dn) = ds.attribute("name") else { continue };+        let mut syms: Vec<String> = ds.descendants().filter(|n| n.is_element() && n.tag_name().name() == "gate")+            .filter_map(|g| g.attribute("symbol").map(str::to_string)).collect();+        syms.sort(); syms.dedup();+        let mut pacs: Vec<String> = ds.descendants().filter(|n| n.is_element() && n.tag_name().name() == "device")+            .filter_map(|d| d.attribute("package").map(str::to_string)).collect();+        pacs.sort(); pacs.dedup();+        out.dev_sym.insert(dn.to_string(), syms);+        out.dev_pac.insert(dn.to_string(), pacs);+    }+    Ok(out)+}++/// One package's 3D binding: the urn Fusion resolves for `<package name=…>`.+#[derive(Debug, Clone, PartialEq, serde::Serialize, serde::Deserialize)]+pub struct Binding {+    pub package: String,+    pub wip_urn: String,+}++/// Inject EAGLE `<packages3d>` + per-device `<package3dinstances>` into an .lbr, MERGE-AWARE+/// and IDEMPOTENT (safe to re-run with any subset of parts). Bindings already in the file+/// are read first and new ones win on conflict; every prior 3D block is stripped and the+/// full merged set re-emitted, so a re-run with only the parts that failed last time+/// ACCUMULATES instead of wiping the parts that already succeeded. Byte-for-byte the+/// Python bridge's `_inject_package3d_bindings` (golden-tested), including its one quirk:+/// each re-run leaves one more blank line before `<symbols>` (harmless to Fusion).+///+/// AI hint: this is text surgery, not XML, on purpose: Fusion writes this markup in exactly+/// this shape and position, and re-serialising the whole document through an XML library+/// would move attributes and whitespace Fusion is sensitive to.+pub fn inject_package3d_bindings(lbr_text: &str, bindings: &[Binding]) -> String {+    use regex::Regex;+    // insertion order matters (the emitted block order is the merged map's order)+    let mut merged: Vec<(String, String)> = Vec::new();+    let mut upsert = |pkg: &str, urn: &str| {+        if let Some(e) = merged.iter_mut().find(|(p, _)| p == pkg) { e.1 = urn.to_string(); }+        else { merged.push((pkg.to_string(), urn.to_string())); }+    };+    let existing = Regex::new(r"(?s)<packages3d>(.*?)</packages3d>").unwrap();+    let one = Regex::new(r#"<package3d name="([^"]+)"[^>]*wip_urn="([^"]+)""#).unwrap();+    if let Some(em) = existing.captures(lbr_text) {+        for pm in one.captures_iter(em.get(1).map(|m| m.as_str()).unwrap_or("")) { upsert(&pm[1], &pm[2]); }+    }+    for b in bindings { upsert(&b.package, &b.wip_urn); }++    let mut text = Regex::new(r"(?s)\s*<packages3d>.*?</packages3d>").unwrap().replace_all(lbr_text, "").to_string();+    text = Regex::new(r"(?s)\s*<package3dinstances>.*?</package3dinstances>").unwrap().replace_all(&text, "").to_string();++    let blocks: Vec<String> = merged.iter().map(|(pkg, urn)| format!(+        r#"<package3d name="{pkg}" urn="" wip_urn="{urn}" locally_modified="yes" type="model"><description>{pkg}</description><packageinstances><packageinstance name="{pkg}"/></packageinstances></package3d>"#)).collect();+    let pkgs3d = format!("<packages3d>\n{}\n</packages3d>\n", blocks.join("\n"));+    text = text.replacen("</packages>", &format!("</packages>\n{pkgs3d}"), 1);++    let dev_re = Regex::new(r"(?s)<device\b[^>]*>.*?</device>").unwrap();+    let pkg_attr = Regex::new(r#"package="([^"]+)""#).unwrap();+    dev_re.replace_all(&text, |m: &regex::Captures| {+        let dev = m.get(0).unwrap().as_str();+        if let Some(pm) = pkg_attr.captures(dev) {+            if let Some((_, urn)) = merged.iter().find(|(p, _)| p == &pm[1]) {+                if dev.contains("</connects>") {+                    let inst = format!(r#"<package3dinstances><package3dinstance package3d_urn="{urn}"/></package3dinstances>"#);+                    return dev.replacen("</connects>", &format!("</connects>\n{inst}"), 1);+                }+            }+        }+        dev.to_string()+    }).to_string()+}++// ───────────────────────── bundle pairing (library_bundle.py) ─────────────────────────++/// One orderable variant of a deviceset (a `<device>` × `<technology>`), with its attributes.+#[derive(Debug, Clone, Default, PartialEq, serde::Serialize, serde::Deserialize)]+pub struct Variant {+    #[serde(default)] pub device: String,+    #[serde(default)] pub technology: String,+    #[serde(default, rename = "fullName")] pub full_name: String,+    #[serde(default)] pub package: String,+    #[serde(default)] pub attributes: BTreeMap<String, String>,+    #[serde(default)] pub missing: Vec<String>,+}++#[derive(Debug, Clone, Default, PartialEq, serde::Serialize, serde::Deserialize)]+pub struct Deviceset {+    #[serde(default)] pub name: String,+    #[serde(default)] pub description: String,+    #[serde(default)] pub variants: Vec<Variant>,+}++#[derive(Debug, Clone, Default, PartialEq, serde::Serialize, serde::Deserialize)]+pub struct Totals {+    pub devicesets: usize,+    pub variants: usize,+    #[serde(rename = "withMpn")] pub with_mpn: usize,+    #[serde(rename = "withDistributor")] pub with_distributor: usize,+}++/// `_library_part_metadata`: every orderable variant in a .lbr with its attributes.+#[derive(Debug, Clone, Default, PartialEq, serde::Serialize, serde::Deserialize)]+pub struct LibraryMetadata {+    #[serde(default)] pub ok: bool,+    #[serde(default)] pub devicesets: Vec<Deviceset>,+    #[serde(default)] pub totals: Totals,+}++pub const DISTRIBUTOR_ATTRS: [&str; 3] = ["MOUSER_PN", "DIGIKEY_PN", "LCSC_PN"];++/// Offline, no Fusion: what an EE needs to ORDER each variant, and what is missing.+pub fn library_part_metadata(xml: &str) -> Result<LibraryMetadata, LbrError> {+    let opts = roxmltree::ParsingOptions { allow_dtd: true, ..Default::default() };+    let doc = roxmltree::Document::parse_with_options(xml, opts).map_err(|e| LbrError::NotALibrary(e.to_string()))?;+    let el = |n: roxmltree::Node, tag: &str| n.is_element() && n.tag_name().name() == tag;+    let mut out = Vec::new();+    for ds in doc.root_element().descendants().filter(|n| el(*n, "deviceset")) {+        let ds_name = ds.attribute("name").unwrap_or("").to_string();+        let ds_desc = ds.children().find(|c| el(*c, "description")).and_then(|d| d.text()).unwrap_or("").trim().to_string();+        let mut variants = Vec::new();+        for dev in ds.descendants().filter(|n| el(*n, "device")) {+            let dev_name = dev.attribute("name").unwrap_or("").to_string();+            let techs: Vec<Option<roxmltree::Node>> = {+                let t: Vec<_> = dev.descendants().filter(|n| el(*n, "technology")).map(Some).collect();+                if t.is_empty() { vec![None] } else { t }+            };+            for tech in techs {+                let tname = tech.and_then(|t| t.attribute("name")).unwrap_or("").to_string();+                let mut attrs = BTreeMap::new();+                if let Some(t) = tech {+                    for a in t.descendants().filter(|n| el(*n, "attribute")) {+                        let n = a.attribute("name").unwrap_or("").to_uppercase();+                        let v = a.attribute("value").unwrap_or("").trim().to_string();+                        if !n.is_empty() && !v.is_empty() { attrs.insert(n, v); }+                    }+                }+                let mut missing: Vec<String> = ["MPN", "MANUFACTURER"].iter().filter(|k| attrs.get(**k).map(|v| v.is_empty()).unwrap_or(true)).map(|k| k.to_string()).collect();+                if !DISTRIBUTOR_ATTRS.iter().any(|d| attrs.get(*d).map(|v| !v.is_empty()).unwrap_or(false)) {+                    missing.push("a distributor PN (MOUSER_PN/DIGIKEY_PN/LCSC_PN)".into());+                }+                variants.push(Variant { full_name: format!("{ds_name}{dev_name}{tname}"), device: dev_name.clone(), technology: tname,+                                        package: dev.attribute("package").unwrap_or("").to_string(), attributes: attrs, missing });+            }+        }+        out.push(Deviceset { name: ds_name, description: ds_desc, variants });+    }+    let totals = Totals {+        devicesets: out.len(),+        variants: out.iter().map(|d| d.variants.len()).sum(),+        with_mpn: out.iter().flat_map(|d| &d.variants).filter(|v| v.attributes.get("MPN").map(|s| !s.is_empty()).unwrap_or(false)).count(),+        with_distributor: out.iter().flat_map(|d| &d.variants).filter(|v| DISTRIBUTOR_ATTRS.iter().any(|d| v.attributes.get(*d).map(|s| !s.is_empty()).unwrap_or(false))).count(),+    };+    Ok(LibraryMetadata { ok: true, devicesets: out, totals })+}++/// alnum-only, case-folded: how two names are compared when their spellings differ.+pub fn norm(s: &str) -> String { s.to_lowercase().chars().filter(|c| c.is_ascii_alphanumeric()).collect() }++/// A library folder name Fusion and the wiki both accept.+pub fn library_name_ok(name: &str) -> bool {+    let mut it = name.chars();+    match it.next() { Some(c) if c.is_ascii_alphanumeric() => {}, _ => return false }+    name.len() <= 64 && it.all(|c| c.is_ascii_alphanumeric() || " ._-".contains(c))+}++fn stem(p: &str) -> String {+    let base = p.rsplit(['/', '\\']).next().unwrap_or(p);+    match base.rfind('.') { Some(i) if i > 0 => base[..i].to_string(), _ => base.to_string() }+}++#[derive(Debug, Clone, PartialEq, serde::Serialize, serde::Deserialize)]+pub struct PairedPart {+    pub deviceset: String,+    pub device: String,+    pub package: String,+    #[serde(rename = "lbrPath")] pub lbr_path: String,+    #[serde(rename = "modelPath")] pub model_path: String,+    #[serde(rename = "matchedBy")] pub matched_by: String,+}+#[derive(Debug, Clone, PartialEq, serde::Serialize, serde::Deserialize)]+pub struct Unbound { pub package: String, pub deviceset: String, pub device: String, pub why: String }+#[derive(Debug, Clone, PartialEq, serde::Serialize, serde::Deserialize)]+pub struct Shared { pub deviceset: String, pub package: String, pub via: String }+#[derive(Debug, Clone, Default, PartialEq, serde::Serialize, serde::Deserialize)]+pub struct Pairing {+    pub parts: Vec<PairedPart>,+    pub unbound: Vec<Unbound>,+    pub shared: Vec<Shared>,+    pub models: Vec<String>,+    #[serde(rename = "modelsUnused")] pub models_unused: Vec<String>,+}++/// Pair every (deviceset, PACKAGE) in the .lbr with a STEP by name. ONE PART PER PACKAGE+/// VARIANT, not per deviceset (LAN7800 "", "-HV", "-SB" each bind their own STEP so Change+/// Package swaps footprint and 3D together); devicesets sharing an already-paired package are+/// `shared`, never unbound. Match order per unit: the package name, then deviceset+device (or+/// the bare deviceset), exact → normalised → normalised prefix (≥4 chars). A one-deviceset+/// library with one model pairs regardless of name. Same answers as `library_bundle.pair_parts`.+pub fn pair_parts(lbr_path: &str, models: &[String], metadata: &LibraryMetadata) -> Pairing {+    let mut units: Vec<(String, String, String)> = Vec::new();+    let mut devicesets = Vec::new();+    for d in &metadata.devicesets {+        devicesets.push(d.name.clone());+        let mut seen: Vec<String> = Vec::new();+        for v in &d.variants {+            if v.package.is_empty() || seen.contains(&v.package) { continue; }+            seen.push(v.package.clone());+            units.push((d.name.clone(), v.device.clone(), v.package.clone()));+        }+        if seen.is_empty() { units.push((d.name.clone(), String::new(), String::new())); }+    }+    let primary_pkg = |ds: &str| units.iter().find(|(d, _, pk)| d == ds && !pk.is_empty()).map(|u| u.2.clone()).unwrap_or_default();+    // later models overwrite earlier ones on a duplicate key, exactly like the dict comprehensions+    let mut by_stem: BTreeMap<String, String> = BTreeMap::new();+    let mut by_norm: Vec<(String, String)> = Vec::new(); // insertion-ordered like a dict+    for m in models {+        by_stem.insert(stem(m), m.clone());+        let k = norm(&stem(m));+        if let Some(e) = by_norm.iter_mut().find(|(kk, _)| *kk == k) { e.1 = m.clone(); } else { by_norm.push((k, m.clone())); }+    }+    let mut parts = Vec::new(); let mut unbound = Vec::new(); let mut shared = Vec::new();+    let mut used: Vec<String> = Vec::new();+    let mut bound_pkgs: BTreeMap<String, String> = BTreeMap::new();+    for (ds, dev, pk) in &units {+        if !pk.is_empty() && bound_pkgs.contains_key(pk) {+            shared.push(Shared { deviceset: ds.clone(), package: pk.clone(), via: bound_pkgs[pk].clone() });+            continue;+        }+        let mut cands: Vec<(String, &str)> = Vec::new();+        if !pk.is_empty() { cands.push((pk.clone(), "package")); }+        if !dev.is_empty() { cands.push((format!("{ds}{dev}"), "deviceset+device")); } else { cands.push((ds.clone(), "deviceset")); }+        let mut hit: Option<(String, String)> = None;+        for (name, how) in &cands {+            if let Some(m) = by_stem.get(name) { if !used.contains(m) { hit = Some((m.clone(), how.to_string())); break; } }+        }+        if hit.is_none() {+            for (name, how) in &cands {+                let n = norm(name);+                if let Some((_, m)) = by_norm.iter().find(|(k, _)| !n.is_empty() && *k == n) {+                    if !used.contains(m) { hit = Some((m.clone(), format!("{how}-normalised"))); break; }+                }+            }+        }+        if hit.is_none() {+            'outer: for (name, how) in &cands {+                let n = norm(name);+                for (k, m) in &by_norm {+                    if !n.is_empty() && !used.contains(m) && (k.starts_with(&n) || n.starts_with(k.as_str())) && n.len() >= 4 {+                        hit = Some((m.clone(), format!("{how}-prefix"))); break 'outer;+                    }+                }+            }+        }+        match hit {+            Some((m, how)) => {+                used.push(m.clone());+                let package = if !pk.is_empty() { pk.clone() } else { let p = primary_pkg(ds); if p.is_empty() { ds.clone() } else { p } };+                parts.push(PairedPart { deviceset: ds.clone(), device: dev.clone(), package, lbr_path: lbr_path.to_string(), model_path: m, matched_by: how });+                if !pk.is_empty() { bound_pkgs.insert(pk.clone(), ds.clone()); }+            }+            None => unbound.push(Unbound { package: if pk.is_empty() { ds.clone() } else { pk.clone() }, deviceset: ds.clone(), device: dev.clone(),+                                          why: format!("no STEP in the bundle named like the package {} or the device {}", py_repr(pk), py_repr(&format!("{ds}{dev}"))) }),+        }+    }+    if parts.is_empty() && units.len() == 1 && models.len() == 1 {+        unbound.clear();+        let (ds, dev, pk) = &units[0];+        used.push(models[0].clone());+        let package = if !pk.is_empty() { pk.clone() } else { let p = primary_pkg(ds); if p.is_empty() { ds.clone() } else { p } };+        parts.push(PairedPart { deviceset: ds.clone(), device: dev.clone(), package, lbr_path: lbr_path.to_string(), model_path: models[0].clone(), matched_by: "only-one-of-each".into() });+    }+    let models_unused = models.iter().filter(|m| !used.contains(m)).cloned().collect();+    Pairing { parts, unbound, shared, models: models.to_vec(), models_unused }+}++/// Python's `%r` for a str: single-quoted unless the text has a single quote and no double.+fn py_repr(s: &str) -> String {+    if s.contains('\'') && !s.contains('"') { format!("\"{s}\"") } else { format!("'{}'", s.replace('\\', "\\\\").replace('\'', "\\'")) }+}++// ───────────────────────── per-part split (library_bundle.split_per_part) ─────────────────────────++/// An owned, editable element tree that serialises exactly like Python's ElementTree+/// (`ET.ElementTree(root).write(fp, encoding="utf-8", xml_declaration=True)`): attributes in+/// document order, `<x />` for an element with no text and no children, `&`/`<`/`>` escaped+/// in text, plus `"` and the three control characters in attributes, no DOCTYPE, no root+/// tail. Comments and processing instructions are dropped, as ET's default parser drops them.+#[derive(Debug, Clone, PartialEq)]+pub struct Element {+    pub tag: String,+    pub attrs: Vec<(String, String)>,+    /// Text before the first child (ET `.text`).+    pub text: String,+    pub children: Vec<Element>,+    /// Text after this element's close, inside the parent (ET `.tail`).+    pub tail: String,+}++impl Element {+    pub fn new(tag: &str) -> Element { Element { tag: tag.into(), attrs: vec![], text: String::new(), children: vec![], tail: String::new() } }+    pub fn attr(&self, k: &str) -> Option<&str> { self.attrs.iter().find(|(kk, _)| kk == k).map(|(_, v)| v.as_str()) }+    pub fn find_child(&self, tag: &str) -> Option<&Element> { self.children.iter().find(|c| c.tag == tag) }+    pub fn find_child_mut(&mut self, tag: &str) -> Option<&mut Element> { self.children.iter_mut().find(|c| c.tag == tag) }+    /// Depth-first search (ET `.//tag`).+    pub fn find_deep_mut(&mut self, tag: &str) -> Option<&mut Element> {+        if self.tag == tag { return Some(self); }+        for c in self.children.iter_mut() { if let Some(f) = c.find_deep_mut(tag) { return Some(f); } }+        None+    }+    fn from_node(n: roxmltree::Node) -> Element {+        let mut e = Element::new(n.tag_name().name());+        e.attrs = n.attributes().map(|a| (a.name().to_string(), a.value().to_string())).collect();+        let mut last_child: Option<usize> = None;+        for c in n.children() {+            if c.is_element() {+                e.children.push(Element::from_node(c));+                last_child = Some(e.children.len() - 1);+            } else if c.is_text() {+                match last_child { Some(i) => e.children[i].tail.push_str(c.text().unwrap_or("")), None => e.text.push_str(c.text().unwrap_or("")) }+            }+        }+        e+    }+    pub fn parse(xml: &str) -> Result<Element, LbrError> {+        let opts = roxmltree::ParsingOptions { allow_dtd: true, ..Default::default() };+        let doc = roxmltree::Document::parse_with_options(xml, opts).map_err(|e| LbrError::NotALibrary(e.to_string()))?;+        Ok(Element::from_node(doc.root_element()))+    }+    fn write(&self, out: &mut String, is_root: bool) {+        out.push('<'); out.push_str(&self.tag);+        for (k, v) in &self.attrs { out.push(' '); out.push_str(k); out.push_str("=\""); out.push_str(&escape_attrib(v)); out.push('"'); }+        if self.text.is_empty() && self.children.is_empty() { out.push_str(" />"); }+        else {+            out.push('>'); out.push_str(&escape_cdata(&self.text));+            for c in &self.children { c.write(out, false); }+            out.push_str("</"); out.push_str(&self.tag); out.push('>');+        }+        if !is_root { out.push_str(&escape_cdata(&self.tail)); }+    }+    /// The whole document as Python's ElementTree writes it.+    pub fn to_xml_document(&self) -> String {+        let mut s = String::from("<?xml version='1.0' encoding='utf-8'?>\n");+        self.write(&mut s, true);+        s+    }+}++fn escape_cdata(s: &str) -> String { s.replace('&', "&amp;").replace('<', "&lt;").replace('>', "&gt;") }+fn escape_attrib(s: &str) -> String {+    s.replace('&', "&amp;").replace('<', "&lt;").replace('>', "&gt;").replace('"', "&quot;")+        .replace('\r', "&#13;").replace('\n', "&#10;").replace('\t', "&#09;")+}++#[derive(Debug, Clone, PartialEq, serde::Serialize, serde::Deserialize)]+pub struct SplitEntry {+    #[serde(rename = "lbrPath")] pub lbr_path: String,+    pub package: String,     pub symbols: Vec<String>,     pub packages: Vec<String>,-    pub devicesets: Vec<String>,-    pub package3d: Vec<String>, } -pub fn artifact_names(_xml: &str) -> Result<ArtifactNames, LbrError> {-    Err(LbrError::NotPorted("_library_artifact_names"))+fn safe_name(s: &str) -> String {+    let mut out = String::new(); let mut in_run = false;+    for c in s.chars() {+        if c.is_ascii_alphanumeric() || "._-".contains(c) { out.push(c); in_run = false; }+        else if !in_run { out.push('_'); in_run = true; }+    }+    out+}++/// One library per deviceset AND one per (deviceset, package variant), so Fusion's Package3D+/// generator (which loads the FIRST package of the open library whatever path it is given)+/// mints each chip on its own footprint. Each file keeps the merged library's header and+/// layers and carries exactly the packages that deviceset's devices use (primary first), the+/// symbols its gates use, and the deviceset itself, with any prior `<package3dinstances>`+/// stripped. Keys: `<deviceset>` (primary package file) and `<deviceset>::<package>`.+/// Byte-for-byte what `library_bundle.split_per_part` writes (golden-tested).+pub fn split_per_part(xml: &str, out_dir: &std::path::Path) -> Result<BTreeMap<String, SplitEntry>, LbrError> {+    let root = Element::parse(xml)?;+    std::fs::create_dir_all(out_dir).map_err(|e| LbrError::NotALibrary(format!("cannot create {}: {e}", out_dir.display())))?;+    let mut probe = root.clone();+    let lib = probe.find_deep_mut("library").ok_or_else(|| LbrError::NotALibrary("no <library>".into()))?.clone();+    let pk_all: Vec<Element> = lib.find_child("packages").map(|p| p.children.iter().filter(|c| c.tag == "package").cloned().collect()).unwrap_or_default();+    let sy_all: Vec<Element> = lib.find_child("symbols").map(|p| p.children.iter().filter(|c| c.tag == "symbol").cloned().collect()).unwrap_or_default();+    let devicesets: Vec<Element> = lib.find_child("devicesets").map(|p| p.children.iter().filter(|c| c.tag == "deviceset").cloned().collect()).unwrap_or_default();+    let mut out = BTreeMap::new();+    for ds in &devicesets {+        let name = ds.attr("name").unwrap_or("").to_string();+        let mut pkgs: Vec<String> = Vec::new();+        if let Some(devs) = ds.find_child("devices") {+            for d in devs.children.iter().filter(|c| c.tag == "device") {+                if let Some(pk) = d.attr("package") { if !pk.is_empty() && !pkgs.iter().any(|p| p == pk) { pkgs.push(pk.to_string()); } }+            }+        }+        let mut syms: Vec<String> = Vec::new();+        if let Some(gates) = ds.find_child("gates") {+            for g in gates.children.iter().filter(|c| c.tag == "gate") {+                if let Some(sy) = g.attr("symbol") { if !sy.is_empty() && !syms.iter().any(|s| s == sy) { syms.push(sy.to_string()); } }+            }+        }+        let mut variants: Vec<Option<String>> = vec![None];+        variants.extend(pkgs.iter().cloned().map(Some));+        for only_pk in variants {+            let mut new_root = root.clone();+            let new_lib = new_root.find_deep_mut("library").unwrap();+            // ET.remove drops the element AND its tail+            new_lib.children.retain(|c| !matches!(c.tag.as_str(), "packages" | "symbols" | "devicesets" | "packages3d"));+            let use_pkgs: Vec<String> = match &only_pk { Some(p) => vec![p.clone()], None => pkgs.clone() };+            let mut pe = Element::new("packages");+            for pk in &use_pkgs { if let Some(p) = pk_all.iter().find(|p| p.attr("name") == Some(pk.as_str())) { pe.children.push(p.clone()); } }+            let mut se = Element::new("symbols");+            for sy in &syms { if let Some(s) = sy_all.iter().find(|s| s.attr("name") == Some(sy.as_str())) { se.children.push(s.clone()); } }+            let mut de = Element::new("devicesets");+            let mut ds2 = ds.clone();+            if let Some(devs) = ds2.find_child_mut("devices") {+                for dev in devs.children.iter_mut().filter(|c| c.tag == "device") {+                    dev.children.retain(|c| c.tag != "package3dinstances");+                }+                if let Some(pk) = &only_pk {+                    devs.children.retain(|c| c.tag != "device" || c.attr("package") == Some(pk.as_str()));+                }+            }+            de.children.push(ds2);+            // EAGLE order inside <library>: description?, packages, symbols, devicesets+            let head: Vec<Element> = new_lib.children.iter().filter(|c| !matches!(c.tag.as_str(), "packages" | "symbols" | "devicesets")).cloned().collect();+            new_lib.children = head; new_lib.children.push(pe); new_lib.children.push(se); new_lib.children.push(de);+            let mut safe = safe_name(&name); if safe.is_empty() { safe = "part".into(); }+            if let Some(pk) = &only_pk { let mut sp = safe_name(pk); if sp.is_empty() { sp = "pkg".into(); } safe = format!("{safe}__{sp}"); }+            let fp = out_dir.join(format!("{safe}.lbr"));+            std::fs::write(&fp, new_root.to_xml_document()).map_err(|e| LbrError::NotALibrary(format!("cannot write {}: {e}", fp.display())))?;+            let entry = SplitEntry { lbr_path: fp.to_string_lossy().replace('\\', "/"), package: only_pk.clone().unwrap_or_else(|| pkgs.first().cloned().unwrap_or_default()), symbols: syms.clone(), packages: use_pkgs };+            match &only_pk { Some(pk) => { out.insert(format!("{name}::{pk}"), entry); } None => { out.insert(name.clone(), entry); } }+        }+    }+    Ok(out)+}++// ───────────────────────── bundle staging (library_bundle.py) ─────────────────────────++pub const MAX_ZIP_BYTES: u64 = 256 * 1024 * 1024;+pub const MAX_UNPACKED_BYTES: u64 = 512 * 1024 * 1024;+const MODEL_EXT: [&str; 2] = [".step", ".stp"];++#[derive(Debug, thiserror::Error)]+pub enum BundleError {+    #[error("unsafe zip entry: {0}")]+    UnsafeEntry(String),+    #[error("bundle unpacks past the {0} MB cap")]+    TooBig(u64),+    #[error("zip error: {0}")]+    Zip(String),+    #[error("io error: {0}")]+    Io(String),+}++/// Extract with a zip-slip guard and an unpacked-size cap. Returns files extracted.+/// Same refusals as `library_bundle.safe_extract`: an absolute entry, a drive-letter entry,+/// or any `..` segment is refused before a byte is written.+pub fn safe_extract(zip_path: &std::path::Path, dest: &std::path::Path) -> Result<usize, BundleError> {+    let f = std::fs::File::open(zip_path).map_err(|e| BundleError::Io(e.to_string()))?;+    let mut zf = zip::ZipArchive::new(f).map_err(|e| BundleError::Zip(e.to_string()))?;+    std::fs::create_dir_all(dest).map_err(|e| BundleError::Io(e.to_string()))?;+    let dest = dest.canonicalize().map_err(|e| BundleError::Io(e.to_string()))?;+    let (mut total, mut count) = (0u64, 0usize);+    for i in 0..zf.len() {+        let mut entry = zf.by_index(i).map_err(|e| BundleError::Zip(e.to_string()))?;+        let name = entry.name().to_string();+        if name.is_empty() || name.ends_with('/') { continue; }+        let norm = name.replace('\\', "/");+        let drive = norm.len() >= 2 && norm.as_bytes()[0].is_ascii_alphabetic() && norm.as_bytes()[1] == b':';+        if norm.starts_with('/') || drive || norm.split('/').any(|s| s == "..") { return Err(BundleError::UnsafeEntry(name)); }+        total += entry.size();+        if total > MAX_UNPACKED_BYTES { return Err(BundleError::TooBig(MAX_UNPACKED_BYTES >> 20)); }+        let target = dest.join(&norm);+        if !target.starts_with(&dest) { return Err(BundleError::UnsafeEntry(name)); }+        if let Some(p) = target.parent() { std::fs::create_dir_all(p).map_err(|e| BundleError::Io(e.to_string()))?; }+        let mut out = std::fs::File::create(&target).map_err(|e| BundleError::Io(e.to_string()))?;+        std::io::copy(&mut entry, &mut out).map_err(|e| BundleError::Io(e.to_string()))?;+        count += 1;+    }+    Ok(count)+}++#[derive(Debug, Clone, Default, PartialEq)]+pub struct BundleFiles {+    /// The shallowest .lbr (ties broken by path), or None.+    pub lbr: Option<String>,+    /// Every .step/.stp anywhere in the bundle, sorted.+    pub models: Vec<String>,+    pub all_lbrs: Vec<String>,+}++fn walk(dir: &std::path::Path, out: &mut Vec<String>) {+    if let Ok(rd) = std::fs::read_dir(dir) {+        for e in rd.flatten() {+            let p = e.path();+            if p.is_dir() { walk(&p, out); } else { out.push(p.to_string_lossy().to_string()); }+        }+    }+}++/// The shallowest .lbr wins; models are every .step/.stp anywhere in the bundle.+pub fn find_bundle_files(root: &std::path::Path) -> BundleFiles {+    let mut files = Vec::new(); walk(root, &mut files);+    let sep = std::path::MAIN_SEPARATOR;+    let mut lbrs: Vec<String> = files.iter().filter(|f| f.to_lowercase().ends_with(".lbr")).cloned().collect();+    let mut models: Vec<String> = files.iter().filter(|f| { let l = f.to_lowercase(); MODEL_EXT.iter().any(|x| l.ends_with(x)) }).cloned().collect();+    lbrs.sort_by(|a, b| (a.matches(sep).count(), a.as_str()).cmp(&(b.matches(sep).count(), b.as_str())));+    models.sort();+    BundleFiles { lbr: lbrs.first().cloned(), models, all_lbrs: lbrs }+}++/// Copy the .lbr and its models to the stable install folder; returns the new .lbr path and+/// the old→new model map, forward slashes throughout (what the chain and the wiki expect).+pub fn place_library(lbr_path: &std::path::Path, models: &[String], dest_dir: &std::path::Path) -> Result<(String, BTreeMap<String, String>), BundleError> {+    std::fs::create_dir_all(dest_dir).map_err(|e| BundleError::Io(e.to_string()))?;+    let new_lbr = dest_dir.join(lbr_path.file_name().unwrap_or_default());+    std::fs::copy(lbr_path, &new_lbr).map_err(|e| BundleError::Io(e.to_string()))?;+    let mut map = BTreeMap::new();+    for m in models {+        let src = std::path::Path::new(m);+        let t = dest_dir.join(src.file_name().unwrap_or_default());+        let same = match (src.canonicalize(), t.canonicalize()) { (Ok(a), Ok(b)) => a == b, _ => false };+        if !same { std::fs::copy(src, &t).map_err(|e| BundleError::Io(e.to_string()))?; }+        map.insert(m.clone(), t.to_string_lossy().replace('\\', "/"));+    }+    Ok((new_lbr.to_string_lossy().replace('\\', "/"), map)) }
server.py+166−126
@@ -44,7 +44,7 @@ from handlers.dialog_classify import classify_blocking_dialogs, classify_launch_ from install_addin import install as install_addin import aps import describe-import ad_client+import ab_client  DEFAULT_PORT = 8773 ADDIN_PORT = 8774@@ -153,11 +153,11 @@ def _reclaim_seat_from_peers() -> int:     of peers signalled (0 if the direct API/peers are unavailable)."""     n = 0     try:-        if not ad_client.available():+        if not ab_client.available():             return 0-        for peer in ad_client.peers():+        for peer in ab_client.peers():             try:-                if ad_client.call("fusion_stop", {}, target=peer) is not None:+                if ab_client.call("fusion_stop", {}, target=peer) is not None:                     n += 1             except Exception:                 pass@@ -179,7 +179,7 @@ def _resolve_seat_via_uia(dlg: dict) -> bool:     via the ab 1.9.84 direct API. Never raises."""     try:         hwnd = dlg.get("hwnd")-        if not hwnd or not ad_client.available():+        if not hwnd or not ab_client.available():             return False         # TWO variants of the seat dialog, opposite buttons (John, 2026-08-21: "when you see         # this dialog, always regrab the session"):@@ -190,7 +190,7 @@ def _resolve_seat_via_uia(dlg: dict) -> bool:         # Try Continue first (the common variant), then Check again. desktop_ui_click on a         # button that does not exist is a cheap no-op, so probing both costs nothing.         for btn in ("Continue", "Check again"):-            res = ad_client.call("desktop_ui_click", {"hwnd": hwnd, "name": btn}, timeout=4)+            res = ab_client.call("desktop_ui_click", {"hwnd": hwnd, "name": btn}, timeout=4)             if res and (res.get("success") or res.get("status") == "ok"):                 if btn == "Check again":                     print("[Fusion Bridge] seat: clicked 'Check again' to REGRAB the suspended "@@ -209,9 +209,9 @@ def _owned_popup_count(main_hwnd) -> int:     cleared, instead of trusting a window-size heuristic (which false-'resolved' the     823x262 'Suspend Remote Session' confirm). Returns the count, or -1 if unavailable."""     try:-        if not main_hwnd or not ad_client.available():+        if not main_hwnd or not ab_client.available():             return -1-        res = ad_client.call("desktop_screenshot_window", {"hwnd": int(main_hwnd)}, timeout=4)+        res = ab_client.call("desktop_screenshot_window", {"hwnd": int(main_hwnd)}, timeout=4)         if isinstance(res, dict):             c = res.get("ownedPopupCount")             if c is None and isinstance(res.get("data"), dict):@@ -253,7 +253,7 @@ def _resolve_seat_dialog(max_clicks: int = 3) -> dict:         saw = True         parent = dlg.get("owner")         if _resolve_seat_via_uia(dlg):     # count only clicks that actually landed, so-            clicks += 1                    # seatDialogAutoResolved isn't a lie when ad_client is down+            clicks += 1                    # seatDialogAutoResolved isn't a lie when ab_client is down         _t.sleep(1.5)         # Ground-truth confirm via parent/child screenshot (John's insisted-on check).         cnt = _owned_popup_count(parent)@@ -266,7 +266,7 @@ def _resolve_seat_dialog(max_clicks: int = 3) -> dict:   def _find_adom_desktop_cli() -> str | None:-    """Locate the host app's CLI so the bridge can drive it when the in-process ad_client is down.+    """Locate the host app's CLI so the bridge can drive it when the in-process ab_client is down.      Accepts BOTH names. The host app was renamed Adom Bridge -> Adom Bridge in v2.0.0 (issue #537),     so a box may carry either layout: a renamed install has "Adom Bridge"/adom-bridge-cli.exe, an@@ -306,7 +306,7 @@ def _find_adom_desktop_cli() -> str | None:  def _cli_notify_all(title: str, body: str, level: str, buttons=None) -> dict:     """Deliver a toast to EVERY connected desktop via `adom-bridge --target all notify_user`. This is-    the reliable cross-ab path when the bridge's in-process ad_client is down (headless VM): the CLI+    the reliable cross-ab path when the bridge's in-process ab_client is down (headless VM): the CLI     joins the relay itself and `--target all` reaches the user's real machine (not just this VM).     Best-effort + never raises. Returns {delivered, targets, error}. (John 2026-07-14: the notify MUST     actually leave the box - a returned-but-unsent payload is the bug, not a feature.)"""@@ -601,13 +601,13 @@ def _handle_notify_owner(args: dict) -> dict:     John's standing rule (2026-07-07): the bridge/AI does EVERYTHING itself; the only     legitimate uses are true human walls - password/2FA entry, UAC elevation, a physical     action. When this bridge runs on an unattended VM, the toast must reach the machine-    the user is actually AT - ad_client.notify(reach_user=True) fans out to all peer ADs+    the user is actually AT - ab_client.notify(reach_user=True) fans out to all peer ADs     on the relay, so it lands on their main computer too. Returns which targets were hit."""     title = args.get("title") or "Fusion bridge needs you"     body = args.get("body") or args.get("message") or "A human step is required to continue."     level = args.get("level") or "warning"-    if not ad_client.available():-        # ad_client is the bridge's IN-PROCESS ab API - it is routinely UNAVAILABLE when this bridge+    if not ab_client.available():+        # ab_client is the bridge's IN-PROCESS ab API - it is routinely UNAVAILABLE when this bridge         # runs on an unattended Hyper-V VM (found live 2026-07-14: the toast silently never reached the         # user, and the AI "forgot" to relay it - exactly the failure John demanded we engineer out).         # SELF-DELIVER via the adom-bridge CLI, which connects to the relay independently: `--target@@ -630,7 +630,7 @@ def _handle_notify_owner(args: dict) -> dict:             "_hint": ("Relay the notifyUser payload yourself: `adom-bridge --target all notify_user "                       "{title, body, level}` (or `--target <the user's host>`)."),         }-    res = ad_client.notify(title, body, level=level, reach_user=True) or {}+    res = ab_client.notify(title, body, level=level, reach_user=True) or {}     # reach_user fans out via target="all", whose response is a BROADCAST envelope     # {results:{host:{action}}, summary:{ok,failed,total}, targets:[...]}. Recognize that shape for     # success (summary.ok>0) - not just the single-target {status:"ok"} - and report the ACTUAL@@ -640,7 +640,7 @@ def _handle_notify_owner(args: dict) -> dict:     reached = res.get("targets") or (["self"] if ok else [])     return {         "success": ok,-        "via": "ad_client:direct(all)",+        "via": "ab_client:direct(all)",         "targets": reached,         "_hint": ("Toast fanned out to ALL connected desktops (the user's machine included) via the "                   "in-process ab direct API. This is the LAST RESORT - only use after exhausting "@@ -674,7 +674,7 @@ def _notify_before_foreground(reason: str) -> None:             "hopefully Autodesk makes their app fully AI-drivable soon so I can skip these "             "workarounds. Sorry for the interruption!"         )-        ad_client.notify("Adom is foregrounding Fusion (last resort)", body,+        ab_client.notify("Adom is foregrounding Fusion (last resort)", body,                          level="info", reach_user=True)     except Exception:         pass@@ -718,7 +718,7 @@ def _handle_new_electronics_from_eagle(args: dict) -> dict:                 "_hint": "Author the EAGLE .sch + paired .brd (same basename, same folder), stage both "                          "to Windows (send_files), then call with {schPath, brdPath}."}     brd = (args.get("brdPath") or (sch[:-4] + ".brd")).replace("\\", "/")-    if not ad_client.available():+    if not ab_client.available():         # Do NOT dead-end here (old bug: hard-failed even on latest ab when this bridge process         # didn't get ab's injected direct-API env). The RIGHT move is the background route.         brd_guess = (args.get("brdPath") or (sch[:-4] + ".brd")).replace("\\", "/")@@ -733,7 +733,7 @@ def _handle_new_electronics_from_eagle(args: dict) -> dict:                           "fusion_execute_text_command and drive the two Open dialogs (foreground; last resort).")}      def _find_open_dialog():-        res = ad_client.call("desktop_list_windows", {}) or {}+        res = ab_client.call("desktop_list_windows", {}) or {}         out = res.get("output") if isinstance(res, dict) else None         if isinstance(out, str):             try: out = json.loads(out)@@ -746,9 +746,9 @@ def _handle_new_electronics_from_eagle(args: dict) -> dict:      def _pick(hwnd, basename):         # select the file by accessible name, then click Open - both background UIA-        ad_client.call("desktop_ui_click", {"hwnd": hwnd, "name": basename})+        ab_client.call("desktop_ui_click", {"hwnd": hwnd, "name": basename})         _t.sleep(1.0)-        ad_client.call("desktop_ui_click", {"hwnd": hwnd, "name": "Open"})+        ab_client.call("desktop_ui_click", {"hwnd": hwnd, "name": "Open"})      import os as _os     sch_base, brd_base = _os.path.basename(sch), _os.path.basename(brd)@@ -2472,6 +2472,11 @@ def _handle_fusion_readiness(fusion_info: dict, args: dict) -> dict:                                           "to run it: fusion_set_auto_update {enabled:true} or a "                                           "one-off background click on the next sweep."                                           % ("opted out" if not _auto_update_enabled() else "pending"))+    _asf = _addin_start_failure()+    if _asf:+        result["addinStartFailed"] = _asf+        result["_hint"] = _asf["_hint"] + " " + str(result.get("_hint") or "")+     return result  @@ -3170,6 +3175,28 @@ def _check_main_thread_blocked() -> bool:         return False  ++def _addin_start_failure() -> dict | None:+    """The add-in's own start-failure marker (%TEMP%/adom-fusion/addin-start-failed.json),+    written by AdomBridge.run() when an import blew up (issue #75: a stale copy in the legacy+    AddIns dir). Surfaced by fusion_readiness and fusion_addin_status until the add-in starts+    cleanly again (a clean start deletes the marker)."""+    try:+        base = os.environ.get("TEMP") or os.path.expanduser("~")+        fp = os.path.join(base, "adom-fusion", "addin-start-failed.json")+        if not os.path.isfile(fp):+            return None+        with open(fp, "r", encoding="utf-8") as fh:+            m = json.load(fh)+        m["markerPath"] = fp+        m["_hint"] = ("Fusion loaded a BROKEN copy of the add-in from %s. The bridge re-syncs every "+                      "AddIns dir on start; restart Fusion with fusion_restart_for_addin {save:true} "+                      "so it loads the repaired copy. If it keeps failing, delete that folder and "+                      "restart Fusion: the bridge reinstalls it." % m.get("addinDir"))+        return m+    except Exception:+        return None+ def _probe_addin(timeout: float = 0.5) -> dict | None:     """Check if the Fusion add-in HTTP server is running. @@ -3301,6 +3328,9 @@ def _handle_addin_status() -> dict:     result = {"success": True, "output": json.dumps(status), **status}     if stale.get("addinStale"):         result["_hint"] = stale["_staleHint"]+    _asf = _addin_start_failure()+    if _asf and isinstance(result, dict):+        result["addinStartFailed"] = _asf     return result  @@ -5297,7 +5327,7 @@ def _taskbar_progress(pct=None):     progress bar during chain stages, paced by this machine's measured estimates.     pct=None clears the bar. Best-effort: never raises, silently no-ops without ab."""     try:-        if not ad_client.available():+        if not ab_client.available():             return         info = get_fusion_window_info() or {}         hwnd = info.get("hwnd") or (info.get("mainWindow") or {}).get("hwnd")@@ -5309,7 +5339,7 @@ def _taskbar_progress(pct=None):         else:             args["progress"] = {"state": "normal",                                 "value": max(1, min(100, int(pct))), "max": 100}-        ad_client.call("desktop_taskbar", args, timeout=4)+        ab_client.call("desktop_taskbar", args, timeout=4)     except Exception:         pass @@ -6284,10 +6314,10 @@ def _describe_profile(p: dict) -> str:  def _ad_direct_api_call(verb: str, args: dict, timeout: float = 60) -> dict | None:     """Call an ab verb over the loopback DIRECT API - the correct, non-foregrounding way for a-    bridge to reach ab when the in-process ad_client is down (issue #295). ab spawns the bridge+    bridge to reach ab when the in-process ab_client is down (issue #295). ab spawns the bridge     with ADOM_DIRECT_API_URL in its env (fallback: ~/.adom/direct-api-port); POST     {"command", "args"} to <base>/command. Returns the parsed result dict, or None if the direct-    API is unavailable or errors (so _ad_call can fall through). Never shells the GUI exe."""+    API is unavailable or errors (so _ab_call can fall through). Never shells the GUI exe."""     base = (os.environ.get("ADOM_DIRECT_API_URL") or "").strip()     if not base:         try:@@ -6303,7 +6333,7 @@ def _ad_direct_api_call(verb: str, args: dict, timeout: float = 60) -> dict | No         body = json.dumps({"command": verb, "args": args}).encode("utf-8")         headers = {"Content-Type": "application/json"}         # The bridge token is what keeps a bridge's own callbacks exempt from ab's identity gate-        # (ad_client already sends it; this path used to omit it).+        # (ab_client already sends it; this path used to omit it).         tok = os.environ.get("ADOM_BRIDGE_TOKEN") or ""         if tok:             headers["X-Adom-Bridge-Token"] = tok@@ -6334,12 +6364,12 @@ def _ad_direct_api_call(verb: str, args: dict, timeout: float = 60) -> dict | No     return None  -def _ad_call(verb: str, args: dict, timeout: int = 60) -> dict:+def _ab_call(verb: str, args: dict, timeout: int = 60) -> dict:     """Call another ab verb (nbrowser_*, desktop_*) from inside this bridge. -    Prefers the in-process ad_client; then the loopback DIRECT API (issue #295); only as a last+    Prefers the in-process ab_client; then the loopback DIRECT API (issue #295); only as a last     resort shells the host app's **-cli** exe (adom-bridge-cli.exe, NEVER the GUI adom-bridge.exe,-    which would foreground ab). Works on an unattended VM where ad_client is down. Never raises -+    which would foreground ab). Works on an unattended VM where ab_client is down. Never raises -     returns {} on failure so callers can degrade to instructing the AI instead.      Attribution (issue #348) is stamped into args HERE, once, so it survives on ALL THREE paths -@@ -6349,8 +6379,8 @@ def _ad_call(verb: str, args: dict, timeout: int = 60) -> dict:     args = _stamp_caller(args)     why = []     try:-        if ad_client.available():-            r = ad_client.call(verb, args, timeout=timeout)+        if ab_client.available():+            r = ab_client.call(verb, args, timeout=timeout)             if isinstance(r, dict):                 inner = r.get("output")                 if isinstance(inner, str) and inner.strip().startswith("{"):@@ -6360,11 +6390,11 @@ def _ad_call(verb: str, args: dict, timeout: int = 60) -> dict:                     except Exception:                         return r                 return r-            why.append("ad_client.call returned %r" % type(r).__name__)+            why.append("ab_client.call returned %r" % type(r).__name__)         else:-            why.append("ad_client unavailable")+            why.append("ab_client unavailable")     except Exception as e:-        why.append("ad_client raised %s" % e)+        why.append("ab_client raised %s" % e)     # #295: prefer the loopback DIRECT API - it reaches ab without foregrounding anything.     direct = _ad_direct_api_call(verb, args, timeout=timeout)     if isinstance(direct, dict):@@ -6557,7 +6587,7 @@ def _find_update_offer(hwnd=None) -> dict:             pass         for h in candidates:             for label in _UPDATE_BUTTONS:-                r = _find_controls_any(_ad_call("desktop_find_control", {"hwnd": h, "name": label}, timeout=20))+                r = _find_controls_any(_ab_call("desktop_find_control", {"hwnd": h, "name": label}, timeout=20))                 best = r.get("best") or {}                 if best.get("name") and best.get("invokable"):                     import time as _ut@@ -6592,7 +6622,7 @@ def _apply_fusion_update_silently(hwnd=None) -> dict:                   "DEFERRED, will retry when idle" % (offer["button"], idle.get("why")))             return {"applied": False, "deferred": True, "button": offer["button"],                     "why": "exit-class update button deferred: %s" % idle.get("why")}-    r = _unwrap(_ad_call("desktop_ui_click",+    r = _unwrap(_ab_call("desktop_ui_click",                          {"hwnd": int(offer["hwnd"]), "name": offer["button"]}, timeout=30))     ok = bool(r.get("invoked") or r.get("success"))     if ok:@@ -6734,13 +6764,13 @@ def _find_save_prompt() -> dict:                 continue             title = str(d.get("title") or "").strip().lower()             for label in (() if h in _NO_DONTSAVE_HWNDS else _DONT_SAVE_LABELS):-                r = _find_controls_any(_ad_call("desktop_find_control",+                r = _find_controls_any(_ab_call("desktop_find_control",                                                 {"hwnd": int(h), "name": label}, timeout=10))                 best = r.get("best") or {}                 if best.get("name") and best.get("invokable"):                     text = ""                     try:-                        t = _find_controls_any(_ad_call("desktop_find_control",+                        t = _find_controls_any(_ab_call("desktop_find_control",                                                         {"hwnd": int(h), "name": "save"}, timeout=10))                         for c in ([t.get("best") or {}] + list(t.get("controls") or [])):                             nm = str(c.get("name") or "")@@ -6862,7 +6892,7 @@ def _dismiss_save_prompt_if_bridge_doc() -> dict:         return {"handled": False, "why": "unattributed save prompt - strike %d of 2 before Escape" % n,                 "hwnd": prompt["hwnd"]}     if prompt.get("button"):-        r = _unwrap(_ad_call("desktop_ui_click",+        r = _unwrap(_ab_call("desktop_ui_click",                              {"hwnd": prompt["hwnd"], "name": prompt["button"]}, timeout=15))         if r.get("invoked") or r.get("success"):             print("[Fusion Bridge] save-prompt sentinel: clicked %r for bridge doc %r (background)"@@ -6873,7 +6903,7 @@ def _dismiss_save_prompt_if_bridge_doc() -> dict:     # that loop is exactly what John saw twice in a row (2026-08-20). Try the decisive     # button first; Escape stays as the fallback when UIA is unavailable.     try:-        r2 = _unwrap(_ad_call("desktop_ui_click",+        r2 = _unwrap(_ab_call("desktop_ui_click",                               {"hwnd": prompt["hwnd"], "name": "Don't Save"}, timeout=15))         if r2.get("invoked") or r2.get("success"):             print("[Fusion Bridge] save-prompt sentinel: clicked \'Don\'t Save\' for bridge doc %r "@@ -7335,10 +7365,10 @@ def _handle_mcp_enable(fusion_info: dict, args: dict) -> dict:      _announce_foreground("turning on Fusion's MCP server in preferences")     steps = []-    r = _unwrap(_ad_call("fusion_execute_text_command", {"command": "Commands.Start PreferencesCommand"}, timeout=45))+    r = _unwrap(_ab_call("fusion_execute_text_command", {"command": "Commands.Start PreferencesCommand"}, timeout=45))     _t.sleep(3)     dlg = None-    for w in (_unwrap(_ad_call("desktop_list_windows", {}, timeout=40)).get("windows") or []):+    for w in (_unwrap(_ab_call("desktop_list_windows", {}, timeout=40)).get("windows") or []):         if str(w.get("title", "")).strip() == "Preferences":             dlg = w.get("hwnd")     if not dlg:@@ -7347,7 +7377,7 @@ def _handle_mcp_enable(fusion_info: dict, args: dict) -> dict:     steps.append("opened Preferences")      def shot():-        s = _unwrap(_ad_call("desktop_screenshot_window", {"hwnd": int(dlg)}, timeout=45))+        s = _unwrap(_ab_call("desktop_screenshot_window", {"hwnd": int(dlg)}, timeout=45))         cm = s.get("coordMap") or {}         img = cm.get("image") or {}         return cm.get("shotId"), (img.get("w") or 1400), (img.get("h") or 836)@@ -7356,24 +7386,24 @@ def _handle_mcp_enable(fusion_info: dict, args: dict) -> dict:     if not sid_:         return {"success": False, "error": "could not capture Preferences", "steps": steps}     # fractional coords, measured on the real dialog (1400x836): expand arrow, then API row-    _ad_call("desktop_click", {"space": "image", "shotId": sid_, "hwnd": int(dlg),+    _ab_call("desktop_click", {"space": "image", "shotId": sid_, "hwnd": int(dlg),                                "x": int(W * 0.029), "y": int(H * 0.092)}, timeout=30)   # expand General     steps.append("expanded General")     _t.sleep(2)     sid_, W, H = shot()-    _ad_call("desktop_click", {"space": "image", "shotId": sid_, "hwnd": int(dlg),+    _ab_call("desktop_click", {"space": "image", "shotId": sid_, "hwnd": int(dlg),                                "x": int(W * 0.071), "y": int(H * 0.135)}, timeout=30)   # API row     steps.append("selected API")     _t.sleep(2)     sid_, W, H = shot()-    _ad_call("desktop_click", {"space": "image", "shotId": sid_, "hwnd": int(dlg),+    _ab_call("desktop_click", {"space": "image", "shotId": sid_, "hwnd": int(dlg),                                "x": int(W * 0.493), "y": int(H * 0.569)}, timeout=30)   # the checkbox     steps.append("ticked 'Fusion MCP Server'")     _t.sleep(1)-    _ad_call("desktop_click", {"space": "image", "shotId": sid_, "hwnd": int(dlg),+    _ab_call("desktop_click", {"space": "image", "shotId": sid_, "hwnd": int(dlg),                                "x": int(W * 0.839), "y": int(H * 0.948)}, timeout=30)   # Apply     _t.sleep(2)-    _ad_call("desktop_click", {"space": "image", "shotId": sid_, "hwnd": int(dlg),+    _ab_call("desktop_click", {"space": "image", "shotId": sid_, "hwnd": int(dlg),                                "x": int(W * 0.894), "y": int(H * 0.948)}, timeout=30)   # OK     steps.append("clicked Apply then OK")     _t.sleep(4)@@ -7436,14 +7466,14 @@ _PREF_SECTIONS = {   def _prefs_dialog_hwnd():-    for w in (_unwrap(_ad_call("desktop_list_windows", {}, timeout=40)).get("windows") or []):+    for w in (_unwrap(_ab_call("desktop_list_windows", {}, timeout=40)).get("windows") or []):         if str(w.get("title", "")).strip() == "Preferences":             return w.get("hwnd")     return None   def _prefs_shot(hwnd):-    s = _unwrap(_ad_call("desktop_screenshot_window", {"hwnd": int(hwnd)}, timeout=45))+    s = _unwrap(_ab_call("desktop_screenshot_window", {"hwnd": int(hwnd)}, timeout=45))     cm = s.get("coordMap") or {}     img = cm.get("image") or {}     return (cm.get("shotId"), img.get("w") or 1400, img.get("h") or 836,@@ -7462,7 +7492,7 @@ def _handle_prefs_open(fusion_info: dict, args: dict) -> dict:     hwnd = _prefs_dialog_hwnd()     if not hwnd:         _announce_foreground("opening Fusion preferences")-        _ad_call("fusion_execute_text_command",+        _ab_call("fusion_execute_text_command",                  {"command": "Commands.Start PreferencesCommand"}, timeout=45)         _t.sleep(3)         hwnd = _prefs_dialog_hwnd()@@ -7482,12 +7512,12 @@ def _handle_prefs_open(fusion_info: dict, args: dict) -> dict:         if parent:             pspec = _PREF_SECTIONS[parent]             # click the expand arrow, left of the parent label-            _ad_call("desktop_click", {"space": "image", "shotId": sid_, "hwnd": int(hwnd),+            _ab_call("desktop_click", {"space": "image", "shotId": sid_, "hwnd": int(hwnd),                                        "x": int(W * 0.029), "y": int(H * pspec[2])}, timeout=30)             steps.append("expanded %s" % parent)             _t.sleep(2)             sid_, W, H, _p = _prefs_shot(hwnd)-        _ad_call("desktop_click", {"space": "image", "shotId": sid_, "hwnd": int(hwnd),+        _ab_call("desktop_click", {"space": "image", "shotId": sid_, "hwnd": int(hwnd),                                    "x": int(W * fx), "y": int(H * fy)}, timeout=30)         steps.append("selected %s" % section)         _t.sleep(2)@@ -7515,13 +7545,13 @@ def _handle_prefs_close(fusion_info: dict, args: dict) -> dict:                 "_hint": "Preferences was not open; nothing to do."}     sid_, W, H, _p = _prefs_shot(hwnd)     if save:-        _ad_call("desktop_click", {"space": "image", "shotId": sid_, "hwnd": int(hwnd),+        _ab_call("desktop_click", {"space": "image", "shotId": sid_, "hwnd": int(hwnd),                                    "x": int(W * 0.839), "y": int(H * 0.948)}, timeout=30)  # Apply         _t.sleep(2)-        _ad_call("desktop_click", {"space": "image", "shotId": sid_, "hwnd": int(hwnd),+        _ab_call("desktop_click", {"space": "image", "shotId": sid_, "hwnd": int(hwnd),                                    "x": int(W * 0.894), "y": int(H * 0.948)}, timeout=30)  # OK     else:-        _ad_call("desktop_click", {"space": "image", "shotId": sid_, "hwnd": int(hwnd),+        _ab_call("desktop_click", {"space": "image", "shotId": sid_, "hwnd": int(hwnd),                                    "x": int(W * 0.951), "y": int(H * 0.948)}, timeout=30)  # Cancel     _t.sleep(2)     return {"success": True, "closed": _prefs_dialog_hwnd() is None, "saved": bool(save),@@ -7624,7 +7654,7 @@ def _fusion_window():     """Find the main Fusion window via desktop_list_windows. Returns the whole entry (it already     carries `rect`, so we get geometry in the SAME call - there is no desktop_window_info verb).     Returns {} when not found."""-    wins = _unwrap(_ad_call("desktop_list_windows", {}, timeout=40)).get("windows") or []+    wins = _unwrap(_ab_call("desktop_list_windows", {}, timeout=40)).get("windows") or []     cand = {}     for w in wins:         t = str(w.get("title", ""))@@ -7671,7 +7701,7 @@ def _track_signin_window(session_id, profile, kind="fusion"):  def _close_tracked_window(w) -> bool:     """Close one window WE opened. nbrowser_close_window only closes our own sessions."""-    r = _ad_call("nbrowser_close_window",+    r = _ab_call("nbrowser_close_window",                  {"sessionId": w["sessionId"], "profile": w.get("profile")}, timeout=30)     return isinstance(r, dict) and not r.get("_adCallError") @@ -7903,13 +7933,13 @@ def _seat_sweep_tick():         if _uh:             _ours = None             for _probe in tuple(_BRIDGE_DOCS)[:8] + _BRIDGE_DOC_BUILTINS:-                _rr = _find_controls_any(_ad_call("desktop_find_control",+                _rr = _find_controls_any(_ab_call("desktop_find_control",                         {"hwnd": int(_uh), "contains": _probe}, timeout=8))                 if (_rr.get("best") or {}).get("name"):                     _ours = _probe                     break             if _ours:-                _ck2 = _unwrap(_ad_call("desktop_ui_click",+                _ck2 = _unwrap(_ab_call("desktop_ui_click",                         {"hwnd": int(_uh), "name": "Don't Save"}, timeout=10))                 _bridge_action_ledger("unsaved_changes_dont_save",                         {"hwnd": int(_uh), "docMatch": _ours,@@ -7941,7 +7971,7 @@ def _seat_sweep_tick():     try:         for _d in (classify_blocking_dialogs() or []):             if _d.get("category") == "create_confirm":-                _unwrap(_ad_call("desktop_ui_click", {"hwnd": _d.get("hwnd"), "name": "No"}, timeout=12))+                _unwrap(_ab_call("desktop_ui_click", {"hwnd": _d.get("hwnd"), "name": "No"}, timeout=12))                 print("[Fusion Bridge] janitor: declined a stray Create-new prompt (%s)"                       % _d.get("matchedOn", "title"))                 _bridge_action_ledger("create_confirm_declined", {"matchedOn": _d.get("matchedOn")})@@ -8002,7 +8032,7 @@ def _seat_resolve_section():             if now - _last_seat_takeover_notice[0] > 120:                 _last_seat_takeover_notice[0] = now                 try:-                    ad_client.notify(+                    ab_client.notify(                         "Fusion seat taken over on this machine",                         "Autodesk flagged your account's session limit, so I clicked through the "                         "'Active Sessions Exceeded' dialogs in the background and took the seat here. "@@ -8083,7 +8113,7 @@ def _announce_foreground(reason: str) -> bool:     (no toast stack, auto-clears), so that is what we use. Best-effort; never blocks the action.     """     try:-        _ad_call("desktop_caption",+        _ab_call("desktop_caption",                  {"text": "Adom: %s" % reason, "id": "fusion-bridge-foreground",                   "expiresInMs": 2500}, timeout=15)         return True@@ -8096,13 +8126,13 @@ def _click_background_first(hwnd, name=None, shot_id=None, x=None, y=None, reaso     to SendInput - which DOES foreground - when there is no UIA node, and in that case announces     WHY to the user first. Returns {clicked, via, why, announced}."""     if name:-        r = _unwrap(_ad_call("desktop_ui_click", {"hwnd": int(hwnd), "name": name}, timeout=30))+        r = _unwrap(_ab_call("desktop_ui_click", {"hwnd": int(hwnd), "name": name}, timeout=30))         if r.get("ok") or r.get("clicked") or r.get("invoked"):             return {"clicked": True, "via": "uia_background", "announced": False,                     "why": "UIA Invoke on %r (background, no focus steal)" % name}     if shot_id is not None and x is not None and y is not None:         announced = _announce_foreground(reason or "clicking %s for you" % (name or "a button"))-        _ad_call("desktop_click", {"space": "image", "shotId": shot_id,+        _ab_call("desktop_click", {"space": "image", "shotId": shot_id,                                    "x": int(x), "y": int(y), "hwnd": int(hwnd)}, timeout=30)         return {"clicked": True, "via": "foreground_click", "announced": announced,                 "why": "no UIA node; used SendInput (DOES foreground - user was told why)"}@@ -8117,7 +8147,7 @@ def _click_background_first(hwnd, name=None, shot_id=None, x=None, y=None, reaso # and hand the AI an exact, mechanical way to clear it without them. def _autodesk_window_hwnds() -> set:     """hwnds of every Autodesk-ish BROWSER window right now (used to tell NEW from STALE)."""-    wins = _unwrap(_ad_call("desktop_list_windows", {}, timeout=40)).get("windows") or []+    wins = _unwrap(_ab_call("desktop_list_windows", {}, timeout=40)).get("windows") or []     out = set()     for w in wins:         tl = str(w.get("title", "")).lower()@@ -8135,7 +8165,7 @@ def _advance_credentials_page(hwnd, email) -> dict:     import time as _t     out = {"advanced": False, "wall": "credentials", "detail": ""}     try:-        found = _find_controls_any(_ad_call("desktop_find_control", {"hwnd": int(hwnd), "role": "edit"}, timeout=40))+        found = _find_controls_any(_ab_call("desktop_find_control", {"hwnd": int(hwnd), "role": "edit"}, timeout=40))         ctrls = found.get("controls") or []         if not ctrls and found.get("best"):             ctrls = [found["best"]]@@ -8149,20 +8179,20 @@ def _advance_credentials_page(hwnd, email) -> dict:                                             for k in ("email", "username", "user id", "userid"))] or ctrls[:1]         if email and emailish:             c = emailish[0]-            r = _unwrap(_ad_call("desktop_ui_set", {"hwnd": int(hwnd),+            r = _unwrap(_ab_call("desktop_ui_set", {"hwnd": int(hwnd),                                                     "automationId": c.get("automationId"),                                                     "name": c.get("name"), "value": email}, timeout=30))             if r.get("success") or r.get("ok"):                 out["detail"] = "typed email %r into %r (background UIA)" % (email, c.get("name") or c.get("automationId"))                 for btn in ("Next", "NEXT", "Sign in", "Sign In", "Continue"):-                    rb = _unwrap(_ad_call("desktop_ui_click", {"hwnd": int(hwnd), "name": btn}, timeout=25))+                    rb = _unwrap(_ab_call("desktop_ui_click", {"hwnd": int(hwnd), "name": btn}, timeout=25))                     if rb.get("success") or rb.get("ok") or rb.get("status") == "ok":                         out["advanced"] = True                         out["detail"] += "; invoked %r" % btn                         break                 if out["advanced"]:                     _t.sleep(4)-                    found2 = _unwrap(_ad_call("desktop_find_control", {"hwnd": int(hwnd), "role": "edit"}, timeout=40))+                    found2 = _unwrap(_ab_call("desktop_find_control", {"hwnd": int(hwnd), "role": "edit"}, timeout=40))                     ctrls2 = found2.get("controls") or found2.get("matches") or []                     if not ctrls2 and found2.get("best"):                         ctrls2 = [found2["best"]]@@ -8183,7 +8213,7 @@ def _detect_signin_wall(ignore_hwnds=None) -> dict:     reported as the live wall and we toast the user about nothing (seen live 2026-07-22).     """     ignore = set(ignore_hwnds or ())-    wins = _unwrap(_ad_call("desktop_list_windows", {}, timeout=40)).get("windows") or []+    wins = _unwrap(_ab_call("desktop_list_windows", {}, timeout=40)).get("windows") or []     best = {}     for w in wins:         if w.get("hwnd") in ignore:@@ -8326,10 +8356,10 @@ def _fusion_click_signin() -> dict:     rect = win.get("rect") or {}     if (rect.get("left") is not None and rect["left"] <= -30000) or \        (0 < (rect.get("width") or 0) < 600) or (0 < (rect.get("height") or 0) < 400):-        _ad_call("desktop_set_window_state", {"hwnd": int(hwnd), "state": "restore"}, timeout=30)+        _ab_call("desktop_set_window_state", {"hwnd": int(hwnd), "state": "restore"}, timeout=30)         _t.sleep(1.5) -    _raw = _ad_call("desktop_screenshot_window", {"hwnd": int(hwnd)}, timeout=45)+    _raw = _ab_call("desktop_screenshot_window", {"hwnd": int(hwnd)}, timeout=45)     shot = _unwrap(_raw)     # ab has moved coordMap across envelope levels between versions (sibling of data /     # nested / stringified in output on 2.0.46) - search recursively, never guess a level.@@ -8343,7 +8373,7 @@ def _fusion_click_signin() -> dict:         W = w_rect.get("width") or 0; H = w_rect.get("height") or 0         if L is not None and W > 400 and H > 300:             sx = int(L + W * 0.5); sy = int(T + H * 0.57)-            rr = _unwrap(_ad_call("desktop_click", {"x": sx, "y": sy, "hwnd": int(hwnd),+            rr = _unwrap(_ab_call("desktop_click", {"x": sx, "y": sy, "hwnd": int(hwnd),                                                     "reason": "signing Fusion in for you (clicking Sign In, screen-space fallback)"},                                   timeout=30))             okc = rr.get("success") or rr.get("ok") or rr.get("status") == "ok"@@ -8406,7 +8436,7 @@ def _drive_sso_signin(profile: str, tab_id, email: str, provider: str = "google"     steps = []      def ev(expr):-        r = _unwrap(_ad_call("nbrowser_eval", {"profile": profile, "tabId": tab_id,+        r = _unwrap(_ab_call("nbrowser_eval", {"profile": profile, "tabId": tab_id,                                                "expression": expr}, timeout=45))         return r.get("result") or r.get("value") @@ -8414,7 +8444,7 @@ def _drive_sso_signin(profile: str, tab_id, email: str, provider: str = "google"         a = {"profile": profile, "tabId": tab_id}         if text: a["text"] = text         if selector: a["selector"] = selector-        r = _unwrap(_ad_call("nbrowser_click", a, timeout=45))+        r = _unwrap(_ab_call("nbrowser_click", a, timeout=45))         return bool(r.get("ok") or r.get("success") or r.get("changed"))      btn = _SSO_BUTTONS.get((provider or "google").lower(), _SSO_BUTTONS["google"])@@ -8470,7 +8500,7 @@ def _orchestrate_signin_2fa(args: dict) -> dict:      # Find the code field WITHOUT touching the foreground. Autodesk renders either one input or a     # segmented set, so try the obvious accessible names, then fall back to the first edit control.-    found = _unwrap(_ad_call("desktop_find_control",+    found = _unwrap(_ab_call("desktop_find_control",                              {"hwnd": int(hwnd), "role": "edit"}, timeout=40))     ctrls = found.get("controls") or found.get("matches") or []     steps = []@@ -8478,14 +8508,14 @@ def _orchestrate_signin_2fa(args: dict) -> dict:     if len(ctrls) >= len(digits) and len(ctrls) >= 4:         # segmented: one box per digit         for i, ch in enumerate(digits[:len(ctrls)]):-            _ad_call("desktop_ui_set", {"hwnd": int(hwnd),+            _ab_call("desktop_ui_set", {"hwnd": int(hwnd),                                         "automationId": ctrls[i].get("automationId"),                                         "name": ctrls[i].get("name"), "value": ch}, timeout=20)         filled = True         steps.append("typed %d digits into %d segmented boxes (background UIA)" % (len(digits), len(ctrls)))     elif ctrls:         c = ctrls[0]-        r = _unwrap(_ad_call("desktop_ui_set", {"hwnd": int(hwnd),+        r = _unwrap(_ab_call("desktop_ui_set", {"hwnd": int(hwnd),                                                 "automationId": c.get("automationId"),                                                 "name": c.get("name"), "value": digits}, timeout=20))         filled = bool(r.get("ok") or r.get("set") or r is not None)@@ -8569,7 +8599,7 @@ def _orchestrate_signin(args: dict) -> dict:                 "statusVerb": "fusion_signin"}      # profiles known to ABE (for target selection + naming)-    profs = _ad_call("nbrowser_profiles", {}, timeout=40)+    profs = _ab_call("nbrowser_profiles", {}, timeout=40)     pdata = profs.get("data", profs) if isinstance(profs, dict) else {}     choices = [p for p in (pdata.get("profiles") or []) if isinstance(p, dict) and p.get("profile")] @@ -8628,7 +8658,7 @@ def _orchestrate_signin(args: dict) -> dict:         for c in choices:             if not c.get("live"):                 continue-            ls = _ad_call("nbrowser_login_state",+            ls = _ab_call("nbrowser_login_state",                           {"profile": c["profile"], "url": "https://accounts.autodesk.com/"}, timeout=40)             lsd = ls.get("data", ls) if isinstance(ls, dict) else {}             c["_ad"] = "signed-in" if lsd.get("loggedIn") else ("signed-out" if lsd.get("ok") is not None else "unprobed")@@ -8702,7 +8732,7 @@ def _orchestrate_signin(args: dict) -> dict:     _open_args = {"sessionId": sess, "url": auth["url"], "background": True,                   "profile": target, "thread": "fusion-signin",                   "purpose": "Fusion Autodesk sign-in (correct profile)"}-    r = _ad_call("nbrowser_open_window", _open_args, timeout=60)+    r = _ab_call("nbrowser_open_window", _open_args, timeout=60)     rd_open = r.get("data", r) if isinstance(r, dict) else {}     opened = bool(rd_open.get("sessionId") or r.get("ok") or r.get("success")                   or rd_open.get("opened") or ("opened in the BACKGROUND" in str(r.get("_hint", ""))))@@ -8712,7 +8742,7 @@ def _orchestrate_signin(args: dict) -> dict:         # that profile. nbrowser_open_os_window does not - it opens the URL in the real profile         # at the OS level, which is all an OAuth consent needs. This keeps the multi-profile fix         # working on machines with no extension (same gap as APS issue #12).-        r2 = _ad_call("nbrowser_open_os_window", _open_args, timeout=60)+        r2 = _ab_call("nbrowser_open_os_window", _open_args, timeout=60)         if isinstance(r2, dict) and not r2.get("_adCallError"):             # ok:false just means the hwnd was not resolved in ~8s; the launch usually still fired             opened = bool(r2.get("ok") or r2.get("hwnd") or "Launch fired" in str(r2.get("_hint", "")))@@ -8731,7 +8761,7 @@ def _orchestrate_signin(args: dict) -> dict:     # signed into the identity provider, this completes the whole login silently.     sso = None     if opened and open_via == "extension" and args.get("driveSso", True):-        tabs = _unwrap(_ad_call("nbrowser_list_tabs", {"profile": target}, timeout=45)).get("tabs") or []+        tabs = _unwrap(_ab_call("nbrowser_list_tabs", {"profile": target}, timeout=45)).get("tabs") or []         my_tab = None         for t in tabs:             if "autodesk" in str(t.get("url", "")).lower() and t.get("owner") == "agent-opened":@@ -9187,7 +9217,7 @@ def _show_library_view(name, suffix, lbr_path=None):                     _body = _d.get("title") or ""                 if str(name).lower() in _body.lower():                     _about_this = True-                _unwrap(_ad_call("desktop_ui_click", {"hwnd": _d.get("hwnd"), "name": "No"}, timeout=12))+                _unwrap(_ab_call("desktop_ui_click", {"hwnd": _d.get("hwnd"), "name": "No"}, timeout=12))             if not _about_this:                 print("[Fusion Bridge] declined a STALE Create-new prompt that does not mention "                       "%r - clearing it and trusting the EDIT's own result" % name)@@ -9646,7 +9676,7 @@ _TOUR_PANEL_ID = "fusion-tour" def _tour_panel_show(state, stops, stage, playing=True, reason=""):     """Create/update the remote (idempotent by id). Best-effort, never raises."""     try:-        _ad_call("desktop_demo_panel", {+        _ab_call("desktop_demo_panel", {             "action": "show", "id": _TOUR_PANEL_ID, "title": "Fusion bridge tour",             "stops": stops, "stage": int(stage), "state": "playing" if playing else "paused",             "position": "bottom-right",@@ -9662,7 +9692,7 @@ def _tour_panel_pump(state):     button names seen (for the progress stream). Never raises."""     seen = []     try:-        r = _ad_call("desktop_demo_panel", {"action": "poll", "id": _TOUR_PANEL_ID}, timeout=8) or {}+        r = _ab_call("desktop_demo_panel", {"action": "poll", "id": _TOUR_PANEL_ID}, timeout=8) or {}         events = _find_key(r, "events") or []         for ev in events:             b = str((ev or {}).get("button") or "").lower()@@ -9686,7 +9716,7 @@ def _tour_panel_pump(state):  def _tour_panel_hide():     try:-        _ad_call("desktop_demo_panel", {"action": "hide", "id": _TOUR_PANEL_ID}, timeout=8)+        _ab_call("desktop_demo_panel", {"action": "hide", "id": _TOUR_PANEL_ID}, timeout=8)     except Exception:         pass @@ -9706,7 +9736,7 @@ def _tour_caption(text: str, seconds: int = 45):     try:         # dash-demo fleet ruling: ALWAYS the bridge's DEFAULT caption size and position -         # seven apps, one caption look. If the default is wrong, fix it in ab for everyone.-        _ad_call("desktop_caption", {"text": text, "id": "fusion-tour",+        _ab_call("desktop_caption", {"text": text, "id": "fusion-tour",                                      "duration": max(15000, min(int(seconds) * 2000, 240000))},                  timeout=10)     except Exception:@@ -10173,7 +10203,7 @@ def _orchestrate_demo_tour(args: dict) -> dict:                 state["tourDocsSweepError"] = str(_swe)[:200]             if args.get("captions", True):                 try:-                    _ad_call("desktop_caption", {"action": "hide", "id": "fusion-tour"}, timeout=8)+                    _ab_call("desktop_caption", {"action": "hide", "id": "fusion-tour"}, timeout=8)                 except Exception:                     pass             if remote:@@ -11208,14 +11238,14 @@ def _orchestrate_demo(args: dict) -> dict:         # DO IT, don't just describe it: open the Autodesk sign-in in the user's OWN browser,         # in the BACKGROUND so we never yank them out of what they're doing. Then tell the AI         # exactly WHERE it is waiting and offer the three ways forward.-        nb = _ad_call("nbrowser_readiness", {}, timeout=40)+        nb = _ab_call("nbrowser_readiness", {}, timeout=40)         nb_data = nb.get("data", nb) if isinstance(nb, dict) else {}         nb_state = nb_data.get("state")         opened, where, profile_used = False, "", ""         pick_reason, ambiguous = "", False         choices = []         if nb_state == "ready":-            profs = _ad_call("nbrowser_profiles", {}, timeout=40)+            profs = _ab_call("nbrowser_profiles", {}, timeout=40)             pdata = profs.get("data", profs) if isinstance(profs, dict) else {}             # profiles are DICTS: {profile,label,email,browser,displayName,profileDir,active,live,...}             for p in (pdata.get("profiles") or []):@@ -11240,7 +11270,7 @@ def _orchestrate_demo(args: dict) -> dict:                 if not c["live"]:                     c["autodesk"] = "unprobed(asleep)"                     continue-                ls = _ad_call("nbrowser_login_state",+                ls = _ab_call("nbrowser_login_state",                               {"profile": c["profile"], "url": "https://accounts.autodesk.com/"},                               timeout=45)                 lsd = ls.get("data", ls) if isinstance(ls, dict) else {}@@ -11279,7 +11309,7 @@ def _orchestrate_demo(args: dict) -> dict:                   "background": True, "thread": "fusion-demo", "purpose": "Autodesk sign-in for Fusion"}             if profile_used:                 oa["profile"] = profile_used-            r = _ad_call("nbrowser_open_window", oa, timeout=60)+            r = _ab_call("nbrowser_open_window", oa, timeout=60)             opened = bool((r.get("data", r) if isinstance(r, dict) else {}).get("sessionId") or r.get("ok") or r.get("success"))             if not where:                 where = "your browser"@@ -11725,7 +11755,7 @@ def _time_and_indicate(command: str, args: dict, run):             stop = None     if slow and mode in ("flash", "both"):         try:-            _ad_call("desktop_taskbar", {"titleContains": "Autodesk Fusion",+            _ab_call("desktop_taskbar", {"titleContains": "Autodesk Fusion",                                          "flash": "until_focused"}, timeout=10)         except Exception:             pass@@ -11749,7 +11779,7 @@ def _time_and_indicate(command: str, args: dict, run):                 from handlers.taskbar_overlay import end_activity_progress                 end_activity_progress(command, failed=failed)                 if mode in ("flash", "both"):-                    _ad_call("desktop_taskbar", {"titleContains": "Autodesk Fusion",+                    _ab_call("desktop_taskbar", {"titleContains": "Autodesk Fusion",                                                  "flash": "stop"}, timeout=8)             except Exception:                 pass@@ -11773,7 +11803,7 @@ def _handle_set_activity_indicator(fusion_info: dict, args: dict) -> dict:                 pass         if mode in ("none", "progress"):             try:-                _ad_call("desktop_taskbar", {"titleContains": "Autodesk Fusion",+                _ab_call("desktop_taskbar", {"titleContains": "Autodesk Fusion",                                              "flash": "stop"}, timeout=8)             except Exception:                 pass@@ -14732,14 +14762,14 @@ def dispatch_command(command: str, args: dict, caller_identity: dict = None) ->                 for _d in (classify_blocking_dialogs() or []):                     _cat = _d.get("category")                     if _cat == "upload_in_progress":-                        _unwrap(_ad_call("desktop_ui_click",+                        _unwrap(_ab_call("desktop_ui_click",                                          {"hwnd": _d.get("hwnd"), "name": "No"}, timeout=12))                         if isinstance(res, dict):                             res["uploadCloseDeclined"] = True                         print("[Fusion Bridge] answered the Hub upload-close confirm with "                               "No (work-preserving; close retried after jobs drain)")                     elif _cat == "create_confirm":-                        _unwrap(_ad_call("desktop_ui_click",+                        _unwrap(_ab_call("desktop_ui_click",                                          {"hwnd": _d.get("hwnd"), "name": "No"}, timeout=12))                         if isinstance(res, dict):                             res["createPromptDeclined"] = True@@ -14747,7 +14777,7 @@ def dispatch_command(command: str, args: dict, caller_identity: dict = None) ->                               "chokepoint (raised by a raw EDIT; Yes would fabricate an "                               "empty artifact)")                     elif _cat in ("launch_error", "lbr_open_error"):-                        _unwrap(_ad_call("desktop_ui_click",+                        _unwrap(_ab_call("desktop_ui_click",                                          {"hwnd": _d.get("hwnd"), "name": "OK"}, timeout=12))                         print("[Fusion Bridge] dismissed a standing %s dialog at the "                               "chokepoint" % _cat)@@ -14837,7 +14867,7 @@ def _dispatch_command_inner(command: str, args: dict, caller_identity: dict = No                                   "window comes forward, so they can yield.")}             _note_focus_event(command, "foreground", fg_reason)             try:-                ad_client.notify("Fusion is coming to the foreground",+                ab_client.notify("Fusion is coming to the foreground",                                  fg_reason + " (Requested by your AI - Fusion verbs otherwise always "                                  "run in the background.)", level="info", reach_user=True)                 _time.sleep(3.0)   # the user's 3-second heads-up BEFORE any focus change@@ -15528,6 +15558,16 @@ def main():             print(f"  Add-in sync failed: {e}")     else:         print(f"  WARNING: Fusion 360 not found. Some commands will fail.")+        # Detection can miss a Fusion that is nonetheless installed (another profile, an odd+        # webdeploy root). If ANY AddIns dir exists the add-in is in use there, so sync it+        # anyway: idempotent, and the only way a stale copy ever gets repaired (issue #75).+        try:+            from install_addin import TARGET_CANDIDATES as _tc+            if any(c.exists() for c in _tc):+                print(f"[Fusion Bridge] An AddIns dir exists though Fusion was not detected: syncing the add-in anyway")+                install_addin()+        except Exception as e:+            print(f"  Add-in sync (undetected Fusion) failed: {e}")      addin_health = _probe_addin()     if addin_health:@@ -15842,24 +15882,24 @@ def _handle_live_feed(fusion_info: dict, args: dict) -> dict:                 if not hwnd:                     _LIVE_FEED["noHwnd"] += 1                 if hwnd:-                    # The in-process ad_client returns a bare image ({data:{image,format,...}}) and+                    # The in-process ab_client returns a bare image ({data:{image,format,...}}) and                     # NO owned popups; the loopback direct API returns the full capture with                     # screenshots[] (banner, panels, dialogs) and box paths. Prefer it here.                     # PROBED 2026-08-29 (fusion_live_feed {action:"probe"}): the in-process-                    # ad_client returns the FULL capture (fullPath, fullWidth, ownedPopupCount,+                    # ab_client returns the FULL capture (fullPath, fullWidth, ownedPopupCount,                     # screenshots[] with every owned popup); the loopback direct API returns a-                    # bare inline image with no popups. _ad_call would unwrap ad_client's `output`+                    # bare inline image with no popups. _ab_call would unwrap ab_client's `output`                     # string and throw the outer shape away, so call the client directly here.                     shot = None                     try:-                        if ad_client.available():-                            _r = ad_client.call("desktop_screenshot_window", {"hwnd": int(hwnd)}, timeout=20)+                        if ab_client.available():+                            _r = ab_client.call("desktop_screenshot_window", {"hwnd": int(hwnd)}, timeout=20)                             if isinstance(_r, dict) and (_r.get("fullPath") or _r.get("screenshots") is not None):                                 shot = _r                     except Exception as _ce:-                        _LIVE_FEED["lastError"] = ("ad_client: %s" % _ce)[:200]+                        _LIVE_FEED["lastError"] = ("ab_client: %s" % _ce)[:200]                     if not isinstance(shot, dict):-                        shot = _ad_call("desktop_screenshot_window", {"hwnd": int(hwnd)}, timeout=20) or {}+                        shot = _ab_call("desktop_screenshot_window", {"hwnd": int(hwnd)}, timeout=20) or {}                     # The ab reply nests the capture under `data` ({data:{fullPath,...}, message}).                     # Reading the top level gave "screenshot returned no path: ['data','message']"                     # on every tick all session (found by the honesty fields, 2026-08-29).@@ -16037,7 +16077,7 @@ def _dpi_scale_of(win_row: dict) -> float:     moves around, hence the recursive lookup); falls back to the screen/……ratio, then 1.0.     A wrong scale is why the first managed-library run clicked at (283,51) instead of (592,129)."""     try:-        sc = _ad_call("desktop_screenshot_screen", {}, timeout=40) or {}+        sc = _ab_call("desktop_screenshot_screen", {}, timeout=40) or {}         v = _find_key(sc, "dpiScale")         if v:             return float(v)@@ -16063,7 +16103,7 @@ def _frac_to_screen(frac_xy, info): def _fusion_main_window():     """{hwnd, rect, dpiScale} for Fusion's main window, or None."""     try:-        r = _ad_call("desktop_list_windows", {}, timeout=20) or {}+        r = _ab_call("desktop_list_windows", {}, timeout=20) or {}         out = r.get("output")         data = json.loads(out) if isinstance(out, str) else (r.get("data") or {})         for w in ((data.get("data") or data).get("windows") or []):@@ -16141,7 +16181,7 @@ def _popup_menu_rect(timeout_s=5.0):     deadline = _t.time() + timeout_s     while _t.time() < deadline:         try:-            resp = _ad_call("desktop_list_windows", {}, timeout=25)+            resp = _ab_call("desktop_list_windows", {}, timeout=25)             wins = _find_key(resp, "windows") or []             cands = []             for w in wins:@@ -16175,7 +16215,7 @@ def _wait_for_top_window(title_substr, timeout_s=25.0):     deadline = _t.time() + timeout_s     while _t.time() < deadline:         try:-            resp = _ad_call("desktop_list_windows", {}, timeout=25)+            resp = _ab_call("desktop_list_windows", {}, timeout=25)             for w in (_find_key(resp, "windows") or []):                 if want in str(w.get("title") or "").lower():                     return w@@ -16196,7 +16236,7 @@ def _wait_for_top_window_gone(title_substr, timeout_s=15.0):     deadline = _t.time() + timeout_s     while _t.time() < deadline:         try:-            resp = _ad_call("desktop_list_windows", {}, timeout=25)+            resp = _ab_call("desktop_list_windows", {}, timeout=25)             wins = _find_key(resp, "windows")             if wins is not None and not any(want in str(w.get("title") or "").lower()                                             for w in wins):@@ -16217,7 +16257,7 @@ def _picker_rows(hwnd):     per row. Only REALIZED rows are exposed (Qt virtualizes the list), so this returns what is     on screen, not necessarily the whole catalogue."""     try:-        resp = _ad_call("desktop_ui_tree", {"hwnd": hwnd, "maxDepth": 14}, timeout=60)+        resp = _ab_call("desktop_ui_tree", {"hwnd": hwnd, "maxDepth": 14}, timeout=60)     except Exception as e:         return [], str(e)     cells = []@@ -16280,9 +16320,9 @@ def _ribbon_click(win, key, why, popup=False):     x = int(ox + _LIBIO_DIP[key][0] * scale)     y = int(oy + _LIBIO_DIP[key][1] * scale)     if popup:-        _ad_call("desktop_mouse_click", {"x": x, "y": y, "reason": why}, timeout=25)+        _ab_call("desktop_mouse_click", {"x": x, "y": y, "reason": why}, timeout=25)     else:-        _ad_call("desktop_click", {"hwnd": win["hwnd"], "x": x, "y": y, "cursor": False,+        _ab_call("desktop_click", {"hwnd": win["hwnd"], "x": x, "y": y, "cursor": False,                                    "reason": why}, timeout=25)     return {"step": key, "at": [x, y], "scale": scale} @@ -16323,7 +16363,7 @@ def _libio_menu(win, item, why, steps):     x = int(rect["left"] + rect["width"] / 2)     y = int(rect["top"] + (idx + 0.5) * rect["height"] / n)     # A popup item goes through the raw mouse verb: re-focusing Fusion here would dismiss it.-    _ad_call("desktop_mouse_click", {"x": x, "y": y, "reason": why}, timeout=25)+    _ab_call("desktop_mouse_click", {"x": x, "y": y, "reason": why}, timeout=25)     steps.append({"step": item, "at": [x, y], "menuRect": rect})     _t.sleep(2.0)     return None@@ -16357,7 +16397,7 @@ def _handle_web_signin(fusion_info: dict, args: dict) -> dict:     steps = []      def _pup(verb, a, timeout=90):-        return _ad_call("pup_" + verb, a, timeout=timeout) or {}+        return _ab_call("pup_" + verb, a, timeout=timeout) or {}      def _state():         r = _pup("eval", {"sessionId": sess, "expression":@@ -16479,7 +16519,7 @@ def _click_cef_button(win, x_frac, y_frac, why):     w, h = int(r.get("width") or 0), int(r.get("height") or 0)     if left is None or top is None or not (w and h):         return False-    _ad_call("desktop_click", {"x": int(left + w * x_frac), "y": int(top + h * y_frac),+    _ab_call("desktop_click", {"x": int(left + w * x_frac), "y": int(top + h * y_frac),                                "hwnd": win.get("hwnd"), "cursor": False, "reason": why},              timeout=25)     return True@@ -16677,7 +16717,7 @@ def _handle_managed_library_link(fusion_info: dict, args: dict) -> dict:     want = (args.get("name") or "").strip()      def _close(why):-        _ad_call("desktop_ui_click", {"hwnd": hwnd, "name": "Cancel", "reason": why}, timeout=30)+        _ab_call("desktop_ui_click", {"hwnd": hwnd, "name": "Cancel", "reason": why}, timeout=30)      if not want:         _close("close the Select Managed Library picker after listing it read-only")@@ -16698,11 +16738,11 @@ def _handle_managed_library_link(fusion_info: dict, args: dict) -> dict:                 "_hint": "Names are exact. If you expect a library shared from another Autodesk "                          "account, it only appears once its MANAGED FOLDER is shared with this "                          "account on library.io - being in the same Fusion hub is not enough."}-    _ad_call("desktop_click", {"x": int(hit["_at"][0]), "y": int(hit["_at"][1]), "cursor": False,+    _ab_call("desktop_click", {"x": int(hit["_at"][0]), "y": int(hit["_at"][1]), "cursor": False,                                "reason": "select %s in the Select Managed Library picker" % want},              timeout=25)     _t.sleep(1)-    ok = _ad_call("desktop_ui_click", {"hwnd": hwnd, "name": "OK",+    ok = _ab_call("desktop_ui_click", {"hwnd": hwnd, "name": "OK",                                        "reason": "confirm the managed-library link"}, timeout=30) or {}     steps.append({"step": "confirm", "row": hit["name"], "clicked": bool(_find_key(ok, "success"))})     _t.sleep(2)@@ -16746,12 +16786,12 @@ def _handle_library_manager(fusion_info: dict, args: dict) -> dict:     tab = (args.get("tab") or "private").lower()     label = {"hub": "Hub Libraries", "public": "Public Libraries",              "private": "Private Libraries"}.get(tab, "Private Libraries")-    click = _ad_call("desktop_ui_click", {"hwnd": dlg.get("hwnd"), "name": label,+    click = _ab_call("desktop_ui_click", {"hwnd": dlg.get("hwnd"), "name": label,                                           "reason": "switch Library Manager to %s" % label},                      timeout=40) or {}     steps.append({"step": "tab", "label": label, "ok": bool(click.get("success"))})     _t.sleep(12)   # library.io fetches are slow; Library Manager can even show Not Responding-    tree = _ad_call("desktop_ui_tree", {"hwnd": dlg.get("hwnd"), "maxDepth": 10,+    tree = _ab_call("desktop_ui_tree", {"hwnd": dlg.get("hwnd"), "maxDepth": 10,                                         "maxNodes": 600}, timeout=120) or {}     rows = [] @@ -16861,13 +16901,13 @@ def _handle_managed_library_create(fusion_info: dict, args: dict) -> dict:                 "_hint": "Push offers Create only for a library that is NOT yet linked. If this "                          "library is already linked, fusion_managed_library_push pushes a new "                          "version instead - check fusion_alerts."}-    ok = _ad_call("desktop_ui_click", {"hwnd": dlg.get("hwnd"), "name": "Create",+    ok = _ab_call("desktop_ui_click", {"hwnd": dlg.get("hwnd"), "name": "Create",                                        "reason": "create the managed library on library.io"},                   timeout=30) or {}     steps.append({"step": "create", "ok": bool(ok.get("success"))})     conf = _wait_for_dialog("Create Managed Library", 40)   # the confirmation reuses the title     if conf:-        _ad_call("desktop_ui_click", {"hwnd": conf.get("hwnd"), "name": "OK",+        _ab_call("desktop_ui_click", {"hwnd": conf.get("hwnd"), "name": "OK",                                       "reason": "acknowledge the managed-library confirmation"},                  timeout=30)         steps.append({"step": "ok"})