← Commit history
BRIDGE_VERSION+1−1
@@ -1 +1 @@-1.10.36\ No newline at end of file+1.10.37\ 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.36",+  "version": "1.10.37",   "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+10−4
@@ -1,6 +1,6 @@ {   "contract": "fusion-bridge verb catalog",-  "frozen": "2026-09-21",+  "frozen": "2026-09-22",   "note": "The Python bridge and the Rust bridge must both serve this catalog byte-for-byte in meaning: same names, same args, same result shapes, same timeouts. Regenerate ONLY from describe.py and diff in CI.",   "source": "describe.py",   "verbs": [@@ -2559,12 +2559,14 @@       "longRunning": false,       "name": "fusion_screenshot_fusion",       "output": {+        "fullPath": "str (auto-pulled by ab, 1.10.37)",+        "safePath": "str",         "savedTo": "str",         "sizeKB": "int",         "success": "bool"       },       "statusVerb": null,-      "summary": "Background PrintWindow capture of Fusion's MAIN window (largest same-title window; works occluded/bottom-of-z-order, never raises anything) or a specific dialog by hwnd. Returns savedTo (PNG/WebP on the box; pull with pull_file). (Live since early versions but undescribed until 2026-08-15 - it was invisible to callers AND to the drift check.)",+      "summary": "Background PrintWindow capture of Fusion's MAIN window (largest same-title window; works occluded/bottom-of-z-order, never raises anything) or a specific dialog by hwnd. Returns fullPath/safePath (ab auto-pulls them to the caller as localPath, 1.10.37) plus the older savedTo. (Live since early versions but undescribed until 2026-08-15 - it was invisible to callers AND to the drift check.)",       "timeoutSeconds": 60     },     {@@ -2607,7 +2609,10 @@       },       "input": {         "action": "optional str (apply|list|export|show)",-        "druPath": "optional str local .edru/.dru to load as-is",+        "druBase64": "optional str: the same, base64-encoded",+        "druName": "optional str: file name for the staged copy",+        "druPath": "optional str .edru/.dru ALREADY on the Fusion machine, loaded as-is",+        "druText": "optional str: the whole .edru/.dru file as text, for rules that exist only on YOUR side (a container). The bridge stages it on the box and loads it (1.10.37; replaces ab's removed fusion_load_design_rules)",         "fab": "optional str vendorSlug (jlcpcb|pcbway|osh-park|seeed-fusion|eurocircuits|aisler|elecrow|advanced-circuits-4pcb|sierra-circuits|macrofab|nextpcb|allpcb|adom-instapcb)",         "layers": "optional str (auto|2|4|6)",         "process": "optional str (the vendor's tier: standard|advanced|proto|enig|hasl|...; default standard)"@@ -2620,7 +2625,7 @@         "pack": "dict {fab, vendor, process, layers, file, record}"       },       "statusVerb": null,-      "summary": "Load a FAB's design rules (DRC) into the open board. The rule packs live on the wiki page adom/pcb-design-rules (JLCPCB, PCBWay, OSH Park, Seeed, Eurocircuits, Aisler, Elecrow, Advanced Circuits, Sierra, MacroFab, NextPCB, ALLPCB and Adom InstaPCB; one .edru per vendor/process/layer count, every number citing the vendor's own capabilities page): the bridge fetches the pack, stages it on the box, and the add-in runs `drc load`. {action:'list'} answers with every (fab, process, layers) row so you can offer the user a real choice; no fab means the bundled Adom InstaPCB rules; druPath loads a local .edru. Layers auto-detects from the open board. ALWAYS confirm with fusion_alerts afterwards: a refused file is an in-canvas toast, never a dialog.",+      "summary": "Load a FAB's design rules (DRC) into the open board. The rule packs live on the wiki page adom/pcb-design-rules (JLCPCB, PCBWay, OSH Park, Seeed, Eurocircuits, Aisler, Elecrow, Advanced Circuits, Sierra, MacroFab, NextPCB, ALLPCB and Adom InstaPCB; one .edru per vendor/process/layer count, every number citing the vendor's own capabilities page): the bridge fetches the pack, stages it on the box, and the add-in runs `drc load`. {action:'list'} answers with every (fab, process, layers) row so you can offer the user a real choice; no fab means the bundled Adom InstaPCB rules for the board's layer count (what ab's removed fusion_apply_instapcb_rules did); druPath loads an .edru already on the Fusion machine; druText/druBase64 loads one that exists only on your side. Layers auto-detects from the open board. ALWAYS confirm with fusion_alerts afterwards: a refused file is an in-canvas toast, never a dialog.",       "timeoutSeconds": 90     },     {@@ -3014,6 +3019,7 @@       "longRunning": true,       "name": "fusion_take_screenshot",       "output": {+        "fullPath": "str (auto-pulled by ab, 1.10.37)",         "path": "str",         "success": "bool"       },
dashboard/Cargo.lock+1−1
@@ -4,7 +4,7 @@ version = 4  [[package]] name = "fusion-dashboard"-version = "1.10.36"+version = "1.10.37" dependencies = [  "serde_json", ]
dashboard/Cargo.toml+1−1
@@ -1,6 +1,6 @@ [package] name = "fusion-dashboard"-version = "1.10.36"+version = "1.10.37" edition = "2021"  [dependencies]
describe.py+4−4
@@ -261,7 +261,7 @@ _T = [      {"runId": "required str (from fusion_new_electronics_from_eagle)"},      {"success": "bool", "stage": "str", "documentName": "str", "partsExpected": "int", "partsPlaced": "int", "result": "obj"}, 20, None, False, {"runId": "1a2b3c"}),     ("fusion_make_test_project", "Create a NEW electronics test project FROM SCRATCH around a library part - the Web Control demo's PROJECT button, and the answer to 'a customer only has a component page'. Generates a minimal test circuit server-side (the part + a generated 330R series resistor with VCC/GND stubs on a small board) as real EAGLE .sch/.brd, then imports the pair as a new electronics design. NO cloud, NO sign-in, no pre-existing content. After it, fusion_show_schematic / fusion_show_2d_board / fusion_show_3d_board all work on the result. Wiring uses the deviceset's own connects (first two pins), so 2-terminal parts get a real circuit and big ICs get a structurally-valid integration proof.", {"libraryPath": "required str (WINDOWS path .lbr; stage from container first)", "deviceset": "optional str (defaults to the library's first deviceset)", "device": "optional str: the DEVICE (package variant) to place, e.g. \"-HV\" (default: the deviceset's first device)"}, {"success": "bool", "schPath": "str", "brdPath": "str", "circuit": "str"}, 300, None, True, {"libraryPath": "C:/Temp/IN-S42ATR.lbr"}),-    ("fusion_screenshot_fusion", "Background PrintWindow capture of Fusion's MAIN window (largest same-title window; works occluded/bottom-of-z-order, never raises anything) or a specific dialog by hwnd. Returns savedTo (PNG/WebP on the box; pull with pull_file). (Live since early versions but undescribed until 2026-08-15 - it was invisible to callers AND to the drift check.)", {"hwnd": "optional int (a dialog; default the main window)"}, {"success": "bool", "savedTo": "str", "sizeKB": "int"}, 60, None, False, {}),+    ("fusion_screenshot_fusion", "Background PrintWindow capture of Fusion's MAIN window (largest same-title window; works occluded/bottom-of-z-order, never raises anything) or a specific dialog by hwnd. Returns fullPath/safePath (ab auto-pulls them to the caller as localPath, 1.10.37) plus the older savedTo. (Live since early versions but undescribed until 2026-08-15 - it was invisible to callers AND to the drift check.)", {"hwnd": "optional int (a dialog; default the main window)"}, {"success": "bool", "fullPath": "str (auto-pulled by ab, 1.10.37)", "safePath": "str", "savedTo": "str", "sizeKB": "int"}, 60, None, False, {}),     ("fusion_bind_library_3d", "LOW-LEVEL primitive: binds ONE STEP onto one deviceset. Prefer fusion_create_library with parts[] (which handles one part or many through a single path); reach for this only when you are driving a single bind deliberately. The CORRECT library in ONE call (the Library button's real bar: the 3D chip renders in the Content Manager preview): mints the part's 3D as a cloud f3d (make_3d_package), injects the wip_urn binding into the .lbr ON DISK (packages3d + package3dinstances), and reopens the library. Needs a signed-in Fusion session - the one cloud-dependent step in the wiki-to-EE circle. Autodesk's thumbnail preview lags ~2-3 min behind a fresh mint. NOTE: distinct from the older fusion_build_library_3d (multi-part batch pipeline).", {"libraryPath": "required str (WINDOWS .lbr path)", "stepPath": "required str (WINDOWS .step path)", "deviceset": "optional str (defaults to the library's first)"}, {"success": "bool", "wip_urn": "str", "bound": "bool", "reopened": "bool"}, 480, None, True, {"libraryPath": "C:/Temp/PART.lbr", "stepPath": "C:/Temp/PART.step"}),     ("fusion_show_library", "Eight-surfaces contract: the LIBRARY surface. libraryPath (WINDOWS .lbr) opens it; name alone handles the already-open case (symmetric with kicad_show_library). Background; returns a screenshot path.", {"capture": "optional bool - ONE call = ONE consent: the bridge settles the surface (waits for the add-in main thread + a paint beat) and returns proof inline as shot:{title,image,thumbnailDataUrl,maxWidth} (base64 frame + a ready-to-use data: URL for BROWSER callers who cannot pull a file; set captureMaxWidth to shrink it - 320 keeps a /command body small, default 640. The bridge encodes PNG; the relay may transcode to WebP, so sniff rather than assume). Use this instead of polling fusion_state after a show - page-side settle polling costs one human consent per poll (issue #30).", "libraryPath": "optional str (WINDOWS .lbr)", "name": "optional str (already-open library name)"}, {"success": "bool", "view": "str", "screenshot": "str"}, 240, None, True, {"libraryPath": "C:/Temp/PART.lbr"}),     ("fusion_show_project", "Eight-surfaces contract: bring the PROJECT/design to its top level. No args = activate this session's generated test project (fusion_make_test_project). query = cloud design name (already-open match wins, else fire-and-poll open). urn = open_by_urn passthrough. Background; returns a screenshot path.", {"capture": "optional bool - ONE call = ONE consent: the bridge settles the surface (waits for the add-in main thread + a paint beat) and returns proof inline as shot:{title,image,thumbnailDataUrl,maxWidth} (base64 frame + a ready-to-use data: URL for BROWSER callers who cannot pull a file; set captureMaxWidth to shrink it - 320 keeps a /command body small, default 640. The bridge encodes PNG; the relay may transcode to WebP, so sniff rather than assume). Use this instead of polling fusion_state after a show - page-side settle polling costs one human consent per poll (issue #30).", "query": "optional str (cloud design name)", "urn": "optional str", "fileName": "alias of query"}, {"success": "bool", "view": "str", "document": "str", "screenshot": "str"}, 240, "fusion_get_app_state", True, {"query": "BQ25792"}),@@ -289,8 +289,8 @@ _T = [      {"outputPath": "optional str", "grouped": "optional bool (default true)"}, {"componentCount": "int", "grouped": "bool"}, 60, None, False, {"outputPath": "C:/tmp/bom.csv"}),     ("fusion_export_cpl", "Export the Component Placement List (pick-and-place) CSV.",      {"outputPath": "optional str", "side": "optional str (all|top|bottom)"}, {"topCount": "int", "bottomCount": "int"}, 60, None, False, {"outputPath": "C:/tmp/cpl.csv"}),-    ("fusion_set_design_rules", "Load a FAB's design rules (DRC) into the open board. The rule packs live on the wiki page adom/pcb-design-rules (JLCPCB, PCBWay, OSH Park, Seeed, Eurocircuits, Aisler, Elecrow, Advanced Circuits, Sierra, MacroFab, NextPCB, ALLPCB and Adom InstaPCB; one .edru per vendor/process/layer count, every number citing the vendor's own capabilities page): the bridge fetches the pack, stages it on the box, and the add-in runs `drc load`. {action:'list'} answers with every (fab, process, layers) row so you can offer the user a real choice; no fab means the bundled Adom InstaPCB rules; druPath loads a local .edru. Layers auto-detects from the open board. ALWAYS confirm with fusion_alerts afterwards: a refused file is an in-canvas toast, never a dialog.",-     {"fab": "optional str vendorSlug (jlcpcb|pcbway|osh-park|seeed-fusion|eurocircuits|aisler|elecrow|advanced-circuits-4pcb|sierra-circuits|macrofab|nextpcb|allpcb|adom-instapcb)", "process": "optional str (the vendor's tier: standard|advanced|proto|enig|hasl|...; default standard)", "layers": "optional str (auto|2|4|6)", "druPath": "optional str local .edru/.dru to load as-is", "action": "optional str (apply|list|export|show)"}, {"activeWorkspace": "str", "description": "str", "pack": "dict {fab, vendor, process, layers, file, record}"}, 90, None, False, {"fab": "jlcpcb", "layers": "2"}),+    ("fusion_set_design_rules", "Load a FAB's design rules (DRC) into the open board. The rule packs live on the wiki page adom/pcb-design-rules (JLCPCB, PCBWay, OSH Park, Seeed, Eurocircuits, Aisler, Elecrow, Advanced Circuits, Sierra, MacroFab, NextPCB, ALLPCB and Adom InstaPCB; one .edru per vendor/process/layer count, every number citing the vendor's own capabilities page): the bridge fetches the pack, stages it on the box, and the add-in runs `drc load`. {action:'list'} answers with every (fab, process, layers) row so you can offer the user a real choice; no fab means the bundled Adom InstaPCB rules for the board's layer count (what ab's removed fusion_apply_instapcb_rules did); druPath loads an .edru already on the Fusion machine; druText/druBase64 loads one that exists only on your side. Layers auto-detects from the open board. ALWAYS confirm with fusion_alerts afterwards: a refused file is an in-canvas toast, never a dialog.",+     {"fab": "optional str vendorSlug (jlcpcb|pcbway|osh-park|seeed-fusion|eurocircuits|aisler|elecrow|advanced-circuits-4pcb|sierra-circuits|macrofab|nextpcb|allpcb|adom-instapcb)", "process": "optional str (the vendor's tier: standard|advanced|proto|enig|hasl|...; default standard)", "layers": "optional str (auto|2|4|6)", "druPath": "optional str .edru/.dru ALREADY on the Fusion machine, loaded as-is", "druText": "optional str: the whole .edru/.dru file as text, for rules that exist only on YOUR side (a container). The bridge stages it on the box and loads it (1.10.37; replaces ab's removed fusion_load_design_rules)", "druBase64": "optional str: the same, base64-encoded", "druName": "optional str: file name for the staged copy", "action": "optional str (apply|list|export|show)"}, {"activeWorkspace": "str", "description": "str", "pack": "dict {fab, vendor, process, layers, file, record}"}, 90, None, False, {"fab": "jlcpcb", "layers": "2"}),     ("fusion_detect_layers", "Detect 2-layer vs 4-layer board (ULP + CAM comparison).",      {}, {"layerCount": "int", "copperLayers": "[int]"}, 90, None, False, {}),     ("fusion_dismiss_blocking_dialogs", "Detect + classify (and optionally dismiss) blocking Fusion dialogs. Answers every one with the safe button (No/Cancel via WM_CLOSE, never Yes, never focus-stolen). {categories:[...]} narrows the sweep to those categories (e.g. [\"crash_report\"], the launch janitor's scope).",@@ -326,7 +326,7 @@ _T = [      {"name": "required str", "projectName": "optional str", "folderPath": "optional str", "description": "optional str"},      {"success": "bool", "urn": "str"}, 180, None, False, {"name": "TJA1050", "projectName": "Main"}),     ("fusion_take_screenshot", "Screenshot the Fusion window in the BACKGROUND (no focus steal, reads through occlusion). Use it to VERIFY your own work: geometry at the wrong Z, an empty view, or a Placeholder 3D body all render successfully and only a look at the pixels catches them.",-     {"outputPath": "optional str"}, {"success": "bool", "path": "str"}, 60, None, True, {}),+     {"outputPath": "optional str"}, {"success": "bool", "fullPath": "str (auto-pulled by ab, 1.10.37)", "path": "str"}, 60, None, True, {}),     ("fusion_verb_times", "This machine MEASURED timings per verb/stage - the same store that paces every progress bar and ETA. Read it to see whether an estimate is measured (this box has run it) or typical (still the seeded literal), and to explain a slow run.",      {}, {"stages": "{name: {count, p50, p90}}"}, 15, None, True, {}),     ("fusion_library_parts", "Every ORDERABLE variant in a .lbr with its part numbers - offline, no Fusion needed. For an EE this is the point of the library: two variants can share ONE symbol, ONE footprint and ONE 3D body and still be different products (an AEC-Q100 automotive part vs the baseline one), and that difference lives ENTIRELY in the metadata. EAGLE spells the orderable identity as deviceset + device + technology: <device> carries the PACKAGE variant, <technology> carries the GRADE/temperature variant, and attributes hang off <technology>. Reports per-variant MPN / MANUFACTURER / MOUSER_PN / DIGIKEY_PN / LCSC_PN (the exact names this bridge's own BOM export reads), plus warnings when variants are indistinguishable, share an MPN, or a deviceset has no description. These warnings never block the library from opening - they say whether an EE could actually order from it.",
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.36"+DASH_VERSION="1.10.37" 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.36",+  "version": "1.10.37",   "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+10−26
@@ -1,6 +1,6 @@ {   "slug": "fusion-bridge",-  "version": "1.10.36",+  "version": "1.10.37",   "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",@@ -76,6 +80,9 @@     "add a 3d model to a component",     "make a fusion 360 library with 3d",     "set up the fusion bridge",+    "fusion dashboard",+    "fusion status page",+    "open the fusion launch page",     "aps not configured",     "share aps client id across my org",     "set up fusion cloud search for my company",@@ -108,28 +115,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.36"+version = "1.10.37" edition = "2021" license = "MIT" 
rust/bridge/src/lifecycle.rs+8−2
@@ -222,9 +222,11 @@ pub fn dialog_inventory(ctx: &Ctx) -> Vec<Value> {             }         }         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")),+        let mut e = 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}));+            "text": text.join(" ").chars().take(600).collect::<String>(), "buttons": buttons});+        if let Some(k) = &known { if !k["closable"].is_null() { e["closable"] = k["closable"].clone(); } if !k["notClosableWhy"].is_null() { e["notClosableWhy"] = k["notClosableWhy"].clone(); } }+        out.push(e);     }     out }@@ -241,6 +243,10 @@ pub fn expire_dialogs(ctx: &Ctx, before: &[Value]) -> (Vec<Value>, Vec<Value>) {         tried.push(json!({"step": "fusion_dismiss_blocking_dialogs (known rules)", "dismissed": r["dismissed"]}));     }     for d in before.iter().filter(|d| matches!(d["category"].as_str(), Some("unknown") | Some("file_dialog"))) {+        if d["closable"].as_bool() == Some(false) {+            tried.push(json!({"step": "left open (not a dialog)", "hwnd": d["hwnd"], "title": d["title"], "why": d["notClosableWhy"]}));+            continue;+        }         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()}));
rust/bridge/src/main.rs+2−1
@@ -426,12 +426,13 @@ fn main() {                             "activate_document" => orchestrations::activate_with_same_name_note(&parsed["args"], &pt),                             _ => addin::pass_through(&cmd, &parsed["args"], &ctx),                         };+                        small_verbs::add_image_delivery(&mut r);                         ind.stop(r["success"].as_bool() == Some(false));                         timings::log_run(&sd, &cmd, t0.elapsed().as_secs_f64(), r["success"].as_bool() != Some(false));                         r["shell"] = json!("rust"); r },                     "fusion_start" | "start" | "launch" if addin_owner_is_exe(&**h) => { let (sc, ca) = (sidecar.clone(), caller.clone()); let side = move |verb: &str, a: Value, t: u64| proxy_with_timeout(&sc, &json!({"command": verb, "args": a}).to_string(), &ca, t); let sd = h.bridge_state_dir(); let dlg = |r: Value| r; let ctx = addin::Ctx { host: &**h, fusion_exe: h.fusion_executable(), short_path: |p| host().short_path(p), state_dir: sd.clone(), caller: caller.clone(), pids: h.fusion_processes().iter().map(|p| p.pid).collect(), sidecar: &side, dialogs: &dlg }; let t0 = Instant::now(); let ind = timings::Indicator::start(&sd, "start", &caller); let mut r = launch::start(&ctx, &parsed["args"]); ind.stop(r["success"].as_bool() == Some(false)); timings::log_run(&sd, "start", t0.elapsed().as_secs_f64(), r["success"].as_bool() != Some(false)); r["shell"] = json!("rust"); r },                     "fusion_open_lbr" | "open_lbr" if addin_owner_is_exe(&**h) => { let (sc, ca, ca2) = (sidecar.clone(), caller.clone(), caller.clone()); let side = move |verb: &str, a: Value, t: u64| proxy_with_timeout(&sc, &json!({"command": verb, "args": a}).to_string(), &ca, t); let sd = h.bridge_state_dir(); let pids: Vec<u32> = h.fusion_processes().iter().map(|p| p.pid).collect(); let dlg = |r: Value| small_verbs::merge_dialog_array(r, &ca2, &pids); let ctx = addin::Ctx { host: &**h, fusion_exe: h.fusion_executable(), short_path: |p| host().short_path(p), state_dir: sd.clone(), caller: caller.clone(), pids: pids.clone(), sidecar: &side, dialogs: &dlg }; let t0 = Instant::now(); let ind = timings::Indicator::start(&sd, "open_lbr", &caller); let mut r = orchestrations::open_lbr(&ctx, &parsed["args"], &|v, a| addin::pass_through(v, &a, &ctx)); ind.stop(r["success"].as_bool() == Some(false)); timings::log_run(&sd, "open_lbr", t0.elapsed().as_secs_f64(), r["success"].as_bool() != Some(false)); r["shell"] = json!("rust"); r },-                    v @ ("fusion_show_symbol" | "show_symbol" | "fusion_show_footprint" | "show_footprint" | "fusion_show_library" | "show_library" | "fusion_show_project" | "show_project" | "fusion_show_schematic" | "show_schematic" | "fusion_show_2d_board" | "show_2d_board" | "fusion_show_3d_board" | "show_3d_board" | "fusion_capture_library_views" | "capture_library_views" | "fusion_open_cloud_file" | "open_cloud_file" | "fusion_save_lbr" | "save_lbr" | "fusion_cleanup_cloud_files" | "cleanup_cloud_files" | "fusion_inspect_bodies" | "inspect_bodies" | "fusion_board_stackup" | "board_stackup" | "fusion_export_optimized_glb" | "export_optimized_glb" | "fusion_fetch_optimized_glb" | "fetch_optimized_glb" | "fusion_set_design_rules" | "set_design_rules" | "fusion_make_test_project" | "make_test_project" | "fusion_make_showcase_project" | "make_showcase_project" | "fusion_open_design" | "open_design" | "fusion_new_electronics_from_eagle" | "new_electronics_from_eagle" | "fusion_aps_open" | "aps_open" | "fusion_schematic" | "schematic" | "fusion_eagle_import_status" | "eagle_import_status" | "fusion_show_attributes" | "show_attributes") if addin_owner_is_exe(&**h) => { let (sc, ca, ca2) = (sidecar.clone(), caller.clone(), caller.clone()); let side = move |verb: &str, a: Value, t: u64| proxy_with_timeout(&sc, &json!({"command": verb, "args": a}).to_string(), &ca, t); let sd = h.bridge_state_dir(); let pids: Vec<u32> = h.fusion_processes().iter().map(|p| p.pid).collect(); let dlg = |r: Value| small_verbs::merge_dialog_array(r, &ca2, &pids); let ctx = addin::Ctx { host: &**h, fusion_exe: h.fusion_executable(), short_path: |p| host().short_path(p), state_dir: sd.clone(), caller: caller.clone(), pids: pids.clone(), sidecar: &side, dialogs: &dlg }; let cmd = v.strip_prefix("fusion_").unwrap_or(v); let t0 = Instant::now(); let ind = timings::Indicator::start(&sd, cmd, &caller); let pass = |verb: &str, a: Value| addin::pass_through(verb, &a, &ctx); let r0 = match cmd { "show_symbol" => orchestrations::show_symbol(&ctx, &parsed["args"], &pass), "show_footprint" => orchestrations::show_footprint(&ctx, &parsed["args"], &pass), "show_library" => orchestrations::show_library(&ctx, &parsed["args"], &pass), "show_project" => match orchestrations::show_project(&ctx, &parsed["args"], &pass) { Some(r) => r, None => side("show_project", parsed["args"].clone(), 240) }, "capture_library_views" => small_verbs::apply_failure_dialogs(orchestrations::capture_library_views(&ctx, &parsed["args"], &pass), &caller, &pids), "open_cloud_file" => orchestrations::open_cloud_file(&ctx, &parsed["args"], &pass), "save_lbr" => small_verbs::merge_dialog_array(orchestrations::save_lbr(&ctx, &parsed["args"], &pass), &caller, &pids), "cleanup_cloud_files" => orchestrations::cleanup_cloud_files(&parsed["args"], &pass), "inspect_bodies" => orchestrations::inspect_bodies(&parsed["args"], &pass), "board_stackup" => services::board_stackup(&parsed["args"], &pass), "export_optimized_glb" => if parsed["args"]["async"].as_bool() == Some(true) { services::start_glb_export_async(&ctx, &parsed["args"]) } else { services::export_optimized_glb(&parsed["args"], &pass) }, "fetch_optimized_glb" => if parsed["args"]["exportId"].is_string() { services::glb_export_async_status(&parsed["args"]) } else { services::fetch_optimized_glb(&parsed["args"]) }, "set_design_rules" => services::set_design_rules(&ctx, &parsed["args"], &pass), "make_test_project" => small_verbs::merge_dialog_array(testproject::make_test_project(&ctx, &parsed["args"], &pass), &caller, &pids), "make_showcase_project" => small_verbs::merge_dialog_array(testproject::make_showcase_project(&ctx, &parsed["args"], &pass), &caller, &pids), "open_design" => orchestrations::open_design(&ctx, &parsed["args"], &pass), "new_electronics_from_eagle" => orchestrations::new_electronics_from_eagle(&ctx, &parsed["args"], &pass), "schematic" => orchestrations::schematic(&ctx, &parsed["args"], &pass), "eagle_import_status" => orchestrations::eagle_import_status(&ctx, &parsed["args"]), "show_attributes" => orchestrations::show_attributes(&ctx, &parsed["args"], &pass), "aps_open" => orchestrations::aps_open(&ctx, &parsed["args"], &pass), other => orchestrations::open_with_screenshot(&ctx, other, &parsed["args"], &pass) }; let mut r = small_verbs::merge_dialog_array(r0, &caller, &pids); ind.stop(r["success"].as_bool() == Some(false)); timings::log_run(&sd, cmd, t0.elapsed().as_secs_f64(), r["success"].as_bool() != Some(false)); r["shell"] = json!("rust"); r },+                    v @ ("fusion_show_symbol" | "show_symbol" | "fusion_show_footprint" | "show_footprint" | "fusion_show_library" | "show_library" | "fusion_show_project" | "show_project" | "fusion_show_schematic" | "show_schematic" | "fusion_show_2d_board" | "show_2d_board" | "fusion_show_3d_board" | "show_3d_board" | "fusion_capture_library_views" | "capture_library_views" | "fusion_open_cloud_file" | "open_cloud_file" | "fusion_save_lbr" | "save_lbr" | "fusion_cleanup_cloud_files" | "cleanup_cloud_files" | "fusion_inspect_bodies" | "inspect_bodies" | "fusion_board_stackup" | "board_stackup" | "fusion_export_optimized_glb" | "export_optimized_glb" | "fusion_fetch_optimized_glb" | "fetch_optimized_glb" | "fusion_set_design_rules" | "set_design_rules" | "fusion_make_test_project" | "make_test_project" | "fusion_make_showcase_project" | "make_showcase_project" | "fusion_open_design" | "open_design" | "fusion_new_electronics_from_eagle" | "new_electronics_from_eagle" | "fusion_aps_open" | "aps_open" | "fusion_schematic" | "schematic" | "fusion_eagle_import_status" | "eagle_import_status" | "fusion_show_attributes" | "show_attributes") if addin_owner_is_exe(&**h) => { let (sc, ca, ca2) = (sidecar.clone(), caller.clone(), caller.clone()); let side = move |verb: &str, a: Value, t: u64| proxy_with_timeout(&sc, &json!({"command": verb, "args": a}).to_string(), &ca, t); let sd = h.bridge_state_dir(); let pids: Vec<u32> = h.fusion_processes().iter().map(|p| p.pid).collect(); let dlg = |r: Value| small_verbs::merge_dialog_array(r, &ca2, &pids); let ctx = addin::Ctx { host: &**h, fusion_exe: h.fusion_executable(), short_path: |p| host().short_path(p), state_dir: sd.clone(), caller: caller.clone(), pids: pids.clone(), sidecar: &side, dialogs: &dlg }; let cmd = v.strip_prefix("fusion_").unwrap_or(v); let t0 = Instant::now(); let ind = timings::Indicator::start(&sd, cmd, &caller); let pass = |verb: &str, a: Value| addin::pass_through(verb, &a, &ctx); let r0 = match cmd { "show_symbol" => orchestrations::show_symbol(&ctx, &parsed["args"], &pass), "show_footprint" => orchestrations::show_footprint(&ctx, &parsed["args"], &pass), "show_library" => orchestrations::show_library(&ctx, &parsed["args"], &pass), "show_project" => match orchestrations::show_project(&ctx, &parsed["args"], &pass) { Some(r) => r, None => side("show_project", parsed["args"].clone(), 240) }, "capture_library_views" => small_verbs::apply_failure_dialogs(orchestrations::capture_library_views(&ctx, &parsed["args"], &pass), &caller, &pids), "open_cloud_file" => orchestrations::open_cloud_file(&ctx, &parsed["args"], &pass), "save_lbr" => small_verbs::merge_dialog_array(orchestrations::save_lbr(&ctx, &parsed["args"], &pass), &caller, &pids), "cleanup_cloud_files" => orchestrations::cleanup_cloud_files(&parsed["args"], &pass), "inspect_bodies" => orchestrations::inspect_bodies(&parsed["args"], &pass), "board_stackup" => services::board_stackup(&parsed["args"], &pass), "export_optimized_glb" => if parsed["args"]["async"].as_bool() == Some(true) { services::start_glb_export_async(&ctx, &parsed["args"]) } else { services::export_optimized_glb(&parsed["args"], &pass) }, "fetch_optimized_glb" => if parsed["args"]["exportId"].is_string() { services::glb_export_async_status(&parsed["args"]) } else { services::fetch_optimized_glb(&parsed["args"]) }, "set_design_rules" => services::set_design_rules(&ctx, &parsed["args"], &pass), "make_test_project" => small_verbs::merge_dialog_array(testproject::make_test_project(&ctx, &parsed["args"], &pass), &caller, &pids), "make_showcase_project" => small_verbs::merge_dialog_array(testproject::make_showcase_project(&ctx, &parsed["args"], &pass), &caller, &pids), "open_design" => orchestrations::open_design(&ctx, &parsed["args"], &pass), "new_electronics_from_eagle" => orchestrations::new_electronics_from_eagle(&ctx, &parsed["args"], &pass), "schematic" => orchestrations::schematic(&ctx, &parsed["args"], &pass), "eagle_import_status" => orchestrations::eagle_import_status(&ctx, &parsed["args"]), "show_attributes" => orchestrations::show_attributes(&ctx, &parsed["args"], &pass), "aps_open" => orchestrations::aps_open(&ctx, &parsed["args"], &pass), other => orchestrations::open_with_screenshot(&ctx, other, &parsed["args"], &pass) }; let mut r = small_verbs::merge_dialog_array(r0, &caller, &pids); small_verbs::add_image_delivery(&mut r); ind.stop(r["success"].as_bool() == Some(false)); timings::log_run(&sd, cmd, t0.elapsed().as_secs_f64(), r["success"].as_bool() != Some(false)); r["shell"] = json!("rust"); r },                     "fusion_state" | "state" if addin_owner_is_exe(&**h) => { let (sc, ca) = (sidecar.clone(), caller.clone()); let side = move |verb: &str, a: Value, t: u64| proxy_with_timeout(&sc, &json!({"command": verb, "args": a}).to_string(), &ca, t); let dlg = |r: Value| r; let ctx = addin::Ctx { host: &**h, fusion_exe: h.fusion_executable(), short_path: |p| host().short_path(p), state_dir: h.bridge_state_dir(), caller: caller.clone(), pids: h.fusion_processes().iter().map(|p| p.pid).collect(), sidecar: &side, dialogs: &dlg }; let mut r = orchestrations::fusion_state(&ctx, &parsed["args"]); r["shell"] = json!("rust"); r },                     v @ ("fusion_make_3d_package" | "make_3d_package" | "fusion_build_library_3d" | "build_library_3d" | "fusion_bind_library_3d" | "bind_library_3d" | "fusion_prove_library" | "prove_library" | "fusion_attach_3d_package" | "attach_3d_package" | "fusion_show_3d_package" | "show_3d_package") if addin_owner_is_exe(&**h) => { let (sc, ca, ca2) = (sidecar.clone(), caller.clone(), caller.clone()); let side = move |verb: &str, a: Value, t: u64| proxy_with_timeout(&sc, &json!({"command": verb, "args": a}).to_string(), &ca, t); let sd = h.bridge_state_dir(); let pids: Vec<u32> = h.fusion_processes().iter().map(|p| p.pid).collect(); let dlg = |r: Value| small_verbs::merge_dialog_array(r, &ca2, &pids); let ctx = addin::Ctx { host: &**h, fusion_exe: h.fusion_executable(), short_path: |p| host().short_path(p), state_dir: sd.clone(), caller: caller.clone(), pids: pids.clone(), sidecar: &side, dialogs: &dlg }; let cmd = v.strip_prefix("fusion_").unwrap_or(v); let t0 = Instant::now(); *LONG_OP.lock().unwrap() = Some(cmd.to_string()); *LONG_OP_SINCE.lock().unwrap() = std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).map(|d| d.as_secs_f64()).unwrap_or(0.0); let pass = |verb: &str, a: Value| addin::pass_through(verb, &a, &ctx); let r0 = match cmd { "make_3d_package" => mint::make_3d_package(&ctx, &parsed["args"], &pass), "bind_library_3d" => mint::bind_library_3d(&ctx, &parsed["args"], &pass), "prove_library" => prove::prove_library(&ctx, &parsed["args"], &pass), "attach_3d_package" => mint::attach_3d_package(&ctx, &parsed["args"], &pass), "show_3d_package" => mint::show_3d_package(&ctx, &parsed["args"], &pass), _ => mint::build_library_3d(&ctx, &parsed["args"], &pass) }; *LONG_OP.lock().unwrap() = None; let mut r = small_verbs::apply_failure_dialogs(r0, &caller, &pids); timings::log_run(&sd, cmd, t0.elapsed().as_secs_f64(), r["success"].as_bool() != Some(false)); r["shell"] = json!("rust"); r },                     v @ ("fusion_apply_fusion_update" | "apply_fusion_update" | "fusion_install_fusion" | "install_fusion" | "fusion_uninstall" | "uninstall" | "fusion_prefs_open" | "prefs_open" | "fusion_prefs_close" | "prefs_close" | "fusion_generate_package" | "generate_package" | "fusion_mcp_status" | "mcp_status" | "fusion_mcp_tools" | "mcp_tools" | "fusion_mcp_call" | "mcp_call" | "fusion_mcp_resources" | "mcp_resources" | "fusion_mcp_enable" | "mcp_enable" | "fusion_library_manager" | "library_manager" | "fusion_managed_library_create" | "managed_library_create" | "fusion_managed_library_link" | "managed_library_link" | "fusion_managed_library_pull" | "managed_library_pull" | "fusion_managed_library_push" | "managed_library_push" | "fusion_managed_library_unlink" | "managed_library_unlink" | "fusion_signin" | "signin" | "fusion_signin_2fa" | "signin_2fa" | "fusion_web_signin" | "web_signin" | "fusion_aps_signin" | "aps_signin" | "fusion_demo" | "demo" | "fusion_demo_progress" | "demo_progress" | "fusion_demo_control" | "demo_control" | "fusion_electronics_query" | "electronics_query" | "fusion_view" | "view" | "fusion_interactive" | "interactive" | "fusion_narrate" | "narrate") if addin_owner_is_exe(&**h) => { let (sc, ca) = (sidecar.clone(), caller.clone()); let side = move |verb: &str, a: Value, t: u64| proxy_with_timeout(&sc, &json!({"command": verb, "args": a}).to_string(), &ca, t); let dlg = |r: Value| r; let ctx = addin::Ctx { host: &**h, fusion_exe: h.fusion_executable(), short_path: |p| host().short_path(p), state_dir: h.bridge_state_dir(), caller: caller.clone(), pids: h.fusion_processes().iter().map(|p| p.pid).collect(), sidecar: &side, dialogs: &dlg }; let pass = |verb: &str, a: Value| addin::pass_through(verb, &a, &ctx); let cmd = v.strip_prefix("fusion_").unwrap_or(v); let mut r = match cmd { "apply_fusion_update" => maintenance::apply_fusion_update(&ctx, &pass), "install_fusion" => maintenance::install_fusion(&ctx), "prefs_open" => maintenance::prefs_open(&ctx, &parsed["args"], &pass), "prefs_close" => maintenance::prefs_close(&ctx, &parsed["args"]), "generate_package" => epg::generate_package(&parsed["args"], VERSION, &pass),
rust/bridge/src/services.rs+21
@@ -230,6 +230,27 @@ pub fn set_design_rules(ctx: &Ctx, args: &Value, pass: &dyn Fn(&str, Value) -> V     let action = a["action"].as_str().unwrap_or("apply").to_lowercase();     let fab = a["fab"].as_str().unwrap_or("").trim().to_string();     let mut dru_path = a["druPath"].as_str().unwrap_or("").replace('\\', "/");+    // Rules that exist only where the CALLER is (a container AI wrote a custom .edru): the bridge+    // cannot read the caller's disk, so it takes the file as content, writes it to the space-free+    // stage folder, and loads that. Replaces ab's CLI-only fusion_load_design_rules, which ab+    // removed in 2.1.145 (page issue #96).+    let inline: Option<Vec<u8>> = if let Some(t) = a["druText"].as_str().filter(|t| !t.trim().is_empty()) { Some(t.as_bytes().to_vec()) }+        else if let Some(b) = a["druBase64"].as_str().filter(|b| !b.trim().is_empty()) {+            use base64::Engine;+            match base64::engine::general_purpose::STANDARD.decode(b.trim()) { Ok(v) => Some(v), Err(e) => return json!({"success": false, "errorCode": "dru_base64_invalid", "error": format!("druBase64 is not valid base64: {e}")}) }+        } else { None };+    if let Some(bytes) = inline {+        if !dru_path.is_empty() || !fab.is_empty() { return json!({"success": false, "errorCode": "dru_source_ambiguous", "error": "Pass ONE rules source: druText/druBase64, druPath, or fab.", "_hint": "druText or druBase64 carries a file from your side; druPath names one already on the Fusion machine; fab picks a pack from adom/pcb-design-rules."}); }+        let head = String::from_utf8_lossy(&bytes[..bytes.len().min(400)]).to_string();+        if !(head.contains("<") || head.contains("=")) { return json!({"success": false, "errorCode": "dru_not_rules", "error": "The content does not look like a Fusion .edru (XML) or EAGLE .dru (key = value) rules file."}); }+        let ext = if head.trim_start().starts_with('<') { "edru" } else { "dru" };+        let dir = match crate::fusionpath::stage_root(&ctx.state_dir) { Ok(d) => d.join("rules"), Err(e) => return json!({"success": false, "errorCode": "stage_unavailable", "error": e}) };+        let _ = std::fs::create_dir_all(&dir);+        let name = a["druName"].as_str().map(|n| n.chars().filter(|c| c.is_ascii_alphanumeric() || *c == '-' || *c == '_').collect::<String>()).filter(|n| !n.is_empty()).unwrap_or_else(|| "caller-rules".into());+        let dst = dir.join(format!("{name}.{ext}"));+        if let Err(e) = std::fs::write(&dst, &bytes) { return json!({"success": false, "errorCode": "stage_write_failed", "error": format!("could not stage the rules file: {e}")}); }+        dru_path = dst.to_string_lossy().replace('\\', "/");+    }     let mut rule_warnings: Vec<Value> = Vec::new();     if action == "list" {         let idx = match design_rules_index(&ctx.state_dir) { Ok(i) => i, Err(e) => return json!({"success": false, "errorCode": "design_rules_page_unreachable", "error": e.chars().take(200).collect::<String>(), "_hint": "The wiki page adom/pcb-design-rules did not answer; the bundled Adom InstaPCB rules still apply with no fab argument."}) };
rust/bridge/src/small_verbs.rs+138−1
@@ -333,6 +333,70 @@ pub fn screenshot_fusion(args: &Value, caller: &fusion_core::ab::Caller) -> Valu     } } +/// ab's image-delivery contract: a reply that carries `fullPath` (and `safePath`), or+/// `screenshots[]` entries with `fullPath`, is auto-pulled to the CALLER's machine, so an AI in a+/// container can read the picture with no second call. Older replies named the Windows path+/// `path`, `savedTo`, `screenshot` or `postOpenScreenshot`, which ab does not pull (page issue+/// #98). This lifts every Windows image path in a reply into that shape and keeps the old keys.+pub fn add_image_delivery(r: &mut Value) {+    if !r.is_object() { return; }+    fn is_img(s: &str) -> bool {+        let l = s.to_ascii_lowercase();+        let b = l.as_bytes();+        b.len() > 3 && b[0].is_ascii_alphabetic() && b[1] == b':' && (b[2] == b'/' || b[2] == b'\\') && (l.ends_with(".png") || l.ends_with(".jpg") || l.ends_with(".jpeg") || l.ends_with(".webp"))+    }+    fn walk(v: &Value, label: &str, depth: u8, out: &mut Vec<(String, String)>) {+        if depth > 7 { return; }+        match v {+            Value::String(s) if is_img(s) => { if !out.iter().any(|(p, _)| p == s) { out.push((s.replace('\\', "/"), label.to_string())); } }+            Value::Object(m) => for (k, x) in m { if k == "output" || k == "_hint" || k.starts_with("_") { continue; } walk(x, &if label.is_empty() { k.clone() } else { format!("{label}.{k}") }, depth + 1, out); },+            Value::Array(a) => for (i, x) in a.iter().enumerate() { walk(x, &format!("{label}[{i}]"), depth + 1, out); },+            _ => {}+        }+    }+    let mut found = Vec::new();+    walk(r, "", 0, &mut found);+    if found.is_empty() { return; }+    if r["fullPath"].is_null() {+        r["fullPath"] = json!(found[0].0);+        if r["safePath"].is_null() { r["safePath"] = json!(found[0].0); }+    }+    let has_shots = r["screenshots"].as_array().map(|a| a.iter().any(|e| e["fullPath"].is_string())).unwrap_or(false);+    if !has_shots {+        r["screenshots"] = json!(found.iter().map(|(p, l)| json!({"fullPath": p, "safePath": p, "label": l})).collect::<Vec<_>>());+    }+}++/// May the bridge send WM_CLOSE to a Fusion window that no rule recognises? Only when it is+/// plainly a DIALOG. Fusion keeps hidden and visible top-level Qt windows that are not dialogs+/// (EAGLE's own schematic and board editor windows, detached document frames), and WM_CLOSE on+/// one of those closes documents and can exit Fusion: that is what happened on ConfRoomROG on+/// 2026-09-22 (a window titled "Fusion360" got WM_CLOSE, Fusion closed every document and ran+/// ExitApplicationCommand, the daily run went red, page issue #97) and on 2026-09-03. A window+/// that fails a test is left open and reported with its text, so the caller decides.+pub fn unknown_window_is_closable(ab: &dyn fusion_core::ab::AbClient, caller: &fusion_core::ab::Caller, dlg: &Value, rows: &[Value]) -> (bool, String) {+    let title = dlg["title"].as_str().unwrap_or("");+    if title.contains("EAGLE") { return (false, "an EAGLE editor window (closing it closes a document)".into()); }+    let r = fusion_core::dialogs::rect_of(&dlg["rect"]);+    let (w, h) = (r["width"].as_i64().unwrap_or(0), r["height"].as_i64().unwrap_or(0));+    let main = rows.iter().filter(|x| x["visible"].as_bool() != Some(false) && x["title"].as_str().map(|t| t.contains("Autodesk Fusion")).unwrap_or(false))+        .map(|x| fusion_core::dialogs::rect_of(&x["rect"])).max_by_key(|m| m["width"].as_i64().unwrap_or(0) * m["height"].as_i64().unwrap_or(0));+    if let Some(m) = main {+        let (mw, mh) = (m["width"].as_i64().unwrap_or(0), m["height"].as_i64().unwrap_or(0));+        if mw > 0 && mh > 0 && w * 10 >= mw * 8 && h * 10 >= mh * 8 { return (false, format!("window-sized ({w}x{h} against the main window's {mw}x{mh}): a main or document window, not a dialog")); }+    }+    let Some(hwnd) = dlg["hwnd"].as_i64() else { return (false, "no window handle".into()) };+    let buttons: Vec<String> = ab.call("desktop_find_controls", json!({"hwnd": hwnd}), caller, 10).ok().map(|r| {+        let d = if r["data"].is_object() { r["data"].clone() } else { r };+        d["controls"].as_array().or(d["matches"].as_array()).cloned().unwrap_or_default().iter()+            .filter(|c| c["controlType"].as_str().or(c["type"].as_str()).or(c["role"].as_str()).unwrap_or("").to_lowercase().contains("button"))+            .filter_map(|c| c["name"].as_str().map(|n| n.trim().to_lowercase())).collect()+    }).unwrap_or_default();+    const DISMISS: &[&str] = &["ok", "cancel", "close", "no", "dismiss", "not now", "later", "skip", "got it", "continue"];+    if buttons.iter().any(|b| DISMISS.iter().any(|d| b == d || b.starts_with(&format!("{d} ")))) { (true, String::new()) }+    else { (false, format!("no Cancel/Close/OK-style button (buttons: {})", if buttons.is_empty() { "none readable".to_string() } else { buttons.join(", ") })) }+}+ /// `classify_blocking_dialogs`: Fusion's Qt dialogs (from ab's window rows), classified by /// title and, for a generic "Fusion" title, by the body text UIA exposes. pub fn classify_blocking_dialogs(ab: &dyn fusion_core::ab::AbClient, caller: &fusion_core::ab::Caller, fusion_pids: &[u32]) -> Vec<Value> {@@ -356,6 +420,11 @@ pub fn classify_blocking_dialogs(ab: &dyn fusion_core::ab::AbClient, caller: &fu         let Some((category, resolution)) = classified else { continue };         let mut e = json!({"hwnd": dlg["hwnd"], "title": title, "category": category, "resolution": resolution});         if let Some(m) = matched_on { e["matchedOn"] = json!(m); }+        if category == "unknown" || category == "generic_modal_read_screenshot" {+            let (ok, why) = unknown_window_is_closable(ab, caller, &dlg, &rows);+            e["closable"] = json!(ok);+            if !ok { e["notClosableWhy"] = json!(why); }+        }         out.push(e);     }     out@@ -376,8 +445,15 @@ pub fn dismiss_blocking_dialogs(state_dir: &Path, args: &Value, caller: &fusion_     }     let mut answered = Vec::new();     let mut upload_active = false;+    let mut left_open = Vec::new();     for d in &classified {         let cat = d["category"].as_str().unwrap_or("");+        if d["closable"].as_bool() == Some(false) {+            eprintln!("[fusion-bridge] dismiss_blocking_dialogs: LEFT OPEN {} {:?}: {}", d["hwnd"], d["title"].as_str().unwrap_or(""), d["notClosableWhy"].as_str().unwrap_or(""));+            crate::fusion_prefs::action_ledger(state_dir, "dialog_left_open", json!({"title": d["title"], "category": cat, "hwnd": d["hwnd"], "why": d["notClosableWhy"]}));+            left_open.push(json!({"title": d["title"], "hwnd": d["hwnd"], "why": d["notClosableWhy"]}));+            continue;+        }         let mut safe = "No/Cancel (WM_CLOSE, background)";         if cat == "upload_in_progress" { upload_active = true; safe = "No - declined close, Hub upload left to drain (NEVER Yes)"; }         let ok = d["hwnd"].as_i64().map(|h| ab.call("desktop_close_window", json!({"hwnd": h}), caller, 15).map(|r| r["ok"].as_bool().or(r["success"].as_bool()).unwrap_or(true)).unwrap_or(false)).unwrap_or(false);@@ -390,7 +466,8 @@ pub fn dismiss_blocking_dialogs(state_dir: &Path, args: &Value, caller: &fusion_     let mut hint = format!("Answered {} blocking dialog(s) in the background (No/Cancel - never Yes, never focus-stolen).", answered.len());     if upload_active { hint.push_str(" One was a Hub-UPLOAD 'lose these changes?' prompt: it was declined (No), but the upload is STILL in flight - poll app.hasActiveJobs (fusion_state) until it drains before any close/saveAs, or you WILL lose the 3D package."); }     taskbar(if upload_active { Some(1.0) } else { None });-    json!({"success": true, "count": answered.len(), "dismissed": answered, "uploadStillActive": upload_active, "_hint": hint})+    if !left_open.is_empty() { hint.push_str(&format!(" LEFT OPEN {} window(s) no rule knows that do not look like a dialog (see leftOpen[].why): WM_CLOSE on a main, document or EAGLE editor window closes documents and can exit Fusion. fusion_check_dialogs shows their text; decide from that.", left_open.len())); }+    json!({"success": true, "count": answered.len(), "dismissed": answered, "leftOpen": left_open, "uploadStillActive": upload_active, "_hint": hint}) }  fn screenshot_hwnd(ab: &dyn fusion_core::ab::AbClient, caller: &fusion_core::ab::Caller, hwnd: i64) -> Option<String> {@@ -462,3 +539,63 @@ pub fn merge_dialog_array(mut result: Value, caller: &fusion_core::ab::Caller, f     result["_hint"] = json!(format!("{}{}", arr["_hint"].as_str().unwrap_or(""), existing.map(|e| format!(" | {e}")).unwrap_or_default()));     apply_failure_dialogs(result, caller, fusion_pids) }++#[cfg(test)]+mod closable_tests {+    use super::*;+    struct FakeAb(Value);+    impl fusion_core::ab::AbClient for FakeAb {+        fn call(&self, _verb: &str, _args: Value, _caller: &fusion_core::ab::Caller, _t: u64) -> Result<Value, fusion_core::ab::AbError> { Ok(self.0.clone()) }+    }+    fn main_row() -> Value { json!({"hwnd": 1, "title": "BQ25792 - Autodesk Fusion ", "className": "Qt683QWindowIcon", "visible": true, "rect": [0, 0, 2400, 1270]}) }+    fn buttons(names: &[&str]) -> FakeAb { FakeAb(json!({"controls": names.iter().map(|n| json!({"name": n, "controlType": "Button"})).collect::<Vec<_>>()})) }++    #[test]+    fn a_small_dialog_with_cancel_may_be_closed() {+        let dlg = json!({"hwnd": 2, "title": "Fusion360", "rect": {"left": 800, "top": 400, "width": 520, "height": 240}});+        let (ok, why) = unknown_window_is_closable(&buttons(&["OK", "Cancel"]), &fusion_core::ab::Caller::default(), &dlg, &[main_row()]);+        assert!(ok, "{why}");+    }+    #[test]+    fn a_window_sized_frame_is_never_closed() {+        // 2026-09-22 (#97): WM_CLOSE on a window like this made Fusion close every document and exit+        let dlg = json!({"hwnd": 3, "title": "Fusion360", "rect": {"left": 0, "top": 0, "width": 2300, "height": 1200}});+        let (ok, why) = unknown_window_is_closable(&buttons(&["Close"]), &fusion_core::ab::Caller::default(), &dlg, &[main_row()]);+        assert!(!ok); assert!(why.contains("window-sized"), "{why}");+    }+    #[test]+    fn an_eagle_editor_window_is_never_closed() {+        let dlg = json!({"hwnd": 4, "title": "4 Board - C:/x/BQ25792.brd - EAGLE 9.7.0 premium", "rect": {"left": 700, "top": 180, "width": 900, "height": 600}});+        let (ok, _) = unknown_window_is_closable(&buttons(&["OK"]), &fusion_core::ab::Caller::default(), &dlg, &[main_row()]);+        assert!(!ok);+    }+    #[test]+    fn a_window_with_no_dismiss_button_is_left_open() {+        let dlg = json!({"hwnd": 5, "title": "Fusion360", "rect": {"left": 800, "top": 400, "width": 600, "height": 400}});+        let (ok, why) = unknown_window_is_closable(&buttons(&["Save", "Export"]), &fusion_core::ab::Caller::default(), &dlg, &[main_row()]);+        assert!(!ok); assert!(why.contains("no Cancel/Close/OK"), "{why}");+    }+}++#[cfg(test)]+mod image_delivery_tests {+    use super::*;+    #[test]+    fn old_path_keys_gain_full_path_and_screenshots() {+        let mut r = json!({"success": true, "path": "C:\\tmp\\conduit-screenshots\\viewport.png", "data": {"postOpenScreenshot": {"screenshots": [{"path": "C:/tmp/b.png"}]}}});+        add_image_delivery(&mut r);+        assert_eq!(r["fullPath"], "C:/tmp/conduit-screenshots/viewport.png");+        assert_eq!(r["screenshots"].as_array().unwrap().len(), 2);+        assert_eq!(r["path"], "C:\\tmp\\conduit-screenshots\\viewport.png", "old key kept");+    }+    #[test]+    fn a_reply_that_already_has_full_path_is_left_alone() {+        let mut r = json!({"fullPath": "C:/x/a.png", "screenshots": [{"fullPath": "C:/x/a.png"}]});+        let before = r.clone(); add_image_delivery(&mut r); assert_eq!(r, before);+    }+    #[test]+    fn replies_without_images_are_untouched() {+        let mut r = json!({"success": true, "outputPath": "C:/tmp/board.sch"});+        let before = r.clone(); add_image_delivery(&mut r); assert_eq!(r, before);+    }+}
skills/fusion-no-modal-dialogs/SKILL.md+8
@@ -213,3 +213,11 @@ save or a discard) to the ones it does not, and runs the verb when that clears t 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.++**What the bridge will never close (1.10.37).** A window no rule knows gets WM_CLOSE only when it is+plainly a dialog: smaller than Fusion's main window, not an EAGLE editor window, and carrying a+Cancel/Close/OK/No-style button. Fusion keeps other top-level Qt windows (EAGLE's own schematic and+board editors, window-sized frames), and WM_CLOSE on one of those closes documents and can exit+Fusion: it did on ConfRoomROG on 2026-09-22 (page issue #97). Such a window is left open and comes+back in `leftOpen[]` (from `fusion_dismiss_blocking_dialogs`) or as `blocked_by_dialog` with its text+and a `notClosableWhy`. Read the text and decide; do not send Escape or WM_CLOSE at it yourself.
tools/check-naming.sh

  
tools/check-portable.sh

  
tools/promote.sh

  
tools/release.sh