← Commit history
BRIDGE_VERSION+1−1
@@ -1 +1 @@-1.9.238\ No newline at end of file+1.9.239\ 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.238",-  "url": "https://wiki.adom.inc/download/adom/fusion-bridge/1.9.238/adom-bridge-fusion-v1.9.238.zip",-  "sha256": "eabd567eadb9e17c29fa16400539b4b7697ff5484b530ab7237ce305f124be5e",-  "size": 534015,+  "version": "1.9.239",+  "url": "https://wiki.adom.inc/download/adom/fusion-bridge/1.9.239/adom-bridge-fusion-v1.9.239.zip",+  "sha256": "bbaf8737741b2f792bc8e0a614af7557c3f4cdaf03abd01ca9e84bc637f1969b",+  "size": 537767,   "verbPrefixes": [     "fusion_"   ],
bridge.json+1−1
@@ -2,7 +2,7 @@   "manifest_version": 1,   "name": "fusion360",   "displayName": "Autodesk Fusion 360",-  "version": "1.9.238",+  "version": "1.9.239",   "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.238"+version = "1.9.239" dependencies = [  "serde_json", ]
dashboard/Cargo.toml+1−1
@@ -1,6 +1,6 @@ [package] name = "fusion-dashboard"-version = "1.9.238"+version = "1.9.239" edition = "2021"  [dependencies]
describe.py+3
@@ -276,6 +276,9 @@ _T = [      {"parts": "optional [{package, lbrPath, modelPath}] - the parts you intend to import (drives the estimate)", "stepPath": "optional single .step when not passing parts[]", "libraryPath": "optional .lbr for context"},      {"success": "bool", "fusion": "{installed, running, addinReady, status, message}", "plan": "[{stage, label, estimatedSec, confidence, needsUserConsent?}]", "estimatedSec": "float", "estimatedSecIfAlreadyOpen": "float", "summary": "str"},      20, None, True, {"parts": [{"package": "AL8843SP-13", "lbrPath": "C:/tmp/al8843.lbr", "modelPath": "C:/tmp/al8843.step"}]}),+    ("fusion_live_feed", "A ~1 fps look INTO Fusion while the bridge drives it, published to a shotlog channel as tagged kinds: [main #n] = the Fusion window each tick (the video), [popup #n.m] = every owned dialog/panel over it tied to its tick (Fusion draws these as separate windows a main capture omits). Proof frames from the import chain are the third kind (kind:proof, with step labels) and are NOT produced here - this feed shows what happens BETWEEN proofs, e.g. during boot or a restart, so a human can see open tabs and dialogs instead of guessing. Runs in the background, never foregrounds. The wiki may choose not to render 1 fps; the bridge owns the feed regardless.",+     {"action": "start|stop|status", "channel": "optional str (default fusion-live)", "intervalSec": "optional float (default 1.0)", "maxSec": "optional float (default 600)"},+     {"success": "bool", "started": "bool", "channel": "str", "frames": "int"}, 15, None, True, {"action": "start", "channel": "fusion-live"}),     ("fusion_restart_for_addin", "Finish add-in setup by restarting Fusion, SAVING the user's open documents first. Call this when any verb returns errorCode addin_restart_required (the add-in was installed/updated while Fusion was already open, and Fusion only loads add-ins at launch). ASK THE USER FIRST when a human is present - the failing verb's payload carries userMessage plus openDocuments/unsavedDocuments so you can show them what is open. Refuses with unsaved_named_document rather than discarding a never-saved document.",      {"save": "optional bool (default true) - save open documents before restarting"},      {"success": "bool", "addinReady": "bool", "tookSec": "float", "steps": "[{step, ok}]"},
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.238"+DASH_VERSION="1.9.239" 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.238",+  "version": "1.9.239",   "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.238",+  "version": "1.9.239",   "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+191−3
@@ -348,6 +348,43 @@ def _cli_notify_all(title: str, body: str, level: str, buttons=None) -> dict:         return {"delivered": False, "error": str(e)[:200]}  +def _set_user_restart_hold(nid):+    """The user chose to restart Fusion themselves; no automation may do it for them."""+    try:+        import pathlib as _pl, json as _js, time as _t, os as _os+        d = _pl.Path(_os.path.expanduser("~")) / ".adom" / "fusion-bridge"+        d.mkdir(parents=True, exist_ok=True)+        (d / "user_restart_hold.json").write_text(_js.dumps({"at": _t.time(), "notification": nid}))+    except Exception:+        pass+++def _user_restart_hold_active():+    """True while the user has said 'I'll handle it' and the add-in has not come back.++    John, 2026-08-28: "i just clicked 'i'll handle it' when asked to restart fusion and+    then you quit fusion on me? i would have expected that i would restart fusion." Two+    racing chains each entered their launch stage, and the launch handler's running-but-+    silent branch restarted Fusion because that is its doctrine (#39). Nothing consulted+    the answer he had just given. This hold is that answer, made visible to every path+    that could restart Fusion. It clears itself the moment the add-in answers (he did+    the restart) or after 4 hours (he forgot; do not brick automation forever).+    """+    try:+        import pathlib as _pl, json as _js, time as _t, os as _os+        f = _pl.Path(_os.path.expanduser("~")) / ".adom" / "fusion-bridge" / "user_restart_hold.json"+        if not f.exists():+            return False+        at = float((_js.loads(f.read_text()) or {}).get("at") or 0)+        if _t.time() - at > 4 * 3600:+            f.unlink(missing_ok=True); return False+        if _addin_round_trip_ready(tries=1):+            f.unlink(missing_ok=True); return False      # they restarted; hold released+        return True+    except Exception:+        return False++ def _watch_restart_choice(notification_id: str, timeout_s: float = 1800.0):     """Wait (in the background) for the user's answer to the restart notice and honour it. @@ -373,8 +410,18 @@ def _watch_restart_choice(notification_id: str, timeout_s: float = 1800.0):                 i = out.find("{")                 x = _js.loads(out[i:]) if i >= 0 else {}                 x = x.get("data") or x-                btn = x.get("buttonId") or x.get("button") or x.get("response")+                # ab reports the press as response: {"action": "<label>"} - a DICT. The+                # first version read `response` as a string, got a dict, treated it as falsy,+                # and fell into the decline branch WITHOUT recording anything. John pressed+                # "I'll handle it" (2026-08-28 21:55) and the ledger has no trace of it.+                # A choice the bridge cannot even see is a choice it cannot honour.+                resp = x.get("response")+                if isinstance(resp, dict):+                    resp = resp.get("action") or resp.get("button") or resp.get("id")+                btn = x.get("buttonId") or x.get("button") or resp                 if x.get("pending") is False or btn or x.get("dismissed"):+                    _bridge_action_ledger("restart_choice_seen",+                                          {"raw": str(btn)[:40], "notification": notification_id})                     if btn in ("restart", "Save and restart"):                         _bridge_action_ledger("restart_choice", {"choice": "restart",                                                                   "notification": notification_id})@@ -390,6 +437,11 @@ def _watch_restart_choice(notification_id: str, timeout_s: float = 1800.0):                         _bridge_action_ledger("restart_choice",                                               {"choice": btn or "dismissed",                                                "notification": notification_id})+                        # "I'll handle it" means: THE USER OWNS FUSION'S LIFECYCLE NOW.+                        # Record a hold so no automatic path restarts Fusion on their+                        # behalf until they do it themselves (cleared when the add-in+                        # next answers, i.e. they restarted).+                        _set_user_restart_hold(notification_id)                     return             except Exception:                 pass@@ -890,6 +942,19 @@ def _handle_launch(fusion_info: dict, args: dict) -> dict:                           "is busy) and compare elapsedSeconds against fusion_state.timings for "                           "this machine; only past roughly 2x p90 is a restart justified."),             }+        if not addin_ok and _user_restart_hold_active():+            return {"success": False, "errorCode": "user_owns_restart",+                    "alreadyRunning": True, "startKind": "warm",+                    "error": "Fusion is running without its add-in, and the user chose "+                             "'I'll handle it' on the restart notice - so the bridge will NOT "+                             "restart Fusion. The user owns that restart now.",+                    "_hint": "Do not call fusion_start or fusion_restart_for_addin. Wait for "+                             "the user to restart Fusion themselves (the hold releases the "+                             "moment the add-in answers), or ask them again with "+                             "fusion_restart_for_addin only if THEY change their mind."}+        if not addin_ok:+            _bridge_action_ledger("fusion_restart_for_silent_addin_considered",+                                  {"reason": "running but add-in not answering; entering persistence gate"})         if not addin_ok:             # ⛔ PERSISTENCE GATE (John, 2026-08-22, live during the ab library-matrix run):             # "you better not be exiting fusion between library imports." A single missed 5s@@ -11860,6 +11925,30 @@ def _handle_create_library(fusion_info: dict, args: dict) -> dict:     # is now the canonical input and `stepPath` is sugar that normalises into it, so both     # shapes run the identical code path and a 1-part library is not a special case.     parts_in = args.get("parts") or []+    # ⛔ SINGLE-FLIGHT (John, 2026-08-28). Two Create Fusion Library presses seconds apart+    # started TWO chains (cea6c079 and ba243a1c); both entered their launch stage, both+    # restarted Fusion, both then bound into one Fusion at once. There is exactly one+    # Fusion and one main thread; a second import must wait for or attach to the first,+    # never race it. Return the running run's id so the caller can poll it instead.+    try:+        import tempfile as _tfs, json as _jss, time as _tts+        _rp = os.path.join(_tfs.gettempdir(), "fusion-prove-runs.json")+        _runs = _jss.loads(open(_rp, encoding="utf-8").read()) if os.path.exists(_rp) else {}+        _live = [(k, v) for k, v in _runs.items()+                 if v.get("verb", "create_library") in ("create_library", None)+                 and v.get("stage") not in ("done", "failed", None)+                 and v.get("startedAt")+                 and _tts.time() - float(v.get("startedAt")) < 1800]+        if _live:+            _rid, _rv = _live[-1]+            return {"success": True, "started": False, "attached": True,+                    "runId": _rid, "statusVerb": "fusion_prove_result",+                    "stage": _rv.get("stage"),+                    "_hint": "An import is ALREADY running (%s, stage: %s). Not starting a "+                             "second one - two chains racing one Fusion is how it got "+                             "restarted under a user today. Poll this runId." % (_rid, _rv.get("stage"))}+    except Exception:+        pass     if not lbr_p and not (sym_p or mod_p):         return {"success": False, "errorCode": "no_input",                 "error": "Give me EITHER libraryPath (a ready .lbr) OR kicadSymbolPath / "@@ -11910,8 +11999,10 @@ def _handle_create_library(fusion_info: dict, args: dict) -> dict:     run_id = _uuid.uuid4().hex[:12]      def _chain():+        import time as _tst         state = {"stage": "starting", "success": None, "libraryPath": lbr_p or None,-                 "runId": run_id}+                 "runId": run_id, "startedAt": _tst.time(),+                 "verb": "create_library"}         # Register with the hang gate: while this chain runs (and is under the 900s         # ceiling) a non-pumping main thread is WORK, not a hang (#49 mid-mint kills).         _LONG_OP["name"] = "create_library:" + run_id@@ -11994,7 +12085,20 @@ def _handle_create_library(fusion_info: dict, args: dict) -> dict:             _taskbar_progress(100.0 * _est_done[0] / _est_total)         try:             if _needs_launch:-                _enter("launching Fusion (add-in loads at start)")+                # SAY WHAT IS ACTUALLY HAPPENING (John, 2026-08-28: "i don't get why colby's+                # interface is saying 'launching fusion' when its already launched"). This+                # stage name flowed straight up the chain to the wiki's progress line. When+                # Fusion is already running and only the add-in is silent, "launching" is a+                # lie about the reason, and the user cannot reconcile it with the Fusion+                # window in front of them. Name the real condition; the wiki renders `stage`.+                if _is_fusion_running():+                    _enter("Fusion is open but its Adom add-in is not answering - "+                           "restarting Fusion so the add-in loads")+                    state["stageReason"] = "addin_not_answering"+                else:+                    _enter("launching Fusion (add-in loads at start)")+                    state["stageReason"] = "fusion_not_running"+                _save()                 dispatch_command("start", {})                 _deadline = _tc.time() + 240                 while _tc.time() < _deadline and not _addin_round_trip_ready(tries=1):@@ -12104,6 +12208,7 @@ def _handle_create_library(fusion_info: dict, args: dict) -> dict:                 def _frame_for(entry, _d=_dev):                     entry = dict(entry)                     entry["part"] = _d          # tag it so 40 frames stay groupable+                    entry["kind"] = "proof"     # vs "main"/"popup" from fusion_live_feed                     return _publish_frame(entry)                  _r = _handle_prove_library(fusion_info, {@@ -13378,6 +13483,88 @@ def _human_secs(v: float) -> str:     return "%dm %02ds" % (m, sec) if sec else "%dm" % m  +_LIVE_FEED = {"thread": None, "stop": False, "frames": 0, "channel": None}+++def _handle_live_feed(fusion_info: dict, args: dict) -> dict:+    """A ~1 fps look INTO Fusion while the bridge drives it, published as three KINDS.++    John, 2026-08-28, after a chain restarted Fusion under him: "had you shown me a+    screenshot of fusion's interface i could have seen if there were any open tabs or not+    ... i literally should be getting screenshots like every 5 seconds during boot up ...+    or maybe each second, so like a 1 fps video." And: the frames need telling apart -+    a Browser-panel popup, the main window, and a proof shot are three different things+    and a viewer must never have to guess which it is looking at.++    kind=main   the Fusion main window, the 1 fps video. One card per tick.+    kind=popup  every OWNED popup over it (dialogs, Browser/tool panels, the Adom banner),+                each tagged with the tick it belongs to. Fusion draws these as separate+                windows that a main-frame capture omits, so they ride alongside, never+                instead of, the main frame.+    kind=proof  is NOT produced here. Proof frames are the evidence the import chain+                captures (symbol / footprint / 3d / ganged) and they carry their own labels;+                the feed exists so the human can see what happened BETWEEN proofs.++    The wiki may choose not to render 1 fps; that is fine. The bridge owns the feed and+    publishes it to shotlog under `channel`, so a human always has somewhere to look.+    args: {action: start|stop|status, channel?, intervalSec? (default 1.0), maxSec? (600)}+    """+    import threading as _th, time as _t, subprocess as _sp+    action = str(args.get("action") or "status").lower()+    if action == "status":+        return {"success": True, "running": bool(_LIVE_FEED["thread"] and _LIVE_FEED["thread"].is_alive()),+                "frames": _LIVE_FEED["frames"], "channel": _LIVE_FEED["channel"]}+    if action == "stop":+        _LIVE_FEED["stop"] = True+        return {"success": True, "stopped": True, "frames": _LIVE_FEED["frames"]}+    if _LIVE_FEED["thread"] and _LIVE_FEED["thread"].is_alive():+        return {"success": True, "alreadyRunning": True, "channel": _LIVE_FEED["channel"],+                "frames": _LIVE_FEED["frames"]}+    channel = str(args.get("channel") or "fusion-live")+    interval = max(0.5, float(args.get("intervalSec") or 1.0))+    max_sec = min(3600.0, float(args.get("maxSec") or 600.0))+    _LIVE_FEED.update(stop=False, frames=0, channel=channel)++    def _inject(path, desc):+        try:+            _sp.run(["adom-shotlog", "inject", "-c", channel, "-d", desc, "-s",+                     "desktop_screenshot_window", "--native", path],+                    capture_output=True, text=True, timeout=20)+        except Exception:+            pass++    def _run():+        t0 = _t.time(); tick = 0+        while not _LIVE_FEED["stop"] and _t.time() - t0 < max_sec:+            tick += 1+            try:+                info = get_fusion_window_info() or {}+                hwnd = info.get("hwnd") or (info.get("mainWindow") or {}).get("hwnd")+                if hwnd:+                    shot = _ad_call("desktop_screenshot_window", {"hwnd": int(hwnd)}, timeout=20) or {}+                    main = shot.get("localSafePath")+                    pops = shot.get("screenshots") or []+                    title = str(shot.get("title") or info.get("title") or "Fusion")[:60]+                    if main:+                        _inject(main, "[main #%d] %s | %d popup(s)" % (tick, title, len(pops)))+                        _LIVE_FEED["frames"] += 1+                    for j, sp_ in enumerate(pops, 1):+                        if sp_.get("localSafePath"):+                            _inject(sp_["localSafePath"],+                                    "[popup #%d.%d] %s" % (tick, j, str(sp_.get("title") or "dialog")[:50]))+            except Exception:+                pass+            _t.sleep(interval)+        _LIVE_FEED["thread"] = None+    _LIVE_FEED["thread"] = _th.Thread(target=_run, daemon=True); _LIVE_FEED["thread"].start()+    return {"success": True, "started": True, "channel": channel, "intervalSec": interval,+            "maxSec": max_sec,+            "_hint": "Frames land in shotlog channel %r tagged [main #n] (the 1 fps video) and "+                     "[popup #n.m] (dialogs/panels over it, tied to their tick). Proof frames "+                     "from the import chain are a third kind and carry their own step labels. "+                     "Open with: adom-shotlog open -c %s --skinny. Stop with action:stop." % (channel, channel)}++ def _handle_restart_for_addin(fusion_info: dict, args: dict) -> dict:     """Save open documents, restart Fusion, wait for the add-in, report back. @@ -13486,6 +13673,7 @@ def _handle_inspect_bodies(fusion_info: dict, args: dict) -> dict:  COMMAND_HANDLERS["inspect_bodies"] = _handle_inspect_bodies COMMAND_HANDLERS["restart_for_addin"] = _handle_restart_for_addin+COMMAND_HANDLERS["live_feed"] = _handle_live_feed COMMAND_HANDLERS["plan_library_import"] = _handle_plan_library_import COMMAND_HANDLERS["library_parts"] = _handle_library_parts