← Commit history
BRIDGE_VERSION+1−1
@@ -1 +1 @@-1.9.95+1.9.96
adom-bridge-fusion-manifest.json+4−4
@@ -1,10 +1,10 @@ {     "manifest_version": 1,     "name": "fusion360",-    "version": "1.9.95",-    "url": "https://wiki.adom.inc/download/adom/fusion-bridge/1.9.110/adom-bridge-fusion-v1.9.95.zip",-    "sha256": "61f5e21e3d37d497a074ef75eb46cbdb0c6de6bbfa07921571d4f7b8e6eca61f",-    "size": 429881,+    "version": "1.9.96",+    "url": "https://wiki.adom.inc/download/adom/fusion-bridge/1.9.111/adom-bridge-fusion-v1.9.96.zip",+    "sha256": "1c6c1897c34fd53b4d0b5a1454d3208bd40f5f15e8691b58d59e7aca8b1defe3",+    "size": 432038,     "verbPrefixes": [         "fusion_"     ],
bridge.json+1−1
@@ -2,7 +2,7 @@   "manifest_version": 1,   "name": "fusion360",   "displayName": "Autodesk Fusion 360",-  "version": "1.9.95",+  "version": "1.9.96",   "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.",
dashboard/Cargo.toml+1−1
@@ -1,6 +1,6 @@ [package] name = "fusion-dashboard"-version = "1.9.110"+version = "1.9.111" edition = "2021"  [dependencies]
dev-skills/fusion-dialogs/SKILL.mdadded+107
@@ -0,0 +1,107 @@+---+name: fusion-dialogs+description: DEVELOPER-only. How the Fusion bridge deals with modal dialogs - and, far more importantly, how it avoids CAUSING them. Read before writing any verb that drives Fusion's UI, opens a library artifact, closes a document, or reports "add-in not responding". Trigger words - fusion dialog, modal, Create new footprint, save prompt, blocking dialog, add-in not responding, main thread blocked, dismiss dialog.+user-invocable: false+---++# Fusion dialogs: prevent, then detect, then answer++**The rule, in John's words (2026-08-21): "you should never allow dialogs like that to ever+even show cuz they screw you up, so you should pre-emptively make sure they never show up."**++A modal in Fusion is not a cosmetic annoyance. Fusion runs one command at a time on its main+thread, and a modal **holds that thread until somebody answers it**. So every dialog we cause+turns into:++- the verb that raised it hanging,+- every later verb timing out,+- `fusion_addin_not_responding` reported to the caller,+- a human concluding the add-in crashed and restarting Fusion,++which is a spectacular amount of damage from a Yes/No box. Ranked responses:++| rank | approach | when |+|---|---|---|+| 1 | **Never raise it** - check preconditions before asking Fusion | always possible for anything we control |+| 2 | **Detect + answer** deterministically | dialogs raised by Fusion itself (licensing, crash, update) |+| 3 | Report it with a screenshot | genuinely user-decidable only (seat conflict) |++Rank 3 is a last resort, not a default. "Run `fusion_dismiss_blocking_dialogs` afterwards" is+NOT an acceptable design - it hands the caller a mess we chose to make.++## The case that taught this: "Create new footprint 'X'?"++`EDIT <name>.pac` on a name that is **not in the open library** makes Fusion offer to create+it. That modal then blocks the main thread. The bridge shipped for months with this hint at+the call site:++> "EDIT on a missing name pops a 'Create new?' dialog - run fusion_dismiss_blocking_dialogs+> if one appeared."++We *knew*, documented it, and still caused it. That is the exact inversion this skill exists+to prevent.++**The fix is a precondition check, not a sweeper.** `_resolve_artifact_name()` parses the .lbr+(EAGLE libraries are XML, so `<symbol>/<package>/<deviceset>` name attributes are the source+of truth), resolves exact then unique case-insensitive matches, and if the name is absent+**refuses before touching Fusion**:++```+errorCode: "artifact_not_in_library"+available: [... what the library actually holds ...]+_hint:     "Deliberately refused BEFORE touching Fusion ... Nothing was opened and no+            dialog was raised."+```++A caller gets the real answer (wrong name, here are the right ones) in milliseconds instead+of a hang. **Generalise it: before any command that can prompt, verify the thing you are+about to name actually exists.**++## Detection: the title is often useless++Fusion titles several unrelated modals just `Fusion`. A title-only classifier cannot tell+"Create new footprint" from anything else, which is why ours missed it completely.++**The identity lives in the BODY TEXT.** Two ways to read it, both background and read-only:++- `desktop_find_controls {hwnd}` - Qt publishes the prompt as Text controls in the a11y tree.+  This is what `_dialog_text()` uses.+- `desktop_screenshot_window {hwnd}` - ab returns the window **plus its owned child windows**+  as separate images. Use this when UIA is unavailable (some Qt modals expose nothing), and+  when you need to SHOW a human what is blocking.++`classify_blocking_dialogs()` therefore matches on title first, then falls back to body text+whenever the title is generic, and reports `matchedOn: "title" | "bodyText"`.++## Answering: pick the answer that does not hide the bug++For `create_confirm` the bridge answers **No**, deliberately. "Yes" would invent an empty+footprint, the surface would "succeed", and the real fault - a wrong name - would surface+days later as a mysteriously blank part. **Prefer the answer that fails loudly over the one+that produces a plausible artifact.**++Same reasoning as the transfer-integrity rule ab landed on: a zero-filled gap makes a file of+exactly the right length that is silently wrong.++## Never report a blocked main thread as a crash++Three distinct states that look identical to a naive caller, all now distinguished:++| state | errorCode | remedy |+|---|---|---|+| a modal is holding the main thread | classified dialog + `mainThreadBusy` | answer or clear it |+| a long command is still running | `fusion_addin_busy` (+ `busyCommand`, `elapsedSeconds`) | WAIT - a staged surface measured 546s |+| the host stopped pumping messages | `fusion_host_hung` (+ `hungSignals`) | force-restart; nothing reaches it over IPC |+| the add-in genuinely is not serving | `fusion_addin_not_responding` (+ `hostResponding: true`) | restart Fusion; it only loads at launch |++Collapsing these into one error is what sent ab hunting a phantom dialog (#38) and made a+9-minute surface look like a dead add-in (#40).++## Checklist for a new verb that drives the UI++1. Can it prompt? List every dialog it could raise, then remove each cause.+2. Verify names/paths/state BEFORE issuing the command.+3. If a dialog is still possible, classify it by BODY TEXT and choose the answer that does+   not fabricate an artifact.+4. Never return "not responding" without checking busy vs hung vs modal first.+5. Never tell the caller to run a dismisser as part of the happy path.
handlers/dialog_classify.py+47−1
@@ -72,6 +72,21 @@ _RULES = [     #    FusionLauncher, so classify_launch_dialogs enumerates ALL top-level     #    windows to catch them - get_fusion_window_info would miss them). ── +    # "Create new footprint 'X'?" - Fusion offering to CREATE something we asked to open.+    # The bridge should never see this (we verify the name exists first, see+    # _resolve_artifact_name), but a library opened by another route can still raise it, and+    # it BLOCKS THE MAIN THREAD until answered - which reads downstream as "add-in not+    # responding" rather than "a modal is waiting". NOTE the window TITLE is just "Fusion":+    # this is only identifiable from its BODY TEXT, which is why title-only classification+    # missed it entirely (John, 2026-08-21).+    (("create new footprint", "create new symbol", "create new device",+      "create new package", "create new library"),+     "create_confirm",+     "Fusion is asking whether to CREATE an artifact we asked to open, which means the name "+     "does not exist in the library. Answering 'Yes' invents an empty artifact and hides the "+     "real problem, so the bridge answers NO and reports the missing name. Fix the NAME "+     "(fusion_show_* refuses up front with `available` listing what the .lbr actually holds)."),+     # Fusion CRASHED and is offering to send a report. Benign to close (the X == Close,     # the report still uploads its basic data), and CRITICALLY it is the tell that Fusion     # is gone even though a Fusion360.exe still shows in tasklist - which is exactly how@@ -155,7 +170,7 @@ FAILURE_CATEGORIES = {"operation_error"} DECISION_CATEGORIES = {"session_conflict_decision", "session_suspend_confirm"} # Categories the launch code MAY auto-dismiss (a benign ack; WM_CLOSE == Cancel/OK). # Everything else (decisions, sign-in) is SURFACED, never auto-closed.-AUTO_DISMISS_CATEGORIES = {"launch_error", "already_open", "crash_report"}+AUTO_DISMISS_CATEGORIES = {"launch_error", "already_open", "crash_report", "create_confirm"} # A crash report means the app is GONE, not merely dialog-blocked: clearing it is not # enough, the bridge must relaunch. Kept separate from AUTO_DISMISS so a caller can tell # "I closed a nuisance" from "I closed a nuisance and must now restart the app".@@ -250,6 +265,7 @@ def classify_launch_dialogs() -> list:             "title": title,             "category": category,             "resolution": resolution,+            "matchedOn": dlg.get("matchedOn", "title"),             "decision": category in DECISION_CATEGORIES,         })     return out@@ -268,6 +284,27 @@ def close_dialog_bg(hwnd) -> bool:         return False  +def _dialog_text(hwnd) -> str:+    """The dialog's BODY TEXT, read via UIA.++    Fusion titles several distinct modals just "Fusion", so a title-only classifier cannot+    tell "Create new footprint 'X'?" from any other one (John, 2026-08-21). ab exposes the+    a11y tree through desktop_find_controls, and Qt publishes the prompt as Text controls,+    so the identity we need is right there. Read-only and background: no focus is taken.+    Returns "" on any failure - text we could not read must never become a misclassification.+    """+    if not hwnd:+        return ""+    try:+        import ad_client+        r = ad_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]+    except Exception:+        return ""++ def classify_blocking_dialogs() -> list:     """Return classified blocking dialogs currently open in Fusion. @@ -282,6 +319,15 @@ def classify_blocking_dialogs() -> list:     for dlg in info.get("dialogs", []) or []:         title = dlg.get("title", "")         classified = _category_for_title(title)+        # A title-only match of "Fusion" tells us nothing. Fall back to the BODY TEXT, which+        # is where the identity actually lives for Fusion's generic-titled modals.+        if (not classified) or classified[0] in ("generic_modal_read_screenshot", "unknown"):+            body = _dialog_text(dlg.get("hwnd"))+            if body:+                by_text = _category_for_title(body)+                if by_text:+                    classified = by_text+                    dlg["matchedOn"] = "bodyText"         if not classified:             continue         category, resolution = classified
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.110"+DASH_VERSION="1.9.111" 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
package.json+1−1
@@ -1,6 +1,6 @@ {   "slug": "fusion-bridge",-  "version": "1.9.110",+  "version": "1.9.111",   "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+10−7
@@ -1,6 +1,6 @@ {   "slug": "fusion-bridge",-  "version": "1.9.110",+  "version": "1.9.111",   "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",@@ -70,7 +74,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.",@@ -99,9 +106,5 @@       "label": "Part volume",       "prompt": "How much material does each printed part in this assembly use?"     }-  ],-  "author": {-    "name": "John Lauer",-    "email": "[email protected]"-  }+  ] }\ No newline at end of file
server.py+70−4
@@ -6746,9 +6746,75 @@ def _artifact_name_from(args, *frag_keys):     return ""  -def _show_library_view(name, suffix):-    """EDIT to a library view + fit + background screenshot. Library must be open."""++def _library_artifact_names(lbr_path: str) -> dict:+    """The names that actually EXIST in a .lbr: {"sym": [...], "pac": [...], "dev": [...]}.++    EAGLE/Fusion libraries are XML, so the <symbol>/<package>/<deviceset> name attributes+    are the source of truth. Empty dict when the file is unreadable - callers must treat+    "I could not check" as "do not guess", never as "it is missing".+    """+    try:+        import xml.etree.ElementTree as _ET+        root = _ET.parse(lbr_path).getroot()+    except Exception:+        return {}+    def names(tag):+        return [e.get("name") for e in root.iter(tag) if e.get("name")]+    return {"sym": names("symbol"), "pac": names("package"), "dev": names("deviceset")}+++def _resolve_artifact_name(name: str, suffix: str, lbr_path: str):+    """Resolve `name` to something that EXISTS, or say it does not - WITHOUT asking Fusion.++    ⚠️ PREVENTION, NOT CLEANUP (John, 2026-08-21: "you should never allow dialogs like that+    to ever even show cuz they screw you up"). `EDIT <name>.<suffix>` on a name that is not+    in the library pops Fusion's "Create new footprint 'X'?" modal, which blocks the add-in's+    main thread until somebody answers it - so a typo became a hang, and every later verb+    read as "add-in not responding". This bridge KNEW that: the old hint here told callers to+    run fusion_dismiss_blocking_dialogs afterwards. Telling the caller to sweep up a mess we+    chose to make is exactly backwards.++    Returns (resolved_name, None) or (None, error_dict). Exact match wins; then a unique+    case-insensitive match, because EAGLE names round-trip through tools that change case.+    """+    have = _library_artifact_names(lbr_path).get(suffix if suffix in ("sym", "pac", "dev") else "", [])+    if not have:+        return name, None                      # cannot read the library -> do not block the call+    if name in have:+        return name, None+    ci = [h for h in have if h.lower() == str(name).lower()]+    if len(ci) == 1:+        return ci[0], None+    return None, {+        "success": False,+        "error": "No %s named %r in that library, so the EDIT was NOT issued." % (+            {"sym": "symbol", "pac": "footprint", "dev": "device"}.get(suffix, suffix), name),+        "errorCode": "artifact_not_in_library",+        "available": have[:40],+        "_hint": ("Deliberately refused BEFORE touching Fusion: EDIT on a missing name pops a "+                  "'Create new ...?' modal that blocks the add-in's main thread and makes every "+                  "later verb look like a crash. Pick a name from `available` (they are the "+                  "<symbol>/<package>/<deviceset> name attributes in the .lbr, the source of "+                  "truth) and re-call. Nothing was opened and no dialog was raised."),+    }+++def _show_library_view(name, suffix, lbr_path=None):+    """EDIT to a library view + fit + background screenshot. Library must be open.++    Verifies the name EXISTS first when we know the .lbr, so Fusion is never asked to open+    something that would make it prompt (see _resolve_artifact_name).+    """     import time+    if lbr_path:+        resolved, err = _resolve_artifact_name(name, suffix, lbr_path)+        if err:+            return err+        if resolved != name:+            print("[Fusion Bridge] resolved %r -> %r from the library (avoids a Create-new prompt)"+                  % (name, resolved))+            name = resolved     edit = _proxy_to_addin("electron_run", {"command": "EDIT %s.%s" % (name, suffix)}, timeout=15)     if not edit.get("success"):         return {"success": False, "error": "EDIT %s.%s failed: %s" % (name, suffix, edit.get("error")),@@ -6808,7 +6874,7 @@ def _orchestrate_show_symbol(args: dict) -> dict:     err, wrapped = _show_prepare_library(args, symbol_xml=symbol_xml, name=name)     if err:         return err-    out = _show_library_view(name, "sym")+    out = _show_library_view(name, "sym", wrapped or (args.get("libraryPath") or "").replace("\\", "/"))     if wrapped and out.get("success"):         out["wrappedLibrary"] = wrapped         out["_hint"] = ("Bare symbol was wrapped in a generated library at %s (a deviceset with one "@@ -6829,7 +6895,7 @@ def _orchestrate_show_footprint(args: dict) -> dict:     err, wrapped = _show_prepare_library(args, package_xml=package_xml, name=name)     if err:         return err-    out = _show_library_view(name, "pac")+    out = _show_library_view(name, "pac", wrapped or (args.get("libraryPath") or "").replace("\\", "/"))     if wrapped and out.get("success"):         out["wrappedLibrary"] = wrapped         out["_hint"] = "Bare footprint was wrapped in a generated library at " + wrapped + "."