← Commit history
BRIDGE_VERSION+1−1
@@ -1 +1 @@-1.9.176\ No newline at end of file+1.9.177\ No newline at end of file
adom-bridge-fusion-manifest.json+4−4
@@ -1,10 +1,10 @@ {   "manifest_version": 1,   "name": "fusion360",-  "version": "1.9.176",-  "url": "https://wiki.adom.inc/download/adom/fusion-bridge/1.9.176/adom-bridge-fusion-v1.9.176.zip",-  "sha256": "ecc9a01e99e8611ca3d53a861a88977ac316767edebacfe91c96c321c166d4e1",-  "size": 483333,+  "version": "1.9.177",+  "url": "https://wiki.adom.inc/download/adom/fusion-bridge/1.9.177/adom-bridge-fusion-v1.9.177.zip",+  "sha256": "d1836a63ecac8945e8b8a66af1c31e0eedc42295da0da1c06a74166be69c469d",+  "size": 484686,   "verbPrefixes": [     "fusion_"   ],
bridge.json+1−1
@@ -2,7 +2,7 @@   "manifest_version": 1,   "name": "fusion360",   "displayName": "Autodesk Fusion 360",-  "version": "1.9.176",+  "version": "1.9.177",   "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.lock+1−1
@@ -4,7 +4,7 @@ version = 4  [[package]] name = "fusion-dashboard"-version = "1.9.176"+version = "1.9.177" dependencies = [  "serde_json", ]
dashboard/Cargo.toml+1−1
@@ -1,6 +1,6 @@ [package] name = "fusion-dashboard"-version = "1.9.176"+version = "1.9.177" edition = "2021"  [dependencies]
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.176"+DASH_VERSION="1.9.177" 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.176",+  "version": "1.9.177",   "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−8
@@ -1,6 +1,6 @@ {   "slug": "fusion-bridge",-  "version": "1.9.176",+  "version": "1.9.177",   "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,9 +111,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+76
@@ -5061,7 +5061,57 @@ def _auto_update_enabled() -> bool:   # Buttons Fusion puts on its update nag / Job Status panel, best-first.+# ⚠ TWO CLASSES (#50, 2026-08-23 - 9/9 chains lost Fusion mid-PROVE): "Update Now" only+# STARTS a background download (safe anytime), but "Install Now"/"Restart Now" EXIT Fusion+# to apply the update. The sweep used to click all four blindly on its 300s cadence, which+# lines up exactly with the observed clean exit at +344s into a chain. Exit-class buttons+# may now only be clicked when Fusion is verifiably IDLE (no long op, no recent driving,+# no open documents). _UPDATE_BUTTONS = ("Update Now", "Update now", "Install Now", "Restart Now")+_UPDATE_BUTTONS_EXIT_CLASS = ("Install Now", "Restart Now")+++def _bridge_action_ledger(action: str, detail: dict):+    """Append one background-action record to ~/.adom/fusion-bridge/actions.jsonl.++    The bridge log truncates on every respawn (found investigating #50: a morning of+    evidence was gone by the time the report landed), so anything the bridge does to+    Fusion BEHIND the user's back - update clicks, seat takeovers, dialog dismissals,+    doc closes, hang-gate kills - is also written here, append-only, survives respawns.+    One grep answers 'did the bridge touch Fusion at HH:MM?'. Never raises."""+    try:+        import json as _j, time as _t, pathlib as _pl+        p = _pl.Path(os.path.expanduser("~")) / ".adom" / "fusion-bridge" / "actions.jsonl"+        p.parent.mkdir(parents=True, exist_ok=True)+        rec = {"at": _t.strftime("%Y-%m-%d %H:%M:%S"), "epoch": round(_t.time(), 1),+               "action": action, "pid": os.getpid()}+        rec.update(detail or {})+        with open(p, "a", encoding="utf-8") as f:+            f.write(_j.dumps(rec) + "\n")+    except Exception:+        pass+++def _fusion_verifiably_idle() -> dict:+    """May the bridge do something that EXITS Fusion right now? Only when ALL of:+    no registered long op, no driving verb for 3+ minutes, and no open documents+    (best-effort; an unreachable add-in counts as NOT idle - safe default)."""+    import time as _t+    if _LONG_OP.get("name"):+        return {"idle": False, "why": "long op in flight: %s" % _LONG_OP["name"]}+    drove_ago = _t.time() - float(_LAST_DRIVE_AT[0] or 0)+    if _LAST_DRIVE_AT[0] and drove_ago < 180:+        return {"idle": False, "why": "driving verb %ds ago" % int(drove_ago)}+    try:+        st = _proxy_to_addin("document_info", {}, timeout=6)+        docs = (st.get("data") or {}).get("openDocuments") or []+        if not st.get("success"):+            return {"idle": False, "why": "add-in unreachable - cannot prove no open docs"}+        if docs:+            return {"idle": False, "why": "%d document(s) open" % len(docs)}+    except Exception:+        return {"idle": False, "why": "doc check failed - cannot prove idle"}+    return {"idle": True, "why": "no long op, no recent driving, no open documents"}   def _find_controls_any(obj, depth=0):@@ -5130,11 +5180,28 @@ def _apply_fusion_update_silently(hwnd=None) -> dict:     offer = _find_update_offer(hwnd)     if not offer.get("offered"):         return {"applied": False, "why": "no update offered"}+    # ⚠ #50 gate: "Install Now"/"Restart Now" EXIT Fusion to apply the update. Clicking one+    # while a chain is running killed Fusion mid-PROVE 9/9 times (clean exit, no crash+    # artifact, matching this sweep's 300s cadence). Exit-class buttons only fire when+    # Fusion is verifiably idle; otherwise defer - the janitor retries every 300s and will+    # land it the moment the work is done. "Update Now" (download only) stays safe anytime.+    if offer["button"] in _UPDATE_BUTTONS_EXIT_CLASS:+        idle = _fusion_verifiably_idle()+        if not idle.get("idle"):+            _bridge_action_ledger("auto_update_deferred",+                                  {"button": offer["button"], "notIdle": idle.get("why")})+            print("[Fusion Bridge] auto-update: %r on screen but Fusion is NOT idle (%s) - "+                  "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",                          {"hwnd": int(offer["hwnd"]), "name": offer["button"]}, timeout=30))     ok = bool(r.get("invoked") or r.get("success"))     if ok:         print("[Fusion Bridge] auto-update: clicked %r in the background" % offer["button"])+    _bridge_action_ledger("auto_update_click",+                          {"button": offer["button"], "invoked": ok,+                           "exitClass": offer["button"] in _UPDATE_BUTTONS_EXIT_CLASS})     return {"applied": ok, "button": offer["button"],             "why": ("clicked %r in the background; Fusion downloads now and swaps on next launch"                     % offer["button"]) if ok else "found the button but the UIA invoke did not take"}@@ -5312,6 +5379,7 @@ def _close_idle_bridge_docs() -> dict:                 closed.append(name)                 print("[Fusion Bridge] janitor: closed dirty bridge doc %r (verified gone, "                       "any Save-As answered in the same call)" % name)+                _bridge_action_ledger("idle_doc_close", {"doc": name})         except Exception:             pass     return {"closed": closed, "why": "closed %d dirty bridge doc(s)" % len(closed)}@@ -5389,6 +5457,7 @@ def _dismiss_save_prompt_if_bridge_doc() -> dict:         if r2.get("invoked") or r2.get("success"):             print("[Fusion Bridge] save-prompt sentinel: clicked \'Don\'t Save\' for bridge doc %r "                   "(attributed by window title)" % matched)+            _bridge_action_ledger("save_prompt_dismiss", {"doc": matched, "how": "dont_save"})             return {"handled": True, "why": "clicked Don't Save for bridge doc '%s'" % matched}     except Exception:         pass@@ -5398,6 +5467,7 @@ def _dismiss_save_prompt_if_bridge_doc() -> dict:         if esc.get("success"):             print("[Fusion Bridge] save-prompt sentinel: ESCAPED the prompt for bridge doc %r "                   "(Cancel - doc stays open unsaved, main thread freed)" % matched)+            _bridge_action_ledger("save_prompt_dismiss", {"doc": matched, "how": "escape"})             return {"handled": True, "why": "escaped (Cancel) for bridge doc '%s'" % matched}     except Exception:         pass@@ -6427,6 +6497,7 @@ def _seat_sweep_tick():                 _unwrap(_ad_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")})     except Exception:         pass @@ -6450,6 +6521,8 @@ def _seat_resolve_section():                     pass             print("[Fusion Bridge] seat sweep: took over the session (clicks=%s, verified=%s)"                   % (r.get("clicks"), r.get("verified")))+            _bridge_action_ledger("seat_takeover",+                                  {"clicks": r.get("clicks"), "verified": r.get("verified")})     except Exception:         pass @@ -10929,6 +11002,9 @@ def _dispatch_command_inner(command: str, args: dict, caller_identity: dict = No             _h = _confirmed_hung()             if _h.get("hung"):                 _ok = False+                _bridge_action_ledger("hang_gate_kill",+                                      {"heldSeconds": _h.get("heldSeconds"),+                                       "signals": _h.get("signals")})                 try:                     dispatch_command("kill", {})                     _time.sleep(6)