← Commit history
BRIDGE_VERSION+1−1
@@ -1 +1 @@-1.9.202\ No newline at end of file+1.9.203\ No newline at end of file
adom-bridge-fusion-manifest.json+4−4
@@ -1,10 +1,10 @@ {   "manifest_version": 1,   "name": "fusion360",-  "version": "1.9.202",-  "url": "https://wiki.adom.inc/download/adom/fusion-bridge/1.9.202/adom-bridge-fusion-v1.9.202.zip",-  "sha256": "eba8083cf16b8a6001c6c4a7cf8f9424ae3c78d9fc2ff738bf873391a0bbf11c",-  "size": 495989,+  "version": "1.9.203",+  "url": "https://wiki.adom.inc/download/adom/fusion-bridge/1.9.203/adom-bridge-fusion-v1.9.203.zip",+  "sha256": "765dd1ed08bb3a373fff1224943930c8c20262d6b13aa0a4054b3b83add96a61",+  "size": 495992,   "verbPrefixes": [     "fusion_"   ],
bridge.json+1−1
@@ -2,7 +2,7 @@   "manifest_version": 1,   "name": "fusion360",   "displayName": "Autodesk Fusion 360",-  "version": "1.9.202",+  "version": "1.9.203",   "description": "Drive Autodesk Fusion 360 from the cloud: launch Fusion, electronics board layout, design rules, exports (STEP/IGES/STL/3MF/USDZ/OBJ/DXF/DWG/Gerbers/BOM/CPL), fast APS server-indexed cloud search plus browse/recent/file-info/versions, cloud file download/upload and folder creation, and in-app parametric modeling (fusion_run_modeling_script). Never-charge: APS calls are capped to the free tier.",   "homepage": "https://wiki.adom.inc/adom/fusion-bridge",   "author": "Adom Inc.",
dashboard/Cargo.lock+1−1
@@ -4,7 +4,7 @@ version = 4  [[package]] name = "fusion-dashboard"-version = "1.9.202"+version = "1.9.203" dependencies = [  "serde_json", ]
dashboard/Cargo.toml+1−1
@@ -1,6 +1,6 @@ [package] name = "fusion-dashboard"-version = "1.9.202"+version = "1.9.203" edition = "2021"  [dependencies]
dashboard/src/main.rs+10−217
@@ -1419,51 +1419,6 @@ fn handle_conn(app: &Arc<App>, mut stream: TcpStream) {                 }             }         }-        ("POST", "/api/say-send") => {-            // Send to AI (say-strip contract, John's 2026-08-24 ruling): NEW threads only.-            // POST /ai-threads/new (staged name + dock tag) then run-prompt submit:true,-            // through the workspace control API named in ~/.adom/hydrogen-control-url.-            // Resume/existing threads stay parked; this never targets a census tid.-            // ⛔ EMERGENCY PARK (project-c3, 2026-08-24): the fleet Send-to-AI success-            // path CRASHED the ah dev shell during kicad's cold-verify (the historical-            // run-prompt kill). While parked this endpoint refuses BEFORE any control-API-            // call; the page falls back to copy + error toast; the two-button UI stays so-            // un-park is this one flag after the ah build thread's certification.-            const SAY_SEND_PARKED: bool = false;   // UN-PARKED 2026-08-24 (crash family certified dead on the gate-fixed shell; a pre-1.0.142 shell is refused inside say_send)-            let phrase = body["phrase"].as_str().unwrap_or("").to_string();-            if SAY_SEND_PARKED {-                respond_json(&mut stream, &serde_json::json!({"ok": false, "parked": true,-                    "why": "Send to AI is parked fleet-wide (a run-prompt crash on ah is being certified). Copied instead - paste it into any AI thread."}));-            } else if phrase.is_empty() {-                respond_json(&mut stream, &serde_json::json!({"ok": false, "why": "phrase required"}));-            } else {-                respond_json(&mut stream, &say_send(&phrase));-            }-        }-        ("POST", "/api/say-try") => {-            // "Say it" strip Try-it (dock-bar `say` contract, John 2026-08-23: teach users to-            // ASK for the app by its noun). Stages the phrase into this app's own AI thread-            // via the Hydrogen control API - submit:false, so the USER presses Enter; we never-            // run a prompt on their behalf. Reachable on 127.0.0.1:47084 from WSL2 (mirrored-            // networking); in a cloud container it is not, and the UI falls back to copy.-            // SHIP-NIGHT PARK, SERVER HALF (dock-bar contract, project-a6 2026-08-23):-            // while parked, refuse BEFORE any control-API call - a direct POST (curl, a-            // script, another AI) must not be able to reach run-prompt and wake or crash-            // ah's parked AI backend. Both halves flip together when the all-clear lands:-            // this const and SAY_TRY_ENABLED in ui/index.html.-            const SAY_TRY_PARKED: bool = true;-            if SAY_TRY_PARKED {-                respond_json(&mut stream, &serde_json::json!({"ok": false, "parked": true,-                    "why": "Try-it is parked fleet-wide until the ah AI-threads typing fix is certified (dock-bar ship-night posture). Copy the phrase and paste it to your AI instead."}));-            } else {-                let phrase = body["phrase"].as_str().unwrap_or("").to_string();-                if phrase.is_empty() {-                    respond_json(&mut stream, &serde_json::json!({"ok": false, "why": "phrase required"}));-                } else {-                    respond_json(&mut stream, &stage_say_phrase(&phrase));-                }-            }-        }         ("POST", "/ui/click") => {             // CLI-driven front-end click: the page clicks its own control (same path a user takes).             let target = body["target"].as_str().unwrap_or("").to_string();@@ -1595,181 +1550,19 @@ fn version_at_least(have: &str, want: &str) -> bool {     true } -/// Send to AI (say-strip contract). UN-PARKED 2026-08-24 after the ah build thread-/// certified the run-prompt crash family dead on the gate-fixed build.-///-/// Two things here are NOT obvious and both were learned the hard way:-///  1. ONE-CALL COMPOSITE. `POST /ai-threads/new` never returns a thread id (sid/tid are-///     explicitly null since 1.0.143 - a thread has no identity until its first submitted-///     prompt), so the old open-then-target shape could never have worked. Use-///     run-prompt {new:true, name, tags, submit:true} and let it stage the name/tags.-///  2. 120s TIMEOUT. Opening drives the editor and every CDP touch serializes through one-///     gate, so calls QUEUE rather than fail; a short client timeout manufactures phantom-///     failures and a blind retry opens a duplicate tab.-/// Delivery is verified by `delivered_to` (checked against the on-disk transcript), never-/// by the thread name.-fn say_send(phrase: &str) -> serde_json::Value {-    let base = match control_api_base() {-        Some(b) => b,-        None => return serde_json::json!({"ok": false,-            "why": "workspace control API not found (~/.adom/hydrogen-control-url missing)"}),-    };-    // SAFETY GATE: a shell older than 1.0.142 still carries the run-prompt crash defect,-    // so refuse honestly there rather than killing the user's editor. Compared NUMERICALLY.-    const MIN_SHELL: &str = "1.0.142";-    let status = Command::new("curl")-        .args(["-s", "-m", "6", &format!("{}/updater/status", base)])-        .output().ok()-        .and_then(|o| serde_json::from_slice::<serde_json::Value>(&o.stdout).ok());-    // SHAPE (measured live 2026-08-25, NOT assumed): /updater/status answers-    // {ok, _hints, updater:{current_version, latest_version, ...}} - current_version is-    // NESTED. I first read it top-level and my own mock returned it top-level too, so the-    // mock cheerfully confirmed the bug: a mock written from your own assumption cannot-    // falsify that assumption. Accept either shape so a future move does not re-break it.-    let shell_ver = status.as_ref().and_then(|v| {-        v["updater"]["current_version"].as_str()-            .or_else(|| v["current_version"].as_str())-            .map(|s| s.to_string())-    });-    match shell_ver {-        Some(v) if version_at_least(&v, MIN_SHELL) => {}-        Some(v) => return serde_json::json!({"ok": false, "shellVersion": v,-            "why": format!("Adom Hydrogen {} predates the Send-to-AI fix ({} or newer required); copied instead. Update Hydrogen and try again.", v, MIN_SHELL)}),-        None => return serde_json::json!({"ok": false,-            "why": "could not read the Hydrogen version (GET /updater/status) - copied instead rather than risk a pre-fix shell"}),-    }-    let payload = serde_json::json!({-        "new": true, "submit": true, "prompt": phrase,-        "name": "Fusion - say it",-        // Fleet ruling (adom-ui-design 1.9.3): dock: + the WIKI PAGE SLUG, never the card id.-        "tags": ["dock:fusion-bridge"]});-    let out = Command::new("curl")-        .args(["-s", "-m", "120", "-X", "POST", "-H", "Content-Type: application/json",-               "-d", &payload.to_string(), &format!("{}/ai-threads/run-prompt", base)])-        .output();-    let resp = match out {-        Ok(o) if !o.stdout.is_empty() =>-            match serde_json::from_slice::<serde_json::Value>(&o.stdout) {-                Ok(v) => v,-                Err(_) => return serde_json::json!({"ok": false, "why": "control API answered non-JSON"}),-            },-        _ => return serde_json::json!({"ok": false, "why": "control API did not answer within 120s"}),-    };-    if resp["ok"].as_bool() == Some(false) {-        let hint = resp["_hints"].as_str().map(|s| s.to_string())-            .or_else(|| resp["_hints"].as_object().map(|m| m.values()-                .filter_map(|v| v.as_str()).collect::<Vec<_>>().join(" ")))-            .unwrap_or_default();-        return serde_json::json!({"ok": false,-            "why": format!("{}{}",-                resp["error"].as_str().unwrap_or("the AI panel refused the prompt"),-                if hint.is_empty() { String::new() } else { format!(" - {}", hint) })});-    }-    // Verify by DELIVERY, not by the thread name: run-prompt confirms against the AI's own-    // message list, so delivered_to is the only honest success signal.-    // delivered_to may sit at the top level or under result/data - I assumed top-level for-    // current_version and was wrong, so accept the variants rather than guess again.-    // delivered_to IS AN ARRAY (measured live 2026-08-25: {"adopted_empty_tab":true,-    // "delivered_to":[{...}]}), because it lists the on-disk transcript(s) that GAINED this-    // prompt text - the panel's own _hints call it "a diff, not a guess", hence plural.-    // I first read it as a string, so a delivery that SUCCEEDED was reported as a failure --    // the most dangerous shape of wrong, because the honest-looking retry it invites is a-    // double-send into someone's editor. Accept array, string, or nested.-    let dt = {-        let cands = [&resp["delivered_to"], &resp["result"]["delivered_to"],-                     &resp["data"]["delivered_to"], &resp["delivered_in_conversation"]];-        cands.iter().find_map(|v| match *v {-            serde_json::Value::String(s) if !s.is_empty() => Some(s.clone()),-            serde_json::Value::Array(a) if !a.is_empty() => Some(-                a.iter().map(|e| match e {-                    serde_json::Value::String(s) => s.clone(),-                    other => other["sid"].as_str()-                        .or_else(|| other["id"].as_str())-                        .or_else(|| other["path"].as_str())-                        .map(|x| x.to_string())-                        .unwrap_or_else(|| other.to_string().chars().take(80).collect()),-                }).collect::<Vec<_>>().join(", ")),-            _ => None,-        })-    };-    match dt.as_deref() {-        Some(d) if !d.is_empty() => serde_json::json!({"ok": true, "deliveredTo": d,-            "adoptedEmptyTab": resp["adopted_empty_tab"].as_bool()-                .or_else(|| resp["result"]["adopted_empty_tab"].as_bool()).unwrap_or(false)}),-        // FAIL WITH THE EVIDENCE. A bare "no delivered_to" tells the next reader nothing and-        // tempts a re-send to find out, which is exactly the double-delivery hazard. Carry a-        // truncated copy of what the panel actually said so the shape is diagnosable WITHOUT-        // sending a second prompt into someone's editor.-        _ => {-            let raw: String = resp.to_string().chars().take(400).collect();-            serde_json::json!({"ok": false, "panelResponse": raw,-                "why": "the panel accepted the call but did not confirm delivery (no delivered_to in any known field) - copied instead; panelResponse carries what it actually returned"})-        }-    }-}--fn stage_say_phrase(phrase: &str) -> serde_json::Value {-    const TAG: &str = "app:adom/fusion-bridge";-    // 1. CENSUS: open threads. Shape: {"ok":true,"groups":[{"threads":[{"tid","sid"|null,"tags":[...]}]}]}-    let census = hy_get("/ai-threads");-    if census.is_none() {-        return serde_json::json!({"ok": false,-            "why": "Hydrogen control API unreachable (not WSL2/mirrored networking?)"});-    }-    let census = census.unwrap();-    let open_row = census["groups"].as_array().into_iter().flatten()-        .flat_map(|g| g["threads"].as_array().cloned().unwrap_or_default())-        .find(|t| t["tags"].as_array().map_or(false,-            |tags| tags.iter().any(|x| x.as_str() == Some(TAG))));-    if let Some(row) = open_row {-        // Address by sid when present; tid when sid is null (thread open but no session yet).-        let mut payload = serde_json::json!({"prompt": phrase, "submit": false});-        if let Some(sid) = row["sid"].as_str() {-            payload["sid"] = serde_json::json!(sid);-        } else if let Some(tid) = row["tid"].as_str() {-            payload["tid"] = serde_json::json!(tid);-        } else {-            return serde_json::json!({"ok": false, "why": "census row has neither sid nor tid"});-        }-        return match hy_post("/ai-threads/run-prompt", &payload) {-            Some(r) if r["ok"].as_bool() != Some(false) =>-                serde_json::json!({"ok": true, "staged": true, "via": "census",-                    "_hint": "phrase staged in the open app thread's input - the user presses Enter"}),-            _ => serde_json::json!({"ok": false, "why": "run-prompt failed on the open thread"}),-        };-    }-    // 2. HISTORY: closed threads. Shape: {"ok":true,"items":[{"ai","id","title","updated_ms"}]}-    let hist = hy_get(&format!("/ai-threads/history?tag={}", TAG));-    if let Some(item) = hist.as_ref()-        .and_then(|h| h["items"].as_array())-        .and_then(|a| a.first()) {-        let (ai, id) = (item["ai"].as_str().unwrap_or("claude"),-                        item["id"].as_str().unwrap_or(""));-        if !id.is_empty() {-            // Resume reopens the thread tab - the user clicked Try, so that IS their intent.-            let resumed = hy_post("/ai-threads/resume", &serde_json::json!({"ai": ai, "id": id}));-            if resumed.map_or(false, |r| r["ok"].as_bool() != Some(false)) {-                let staged = hy_post("/ai-threads/run-prompt",-                    &serde_json::json!({"sid": id, "prompt": phrase, "submit": false}));-                if staged.map_or(false, |r| r["ok"].as_bool() != Some(false)) {-                    return serde_json::json!({"ok": true, "staged": true, "via": "history+resume",-                        "_hint": "reopened the app thread and staged the phrase - the user presses Enter"});-                }-            }-            return serde_json::json!({"ok": false,-                "why": "app thread exists but is closed and resume/stage failed"});-        }-    }-    // 3. Neither: honest copy fallback.-    serde_json::json!({"ok": false,-        "why": "no AI thread tagged app:adom/fusion-bridge yet - open the app from the dock once"})-}+/// NOTE (2026-08-25): this app no longer implements sending. The shared newbie-panel+/// module owns the whole path - version gate, one-call composite, and the THREE-state+/// delivered_to reading - and our server only PROXIES /updater/status and+/// /ai-threads/run-prompt through unchanged. The old /api/say-send reshaped the panel's+/// reply into {ok, deliveredTo}, which collapses "delivered" and "opened but unconfirmed"+/// into one failure and invites the retry that double-sends. Deleted rather than kept+/// beside the module, for the same reason the hand-rolled CSS was.  /// Is a NEWER version of this same app already serving on the range? Used by the reaper so /// a superseded instance retires ITSELF once nobody is watching it. Without this, every-/// update leaves its predecessor alive: John's laptop accumulated three (1.9.187, 1.9.189,-/// 1.9.193, up to 15h old) squatting ports and serving pre-contract UI, and a probe that-/// asked only ONE of them would have been told, confidently, that the dashboard was current.+/// update leaves its predecessor alive: John's laptop accumulated several (up to 15h old)+/// squatting ports and serving pre-contract UI, and a probe that asked only ONE of them+/// would have been told, confidently, that the dashboard was current. fn newer_version_serving() -> Option<String> {     for p in 8930..=8940u16 {         let out = Command::new("curl")
install.sh+1−1
@@ -21,7 +21,7 @@ echo "Installed fusion-bridge skills: $(ls "$PKG_DIR/skills" 2>/dev/null | tr '\ # binary (the three-artifact pattern: bridge zip -> ab, THIS pkg -> container skills + # this download, page media -> wiki). Per the wiki's PKG_VS_RELEASE policy, binaries are # platform-tagged RELEASE assets, never tarball content - the installer fetches them.-DASH_VERSION="1.9.202"+DASH_VERSION="1.9.203" DASH_URL="https://wiki.adom.inc/download/adom/fusion-bridge/${DASH_VERSION}/fusion-dashboard" DEST="" if [ -w /usr/local/bin ]; then DEST=/usr/local/bin
package.json+1−1
@@ -1,6 +1,6 @@ {   "slug": "fusion-bridge",-  "version": "1.9.202",+  "version": "1.9.203",   "type": "app",   "description": "This package installs the bridge's SKILLS into your container so your AI knows how to drive it; Adom Bridge loads the bridge runtime itself from the release zip.",   "dependencies": {
page.json+11−8
@@ -1,6 +1,6 @@ {   "slug": "fusion-bridge",-  "version": "1.9.202",+  "version": "1.9.203",   "type": "app",   "description": "This package installs the bridge's SKILLS into your container so your AI knows how to drive it; Adom Bridge loads the bridge runtime itself from the release zip.",   "dependencies": {@@ -11,6 +11,10 @@     "uninstall": "./uninstall.sh"   },   "tags": [+    "ab",+    "bridge",+    "fusion",+    "desktop-app",     "fusion360",     "autodesk",     "cad",@@ -75,7 +79,10 @@     "build a basic parts library",     "add a 3d model to a component",     "make a fusion 360 library with 3d",-    "set up the fusion bridge"+    "set up the fusion bridge",+    "fusion dashboard",+    "fusion status page",+    "open the fusion launch page"   ],   "discovery_pitch": "Use the Adom Bridge Fusion 360 bridge when the user wants to DRIVE Autodesk Fusion 360 from the cloud: open/search their team's Fusion cloud designs, lay out a PCB or do electronics, export STEP/IGES/STL/Gerbers/BOM, or build a component library with real rendering 3D. It runs Fusion on the user's own machine via Adom Bridge. NOT for web/browser/login/form-filling (that's the native-browser extension) and NOT for KiCad (that's the kicad bridge).",   "brief": "Drive Autodesk Fusion 360 from the cloud via Adom Bridge: component libraries, IPC package generation, board layout, exports (STEP/Gerbers/BOM/CPL), fast APS cloud search, and parametric modeling.",@@ -104,9 +111,5 @@       "label": "Part volume",       "prompt": "How much material does each printed part in this assembly use?"     }-  ],-  "author": {-    "name": "John Lauer",-    "email": "[email protected]"-  }-}\ No newline at end of file+  ]+}