app
Fusion - the Fusion 360 Bridge
Public Made by Adomby adom
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.
← Commit history
Update 161 file(s)
11 files changed
+113−38
BRIDGE_VERSION+1−1@@ -1 +1 @@-1.10.5+1.10.6
bridge.json+1−1@@ -2,7 +2,7 @@ "manifest_version": 1, "name": "fusion360", "displayName": "Autodesk Fusion 360",- "version": "1.10.5",+ "version": "1.10.6", "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.10.5"+version = "1.10.6" dependencies = [ "serde_json", ]
dashboard/Cargo.toml+1−1@@ -1,6 +1,6 @@ [package] name = "fusion-dashboard"-version = "1.10.5"+version = "1.10.6" 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.10.5"+DASH_VERSION="1.10.6" 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.10.5",+ "version": "1.10.6", "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−27@@ -1,6 +1,6 @@ { "slug": "fusion-bridge",- "version": "1.10.5",+ "version": "1.10.6", "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,28 +111,5 @@ "label": "Part volume", "prompt": "How much material does each printed part in this assembly use?" }- ],- "agent_permissions": {- "allow_bash": [],- "allow": [- "adom-desktop-fusion-bridge read-only verbs (via the adom-bridge CLI): fusion_readiness, fusion_get_app_state, fusion_addin_status, fusion_check_dialogs - state queries; they change nothing.",- "adom-desktop-fusion-bridge read-only verbs: fusion_alerts (reads Fusion's own log for toasts), fusion_document_info, fusion_window_info, fusion_board_info, fusion_library_parts - inspect only.",- "adom-desktop-fusion-bridge read-only verbs: fusion_prove_result (a persisted run report), fusion_plan_library_import (report-only preflight), fusion_list_cloud_projects, fusion_describe.",- "adom-desktop-fusion-bridge evidence captures fusion_screenshot_fusion and fusion_capture_library_views read pixels and write local image files only."- ],- "soft_deny": [- "adom-desktop-fusion-bridge lifecycle verbs fusion_stop, fusion_kill, fusion_restart_for_addin close or kill the Fusion application - keep the human prompt.",- "adom-desktop-fusion-bridge fusion_install_fusion silently installs Autodesk Fusion on the user's machine.",- "adom-desktop-fusion-bridge fusion_cleanup_cloud_files deletes files from the user's Autodesk cloud hub.",- "adom-desktop-fusion-bridge managed-library verbs (fusion_managed_library_push/pull/create/link/unlink) publish to or overwrite the user's library.io libraries."- ],- "environment": [- "adom-desktop-fusion-bridge is the Fusion 360 bridge for Adom Bridge; its fusion_* verbs are invoked through the adom-bridge CLI and drive Autodesk Fusion on the user's own desktop.",- "adom-desktop-fusion-bridge desktop actions also pass Adom Bridge's own human-approval gate (caller identity, per-call reason, Activity Log) - a container approval is the first of two gates."- ]- },- "author": {- "name": "John Lauer",- "email": "[email protected]"- }-}+ ]+}
rust/Cargo.toml+1−1@@ -4,7 +4,7 @@ members = ["core", "lbr", "os-win", "os-mac", "bridge"] # One version for the whole bridge, in lockstep with BRIDGE_VERSION (tools/release.sh bumps both). [workspace.package]-version = "1.10.5"+version = "1.10.6" edition = "2021" license = "MIT"
rust/bridge/src/chainexec.rs+9@@ -147,6 +147,15 @@ pub fn addin_restart_info(ctx: &Ctx, args: &Value, pass: &dyn Fn(&str, Value) -> unames = Some(un); } let yours: Vec<String> = match &unames { Some(u) => { let y = user_unsaved_docs(&ctx.state_dir, u); info["userUnsaved"] = json!(y); info["autoRestart"] = json!(y.is_empty()); y } None => { info["autoRestart"] = json!(false); vec![] } };+ let policy = crate::lifecycle::restart_policy(&ctx.state_dir);+ info["restartPolicy"] = json!(policy);+ let yours = if policy == "auto" {+ // every open document is the AI's own on an Adom box: nothing to ask, the bridge restarts+ if !yours.is_empty() { info["userUnsavedIgnored"] = json!(yours); info["userUnsaved"] = json!([]); }+ info["autoRestart"] = json!(true);+ info["userMessage"] = json!("Fusion is restarting itself to load the Adom add-in (restartPolicy auto). Open documents are saved where Fusion can save them; never-saved scratch is discarded.");+ Vec::new()+ } else { yours }; if fire_toast && info["autoRestart"].as_bool() != Some(true) { let f = ctx.state_dir.join("addin_restart_toast.json"); let _ = std::fs::create_dir_all(&ctx.state_dir);
rust/bridge/src/lifecycle.rs+84−3@@ -141,6 +141,48 @@ pub fn kill(ctx: &Ctx, args: &Value) -> Value { } /// `fusion_restart_for_addin`: save, stop (or kill when only bridge scratch is unsaved), start, wait.+/// Who owns a Fusion restart. "auto" (the default): the bridge restarts Fusion itself whenever+/// the add-in needs it, saving what can be saved and discarding the rest, because on an Adom box+/// the AI is the only thing driving Fusion and every open document is its own (John,+/// 2026-09-07: "you should literally know to just allow fully restarting fusion ... all the open+/// docs are just the ai's docs"). "ask" keeps the toast with the Save-and-restart choice for a+/// machine a person shares with the AI. Pref key `restartPolicy` in prefs.json, or the env+/// ADOM_FUSION_RESTART_POLICY.+pub fn restart_policy(state_dir: &std::path::Path) -> String {+ if let Ok(v) = std::env::var("ADOM_FUSION_RESTART_POLICY") { if !v.trim().is_empty() { return v.trim().to_lowercase(); } }+ crate::small_verbs::get_pref(state_dir, "restartPolicy").and_then(|v| v.as_str().map(|s| s.to_lowercase())).unwrap_or_else(|| "auto".into())+}++/// A plain informational toast on this machine (no buttons, nothing to answer).+pub fn notify_info(ctx: &Ctx, title: &str, body: &str) -> bool {+ match Loopback::from_env() {+ Some(ab) => { let r = ab.call("notify_user", json!({"title": title, "body": body, "level": "info"}), &ctx.caller, 15); r.is_ok() }+ None => false,+ }+}++/// When the add-in's main thread has been blocked by a modal since this instant (0 = not blocked).+static BLOCKED_SINCE: std::sync::Mutex<f64> = std::sync::Mutex::new(0.0);+/// How long a modal may hold the add-in before the bridge restarts Fusion itself (restartPolicy auto).+const BLOCKED_RESTART_AFTER_SECS: f64 = 90.0;++fn now_secs() -> f64 { std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).map(|d| d.as_secs_f64()).unwrap_or(0.0) }++/// Restart Fusion in the background because a modal nobody answers has held the add-in+/// (restartPolicy auto). Save is impossible (the add-in cannot answer), so this is a kill ++/// start; the ledger and the reply carry what was on screen.+fn begin_blocked_restart(ctx: &Ctx, blocking: &[Value], held: f64) -> bool {+ crate::fusion_prefs::action_ledger(&ctx.state_dir, "auto_restart_blocked_modal", json!({"heldSec": (held * 10.0).round() / 10.0,+ "dialogs": blocking.iter().map(|d| json!({"title": d["title"], "category": d["category"]})).collect::<Vec<_>>()}));+ let url = crate::launch::SELF_URL.lock().unwrap().clone().or_else(|| crate::launch::SIDECAR_URL.lock().unwrap().clone());+ let Some(u) = url else { return false; };+ std::thread::spawn(move || {+ let r = ureq::post(&u).timeout(Duration::from_secs(900)).send_json(json!({"command": "fusion_restart_for_addin", "args": {"save": false, "auto": true, "wait": true, "why": "modal blocked the add-in"}})).ok().and_then(|r| r.into_json::<Value>().ok()).unwrap_or(json!({}));+ eprintln!("[fusion-bridge] auto restart after a blocking modal: {{success: {}, errorCode: {}}}", r["success"], r["errorCode"]);+ });+ true+}+ pub fn restart_for_addin(ctx: &Ctx, args: &Value, pass: &dyn Fn(&str, Value) -> Value) -> Value { let sd = ctx.state_dir.clone(); if args["decline"].as_bool() == Some(true) {@@ -166,8 +208,10 @@ pub fn restart_for_addin(ctx: &Ctx, args: &Value, pass: &dyn Fn(&str, Value) -> } let t0 = Instant::now(); let save = args["save"].as_bool().unwrap_or(true);+ let auto_policy = args["auto"].as_bool() == Some(true) || restart_policy(&sd) == "auto"; let mut steps: Vec<Value> = Vec::new(); let mut hard = false;+ let mut discarded: Vec<String> = Vec::new(); if save { let sv = pass("close_all_documents", json!({"save": true})); steps.push(json!({"step": "save", "ok": sv["success"].as_bool().unwrap_or(false), "closed": sv["data"]["closed"], "wouldPrompt": sv["data"]["wouldPrompt"]}));@@ -175,12 +219,25 @@ pub fn restart_for_addin(ctx: &Ctx, args: &Value, pass: &dyn Fn(&str, Value) -> if !wp.is_empty() { let users: Vec<&String> = wp.iter().filter(|n| { let bare = n.replace('*', "").split(" (").next().unwrap_or("").trim().to_lowercase(); !bare.is_empty() && crate::etiquette::bridge_doc_in(&sd, &bare).is_none() && !bare.starts_with("untitled") && !bare.ends_with("_3d") }).collect(); if users.is_empty() { steps.push(json!({"step": "discard-bridge-scratch", "docs": wp})); hard = true; }+ else if auto_policy {+ // restartPolicy auto: every open document is the AI's own; a never-saved one is+ // scratch, so it is discarded rather than left to block the restart+ discarded = users.iter().map(|s| s.to_string()).collect();+ crate::fusion_prefs::action_ledger(&sd, "restart_discarded_unsaved", json!({"docs": discarded}));+ steps.push(json!({"step": "discard-under-auto-policy", "docs": discarded})); hard = true;+ } else { return json!({"success": false, "errorCode": "unsaved_named_document", "error": format!("Cannot restart safely: {} has never been saved, so Fusion would raise its Save box and block the restart.", users.iter().map(|n| fusion_lbr::py_repr_str(n)).collect::<Vec<_>>().join(", ")), "steps": steps, "_hint": "Ask the user where to save it (fusion_save_to_cloud {name, projectName}), then retry. Nothing was closed and no dialog was raised."}); } }- if sv["success"].as_bool() != Some(true) && sv["errorCode"].is_string() && sv["error"].as_str().map(|e| e.contains("Add-in request failed")).unwrap_or(false) {+ if sv["success"].as_bool() != Some(true) && auto_policy {+ // the add-in cannot save (it is the thing that is not answering): restart anyway,+ // hard, and say so; Fusion's own recovery keeps cloud documents' last autosave+ steps.push(json!({"step": "save-unavailable", "error": sv["error"].as_str().unwrap_or("").chars().take(160).collect::<String>()}));+ hard = true;+ }+ if sv["success"].as_bool() != Some(true) && !auto_policy && sv["errorCode"].is_string() && sv["error"].as_str().map(|e| e.contains("Add-in request failed")).unwrap_or(false) { return json!({"success": false, "errorCode": "save_failed", "steps": steps, "error": format!("Refusing to restart because saving failed: {}", sv["error"].as_str().unwrap_or("").chars().take(200).collect::<String>()), "_hint": "Nothing was restarted; the user's documents are untouched."}); } }@@ -190,7 +247,15 @@ pub fn restart_for_addin(ctx: &Ctx, args: &Value, pass: &dyn Fn(&str, Value) -> let lr = crate::launch::start(ctx, &json!({"waitForAddin": true})); steps.push(json!({"step": "start", "ok": lr["success"].as_bool().unwrap_or(false)})); let ready = crate::launch::round_trip_ready_gap(6, 5.0);- json!({"success": ready, "addinReady": ready, "tookSec": (t0.elapsed().as_secs_f64() * 10.0).round() / 10.0, "steps": steps, "errorCode": if ready { Value::Null } else { json!("addin_still_not_responding") },+ if auto_policy {+ let why = args["why"].as_str().unwrap_or("to load the Adom add-in");+ let body = format!("The bridge restarted Fusion {why} (restartPolicy auto: every open document is the AI's own). {}{}",+ if steps.iter().any(|s| s["step"] == "save") { "Documents were saved first. " } else { "Nothing could be saved first. " },+ if discarded.is_empty() { String::new() } else { format!("Discarded never-saved scratch: {}. ", discarded.join(", ")) });+ crate::fusion_prefs::action_ledger(&sd, "auto_restart_done", json!({"ready": ready, "why": why, "steps": steps, "discarded": discarded}));+ let _ = notify_info(ctx, "Fusion restarted by the bridge", &body);+ }+ json!({"success": ready, "addinReady": ready, "restartPolicy": if auto_policy { "auto" } else { "ask" }, "discarded": discarded, "tookSec": (t0.elapsed().as_secs_f64() * 10.0).round() / 10.0, "steps": steps, "errorCode": if ready { Value::Null } else { json!("addin_still_not_responding") }, "_hint": if ready { "Fusion restarted and the add-in answered - re-run the verb that failed." } else { "Fusion restarted but the add-in still is not answering. That is a real defect, not a user problem: check fusion_addin_status and file it." }}) } @@ -249,15 +314,31 @@ pub fn diagnose_addin_timeout(ctx: &Ctx, command: &str, original: &Value) -> Val if !st["walkProgress"].is_null() { resp["progress"] = st["walkProgress"].clone(); } return resp; }+ // restartPolicy auto: a modal nobody answers does not get to hold the AI's Fusion+ // for ever. Seat/sign-in dialogs heal through their own flows and are excluded.+ let policy = restart_policy(&ctx.state_dir);+ let self_healing = blocking.iter().any(|d| matches!(d["category"].as_str().unwrap_or(""), "license_sessions_exceeded" | "session_conflict_decision" | "session_suspend_confirm" | "session_suspended_regrab" | "signin_required" | "upload_in_progress"));+ let held = { let mut s = BLOCKED_SINCE.lock().unwrap(); let n = now_secs(); if *s == 0.0 || n - *s > 900.0 { *s = n; } n - *s };+ if policy == "auto" && !self_healing && held >= BLOCKED_RESTART_AFTER_SECS {+ *BLOCKED_SINCE.lock().unwrap() = 0.0;+ let started = begin_blocked_restart(ctx, &blocking, held);+ let titles = blocking.iter().map(|d| format!("'{}' ({})", d["title"].as_str().unwrap_or(""), d["category"].as_str().unwrap_or(""))).collect::<Vec<_>>().join(", ");+ return json!({"success": false, "errorCode": "fusion_auto_restarting", "restartStarted": started, "heldSec": (held * 10.0).round() / 10.0, "blockingDialogs": blocking,+ "error": format!("A modal has held Fusion's main thread for {:.0}s ({}), so the bridge is restarting Fusion itself (restartPolicy auto). The verb '{command}' did not run.", held, if titles.is_empty() { "no classified dialog; see postOpenScreenshot".to_string() } else { titles }),+ "data": {"command": command, "health": h, "blockingDialogs": blocking, "postOpenScreenshot": shots},+ "_hint": "Poll fusion_readiness until ready:true (60-120 s), then re-run the verb. Unsaved edits since the last autosave are gone; every open document was the AI's own. If the dialog in postOpenScreenshot is one the bridge should answer itself, file it on the fusion-bridge page with the screenshot so it gets a dialog rule."});+ }+ let auto_note = if policy == "auto" { format!(" The bridge restarts Fusion itself if this stays blocked for {:.0}s (restartPolicy auto; blocked {:.0}s so far); re-poll rather than restarting.", BLOCKED_RESTART_AFTER_SECS, held) } else { " This is NOT an add-in crash; with restartPolicy ask the user decides on a restart.".to_string() }; let message = if !blocking.is_empty() { let titles = blocking.iter().map(|d| format!("'{}' ({})", d["title"].as_str().unwrap_or(""), d["category"].as_str().unwrap_or(""))).collect::<Vec<_>>().join(", "); let mut seen = Vec::new(); for d in &blocking { let r = d["resolution"].as_str().unwrap_or("").to_string(); if !seen.contains(&r) { seen.push(r); } }- format!("The AdomBridge add-in is alive but its main thread is blocked by a modal dialog: {titles}. This is NOT an add-in crash — do not restart Fusion. {}", seen.join(" "))+ format!("The AdomBridge add-in is alive but its main thread is blocked by a modal dialog: {titles}.{auto_note} {}", seen.join(" ")) } else { format!("The AdomBridge add-in is alive but its main thread is not responding (status: {main_thread}, pending: {pending}). Fusion 360 likely has a modal dialog open (Document Recovery, error, or update prompt) that is blocking execution. READ the screenshots to identify the dialog, then dismiss with fusion_send_key {{\"key\": \"escape\"}} or fusion_send_key {{\"key\": \"tab\"}} + {{\"key\": \"enter\"}}.") }; return json!({"success": false, "error": "addin_main_thread_blocked", "message": message, "blockingDialogs": blocking, "data": {"command": command, "health": h, "blockingDialogs": blocking, "postOpenScreenshot": shots}}); }+ *BLOCKED_SINCE.lock().unwrap() = 0.0; return json!({"success": false, "error": "addin_command_timeout", "message": format!("Command '{command}' timed out but the add-in reports main thread is {main_thread}. The command may be long-running or stuck."), "data": {"command": command, "health": h, "postOpenScreenshot": shots}}); } }
skills/fusion-web-control/SKILL.md+2−1@@ -79,7 +79,8 @@ the four questions a caller needs before starting: | `not_installed` | Fusion is not on this machine | Offer to install it; the plan includes the install stage (10-30 min) | | `installed_not_running` | Just needs launching | Proceed; launch is a stage, ~60s, folded into the ETA | | `needs_signin` | Running, but at the Autodesk sign-in screen (bridge 1.10.1+) | Drive the sign-in (fusion-autodesk-signin skill); board verbs return this at once, never a restart diagnosis |-| `addin_restart_required` | Running and signed in, but the add-in is not loaded | **Ask the user**, then `fusion_restart_for_addin` |+| `addin_restarting` | Running and signed in, add-in not loaded; the bridge is restarting Fusion itself (restartPolicy auto, the default since 1.10.6: every open document is the AI's own) | Poll `fusion_readiness`, then continue |+| `addin_restart_required` | Same, on a box whose prefs.json sets `restartPolicy: ask` | Show `userMessage`, then `fusion_restart_for_addin` on a yes | | `ready` | Good to go | Start | It returns `plan[]` with a per-stage `estimatedSec` and `confidence`, plus `estimatedSec`