← Commit history
BRIDGE_VERSION+1−1
@@ -1 +1 @@-1.9.199\ No newline at end of file+1.9.198\ 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.197",-  "url": "https://wiki.adom.inc/download/adom/fusion-bridge/1.9.197/adom-bridge-fusion-v1.9.197.zip",-  "sha256": "8ad10ac70f5f5ed3b2826bf12055f9d8dc03addbc25a34363d6a357b6a3e615b",-  "size": 495993,+  "version": "1.9.198",+  "url": "https://wiki.adom.inc/download/adom/fusion-bridge/1.9.198/adom-bridge-fusion-v1.9.198.zip",+  "sha256": "b5be3998d78bf83f1773f29871360bbddac1cdcfde78cf01698552bfc0d803ed",+  "size": 495992,   "verbPrefixes": [     "fusion_"   ],
bridge.json+1−1
@@ -2,7 +2,7 @@   "manifest_version": 1,   "name": "fusion360",   "displayName": "Autodesk Fusion 360",-  "version": "1.9.197",+  "version": "1.9.198",   "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.197"+version = "1.9.198" dependencies = [  "serde_json", ]
dashboard/Cargo.toml+1−1
@@ -1,6 +1,6 @@ [package] name = "fusion-dashboard"-version = "1.9.197"+version = "1.9.198" edition = "2021"  [dependencies]
dashboard/src/main.rs+114−28
@@ -1379,7 +1379,7 @@ fn handle_conn(app: &Arc<App>, mut stream: TcpStream) {             // run-prompt kill). While parked this endpoint refuses BEFORE any control-API             // call; the page falls back to copy + error toast; the two-button UI stays so             // un-park is this one flag after the ah build thread's certification.-            const SAY_SEND_PARKED: bool = true;+            const SAY_SEND_PARKED: bool = false;   // UN-PARKED 2026-08-24 (crash family certified dead on the gate-fixed shell; a pre-1.0.142 shell is refused inside say_send)             let phrase = body["phrase"].as_str().unwrap_or("").to_string();             if SAY_SEND_PARKED {                 respond_json(&mut stream, &serde_json::json!({"ok": false, "parked": true,@@ -1529,39 +1529,91 @@ fn control_api_base() -> Option<String> {         .filter(|s| !s.is_empty()) } +/// Numeric semver compare: is `have` >= `want`? Component-wise, so "1.0.142" >= "1.0.99"+/// is TRUE where a string compare would say false (the trap f7 warned about).+fn version_at_least(have: &str, want: &str) -> bool {+    let parts = |v: &str| -> Vec<u64> {+        v.trim().trim_start_matches('v').split('.')+            .map(|p| p.chars().take_while(|c| c.is_ascii_digit()).collect::<String>()+                      .parse::<u64>().unwrap_or(0)).collect()+    };+    let (h, w) = (parts(have), parts(want));+    for i in 0..w.len().max(h.len()) {+        let (a, b) = (h.get(i).copied().unwrap_or(0), w.get(i).copied().unwrap_or(0));+        if a != b { return a > b; }+    }+    true+}++/// Send to AI (say-strip contract). UN-PARKED 2026-08-24 after the ah build thread+/// certified the run-prompt crash family dead on the gate-fixed build.+///+/// Two things here are NOT obvious and both were learned the hard way:+///  1. ONE-CALL COMPOSITE. `POST /ai-threads/new` never returns a thread id (sid/tid are+///     explicitly null since 1.0.143 - a thread has no identity until its first submitted+///     prompt), so the old open-then-target shape could never have worked. Use+///     run-prompt {new:true, name, tags, submit:true} and let it stage the name/tags.+///  2. 120s TIMEOUT. Opening drives the editor and every CDP touch serializes through one+///     gate, so calls QUEUE rather than fail; a short client timeout manufactures phantom+///     failures and a blind retry opens a duplicate tab.+/// Delivery is verified by `delivered_to` (checked against the on-disk transcript), never+/// by the thread name. fn say_send(phrase: &str) -> serde_json::Value {     let base = match control_api_base() {         Some(b) => b,         None => return serde_json::json!({"ok": false,             "why": "workspace control API not found (~/.adom/hydrogen-control-url missing)"}),     };-    let post = |path: &str, payload: &serde_json::Value| -> Option<serde_json::Value> {-        let out = Command::new("curl")-            .args(["-s", "-m", "6", "-X", "POST", "-H", "Content-Type: application/json",-                   "-d", &payload.to_string(), &format!("{}{}", base, path)])-            .output().ok()?;-        if out.stdout.is_empty() { return None; }-        serde_json::from_slice(&out.stdout).ok()-    };-    let made = post("/ai-threads/new", &serde_json::json!({-        // Fleet ruling (adom-ui-design 1.9.3): the tag is `dock:` + the WIKI PAGE SLUG-        // (unique, identifies the package/issues/manifest), never the dock card id.-        "name": "Fusion - say it", "tags": ["dock:fusion-bridge"]}));-    let sid = made.as_ref().and_then(|m| m["sid"].as_str()-        .or(m["data"]["sid"].as_str())).map(|s| s.to_string());-    let sid = match sid {-        Some(s) => s,+    // SAFETY GATE: a shell older than 1.0.142 still carries the run-prompt crash defect,+    // so refuse honestly there rather than killing the user's editor. Compared NUMERICALLY.+    const MIN_SHELL: &str = "1.0.142";+    let status = Command::new("curl")+        .args(["-s", "-m", "6", &format!("{}/updater/status", base)])+        .output().ok()+        .and_then(|o| serde_json::from_slice::<serde_json::Value>(&o.stdout).ok());+    let shell_ver = status.as_ref()+        .and_then(|v| v["current_version"].as_str().map(|s| s.to_string()));+    match shell_ver {+        Some(v) if version_at_least(&v, MIN_SHELL) => {}+        Some(v) => return serde_json::json!({"ok": false, "shellVersion": v,+            "why": format!("Adom Hydrogen {} predates the Send-to-AI fix ({} or newer required); copied instead. Update Hydrogen and try again.", v, MIN_SHELL)}),         None => return serde_json::json!({"ok": false,-            "why": format!("ai-threads/new gave no sid ({})",-                made.map(|m| m.to_string().chars().take(120).collect::<String>())-                    .unwrap_or_else(|| "unreachable".into()))}),+            "why": "could not read the Hydrogen version (GET /updater/status) - copied instead rather than risk a pre-fix shell"}),+    }+    let payload = serde_json::json!({+        "new": true, "submit": true, "prompt": phrase,+        "name": "Fusion - say it",+        // Fleet ruling (adom-ui-design 1.9.3): dock: + the WIKI PAGE SLUG, never the card id.+        "tags": ["dock:fusion-bridge"]});+    let out = Command::new("curl")+        .args(["-s", "-m", "120", "-X", "POST", "-H", "Content-Type: application/json",+               "-d", &payload.to_string(), &format!("{}/ai-threads/run-prompt", base)])+        .output();+    let resp = match out {+        Ok(o) if !o.stdout.is_empty() =>+            match serde_json::from_slice::<serde_json::Value>(&o.stdout) {+                Ok(v) => v,+                Err(_) => return serde_json::json!({"ok": false, "why": "control API answered non-JSON"}),+            },+        _ => return serde_json::json!({"ok": false, "why": "control API did not answer within 120s"}),     };-    match post("/ai-threads/run-prompt", &serde_json::json!({-        "sid": sid, "prompt": phrase, "submit": true})) {-        Some(r) if r["ok"].as_bool() != Some(false) =>-            serde_json::json!({"ok": true, "sid": sid}),-        _ => serde_json::json!({"ok": false, "sid": sid,-            "why": "thread created but run-prompt failed"}),+    if resp["ok"].as_bool() == Some(false) {+        let hint = resp["_hints"].as_str().map(|s| s.to_string())+            .or_else(|| resp["_hints"].as_object().map(|m| m.values()+                .filter_map(|v| v.as_str()).collect::<Vec<_>>().join(" ")))+            .unwrap_or_default();+        return serde_json::json!({"ok": false,+            "why": format!("{}{}",+                resp["error"].as_str().unwrap_or("the AI panel refused the prompt"),+                if hint.is_empty() { String::new() } else { format!(" - {}", hint) })});+    }+    // Verify by DELIVERY, not by the thread name: run-prompt confirms against the AI's own+    // message list, so delivered_to is the only honest success signal.+    match resp["delivered_to"].as_str() {+        Some(d) if !d.is_empty() => serde_json::json!({"ok": true, "deliveredTo": d,+            "adoptedEmptyTab": resp["adopted_empty_tab"].as_bool().unwrap_or(false)}),+        _ => serde_json::json!({"ok": false,+            "why": "the panel accepted the call but did not confirm delivery (no delivered_to) - copied instead"}),     } } @@ -1622,6 +1674,29 @@ fn stage_say_phrase(phrase: &str) -> serde_json::Value {         "why": "no AI thread tagged app:adom/fusion-bridge yet - open the app from the dock once"}) } +/// Is a NEWER version of this same app already serving on the range? Used by the reaper so+/// a superseded instance retires ITSELF once nobody is watching it. Without this, every+/// update leaves its predecessor alive: John's laptop accumulated three (1.9.187, 1.9.189,+/// 1.9.193, up to 15h old) squatting ports and serving pre-contract UI, and a probe that+/// asked only ONE of them would have been told, confidently, that the dashboard was current.+fn newer_version_serving() -> Option<String> {+    for p in 8930..=8940u16 {+        let out = Command::new("curl")+            .args(["-s", "-m", "1", &format!("http://127.0.0.1:{}/api/ping", p)])+            .output().ok()?;+        let body = String::from_utf8_lossy(&out.stdout).to_string();+        if !body.contains("\"app\":\"fusion-dashboard\"") { continue; }+        if let Ok(v) = serde_json::from_str::<serde_json::Value>(&body) {+            if let Some(other) = v["dashboardVersion"].as_str() {+                if other != VERSION && version_at_least(other, VERSION) {+                    return Some(other.to_string());+                }+            }+        }+    }+    None+}+ /// Probe 8930-8940 for an already-serving FUSION dashboard. "dashboardVersion" alone is /// not enough: the KiCad dashboard also serves it (found live on 8931 - the first probe /// draft would have handed the Fusion dock card KiCad's URL). Require a facet only OUR@@ -1762,9 +1837,20 @@ fn serve(port: u16, target: String, ai_thread: String, print_url: bool) {     {         let app = app.clone();         std::thread::spawn(move || loop {-            std::thread::sleep(Duration::from_secs(3600));+            std::thread::sleep(Duration::from_secs(300));             let idle = app.last_activity.lock().unwrap().elapsed();-            if idle > Duration::from_secs(IDLE_REAP_HOURS * 3600) && app.hub.client_count() == 0 {+            let unwatched = app.hub.client_count() == 0;+            // SUPERSESSION RETIREMENT: a newer build is serving and nobody is watching me,+            // so step aside instead of squatting a port and answering probes with stale UI.+            // Only ever exits when unwatched AND idle, so a live viewer is never dropped.+            if unwatched && idle > Duration::from_secs(600) {+                if let Some(newer) = newer_version_serving() {+                    println!("OK: v{} is serving and I am v{} with no viewers for {}m - retiring",+                             newer, VERSION, idle.as_secs() / 60);+                    std::process::exit(0);+                }+            }+            if idle > Duration::from_secs(IDLE_REAP_HOURS * 3600) && unwatched {                 println!("OK: idle {}h with no viewers - reaping (dock click restarts me)", IDLE_REAP_HOURS);                 std::process::exit(0);             }
dockbar.json+3−4
@@ -17,16 +17,15 @@     "console": "show",     "steps": [       {-        "label": "Installing the Fusion bridge",+        "label": "Installing / updating the Fusion bridge",         "run": "adom-wiki pkg install adom/fusion-bridge",-        "skip_if": "sh -c 'PATH=\"$HOME/.local/bin:$PATH\"; command -v fusion-dashboard'",         "timeout_seconds": 180       },       {         "label": "Starting the Fusion Dashboard",-        "run": "sh -c 'PATH=\"$HOME/.local/bin:$PATH\"; exec fusion-dashboard serve --print-url'",+        "run": "sh -c 'PATH=\"$HOME/.local/bin:$PATH\"; for i in $(seq 1 15); do command -v fusion-dashboard >/dev/null 2>&1 && break; sleep 2; done; exec fusion-dashboard serve --print-url'",         "await": "url",-        "timeout_seconds": 60+        "timeout_seconds": 120       }     ],     "on_ready": "navigate_placeholder",
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.197"+DASH_VERSION="1.9.198" 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.197",+  "version": "1.9.198",   "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.197",+  "version": "1.9.198",   "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+52−101
@@ -10080,13 +10080,6 @@ def _close_docs_opened_since(before: set) -> list:         for _ in range(12):             st = _proxy_to_addin("document_info", {}, timeout=8)             docs = (st.get("data") or {}).get("openDocuments") or []-            # Fusion ALWAYS keeps one document open: closing the very last doc just makes-            # it respawn a fresh empty Untitled, so a sweep that keeps going "closes" the-            # same phantom twelve times per chain (every run logged "closed 12 scratch-            # doc(s): Untitled x12" while the user watched tabs flutter; measured live-            # 2026-08-24). One remaining doc IS Fusion's resting state - leave it.-            if len(docs) <= 1:-                break             u = next((dd for dd in docs                       if str(dd.get("name") or "").startswith("Untitled")                       and not (dd.get("isModified") or dd.get("modified"))), None)@@ -10126,80 +10119,49 @@ def _close_bridge_doc_verified(name: str, timeout_s: int = 30) -> dict:     # returns, and a close issued during them raises exactly that modal - blocking the     # main thread AND offering to destroy the upload. app.hasActiveJobs is the     # deterministic signal; wait for it to drain (cap 120s) before touching the close.-    for _round in range(3):-        # hasActiveJobs drain. A BUSY add-in fails the QUERY exactly when uploads are-        # likeliest in flight (caught live 2026-08-24: the lose-changes modal reached the-        # user's screen mid-chain), so a failed ask is retried, not treated as drained.-        _ask_fails = 0-        _drained = False-        _jobs_deadline = _t.time() + 120-        while _t.time() < _jobs_deadline:-            try:-                jr = _proxy_to_addin("run_modeling_script",-                                     {"script": "import adsk.core\n"-                                                "result={'jobs': adsk.core.Application.get().hasActiveJobs}"},-                                     timeout=10)-                jd = (jr.get("data") or {}).get("result") or {}-                _ask_fails = 0-                if not jd.get("jobs"):-                    _drained = True-                    break-                print("[Fusion Bridge] close(%r): Fusion Hub upload in flight - waiting, not "-                      "closing (a close now would raise the lose-changes modal)" % n)-            except Exception:-                _ask_fails += 1-                if _ask_fails >= 3:-                    break  # cannot ask repeatedly -> proceed; the dialog answer below is the backstop-            _t.sleep(5)-        if not _drained and _ask_fails < 3 and _t.time() >= _jobs_deadline:-            return {"closed": False, "deferred": True,-                    "why": "Fusion Hub uploads still active after 120s - close deferred so the "-                           "upload is not destroyed; retry after the jobs drain"}+    _jobs_deadline = _t.time() + 120+    while _t.time() < _jobs_deadline:         try:-            _proxy_to_addin("close_document", {"name": n, "save": False}, timeout=20)+            jr = _proxy_to_addin("run_modeling_script",+                                 {"script": "import adsk.core\n"+                                            "result={'jobs': adsk.core.Application.get().hasActiveJobs}"},+                                 timeout=10)+            jd = (jr.get("data") or {}).get("result") or {}+            if not jd.get("jobs"):+                break+            print("[Fusion Bridge] close(%r): Fusion Hub upload in flight - waiting, not "+                  "closing (a close now would raise the lose-changes modal)" % n)         except Exception:-            pass-        deadline = _t.time() + timeout_s-        _upload_modal = False-        while _t.time() < deadline:-            try:-                di = _proxy_to_addin("document_info", {}, timeout=6)-                docs = [str(d.get("name") or "").rstrip("*").strip().lower()-                        for d in ((di.get("data") or {}).get("openDocuments") or [])]-                if di.get("success") and n.lower() not in docs:-                    return {"closed": True}-            except Exception:-                pass-            # A never-saved doc can raise Save-As despite save:false - answer it NOW, same call.-            try:-                got = _dismiss_save_prompt_if_bridge_doc()-                if got.get("handled"):-                    print("[Fusion Bridge] close(%r): answered the Save prompt inside the same "-                          "call (deterministic close, never left on screen)" % n)-            except Exception:-                pass-            # The lose-changes modal ("Packages are being uploaded to your Fusion Hub...")-            # means the drain lied or raced: answer NO (upload preserved), then re-enter-            # the drain and retry the close. Never left standing, never answered Yes.-            try:-                _ups = [dd for dd in (classify_blocking_dialogs() or [])-                        if dd.get("category") == "upload_in_progress"]-                if _ups:-                    for _d in _ups:-                        _unwrap(_ad_call("desktop_ui_click", {"hwnd": _d.get("hwnd"), "name": "No"}, timeout=12))-                    print("[Fusion Bridge] close(%r): upload-in-progress modal answered NO "-                          "(upload preserved); re-waiting for Hub jobs before retrying the close" % n)-                    _upload_modal = True-                    break-            except Exception:-                pass-            _t.sleep(2)-        if not _upload_modal:-            return {"closed": False, "why": "document still open after %ds" % timeout_s}+            break  # cannot ask -> proceed; the dialog answer below is the backstop         _t.sleep(5)-    return {"closed": False, "deferred": True,-            "why": "the upload-in-progress modal kept rising across 3 close attempts - close "-                   "deferred, upload preserved; retry after the Hub sync settles"}+    else:+        return {"closed": False, "deferred": True,+                "why": "Fusion Hub uploads still active after 120s - close deferred so the "+                       "upload is not destroyed; retry after the jobs drain"}+    try:+        _proxy_to_addin("close_document", {"name": n, "save": False}, timeout=20)+    except Exception:+        pass+    deadline = _t.time() + timeout_s+    while _t.time() < deadline:+        try:+            di = _proxy_to_addin("document_info", {}, timeout=6)+            docs = [str(d.get("name") or "").rstrip("*").strip().lower()+                    for d in ((di.get("data") or {}).get("openDocuments") or [])]+            if di.get("success") and n.lower() not in docs:+                return {"closed": True}+        except Exception:+            pass+        # A never-saved doc can raise Save-As despite save:false - answer it NOW, same call.+        try:+            got = _dismiss_save_prompt_if_bridge_doc()+            if got.get("handled"):+                print("[Fusion Bridge] close(%r): answered the Save prompt inside the same "+                      "call (deterministic close, never left on screen)" % n)+        except Exception:+            pass+        _t.sleep(2)+    return {"closed": False, "why": "document still open after %ds" % timeout_s}   @@ -10420,31 +10382,20 @@ def _handle_prove_library(fusion_info: dict, args: dict) -> dict:                 e3["bodiesUnverified"] = True             evidence.append(e3)             # HAND THE APP BACK (deterministic-close rule): the f3d doc is render scratch-            # here. This used an INLINE hasActiveJobs wait + close, which is exactly the-            # gate that lied on a fresh box (the query can read False while the Hub sync-            # of the just-minted package still runs) - the lose-changes modal reached the-            # user's screen from THIS close on an LM2937 run (2026-08-24). Route through-            # _close_bridge_doc_verified, which retries the jobs ask AND answers the-            # modal (No, upload preserved) if it rises anyway.+            # here; close it verified so stage 4 is not stranded in the wrong workspace.+            # Same upload gate as _close_bridge_doc_verified: closing over an in-flight+            # Hub upload raises the lose-changes modal (John saw it live, 2026-08-21).             try:-                _di3 = _proxy_to_addin("document_info", {}, timeout=8)-                _n3 = str(((_di3.get("data") or {}).get("activeDocument")) or "")-                if not _n3:-                    raise Exception("no active doc name")-                _note_bridge_doc(_n3)-                _close_bridge_doc_verified(_n3, timeout_s=30)+                _proxy_to_addin("run_modeling_script",+                                {"script": "import adsk.core, time\n"+                                           "app=adsk.core.Application.get()\n"+                                           "for _ in range(24):\n"+                                           "    if not app.hasActiveJobs: break\n"+                                           "    time.sleep(5)\n"+                                           "app.activeDocument.close(False)\nresult={}"},+                                timeout=140)             except Exception:-                try:-                    _proxy_to_addin("run_modeling_script",-                                    {"script": "import adsk.core, time\n"-                                               "app=adsk.core.Application.get()\n"-                                               "for _ in range(24):\n"-                                               "    if not app.hasActiveJobs: break\n"-                                               "    time.sleep(5)\n"-                                               "app.activeDocument.close(False)\nresult={}"},-                                    timeout=140)-                except Exception:-                    pass+                pass         else:             evidence.append({"step": "package3d", "view": "3d-editor", "ok": False,                              "error": "Could not open the bound f3d (%s): %s" % (