← Commit history

Source sync: the page repo was 10 releases stale (1.8.4..1.9.2 shipped only in zips). Full runtime source now browsable: expectDocument guard (mutating + read), API error classifier, inspect_bodies, caller identity, dark-mode + hero skills era. Fixes the docs-vs-code gap Oliver audited in #25.

John Lauer ·11d269695d ·1mo ago ·parent 284b3a9
8 files changed +447−39
BRIDGE_VERSION+1−1
@@ -1 +1 @@-1.9.1\ No newline at end of file+1.9.2\ 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.1",-    "url": "https://wiki.adom.inc/download/adom/fusion-bridge/1.9.1/adom-bridge-fusion-v1.9.1.zip",-    "sha256": "17a5ebb00206d4a31531930559e99b2538f99137263dcc275230e377fb25381f",-    "size": 300071,+    "version": "1.9.2",+    "url": "https://wiki.adom.inc/download/adom/fusion-bridge/1.9.2/adom-bridge-fusion-v1.9.2.zip",+    "sha256": "6c4b942a9856ccd9aa5be4c63c3a144b551582014d17b952e36844b9f3688813",+    "size": 300217,     "verbPrefixes": [         "fusion_"     ],
aps.py+11−3
@@ -333,7 +333,7 @@ def _signin_setup_chain(opened: bool, abe: dict) -> list:                        "purpose": "Autodesk APS consent", "profile": "chrome:<their-autodesk-email>",                        "url": "<authUrl from this response>"},               "note": "EXTENSION-FREE - works with no Adom extension installed. If this verb is "-                      "unknown, the adom-desktop CLI on this box is too old: update it (or have "+                      "unknown, adom-bridge-cli on this box is too old: update it (or have "                       "the user paste authUrl into the right profile by hand)."},              {"step": "user_approves",               "do": "Ask the user to approve in that profile; without the extension we cannot click for them."},@@ -659,8 +659,16 @@ def start_signin(args: dict | None = None) -> dict:         # A browser passed explicitly is now the remembered one (it's what the caller chose to         # drive). A previously-remembered one is refreshed to keep its timestamp warm.         save_signin_browser(pref)-    elif args.get("allowDefaultBrowser"):-        # Legacy fallback — only when the caller opts in (e.g. no extension available).+    elif args.get("allowDefaultBrowser") and not pref:+        # Legacy fallback — only when the caller opts in AND we have NOTHING remembered.+        #+        # Never when a browser IS remembered. The OS default is usually a different, personal+        # profile, so honouring the flag there silently dumps an Autodesk login into the wrong+        # browser. That happened live (2026-07-25): Edge was remembered in EXTENSION mode, which+        # carries no exe, so _launch_url_in_browser could not spawn it, control fell through to+        # this branch, and the consent opened in the user's personal Chrome. A remembered pref+        # that cannot be launched directly belongs on the extension path below, not here.+        # To genuinely prefer the OS default, call fusion_aps_forget_browser first.         try:             webbrowser.open(auth_url)             opened_via = "os_default_browser"
bridge.json+1−1
@@ -2,7 +2,7 @@   "manifest_version": 1,   "name": "fusion360",   "displayName": "Autodesk Fusion 360",-  "version": "1.9.1",+  "version": "1.9.2",   "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.",
describe.py+6
@@ -214,6 +214,12 @@ _T = [      {"name": "str (refdes, e.g. R1)", "x": "optional float", "y": "optional float", "properties": "optional bool (default true)"},      {"success": "bool", "output": "str", "deviceInfo": "str"}, 30, None, True, {"name": "U1"}), +    ("fusion_inspect_bodies", "GEOMETRY READ-BACK: what actually got built, in MILLIMETRES. Per body: bbox, sizeMm, volumeMm3, areaMm2, faceCount, cylindricalFaceCount, appearance, material, isVisible. Use this to VERIFY your own modelling - a body at the wrong Z (a double-applied offset, a part buried inside another) renders perfectly and is INVISIBLE in a screenshot, and this work is often matte-black parts on a dark canvas where pixels are a weak check. cylindricalFaceCount is the cheap way to confirm holes were actually cut: a symmetric cut that reaches nothing fails SILENTLY with no exception. Pass worldSpace:true to resolve bodies through their occurrence transforms (assembly placement); optionally filter with occurrence. Fusion's API is centimetres - this converts for you.",+     {"worldSpace": "optional bool (resolve through occurrence transforms; default false = root component only)", "occurrence": "optional str (substring filter on occurrence name, worldSpace only)"},+     {"success": "bool", "units": "str (mm)", "bodyCount": "int", "documentName": "str",+      "bodies": "[{name,owner,bbox:{x,y,z},sizeMm,volumeMm3,areaMm2,faceCount,cylindricalFaceCount,appearance,material,isVisible}]"},+     130, _STATUS, False, {"worldSpace": True}),+     # ── Self-describe ──────────────────────────────────────────────────────     ("fusion_describe", "Self-describe every verb the bridge exposes (this list) for AD's Verbs tab + runner.",      {}, {"success": "bool", "verbs": "[{name,summary,input,output,timeoutSeconds,statusVerb,longRunning,example}]"}, 20, None, False, {}),
fusion_detect.py+23
@@ -21,7 +21,30 @@ ADDIN_PORT = 8774 # which is exactly what the fusion-onboarding skill runs. We MUST check both, or # an AI-installed (globalinstall) Fusion is invisible and onboarding "succeeds" # yet the bridge still reports not-installed. (Found on the ADOMBASELINE test.)+def _scan_all_user_webdeploy() -> list:+    r"""Scan ALL user profiles for Fusion webdeploy dirs (issue #326).++    Fusion may be installed under a different Windows user profile than the+    current process. Instead of just checking %LOCALAPPDATA%, enumerate all+    user directories under C:\Users and check each one's AppData\Local\Autodesk\webdeploy.+    """+    bases = []+    try:+        users_dir = Path("C:\\Users")+        if users_dir.exists():+            for user_path in users_dir.iterdir():+                if user_path.is_dir():+                    webdeploy = user_path / "AppData" / "Local" / "Autodesk" / "webdeploy" / "production"+                    if webdeploy not in bases:+                        bases.append(webdeploy)+    except Exception:+        pass  # If enumeration fails, fall through to the normal paths below+    return bases+ _WEBDEPLOY_BASES = []+# First, scan ALL user profiles for Fusion (issue #326: cross-user detection)+_WEBDEPLOY_BASES.extend(_scan_all_user_webdeploy())+# Then add the standard environment-based paths (current user + system-wide) for _var in ("LOCALAPPDATA", "ProgramFiles", "ProgramFiles(x86)", "ProgramW6432"):     _root = os.environ.get(_var)     if _root:
handlers/fusion_ui.py+9
@@ -205,6 +205,15 @@ def _find_qt_dialog_windows() -> list:                     h = rect.bottom - rect.top                     if w < 400 or h < 300:                         return True  # small tool panel, skip+                    # ASPECT GUARD (2026-07-23): the size test alone is not enough. Fusion's+                    # docked side panels (BROWSER, Timeline, Comments) are TALL and NARROW and+                    # sail past 400x300 - the BROWSER measured 450x1194 and got reported as a+                    # blocking dialog on an idle Fusion, which makes a driving AI believe it is+                    # blocked when nothing is wrong. Real blocking dialogs (the startup picker,+                    # licensing, recovery) are landscape or roughly square. So: anything markedly+                    # taller than it is wide is a docked panel, not a dialog.+                    if h > w * 1.5:+                        return True  # tall narrow docked panel, skip                  rect = ctypes.wintypes.RECT()                 user32.GetWindowRect(hwnd, ctypes.byref(rect))
server.py+392−30
@@ -94,6 +94,49 @@ def _addin_staleness(reported_version) -> dict: # Populated at startup fusion_info = None +# Caller identity: WHO we are currently acting for (issue #348). AD stamps X-Adom-Caller-* on+# every request it dispatches to us; we echo those back on our own AD callbacks so the user sees+# "chip-fetcher tab 3 (via fusion)" instead of a nameless bridge.+#+# THREAD-LOCAL, deliberately. This is a ThreadingHTTPServer, so a module global would let+# concurrent requests clobber each other and attribute tab 3's work to tab 11 - precisely the+# failure this feature exists to prevent. Thread-local also fixes the stale-identity trap the+# contract calls out: a background thread (health poll, timer, crash cleanup) has no value bound,+# so it can never inherit some earlier request's name.+_caller_tls = threading.local()+++def _set_caller_identity(ident: dict | None) -> None:+    """Bind the calling AI thread's identity to THIS request thread. Empty values are dropped."""+    _caller_tls.value = {k: v for k, v in (ident or {}).items() if v}+++def _get_caller_identity() -> dict:+    """Identity bound to this thread, or {} when we are acting on our own behalf."""+    return getattr(_caller_tls, "value", None) or {}+++def _stamp_caller(args: dict) -> dict:+    """Attribute an OUTGOING AD call (issue #348).++    Forwards the identity of the AI thread we are carrying out work for. When we act on our OWN+    behalf (health poll, timer, cleanup sweep) nothing is bound to this thread, so we name+    ourselves rather than forwarding a stale identity. An explicit caller in args always wins.++    Returns a COPY; the caller's dict is never mutated."""+    out = dict(args or {})+    if out.get("caller") or out.get("aiThread"):+        return out+    ident = _get_caller_identity()+    if ident.get("thread"):+        caller = {"aiThread": ident["thread"]}+        if ident.get("container"):+            caller["containerName"] = ident["container"]+    else:+        caller = {"aiThread": "fusion bridge (self)", "containerName": "local"}+    out["caller"] = caller+    return out+  def _reclaim_seat_from_peers() -> int:     """Free the Autodesk seat held by another machine by stopping Fusion on peer ADs.@@ -205,23 +248,39 @@ def _resolve_seat_dialog(max_clicks: int = 3) -> dict:   def _find_adom_desktop_cli() -> str | None:-    """Locate the adom-desktop CLI/exe so the bridge can drive AD's relay directly (used when the-    in-process ad_client is unavailable, e.g. on a headless VM). Checks the per-user Windows install-    dir, then PATH, then the common Linux/dev locations. Returns a path or None."""+    """Locate the host app's CLI so the bridge can drive it when the in-process ad_client is down.++    Accepts BOTH names. The host app was renamed Adom Desktop -> 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+    older one still has "Adom Desktop"/adom-desktop-cli.exe. Looking for only one silently kills the+    CLI fallback on the other, so we try new-first then old and let whichever exists win.++    The hard rule survives the rename: shell the **-cli** exe only. Never the GUI exe (adom-bridge.exe+    / adom-desktop.exe) - launching that with verb args foregrounds the app on the user's screen every+    single call (issue #295). Prefer the loopback direct API over shelling at all (_ad_direct_api_call).+    """     import shutil     la = os.environ.get("LOCALAPPDATA", "")-    candidates = [-        os.path.join(la, "Adom Desktop", "adom-desktop.exe") if la else None,-        os.path.join(la, "Programs", "Adom Desktop", "adom-desktop.exe") if la else None,-    ]-    for c in candidates:-        if c and os.path.exists(c):-            return c-    return shutil.which("adom-desktop") or shutil.which("adom-desktop.exe")+    apps = (("Adom Bridge", "adom-bridge-cli.exe"), ("Adom Desktop", "adom-desktop-cli.exe"))+    if la:+        for folder, exe in apps:+            for parent in (la, os.path.join(la, "Programs")):+                c = os.path.join(parent, folder, exe)+                if os.path.exists(c):+                    return c+    # PATH: the CLI shim only. On Windows NEVER resolve the bare name - that is the GUI exe.+    for shim in ("adom-bridge-cli", "adom-desktop-cli"):+        hit = shutil.which(shim)+        if hit:+            return hit+    if os.name != "nt":+        # On Linux/dev the bare name IS the CLI.+        return shutil.which("adom-bridge") or shutil.which("adom-desktop")+    return None   def _cli_notify_all(title: str, body: str, level: str) -> dict:-    """Deliver a toast to EVERY connected desktop via `adom-desktop --target all notify_user`. This is+    """Deliver a toast to EVERY connected desktop via `adom-bridge-cli --target all notify_user`. This is     the reliable cross-AD path when the bridge's in-process ad_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@@ -229,7 +288,7 @@ def _cli_notify_all(title: str, body: str, level: str) -> dict:     import subprocess as _sp     exe = _find_adom_desktop_cli()     if not exe:-        return {"delivered": False, "error": "adom-desktop CLI not found"}+        return {"delivered": False, "error": "adom-bridge-cli not found"}     # STICKY by default: a human-wall alert must NOT vanish in a few seconds (John 2026-07-14 - the     # first toasts disappeared before he noticed them). scenario:reminder keeps it on screen until the     # user acts; it REQUIRES >=1 button, so include one. durationLong is a belt-and-suspenders ~25s.@@ -265,24 +324,24 @@ def _handle_notify_owner(args: dict) -> dict:         # ad_client is the bridge's IN-PROCESS AD 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-desktop CLI, which connects to the relay independently: `--target+        # SELF-DELIVER via adom-bridge-cli, which connects to the relay independently: `--target         # all` fans the toast out to EVERY connected desktop (the VM + the user's real machines), so it         # reliably lands on the computer the user is actually AT. No AI relay step to forget.         cli = _cli_notify_all(title, body, level)         if cli.get("delivered"):             return {"success": True, "via": "cli:--target all", "targets": cli.get("targets"),                     "_hint": ("Toast fanned out to ALL connected desktops (the user's main machine "-                              "included) via the adom-desktop CLI, because the bridge's in-process AD "+                              "included) via adom-bridge-cli, because the bridge's in-process AD "                               "API was unavailable (this VM). WAIT and poll fusion_readiness; do not "                               "re-toast within a few minutes. Only notify when the box is ACTUALLY "                               "ready for the user to act - do not toast for a step you can do yourself.")}         # CLI fallback also failed - return the payload so the AI can relay as the true last resort.         return {             "success": False,-            "error": "AD in-process API unavailable AND the adom-desktop CLI fallback failed: "+            "error": "AD in-process API unavailable AND the adom-bridge-cli fallback failed: "                      + str(cli.get("error"))[:200],             "notifyUser": {"title": title, "body": body, "level": level},-            "_hint": ("Relay the notifyUser payload yourself: `adom-desktop --target all notify_user "+            "_hint": ("Relay the notifyUser payload yourself: `adom-bridge-cli --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 {}@@ -2162,6 +2221,52 @@ def _handle_addin_status() -> dict:     return result  +# Raw Fusion API failures (issue #289 item 3, Oliver). Fusion raises RuntimeError with text like+# "3 : Part Design documents can only contain one component" - no code, no remedy - so an AI has to+# infer the fix, and the first of these invalidates a whole modelling approach. Same idea as the+# dialog classifier, applied one layer down: match the text, attach a STABLE code and the real fix.+_API_ERROR_PATTERNS = (+    ("part design documents can only contain one component", "part_template_single_component",+     "This document came from Fusion's PART template, which permits exactly ONE component, so "+     "occurrences cannot be added. Documents created via documents.add(FusionDesignDocumentType) "+     "ARE assembly-capable. Either model with BODIES inside the single component, or create a new "+     "document through the API and build there. Fusion's API and UI defaults differ here, which is "+     "why this is surprising."),+    ("root component name cannot be changed", "root_rename_unsupported",+     "The root component always takes the DOCUMENT's name and cannot be renamed directly. Rename "+     "the document on save instead, or rename a sub-occurrence."),+    ("refers to a deleted object", "stale_api_handle",+     "A handle you are holding points at an object that was closed or deleted. Re-fetch it from "+     "the CURRENT app.activeDocument / activeProduct instead of reusing one from an earlier call."),+)+++def _classify_api_error(result: dict) -> dict:+    """Give a raw Fusion API exception a stable errorCode + an actionable remedy (issue #289)."""+    if not isinstance(result, dict) or result.get("success") or result.get("errorCode"):+        return result+    data = result.get("data") if isinstance(result.get("data"), dict) else {}+    blob = " ".join(str(x) for x in (result.get("error", ""), result.get("output", ""),+                                     data.get("traceback", ""))).lower()+    for needle, code, hint in _API_ERROR_PATTERNS:+        if needle in blob:+            result["errorCode"] = code+            result["_hint"] = hint+            break+    return result+++# Purchased-subassembly defaults for assembly_bom (issue #289 item 4, Oliver). Fusion's own+# Manage -> BOM renders EVERY subassembly as one collapsible row; the add-in shipped with only+# "with fasteners", so a purchased bearing or pulley unit was descended into and emitted as its+# modeled internals (240 Bearing Ball rows on Wirebening desk, which Fusion never shows at that+# level). Widened HERE at the bridge so no add-in redeploy is needed. An explicit caller-supplied+# treatAsUnit always wins. Note partNumber is NOT a usable signal: Fusion auto-fills it from the+# component name, so the bearing balls carry one too.+_ASSEMBLY_BOM_UNIT_DEFAULTS = ["with fasteners", "with fastener", "with bearings",+                               "bearing", "idler pulley", "pulley", "idler"]++ def _proxy_to_addin(command: str, args: dict, timeout: int = 30) -> dict:     """Proxy a command to the Fusion add-in HTTP server. @@ -2169,6 +2274,10 @@ def _proxy_to_addin(command: str, args: dict, timeout: int = 30) -> dict:     - Add-in alive but main thread blocked (modal dialog) → distinct error     - Add-in HTTP server crashed → connection error     """+    if command == "assembly_bom" and not args.get("treatAsUnit"):+        args = dict(args)+        args["treatAsUnit"] = list(_ASSEMBLY_BOM_UNIT_DEFAULTS)+     body = json.dumps({"command": command, "args": args}).encode("utf-8")     req = urllib.request.Request(         f"http://127.0.0.1:{ADDIN_PORT}/command",@@ -2198,6 +2307,11 @@ def _proxy_to_addin(command: str, args: dict, timeout: int = 30) -> dict:                     result["_hint"] = stale["_staleHint"]                     result.update({k: stale[k] for k in ("addinVersion", "expectedAddinVersion", "addinStale")}) +        # Raw Fusion API exceptions arrive as bare "3 : <text>" RuntimeError strings with no+        # errorCode and no remedy (issue #289 item 3). Classify them here, one layer below the+        # dialog classifier and in the same shape, so a caller can branch on a stable code.+        result = _classify_api_error(result)+         return result      except urllib.error.URLError as e:@@ -2986,7 +3100,7 @@ def _build_library_3d_core(args: dict) -> dict:             "symbol / footprint / component (pin<->pad mapped) views - those are what make EEs trust "             "the library, and the 3D-only shots miss them. "             "PITFALLS: needs Fusion running. ⚠️ RELAY TIMEOUT: a many-part run takes minutes but the "-            "adom-desktop relay times out the REQUEST at ~60s - you may get 'Request timed out' even "+            "Adom Bridge relay times out the REQUEST at ~60s - you may get 'Request timed out' even "             "though the build KEEPS RUNNING server-side and finishes. Do NOT assume it failed: wait, "             "then verify by reading the boundLbr (count <package3d name=) + the before/after PNGs in "             "C:/tmp/conduit-screenshots. If a part is missing, just re-run build_library_3d for the "@@ -3749,12 +3863,71 @@ def _describe_profile(p: dict) -> str:     return bits  +def _ad_direct_api_call(verb: str, args: dict, timeout: float = 60) -> dict | None:+    """Call an AD verb over the loopback DIRECT API - the correct, non-foregrounding way for a+    bridge to reach AD when the in-process ad_client is down (issue #295). AD 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."""+    base = (os.environ.get("ADOM_DIRECT_API_URL") or "").strip()+    if not base:+        try:+            with open(os.path.join(os.path.expanduser("~"), ".adom", "direct-api-port")) as f:+                port = f.read().strip()+            if port:+                base = "http://127.0.0.1:%s" % port+        except Exception:+            return None+    if not base:+        return None+    try:+        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 AD's identity gate+        # (ad_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+        # Echo the identity AD handed us, then name OURSELVES as the delegate (issue #348). The+        # thread alone would hide who ran it; the bridge alone would hide who asked. AD needs both+        # to render "chip-fetcher tab 3 (via fusion)". Delegate is ADDED, never forwarded.+        ident = _get_caller_identity()+        for key, hdr in (("thread", "X-Adom-Caller-Thread"),+                         ("container", "X-Adom-Caller-Container"),+                         ("reason", "X-Adom-Caller-Reason")):+            if ident.get(key):+                headers[hdr] = ident[key]+        headers["X-Adom-Caller-Delegate"] = "fusion"+        req = urllib.request.Request(base.rstrip("/") + "/command", data=body,+                                     headers=headers, method="POST")+        with urllib.request.urlopen(req, timeout=timeout) as resp:+            r = json.loads(resp.read())+        if isinstance(r, dict):+            inner = r.get("output")+            if isinstance(inner, str) and inner.strip().startswith("{"):+                try:+                    return json.loads(inner)+                except Exception:+                    return r+            return r+    except Exception:+        return None+    return None++ def _ad_call(verb: str, args: dict, timeout: int = 60) -> dict:     """Call another AD verb (nbrowser_*, desktop_*) from inside this bridge. -    Prefers the in-process ad_client; falls back to the adom-desktop CLI (which joins the-    relay itself) so this still works on an unattended VM where ad_client is down. Never-    raises - returns {} on failure so callers can degrade to instructing the AI instead."""+    Prefers the in-process ad_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 AD). Works on an unattended VM where ad_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 -+    the in-process client and the CLI subprocess cannot carry HTTP headers, and an explicit caller+    block in args is the highest-precedence form AD accepts. The direct-API path additionally sends+    the X-Adom-Caller-* headers so AD can record the delegation chain."""+    args = _stamp_caller(args)     why = []     try:         if ad_client.available():@@ -3773,11 +3946,16 @@ def _ad_call(verb: str, args: dict, timeout: int = 60) -> dict:             why.append("ad_client unavailable")     except Exception as e:         why.append("ad_client raised %s" % e)+    # #295: prefer the loopback DIRECT API - it reaches AD without foregrounding anything.+    direct = _ad_direct_api_call(verb, args, timeout=timeout)+    if isinstance(direct, dict):+        return direct+    why.append("direct-api unavailable")     try:         import subprocess as _sp, json as _j-        exe = _find_adom_desktop_cli()+        exe = _find_adom_desktop_cli()  # the -cli exe only; never the GUI exe (#295)         if not exe:-            return {"_adCallError": "; ".join(why + ["adom-desktop CLI not found"])}+            return {"_adCallError": "; ".join(why + ["adom-bridge-cli not found"])}         p = _sp.run([exe, verb, _j.dumps(args)], capture_output=True, text=True, timeout=timeout)         out = (p.stdout or "").strip()         if out.startswith("{"):@@ -3969,7 +4147,7 @@ def _mcp_new_session() -> dict:     global _mcp_session_id     r = _mcp_post({"jsonrpc": "2.0", "id": 1, "method": "initialize",                    "params": {"protocolVersion": "2024-11-05", "capabilities": {},-                              "clientInfo": {"name": "adom-desktop-fusion-bridge",+                              "clientInfo": {"name": "fusion-bridge",                                              "version": BRIDGE_VERSION}}})     if r.get("error") or r.get("httpError"):         return {"error": r.get("error") or ("HTTP %s" % r.get("httpError")), "raw": r.get("body")}@@ -5510,8 +5688,62 @@ def _orchestrate_board_stackup(args: dict) -> dict:     }  -def dispatch_command(command: str, args: dict) -> dict:-    """Dispatch a command to the appropriate handler."""+def _assert_active_document(expect: str) -> dict | None:+    """Document guard (issue #289): return a `wrong_document` error dict if Fusion's active document+    is not `expect`, else None. A Fusion tab-switch silently retargets `app.activeDocument`, so a+    mutating verb can hit the wrong file and destroy work. Callers pass `expectDocument` to assert+    intent BEFORE any write. Fails OPEN (returns None) when the active doc cannot be read, so the+    guard never wedges a legitimate call - it only blocks a CONFIRMED mismatch."""+    actual = None+    try:+        st = _proxy_to_addin("get_app_state", {}, timeout=8)+        data = st.get("data") if isinstance(st, dict) else None+        if not isinstance(data, dict):+            out = st.get("output") if isinstance(st, dict) else None+            if isinstance(out, str) and out.strip().startswith("{"):+                data = json.loads(out).get("data", {})+        actual = (data or {}).get("activeDocument") or (st.get("activeDocument") if isinstance(st, dict) else None)+    except Exception:+        actual = None+    if actual and actual != expect:+        return {+            "success": False,+            "errorCode": "wrong_document",+            "expected": expect,+            "actual": actual,+            "error": "Active document is '%s', expected '%s'." % (actual, expect),+            "_hint": ("The active document changed - a Fusion tab-switch retargets the active "+                      "document. Re-activate the intended document, or re-issue with expectDocument "+                      "set to the current name (a rename mid-session also changes it)."),+        }+    return None+++def dispatch_command(command: str, args: dict, caller_identity: dict = None) -> dict:+    """Dispatch a command to the appropriate handler.++    caller_identity: {'thread','container','reason'} from the X-Adom-Caller-* headers AD stamped on+    this request. Bound to THIS thread (issue #348) so any AD callback we make while carrying out+    the verb is attributed to the AI thread that asked for it. Pass None to leave the binding alone.+    """+    if caller_identity is not None:+        _set_caller_identity(caller_identity)+    # Strip the "fusion_" prefix if present — handlers are registered without it (e.g. "aps_signin"+    # not "fusion_aps_signin"), but the relay passes them with the prefix for CLI clarity. Issue #327.+    if command.startswith("fusion_"):+        command = command[7:]++    # Document guard (issue #289 items 1 AND 5, Oliver): a verb carrying `expectDocument` must not+    # run against a document the caller didn't intend. Originally only mutating verbs were guarded;+    # Oliver's BOM session proved READS need it too - a passive tab switch silently returned a+    # different assembly (68/268 vs 94/424 instances) with nothing signalling the retarget. So the+    # assertion now fires for ANY verb that passes the arg: a caller who states an expectation wants+    # it checked. Asserted BEFORE dispatch, then stripped so handlers never see the key.+    if isinstance(args, dict) and args.get("expectDocument"):+        _guard = _assert_active_document(str(args["expectDocument"]))+        if _guard is not None:+            return _guard+        args = {k: v for k, v in args.items() if k != "expectDocument"}     # Direct handlers (don't need the add-in)     handler = COMMAND_HANDLERS.get(command)     if handler is not None:@@ -5523,14 +5755,18 @@ def dispatch_command(command: str, args: dict) -> dict:     # Commands that need Fusion running (add-in commands + orchestrated commands)     _OPEN_WITH_SCREENSHOT = {"open_schematic", "open_board", "show_3d_board", "show_2d_board", "show_schematic", "import_electronics"}     if command in ADDIN_COMMANDS or command in ("open_lbr", "save_lbr", "attach_3d_package", "make_3d_package", "build_library_3d", "capture_library_views", "cleanup_cloud_files", "generate_package", "export_optimized_glb", "board_stackup") or command in _OPEN_WITH_SCREENSHOT:-        if not fusion_info.get("installed"):+        # Issue #326: Fusion may be installed under a different Windows user profile than the current+        # one, so file-path detection fails even though Fusion is running. Check if it's actually+        # running (via tasklist) as a reliable fallback. If running, it's installed somewhere.+        fusion_running = _is_fusion_running()+        if not fusion_info.get("installed") and not fusion_running:             return {                 "success": False,                 "error": "Fusion 360 is not installed on this machine.",                 "errorCode": "fusion_not_installed",                 "_hint": "Fusion 360 isn't installed. Do NOT tell the user to install it themselves - OFFER to install it FOR them and do it on a yes: the fusion-onboarding skill silent-installs Fusion + drives the Autodesk sign-in.",             }-        if not _is_fusion_running():+        if not fusion_running:             return {                 "success": False,                 "error": "Fusion 360 is installed but not running.",@@ -5705,7 +5941,7 @@ def dispatch_command(command: str, args: dict) -> dict:     return {         "success": False,         "error": f"Unknown command: {command}",-        "_hint": "Run `adom-desktop help` or check cli/src/commands.rs to see the list of available fusion_* commands. This command name may be misspelled or not yet implemented.",+        "_hint": "Run `adom-bridge-cli help` or check cli/src/commands.rs to see the list of available fusion_* commands. This command name may be misspelled or not yet implemented.",     }  @@ -5785,10 +6021,17 @@ class FusionBridgeHandler(BaseHTTPRequestHandler):         command = request.get("command", "")         args = request.get("args", {}) +        # Who is asking (issue #348). AD stamps these on every request it dispatches to a bridge.+        caller_identity = {+            "thread": self.headers.get("X-Adom-Caller-Thread", ""),+            "container": self.headers.get("X-Adom-Caller-Container", ""),+            "reason": self.headers.get("X-Adom-Caller-Reason", ""),+        }+         print(f"[Fusion Bridge] Command: {command} | Args: {json.dumps(args)}")          try:-            result = dispatch_command(command, args)+            result = dispatch_command(command, args, caller_identity=caller_identity)             self._respond(200, result)         except Exception as e:             print(f"[Fusion Bridge] ERROR: {e}")@@ -5797,6 +6040,10 @@ class FusionBridgeHandler(BaseHTTPRequestHandler):                 "success": False,                 "error": f"Internal error: {e}",             })+        finally:+            # Unbind before this thread is reused or retired. Without this, a later call made on+            # our OWN behalf could inherit this AI thread's name and misattribute it.+            _set_caller_identity(None)      def _respond(self, status: int, data: dict):         self.send_response(status)@@ -5928,6 +6175,121 @@ def main():         server.server_close()  +# ── Geometry read-back (issue #289 item 2, Oliver) ───────────────────────────────────────────+# There was no way to ask what actually got BUILT, so every caller hand-wrote body enumeration+# inside a modeling script and parsed it back out. That read-back caught both real modelling bugs+# in Oliver's session and NEITHER was visible in a screenshot (a body at Z -167.6 instead of -83.8+# from a double-applied offset; a handle buried inside the plate it should hang below). This work+# is often matte-black parts on a dark canvas, so pixels are a weak verification channel and an AI+# driving the bridge has no other way to check its own output.+#+# Implemented BRIDGE-side on the existing run_modeling_script surface (the add-in already captures+# a script's `result`), so it needs no add-in redeploy and no Fusion restart.+#+# Units are MILLIMETRES, deliberately. Fusion's internals are centimetres, which is a standing+# source of off-by-ten errors when an AI reads these numbers back.+_INSPECT_BODIES_SCRIPT = r'''+import adsk.core, adsk.fusion++_des = adsk.fusion.Design.cast(app.activeProduct)+_WORLD = __WORLD__+_OCC = __OCC__++def _mm(v):      return round(v * 10.0, 4)      # cm  -> mm+def _mm3(v):     return round(v * 1000.0, 4)    # cm3 -> mm3+def _mm2(v):     return round(v * 100.0, 4)     # cm2 -> mm2++def _describe(body, owner):+    bb = body.boundingBox+    mn, mx = bb.minPoint, bb.maxPoint+    cyl = 0+    try:+        for f in body.faces:+            g = f.geometry+            if g and g.surfaceType == adsk.core.SurfaceTypes.CylinderSurfaceType:+                cyl += 1+    except Exception:+        cyl = -1+    def _nm(o):+        try:    return o.name+        except Exception: return None+    return {+        "name": body.name,+        "owner": owner,+        "bbox": {"x": [_mm(mn.x), _mm(mx.x)],+                 "y": [_mm(mn.y), _mm(mx.y)],+                 "z": [_mm(mn.z), _mm(mx.z)]},+        "sizeMm": [_mm(mx.x - mn.x), _mm(mx.y - mn.y), _mm(mx.z - mn.z)],+        "volumeMm3": _mm3(body.volume),+        "areaMm2": _mm2(body.area),+        "faceCount": body.faces.count,+        "cylindricalFaceCount": cyl,+        "appearance": _nm(body.appearance),+        "material": _nm(body.material),+        "isVisible": body.isVisible,+    }++rows = []+root = _des.rootComponent+for b in root.bRepBodies:+    rows.append(_describe(b, root.name))++if _WORLD:+    # Bodies fetched THROUGH an occurrence are proxies whose boundingBox is already resolved+    # into assembly space, so occurrence placement is verified without any matrix maths here.+    for occ in root.allOccurrences:+        if _OCC and _OCC.lower() not in occ.name.lower():+            continue+        try:+            for b in occ.bRepBodies:+                rows.append(_describe(b, occ.name))+        except Exception:+            pass++result = {"units": "mm", "worldSpace": bool(_WORLD),+          "documentName": app.activeDocument.name if app.activeDocument else None,+          "bodyCount": len(rows), "bodies": rows}+'''+++def _handle_inspect_bodies(fusion_info: dict, args: dict) -> dict:+    """fusion_inspect_bodies - what actually got built, in millimetres."""+    world = bool(args.get("worldSpace"))+    occ = args.get("occurrence") or ""+    script = (_INSPECT_BODIES_SCRIPT+              .replace("__WORLD__", "True" if world else "False")+              .replace("__OCC__", repr(str(occ)) if occ else "None"))+    res = _proxy_to_addin("run_modeling_script", {"script": script}, timeout=120)+    if not res.get("success"):+        return res+    data = res.get("data") or {}+    payload = data.get("result")+    if not isinstance(payload, dict):+        return {"success": False, "error": "inspect_bodies returned no structured result",+                "errorCode": "inspect_failed", "data": data,+                "_hint": "The read-back script ran but produced no result dict. Usually means no "+                         "active Design; open a document and retry."}+    n = payload.get("bodyCount", 0)+    payload["success"] = True+    payload["_hint"] = (+        "Bounding boxes, volumes and areas are in MILLIMETRES (Fusion's API is centimetres; "+        "converted here so you do not have to). Check bbox Z against where the part SHOULD sit - "+        "a wrong offset renders perfectly and is invisible in a screenshot. cylindricalFaceCount "+        "is the cheap way to confirm holes were actually cut: a symmetric cut that reaches nothing "+        "fails SILENTLY with no exception, and the count is what surfaces it. "+        + ("Pass worldSpace:true to resolve bodies through their occurrence transforms and verify "+           "assembly placement." if not world else+           "worldSpace:true - occurrence bodies are proxies already resolved into assembly space.")+    ) if n else (+        "No bodies found. If you expected some, the active document may not be the one you built "+        "into (pass expectDocument on mutating verbs to catch that), or the design is empty."+    )+    return payload+++COMMAND_HANDLERS["inspect_bodies"] = _handle_inspect_bodies++ if __name__ == "__main__":     # Surface any fatal startup error to stderr (which AD captures into     # ~/.adom/bridge-logs/fusion360.log) so a spawn-crash is debuggable, then