← Commit history
BRIDGE_VERSION+1−1
@@ -1 +1 @@-1.10.16\ No newline at end of file+1.10.17\ No newline at end of file
bridge.json+1−1
@@ -2,7 +2,7 @@   "manifest_version": 1,   "name": "fusion360",   "displayName": "Autodesk Fusion 360",-  "version": "1.10.16",+  "version": "1.10.17",   "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.",
contracts/describe.json+6−2
@@ -2292,7 +2292,7 @@         "verdict": "str"       },       "statusVerb": null,-      "summary": "POLL a background run started by fusion_create_library or the detached fusion_build_library_3d. Returns stage + stageLabel, plan[] with measured per-stage estimates, stagesDone, percent, etaSec, confidence, and evidence[] filling in AS EACH ARTIFACT LANDS (symbol -> footprint -> 3d -> ganged). For a 3D build it carries per-part progress (minting X (3/7)), partsDone/partsTotal and parts[] with each wip_urn. stage:done only means the chain FINISHED - the top-level verdict (proven/not_proven) is the score, and a run that captured zero frames lands stage:failed with blocked_by_dialog (carrying the dialog text) or no_evidence_captured rather than reporting done. Pass inlineFrames:true on the final fetch for base64 thumbnails a browser can render.",+      "summary": "POLL a background run started by fusion_create_library or the detached fusion_build_library_3d. Returns stage + stageLabel, plan[] with measured per-stage estimates, stagesDone, percent, etaSec, confidence, and evidence[] filling in AS EACH ARTIFACT LANDS (symbol -> footprint -> 3d -> ganged). For a 3D build it carries per-part progress (minting X (3/7)), partsDone/partsTotal and parts[] with each wip_urn. stage:done only means the chain FINISHED - the top-level verdict (proven/not_proven) is the score, and a run that captured zero frames lands stage:failed with blocked_by_dialog (carrying the dialog text) or no_evidence_captured rather than reporting done. Pass inlineFrames:true on the final fetch for base64 thumbnails a browser can render. restarts[] {at, why, kind} lists any restart the chain performed (only with allowRestart). prove.planned / prove.skipped say whether evidence frames were captured and why (AI threads bind only by default).",       "timeoutSeconds": 20     },     {@@ -2845,7 +2845,11 @@     },     {       "example": {},-      "input": {},+      "input": {+        "allowRestart": "optional bool (default false). The chain NEVER relaunches Fusion or restarts the add-in on its own: when the add-in does not answer it surfaces addin_restart_required and waits (resumable). allowRestart:true with restartReason lets it restart, recorded in restarts[] on fusion_prove_result (issue #87)",+        "prove": "optional bool. Default from the CALLER: an AI thread (caller headers set) binds and places only; web control / anonymous callers prove (four evidence frames per part, about 300 s per library). Explicit true/false always wins. From an AI thread, look at one part later with fusion_capture_library_views {packages} (issue #88)",+        "restartReason": "optional str shown to the human when allowRestart is used"+      },       "longRunning": true,       "name": "fusion_start",       "output": {
dashboard/Cargo.lock+1−1
@@ -4,7 +4,7 @@ version = 4  [[package]] name = "fusion-dashboard"-version = "1.10.16"+version = "1.10.17" dependencies = [  "serde_json", ]
dashboard/Cargo.toml+1−1
@@ -1,6 +1,6 @@ [package] name = "fusion-dashboard"-version = "1.10.16"+version = "1.10.17" edition = "2021"  [dependencies]
describe.py+2−2
@@ -31,7 +31,7 @@ _T = [     ("fusion_install_library_bundle", "COMPOSITE, the Fusion twin of kicad_install_library_bundle (#68): ONE call takes a staged Cloud Library bundle (zip of <Lib>.lbr + <Part>.step models, or a bare lbrPath with STEPs beside it), unpacks it safely, places it under ~/Documents/Adom Libraries/<name>/, pairs EVERY deviceset with its STEP by name (deviceset, package, normalised, prefix; a one-part library pairs its single model regardless), lints part numbers (partMetadata: MPN, manufacturer, Mouser/DigiKey/LCSC - reported, never blocking), pre-flights Fusion state (fusion{status}), and starts the same bind -> prove chain fusion_create_library runs. ASYNC BY CONTRACT: returns IMMEDIATELY with runId + statusVerb because a STEP bind is 3-15 minutes and cannot fit a relay window; poll fusion_prove_result (measured percent, four streamed proof frames per part, provedParts[], and an addin_restart_required payload the CALLER turns into the ask). PARTIAL SHIPS: a deviceset with no model is a NAMED skip in unbound[], the rest still bind; partsBound < partsTotal is the signal. Each part is minted on its own single-deviceset library (the Package3D generator loads the FIRST package of the open library, so a merged library would put every chip on part one's footprint), with the STEP placed exactly as published (orient:false, origin trusted), then all urns are bound into the merged library, and after prove the bound library is SAVED TO THE CLOUD under <project>/Adom Libraries so it appears in Fusion's Library Manager (cloudLibrary{saved, fileId, duplicatesBefore} in fusion_prove_result). Per-part scaffolding is deleted; the user is left with one library plus its per-package f3d folder. capture:false only places the files and returns. This is the one contract the wiki CLI (`library install --format fusion`) and the Export button both read; the recipe has one owner.", {"zipPath": "str (staged bundle zip on THIS machine) - or", "lbrPath": "str (staged .lbr, STEP models beside it)", "libraryName": "optional str (default: file stem)", "capture": "optional bool (default true: bind + prove; false: place only)"}, {"installed": "bool", "libraryPath": "str", "partsTotal": "int", "partsToBind": "int", "unbound": "[{package, why}]", "partMetadata": "{ok, summary, warnings[]}", "fusion": "{installed, running, addinReady, status, message}", "runId": "str", "statusVerb": "fusion_prove_result"}, 60, "fusion_prove_result", True, {"zipPath": "C:/Temp/MyLib.zip", "libraryName": "MyLib"}),     ("fusion_create_library", "ONE-CALL library creation for web-control (one call = one consent). EVERY LIBRARY IS MULTI-PART - a single-part library is simply parts:[one entry], and there is no separate single-part verb to choose. Pass parts:[{package, lbrPath, modelPath}] for any number of parts; the older {libraryPath, stepPath} shape still works and is normalised into a one-element parts list, so both run the identical code path and the ETA scales with the part count either way. TWO entry shapes: A) {libraryPath, stepPath} - a ready .lbr plus the 3D; B) {kicadSymbolPath, kicadFootprintPath, stepPath, pinMap?} - KiCad sources; needs adom-lbr, which is Linux-container-only by platform rule, so on Windows/Mac boxes shape B refuses IMMEDIATELY (adom_lbr_unavailable, before any staging or Fusion launch - fusion_readiness reports kicadIngest up front). Convert at AUTHORING time in a container and use shape A. pinMap = explicit {pinName: padName} for when pin numbers do not match pad names (BGA-style). Chains convert -> bind 3D -> prove (four evidence frames + verdict) in the BACKGROUND and returns immediately; poll fusion_prove_result (stage narrates; top-level `verdict` proven/not_proven is the score - `stage:done` only means the chain finished). EVERY PART IS PROVED, not just the first: a 10-component library streams 40 evidence frames, FOUR per component (symbol, footprint, 3d, ganged) in assembly order, each tagged with `part` so a front end can group them as they arrive, and `provedParts[]` reports per-part pass/fail. The library is proven only if EVERY part is - one good part does not vouch for nine unchecked ones. On the FINAL fetch pass {inlineFrames:true} to get each evidence frame as a base64 thumbnailDataUrl a browser can render (plain polls return box paths only). The chain closes its library document on success AND failure - batches need no between-part sweeping. stepPath is required for a PROVEN library - no real 3D body without it.", {"parts": "optional [{package, lbrPath, modelPath}] - THE canonical shape, any count", "libraryPath": "optional str (shape A, or output path for B)", "kicadSymbolPath": "optional str", "kicadFootprintPath": "optional str", "stepPath": "optional str (required for proven)", "pinMap": "optional {pinName: padName}", "name": "optional str"}, {"started": "bool", "statusVerb": "str"}, 30, "fusion_prove_result", True, {"libraryPath": "C:/Temp/PART.lbr", "stepPath": "C:/Temp/PART.step"}),     ("fusion_start", "Launch Fusion 360 (if needed) and wait for the AdomBridge add-in to be ready.",-     {}, {"success": "bool", "addinReady": "bool", "status": "str"}, 150, None, True, {}),+     {"prove": "optional bool. Default from the CALLER: an AI thread (caller headers set) binds and places only; web control / anonymous callers prove (four evidence frames per part, about 300 s per library). Explicit true/false always wins. From an AI thread, look at one part later with fusion_capture_library_views {packages} (issue #88)", "allowRestart": "optional bool (default false). The chain NEVER relaunches Fusion or restarts the add-in on its own: when the add-in does not answer it surfaces addin_restart_required and waits (resumable). allowRestart:true with restartReason lets it restart, recorded in restarts[] on fusion_prove_result (issue #87)", "restartReason": "optional str shown to the human when allowRestart is used"}, {"success": "bool", "addinReady": "bool", "status": "str"}, 150, None, True, {}),     ("fusion_stop", "Gracefully STOP Fusion 360: close docs cleanly + WM_CLOSE, then wait. NO force-kill - the clean opposite of fusion_start. If a window will not close it returns failure; use fusion_kill then.",      {"skipCleanClose": "optional bool"}, {"success": "bool", "output": "str"}, 60, None, False, {}),     ("fusion_kill", "Force-KILL Fusion 360 (taskkill /F) - the desperate path for when fusion_stop cannot close it (stuck modal / wedged process). Closes docs cleanly first when reachable.",@@ -298,7 +298,7 @@ _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_prove_result", "POLL a background run started by fusion_create_library or the detached fusion_build_library_3d. Returns stage + stageLabel, plan[] with measured per-stage estimates, stagesDone, percent, etaSec, confidence, and evidence[] filling in AS EACH ARTIFACT LANDS (symbol -> footprint -> 3d -> ganged). For a 3D build it carries per-part progress (minting X (3/7)), partsDone/partsTotal and parts[] with each wip_urn. stage:done only means the chain FINISHED - the top-level verdict (proven/not_proven) is the score, and a run that captured zero frames lands stage:failed with blocked_by_dialog (carrying the dialog text) or no_evidence_captured rather than reporting done. Pass inlineFrames:true on the final fetch for base64 thumbnails a browser can render.",+    ("fusion_prove_result", "POLL a background run started by fusion_create_library or the detached fusion_build_library_3d. Returns stage + stageLabel, plan[] with measured per-stage estimates, stagesDone, percent, etaSec, confidence, and evidence[] filling in AS EACH ARTIFACT LANDS (symbol -> footprint -> 3d -> ganged). For a 3D build it carries per-part progress (minting X (3/7)), partsDone/partsTotal and parts[] with each wip_urn. stage:done only means the chain FINISHED - the top-level verdict (proven/not_proven) is the score, and a run that captured zero frames lands stage:failed with blocked_by_dialog (carrying the dialog text) or no_evidence_captured rather than reporting done. Pass inlineFrames:true on the final fetch for base64 thumbnails a browser can render. restarts[] {at, why, kind} lists any restart the chain performed (only with allowRestart). prove.planned / prove.skipped say whether evidence frames were captured and why (AI threads bind only by default).",      {"runId": "optional str (omit for the latest run)", "inlineFrames": "optional bool", "shots": "optional int", "sinceShot": "optional int"},      {"stage": "str", "verdict": "str", "evidence": "[]", "percent": "float", "etaSec": "float"},      20, None, True, {"runId": "a3f1c2d4e5b6"}),
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.16"+DASH_VERSION="1.10.17" 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.16",+  "version": "1.10.17",   "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.16",+  "version": "1.10.17",   "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]"-  }-}\ No newline at end of file+  ]+}
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.16"+version = "1.10.17" edition = "2021" license = "MIT" 
rust/bridge/src/addin.rs+2−2
@@ -250,8 +250,8 @@ pub fn pass_through(cmd: &str, args: &Value, ctx: &Ctx) -> Value {             }         }         // raw close_document gate: never close over an in-flight Hub upload (John, 2026-08-21)-        if cmd == "close_document" {-            let deadline = Instant::now() + Duration::from_secs(45);+        if cmd == "close_document" || cmd == "close_all_documents" {+            let deadline = Instant::now() + Duration::from_secs(180);             let mut drained = false;             while Instant::now() < deadline {                 let jr = call_addin("run_modeling_script", &json!({"script": "import adsk.core\nresult={'jobs': adsk.core.Application.get().hasActiveJobs}"}), 10, sidecar);
rust/bridge/src/chains.rs+25−7
@@ -23,6 +23,8 @@ pub trait ChainHost: Send + Sync {     fn fusion_running(&self) -> bool;     fn set_long_op(&self, name: Option<String>);     fn taskbar(&self, pct: Option<f64>);+    /// The AI thread that asked (empty for web control and other anonymous callers).+    fn caller_thread(&self) -> String { String::new() } }  fn now() -> f64 { SystemTime::now().duration_since(UNIX_EPOCH).map(|d| d.as_secs_f64()).unwrap_or(0.0) }@@ -209,7 +211,11 @@ fn run_chain(host: Arc<dyn ChainHost>, run_id: String, args: Value, lbr_p: Strin     let needs_launch = !host.addin_round_trip_ready();     let n_parts = parts_in.len().max(1);     let (per_part, _) = estimate(&sd, "stage:bind_per_part", 60.0);-    let stages: Vec<(&str, f64, bool)> = vec![("launch", 60.0, needs_launch), ("convert", 5.0, false), ("bind", per_part * n_parts as f64, !step_p.is_empty() || !parts_in.is_empty()), ("prove", 300.0, true)];+    // prove is for web control (the wiki page shows the frames); an AI thread binds and places+    // only unless it asks (issue #88). An explicit prove:true/false always wins.+    let do_prove = args["prove"].as_bool().unwrap_or_else(|| host.caller_thread().trim().is_empty());+    state["prove"] = json!({"planned": do_prove, "decidedBy": if args["prove"].is_boolean() { "argument" } else if do_prove { "caller: web control / anonymous" } else { "caller: AI thread (bind only; pass prove:true or use fusion_capture_library_views afterwards)" }});+    let stages: Vec<(&str, f64, bool)> = vec![("launch", 60.0, needs_launch), ("convert", 5.0, false), ("bind", per_part * n_parts as f64, !step_p.is_empty() || !parts_in.is_empty()), ("prove", 300.0, do_prove)];     let plan: Vec<Value> = stages.iter().filter(|(_, _, a)| *a).map(|(n, f, _)| { let (e, c) = estimate(&sd, &format!("stage:{n}"), *f); json!({"stage": n, "estimatedSec": (e.max(*f).max(1.0) * 10.0).round() / 10.0, "confidence": c}) }).collect();     let est_by: Map<String, Value> = plan.iter().map(|p| (p["stage"].as_str().unwrap().to_string(), p["estimatedSec"].clone())).collect();     let est_total: f64 = est_by.values().filter_map(|v| v.as_f64()).sum::<f64>().max(1.0);@@ -255,8 +261,10 @@ fn run_chain(host: Arc<dyn ChainHost>, run_id: String, args: Value, lbr_p: Strin                 let info = host.call("chain_addin_restart_info", json!({"fireToast": false}), 30);                 let mine: Vec<Value> = info["userUnsaved"].as_array().cloned().unwrap_or_default();                 let mut chosen = String::new();-                if mine.is_empty() {-                    enter(&mut state, "Fusion is open but its Adom add-in is not answering - restarting Fusion (only bridge documents are open)");+                if mine.is_empty() && args["allowRestart"].as_bool() == Some(true) {+                    // only with the caller's explicit allowRestart (issue #87): a chain never relaunches Fusion on its own+                    state["restarts"] = json!([{"at": now(), "why": args["restartReason"].as_str().unwrap_or("add-in not answering; caller passed allowRestart:true"), "kind": "fusion"}]);+                    enter(&mut state, "Fusion is open but its Adom add-in is not answering - restarting Fusion (caller allowed it; only bridge documents are open)");                     state["stageReason"] = json!("addin_not_answering"); state["restartChoice"] = json!("auto");                     state["restartReason"] = json!(format!("no unsaved user documents; open: {}", info["openDocuments"])); save(&state);                     let rr = host.call("restart_for_addin", json!({"save": true, "auto": true, "wait": true}), 900);@@ -265,7 +273,7 @@ fn run_chain(host: Arc<dyn ChainHost>, run_id: String, args: Value, lbr_p: Strin                 }                 if chosen != "restarted" {                     let names: Vec<String> = mine.iter().filter_map(|v| v.as_str().map(str::to_string)).collect();-                    enter(&mut state, &format!("Fusion is open but its Adom add-in is not answering - asking the user before restarting ({} unsaved document(s) look like the user's: {})", names.len(), names.join(", ").chars().take(70).collect::<String>()));+                    enter(&mut state, &format!("Fusion is open but its Adom add-in is not answering - not restarting on my own (pass allowRestart:true with a reason, or restart yourself); {} unsaved document(s) look like the user's: {}", names.len(), names.join(", ").chars().take(70).collect::<String>()));                     state["stageReason"] = json!("addin_not_answering");                     let ri = host.call("chain_addin_restart_info", json!({"fireToast": true}), 30);                     state["errorCode"] = json!("addin_restart_required"); state["userMessage"] = ri["userMessage"].clone(); state["documentsKnown"] = ri["documentsKnown"].clone();@@ -336,6 +344,11 @@ fn run_chain(host: Arc<dyn ChainHost>, run_id: String, args: Value, lbr_p: Strin             }         }         // ── prove ──+        if !do_prove {+            state["stage"] = json!("done"); state["success"] = json!(state["bind"]["success"].as_bool().unwrap_or(true) || state["bind"].is_null()); state["verdict"] = json!("bound_not_proven"); state["evidenceCount"] = json!(0);+            state["prove"]["skipped"] = json!(true); state["prove"]["why"] = json!("called from an AI thread: bind and place only (issue #88). Proof frames are for web control; ask for them with prove:true or fusion_capture_library_views {packages} for one part.");+            state["_hint"] = json!("Bound, not proven: every part with a urn in bind.parts is placed in the library. No evidence frames were captured because an AI thread asked; call fusion_capture_library_views {packages:[...]} to look at one part, or re-run with prove:true for the four frames per part (about 300 s per library).");+        } else {         enter(&mut state, "proving (four evidence frames)");         let names = std::fs::read_to_string(&target_lbr).ok().and_then(|x| fusion_lbr::artifact_names(&x).ok()).unwrap_or_default();         let want = args["name"].as_str().unwrap_or("").trim().to_string();@@ -438,6 +451,7 @@ fn run_chain(host: Arc<dyn ChainHost>, run_id: String, args: Value, lbr_p: Strin         } else {             state["stage"] = json!("done"); state["success"] = json!(all_ok); state["verdict"] = json!(if all_ok { "proven" } else { "not_proven" }); state["evidenceCount"] = json!(frames); state["prove"] = prove;         }+        }         if let Some(pub_) = args["_publishToCloud"].as_object() {             if state["bind"]["success"].as_bool() == Some(true) {                 state["stageLabel"] = json!("saving the library to your cloud Library Manager"); save(&state);@@ -448,7 +462,7 @@ fn run_chain(host: Arc<dyn ChainHost>, run_id: String, args: Value, lbr_p: Strin         }         let ok = state["stage"].as_str() == Some("done") && state["success"].as_bool() == Some(true);         banner.send("update", if ok { "Library imported" } else { "Import did not complete" },-                    &(if ok { format!("{} of {} parts proven", state["prove"]["partsProven"], state["prove"]["partsTotal"]) } else { state["error"].as_str().or(state["errorCode"].as_str()).unwrap_or("").chars().take(120).collect() }),+                    &(if ok && state["prove"]["skipped"].as_bool() == Some(true) { format!("{} part(s) bound; proof skipped (AI thread)", state["bind"]["partsBound"]) } else if ok { format!("{} of {} parts proven", state["prove"]["partsProven"], state["prove"]["partsTotal"]) } else { state["error"].as_str().or(state["errorCode"].as_str()).unwrap_or("").chars().take(120).collect() }),                     if ok { Some(100.0) } else { None }, Some(0.0), if ok { "done" } else { "failed" });         banner.hide_later(20);         state["partMetadata"] = crate::orchestrations::part_metadata_report(&target_lbr);@@ -553,11 +567,15 @@ pub fn install_library_bundle(host: Arc<dyn ChainHost>, args: Value) -> Value {             out["_hint"] = json!("Installed, but no deviceset could be paired with a STEP, so there is nothing to bind or prove. Add STEPs named like the deviceset or its package and call again.");             return out;         }-        let chain = create_library(host.clone(), json!({"libraryPath": lbr_path, "parts": parts, "_cleanupAfterBind": [split_dir.to_string_lossy()],+        let ai_thread = !host.caller_thread().trim().is_empty();+        let prove = args["prove"].as_bool().unwrap_or(!ai_thread);+        let chain = create_library(host.clone(), json!({"libraryPath": lbr_path, "parts": parts, "prove": prove, "allowRestart": args["allowRestart"], "restartReason": args["restartReason"], "_cleanupAfterBind": [split_dir.to_string_lossy()],             "_publishToCloud": {"name": lib_name, "folder": "Adom Libraries", "replaceExisting": args["replaceExisting"].as_bool().unwrap_or(false)}}));         for k in ["started", "attached", "runId", "statusVerb", "stage"] { out[k] = chain[k].clone(); }         if chain["success"].as_bool() != Some(true) { out["success"] = json!(false); out["error"] = chain["error"].clone(); out["errorCode"] = chain["errorCode"].clone(); }-        out["_hint"] = json!("Installed to libraryPath. The bind+prove chain is running in the background (it outlives the relay window): poll fusion_prove_result {runId}. partsBound < partsTotal means named skips in unbound[].");+        let prove_est = plan["plan"].as_array().and_then(|a| a.iter().find(|s| s["stage"] == "prove")).and_then(|s| s["estimatedSec"].as_f64()).unwrap_or(300.0);+        out["prove"] = json!({"planned": prove, "decidedBy": if args["prove"].is_boolean() { "argument" } else if ai_thread { "caller: AI thread, bind only" } else { "caller: web control / anonymous, proves" }, "estimatedSec": if prove { prove_est } else { 0.0 }});+        out["_hint"] = json!(if prove { format!("Installed to libraryPath. The bind chain runs in the background, then PROVE captures four evidence frames per part (about {prove_est:.0} s more): poll fusion_prove_result {{runId}}. From an AI thread pass prove:false to skip proof (bind and place only).") } else { "Installed to libraryPath. The bind chain runs in the background (bind and place only, no proof frames: this call came from an AI thread): poll fusion_prove_result {runId} for partsBound and the library path; fusion_capture_library_views {packages} shows one part when you want to look; prove:true re-runs with the frames.".to_string() });         out     })();     if let Some(w) = work { let _ = std::fs::remove_dir_all(w); }
rust/bridge/src/launch.rs+5−1
@@ -322,11 +322,15 @@ pub fn gate(ctx: &Ctx, cmd: &str, args: &Value) -> Option<Value> {             "errorCode": "fusion_host_hung", "hungSignals": h["signals"], "restartAttempted": true,             "_hint": "Do NOT hunt for a blocking dialog - there is not one. Nothing reaches Fusion over IPC while the host is hung. The bridge force-restarted it; poll fusion_readiness."}));     }-    if matches!(h["phase"].as_str(), Some("suspect") | Some("busy-not-hung")) {+    if matches!(h["phase"].as_str(), Some("suspect") | Some("busy-not-hung") | Some("long-op-in-flight")) {         return Some(json!({"success": false, "error": "Fusion's main thread is not answering right now - most likely WORKING on something heavy (a STEP import or save starves the add-in's probes), not hung.",             "errorCode": "fusion_addin_busy", "hostResponding": h["phase"].as_str() == Some("busy-not-hung"), "hungHeldSeconds": h["heldSeconds"],             "_hint": "WAIT and re-poll; do not restart anything. The bridge only declares a real hang after 180s of continuous silence including a 20s probe - if that happens it restarts Fusion itself and says so."}));     }+    // A modal in front of the add-in is never a reason to restart (issue #87, John: "screenshot+    // itself using ab ... give the ai the parent/child array of screenshots so the ai can figure+    // out how to expire that dialog box"). Hand the pictures over instead.+    if let Some(b) = crate::lifecycle::blocked_by_dialog_reply(ctx, cmd) { return Some(b); }     let ri = crate::chainexec::addin_restart_info(ctx, &json!({"fireToast": true}), &|v, a| crate::addin::addin_http(v, &a, 8));     if ri["autoRestart"].as_bool() == Some(true) {         let started = begin_auto_addin_restart(ctx);
rust/bridge/src/lifecycle.rs+88−14
@@ -183,6 +183,80 @@ fn begin_blocked_restart(ctx: &Ctx, blocking: &[Value], held: f64) -> bool {     true } +/// Every dialog in front of Fusion, read programmatically through UIA: title, category (when a+/// rule matches), the visible text and the button names. No pixels involved.+pub fn dialog_inventory(ctx: &Ctx) -> Vec<Value> {+    let Some(ab) = Loopback::from_env() else { return vec![] };+    let mut args = json!({}); if !ctx.pids.is_empty() { args["pids"] = json!(ctx.pids); }+    let rows = match ab.call("desktop_enum_windows", args, &ctx.caller, 10) { Ok(v) => v["windows"].as_array().or_else(|| v["data"]["windows"].as_array()).cloned().unwrap_or_default(), Err(_) => return vec![] };+    let classified = crate::small_verbs::classify_blocking_dialogs(&ab, &ctx.caller, &ctx.pids);+    let mut out = Vec::new();+    for d in fusion_core::dialogs::qt_dialog_rows(&rows) {+        let h = d["hwnd"].as_i64().unwrap_or(0);+        let (mut text, mut buttons): (Vec<String>, Vec<String>) = (vec![], vec![]);+        if let Ok(r) = ab.call("desktop_find_controls", json!({"hwnd": h}), &ctx.caller, 10) {+            let dd = if r["data"].is_object() { r["data"].clone() } else { r };+            for c in dd["controls"].as_array().or(dd["matches"].as_array()).cloned().unwrap_or_default() {+                let name = c["name"].as_str().unwrap_or("").trim().to_string();+                if name.is_empty() { continue; }+                let ty = c["controlType"].as_str().or(c["type"].as_str()).or(c["role"].as_str()).unwrap_or("").to_lowercase();+                if ty.contains("button") { buttons.push(name); } else { text.push(name); }+            }+        }+        let known = classified.iter().find(|c| c["hwnd"].as_i64() == Some(h)).cloned();+        out.push(json!({"hwnd": h, "title": d["title"], "category": known.as_ref().map(|k| k["category"].clone()).unwrap_or(json!("unknown")),+            "resolution": known.as_ref().map(|k| k["resolution"].clone()).unwrap_or(Value::Null),+            "text": text.join(" ").chars().take(600).collect::<String>(), "buttons": buttons}));+    }+    out+}++/// Try to expire the dialogs in front of Fusion without a person and without pictures: the+/// known ones through their rule (fusion_dismiss_blocking_dialogs, WM_CLOSE semantics, never a+/// Yes), the unknown ones with WM_CLOSE (the same as pressing Cancel or the X: nothing is saved+/// or discarded by it). Returns what was tried and what is still up.+pub fn expire_dialogs(ctx: &Ctx, before: &[Value]) -> (Vec<Value>, Vec<Value>) {+    let mut tried = Vec::new();+    let Some(ab) = Loopback::from_env() else { return (tried, before.to_vec()) };+    if before.iter().any(|d| d["category"].as_str() != Some("unknown")) {+        let r = crate::small_verbs::dismiss_blocking_dialogs(&ctx.state_dir, &json!({}), &ctx.caller, &ctx.pids, &|_| {});+        tried.push(json!({"step": "fusion_dismiss_blocking_dialogs (known rules)", "dismissed": r["dismissed"]}));+    }+    for d in before.iter().filter(|d| d["category"].as_str() == Some("unknown")) {+        if let Some(h) = d["hwnd"].as_i64() {+            let r = ab.call("desktop_close_window", json!({"hwnd": h}), &ctx.caller, 10);+            tried.push(json!({"step": "WM_CLOSE (Cancel/X semantics)", "hwnd": h, "title": d["title"], "ok": r.is_ok()}));+        }+    }+    std::thread::sleep(Duration::from_millis(800));+    let after = dialog_inventory(ctx);+    (tried, after)+}++/// When a dialog is in front of Fusion, the reply the gate gives INSTEAD of any restart: first+/// an automatic, safe expiry (rules, then WM_CLOSE); if that clears it the verb simply runs. Only+/// a dialog that survives comes back to the caller, as text and buttons read through UIA first+/// and ab's parent/child screenshots second (issue #87, John: read the text, send the escape,+/// pictures only when that is not enough).+pub fn blocked_by_dialog_reply(ctx: &Ctx, cmd: &str) -> Option<Value> {+    let before = dialog_inventory(ctx);+    let shot0 = crate::small_verbs::screenshot_fusion(&json!({}), &ctx.caller);+    if before.is_empty() && shot0["ownedPopupCount"].as_u64().unwrap_or(0) == 0 { return None; }+    let (tried, after) = expire_dialogs(ctx, &before);+    let shot = crate::small_verbs::screenshot_fusion(&json!({}), &ctx.caller);+    let popups = shot["ownedPopupCount"].as_u64().unwrap_or(0);+    crate::fusion_prefs::action_ledger(&ctx.state_dir, "blocked_by_dialog", json!({"forCommand": cmd, "before": before.iter().map(|d| d["title"].clone()).collect::<Vec<_>>(), "tried": tried, "after": after.iter().map(|d| d["title"].clone()).collect::<Vec<_>>(), "ownedPopups": popups}));+    if after.is_empty() && popups == 0 {+        eprintln!("[fusion-bridge] {cmd}: expired {} dialog(s) programmatically before running", before.len());+        return None;+    }+    let titles = after.iter().map(|d| format!("'{}' ({})", d["title"].as_str().unwrap_or(""), d["category"].as_str().unwrap_or("unknown"))).collect::<Vec<_>>().join(", ");+    Some(json!({"success": false, "errorCode": "blocked_by_dialog", "hostResponding": true, "dialogs": after, "expiryTried": tried,+        "screenshots": {"mainWindow": shot["savedTo"], "ownedPopupCount": popups, "windows": shot["screenshots"]},+        "error": format!("'{cmd}' cannot run: a dialog is in front of Fusion ({}) and the bridge's safe expiry (rules, then WM_CLOSE) did not clear it. The bridge does not restart Fusion over a dialog.", if titles.is_empty() { format!("{popups} owned popup window(s) with no readable controls") } else { titles }),+        "_hint": "dialogs[] carries each box read through UIA: its text and its button names, plus the bridge's resolution when a rule matched. Answer it by name: desktop_ui_click {hwnd, name:\"<button>\"} for the button that does what you want (never a Save/Yes on a scratch document you do not own), or desktop_close_window {hwnd} to cancel. screenshots.windows is ab's parent/child array for when the text is not enough. Then re-run the verb; never restart Fusion for this."}))+}+ 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) {@@ -314,21 +388,21 @@ 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."});+                // Issue #87: a modal that blocks the add-in is handed to the caller as pictures+                // (the main window plus every owned child window) and classified rows; the bridge+                // never restarts Fusion over a dialog. The 90 s auto-restart of 1.10.6 is gone.+                let inv = dialog_inventory(ctx);+                let (tried, after) = expire_dialogs(ctx, &inv);+                let popups = crate::small_verbs::screenshot_fusion(&json!({}), &ctx.caller);+                if !inv.is_empty() && after.is_empty() && popups["ownedPopupCount"].as_u64().unwrap_or(0) == 0 {+                    crate::fusion_prefs::action_ledger(&ctx.state_dir, "dialog_expired_after_timeout", json!({"forCommand": command, "dialogs": inv.iter().map(|d| d["title"].clone()).collect::<Vec<_>>(), "tried": tried}));+                    return json!({"success": false, "errorCode": "dialog_expired_retry", "dialogsExpired": inv, "expiryTried": tried,+                        "error": format!("'{command}' timed out behind a dialog ({}); the bridge expired it programmatically (rules, then WM_CLOSE). Re-run the verb.", inv.iter().map(|d| d["title"].as_str().unwrap_or("").to_string()).collect::<Vec<_>>().join(", ")),+                        "_hint": "The dialog is gone and Fusion answers again: re-run the same verb now. Nothing was restarted."});                 }-                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 auto_note = " The bridge will NOT restart Fusion over a dialog: dialogs[] carries each box's text and buttons read through UIA; answer it by button name (desktop_ui_click {hwnd, name}) or cancel it (desktop_close_window {hwnd}), then re-run the verb.".to_string();+                let blocking = if after.is_empty() { blocking } else { after.clone() };+                let _ = &popups;                 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); } }
rust/bridge/src/main.rs+1
@@ -1304,6 +1304,7 @@ fn gather_readiness_inputs(h: &dyn HostBoth, caller: &Caller) -> fusion_core::re /// The exe as the chains' host: sidecar verbs through the proxy, the add-in directly. struct ExeChainHost { sidecar: Arc<Mutex<Option<Sidecar>>>, caller: Caller, state_dir: std::path::PathBuf } impl chains::ChainHost for ExeChainHost {+    fn caller_thread(&self) -> String { self.caller.thread.clone() }     fn call(&self, verb: &str, args: Value, timeout_secs: u64) -> Value {         // the bind and prove leaves are native when the exe owns the add-in; everything else         // the chain asks for is a sidecar executor
skills/fusion-no-modal-dialogs/SKILL.md+11
@@ -202,3 +202,14 @@ save or close the affected task document. The warning alone does not establish w recovery failed; inspect disk/path/permissions if it recurs in a clean session. Never silence the warning by disabling recovery. The advisory response does not inspect recovery health and does not automatically save or close documents.++## What the bridge does when a dialog still appears (1.10.17)++The bridge never restarts Fusion over a dialog. Before any verb that needs the add-in, and+after a verb times out behind one, it reads every dialog through UIA (title, text, button+names), applies the rule for the ones it knows, sends WM_CLOSE (Cancel or the X: never a+save or a discard) to the ones it does not, and runs the verb when that clears the screen+(`dialog_expired_retry` means re-run). A dialog that survives comes back as `blocked_by_dialog`+with `dialogs[]` (text and buttons) first and ab's parent/child screenshots second: answer it by+button name with `desktop_ui_click {hwnd, name}` or cancel it with `desktop_close_window {hwnd}`,+then re-run. Never restart Fusion for a dialog.
skills/fusion-web-control/SKILL.md+11
@@ -79,6 +79,7 @@ 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 |+| `blocked_by_dialog` | A dialog survived the bridge's safe expiry (rules, then WM_CLOSE) | Read `dialogs[]` (text, buttons); answer by button name, then re-run; never restart | | `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 |@@ -268,3 +269,13 @@ the action ledger, so "why did my Fusion restart" has a timestamped answer.  Rule for any toast: if the body describes something the bridge *could do*, the buttons must let the user say yes or no to exactly that. A bare "Got it" is only for pure FYI.++## Prove is for web control; an AI thread binds only (1.10.17)++`fusion_install_library_bundle` and `fusion_create_library` decide the prove step from the+caller: web control (anonymous, the wiki page shows the frames) proves; an AI thread (caller+headers set) binds and places only and gets `verdict: bound_not_proven` with `partsBound` and+the library path. `prove:true` asks for the four frames per part (about 300 s per library);+`fusion_capture_library_views {packages}` shows one part on demand. A chain never restarts+Fusion on its own: pass `allowRestart:true` with `restartReason` or it surfaces+`addin_restart_required` and waits, resumable; any restart it did perform is in `restarts[]`.
tools/check-naming.sh

  
tools/check-portable.sh

  
tools/promote.sh

  
tools/release.sh