master
Drew Owens A busy add-in is not a missing add-in: never restart Fusion for it 8915d3d 14d ago
1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586878889909192939495969798991001011021031041051061071081091101111121131141151161171181191201211221231241251261271281291301311321331341351361371381391401411421431441451461471481491501511521531541551561571581591601611621631641651661671681691701711721731741751761771781791801811821831841851861871881891901911921931941951961971981992002012022032042052062072082092102112122132142152162172182192202212222232242252262272282292302312322332342352362372382392402412422432442452462472482492502512522532542552562572582592602612622632642652662672682692702712722732742752762772782792802812822832842852862872882892902912922932942952962972982993003013023033043053063073083093103113123133143153163173183193203213223233243253263273283293303313323333343353363373383393403413423433443453463473483493503513523533543553563573583593603613623633643653663673683693703713723733743753763773783793803813823833843853863873883893903913923933943953963973983994004014024034044054064074084094104114124134144154164174184194204214224234244254264274284294304314324334344354364374384394404414424434444454464474484494504514524534544554564574584594604614624634644654664674684694704714724734744754764774784794804814824834844854864874884894904914924934944954964974984995005015025035045055065075085095105115125135145155165175185195205215225235245255265275285295305315325335345355365375385395405415425435445455465475485495505515525535545555565575585595605615625635645655665675685695705715725735745755765775785795805815825835845855865875885895905915925935945955965975985996006016026036046056066076086096106116126136146156166176186196206216226236246256266276286296306316326336346356366376386396406416426436446456466476486496506516526536546556566576586596606616626636646656666676686696706716726736746756766776786796806816826836846856866876886896906916926936946956966976986997007017027037047057067077087097107117127137147157167177187197207217227237247257267277287297307317327337347357367377387397407417427437447457467477487497507517527537547557567577587597607617627637647657667677687697707717727737747757767777787797807817827837847857867877887897907917927937947957967977987998008018028038048058068078088098108118128138148158168178188198208218228238248258268278288298308318328338348358368378388398408418428438448458468478488498508518528538548558568578588598608618628638648658668678688698708718728738748758768778788798808818828838848858868878888898908918928938948958968978988999009019029039049059069079089099109119129139149159169179189199209219229239249259269279289299309319329339349359369379389399409419429439449459469479489499509519529539549559569579589599609619629639649659669679689699709719729739749759769779789799809819829839849859869879889899909919929939949959969979989991000100110021003100410051006100710081009101010111012101310141015101610171018101910201021102210231024102510261027102810291030103110321033103410351036103710381039104010411042104310441045104610471048104910501051105210531054105510561057105810591060106110621063106410651066106710681069107010711072107310741075107610771078107910801081108210831084108510861087108810891090109110921093109410951096109710981099110011011102110311041105110611071108110911101111111211131114111511161117111811191120112111221123112411251126112711281129113011311132113311341135113611371138113911401141114211431144114511461147114811491150115111521153115411551156115711581159116011611162116311641165116611671168116911701171117211731174117511761177117811791180118111821183118411851186118711881189119011911192119311941195119611971198119912001201120212031204120512061207120812091210121112121213121412151216121712181219122012211222122312241225122612271228122912301231123212331234123512361237123812391240124112421243124412451246124712481249125012511252125312541255125612571258125912601261126212631264126512661267126812691270127112721273127412751276127712781279128012811282128312841285128612871288128912901291129212931294129512961297129812991300130113021303130413051306130713081309131013111312131313141315131613171318131913201321132213231324132513261327132813291330133113321333133413351336133713381339134013411342134313441345134613471348134913501351135213531354135513561357135813591360136113621363136413651366136713681369137013711372137313741375137613771378137913801381138213831384138513861387138813891390139113921393139413951396139713981399140014011402140314041405140614071408140914101411141214131414141514161417141814191420142114221423142414251426142714281429143014311432143314341435143614371438143914401441144214431444144514461447144814491450145114521453145414551456145714581459146014611462146314641465146614671468146914701471147214731474147514761477147814791480148114821483148414851486148714881489149014911492149314941495149614971498149915001501150215031504150515061507150815091510151115121513151415151516151715181519152015211522152315241525152615271528152915301531153215331534153515361537153815391540154115421543154415451546154715481549155015511552155315541555155615571558155915601561156215631564156515661567156815691570157115721573157415751576157715781579158015811582158315841585158615871588158915901591159215931594159515961597159815991600160116021603160416051606160716081609161016111612161316141615161616171618161916201621162216231624162516261627162816291630163116321633163416351636163716381639164016411642164316441645164616471648164916501651165216531654165516561657165816591660166116621663166416651666166716681669167016711672167316741675167616771678167916801681168216831684168516861687168816891690169116921693169416951696169716981699170017011702170317041705170617071708170917101711171217131714171517161717171817191720172117221723172417251726172717281729173017311732173317341735173617371738173917401741174217431744174517461747174817491750175117521753175417551756175717581759176017611762176317641765176617671768176917701771177217731774177517761777177817791780178117821783178417851786178717881789179017911792179317941795179617971798179918001801180218031804180518061807180818091810181118121813181418151816181718181819182018211822182318241825182618271828182918301831183218331834183518361837183818391840184118421843184418451846184718481849185018511852185318541855185618571858185918601861186218631864186518661867186818691870187118721873187418751876187718781879188018811882188318841885188618871888188918901891189218931894189518961897189818991900190119021903190419051906190719081909191019111912191319141915
// fusion-dashboard - the fusion bridge's Launch Page (John's Dashboard spec, 2026-08-16).
// The FIRST container-side bridge binary: installed by the wiki pkg, serves the Dashboard
// web app, and aggregates status by shelling the adom-bridge CLI (the bridge stays the
// single source of truth; this is a thin, honest aggregator).
//
// House contract (adom-ui-design + app-creator), all of it:
//   - served favicon + <link rel=icon> (the REAL Fusion app icon, not an invented mark)
//   - SSE live channel at /events (state pushes, toasts, click directives)
//   - POST /ui/toast from day one - the AI narrates ON TOP of the running app
//   - CLI driving verbs: `ui toast|click|state|console` so the AI drives the real UI
//   - /state + /console observability, OK:/ERROR:/Hint: output, --ai-thread identity
//   - 24h idle reaper so containers never accumulate zombie servers
//
// The HTTP server is hand-rolled on TcpListener: tiny_http buffers a streamed body until
// EOF (verified live - zero bytes reached an SSE client), and SSE NEEDS per-event flushes.
// We control every route and run behind the container proxy, so ~120 lines of HTTP/1.1
// with explicit flush() beats a framework that can't stream.
//
// AI HINTS ARE THE PRODUCT as much as the UI: every JSON response carries _hint fields
// teaching a calling AI what it can automate next.

use std::io::{BufRead, BufReader, Read, Write};
use std::net::{TcpListener, TcpStream};
use std::process::Command;
use std::sync::mpsc::{channel, RecvTimeoutError, Sender};
use std::sync::{Arc, Mutex};
use std::time::{Duration, Instant};

mod demo_audio;
use demo_audio::demo_audio;

const UI_HTML: &str = include_str!("../ui/index.html");
// Web-control quick-actions demo (John, 2026-08-28: "whip up your own demo for this web
// control (quick actions) stuff"). Browser -> 127.0.0.1:2468x -> ab -> bridge, the wiki lane.
const QUICK_ACTIONS_HTML: &str = include_str!("../ui/quick-actions.html");
const VERSION: &str = env!("CARGO_PKG_VERSION");
const IDLE_REAP_HOURS: u64 = 24;

// Tab identity: teal rounded square, two green LEDs - matches the dock icon family.
/// The favicon is the REAL Autodesk Fusion application icon, extracted from the user's own
/// installed Fusion360.exe (SHDefExtractIconW at 256px) - NOT a logo we invented. John, 2026-08-19:
/// "just go with the logo the user is familiar with and not try to reinvent the wheel." The tab,
/// the pinned taskbar tile, and the header brand mark all wear the icon they already know.
const FAVICON_PNG: &[u8] = include_bytes!("../ui/fusion-icon.png");
/// The app mark (icons contract, 2026-08-24): ONE line-drawing SVG for the tab favicon,
/// the header mark, the dock card and the wiki page. Never Autodesk's filled badge.
const FAVICON_SVG: &[u8] = include_bytes!("../../docs/img/dock-fusion.svg");
/// The SHARED newbie panel (adom-ui-design 2.0.0), vendored VERBATIM. Never reformat it:
/// it builds its noun-highlight with RegExp() rather than a literal precisely because a
/// page assembled as a template literal turns \b into a BACKSPACE and eats the word
/// boundaries silently - the regex still matches, just too greedily, so nothing looks
/// broken. Seven apps hand-rolled this panel and produced four paddings, two border
/// colours and three Copy drawings; this file is the fix for that class of drift.
const NEWBIE_PANEL_JS: &[u8] = include_bytes!("../ui/vendor/newbie-panel.js");

fn ok(msg: &str) {
    println!("OK: {}", msg);
}
fn err_hint(msg: &str, hint: &str) {
    eprintln!("ERROR: {}", msg);
    eprintln!("Hint: {}", hint);
}

/// Shell an adom-bridge verb and parse its JSON. --ai-thread attributes every call in the
/// user's Activity Log; forwarded from our own --ai-thread flag (app-creator identity rule).
fn bridge(ai_thread: &str, target: &str, verb: &str, args: &str) -> serde_json::Value {
    let out = Command::new("adom-bridge")
        .args(["--target", target, "--ai-thread", ai_thread, verb, args])
        .output();
    match out {
        Ok(o) => serde_json::from_slice(&o.stdout).unwrap_or_else(|_| {
            serde_json::json!({"error": String::from_utf8_lossy(&o.stdout).chars().take(200).collect::<String>()})
        }),
        Err(e) => serde_json::json!({"error": format!("adom-bridge CLI not runnable: {}", e),
            "_hint": "The container needs the adom-bridge CLI on PATH (it ships with every Adom container)."}),
    }
}

fn unwrap_data(v: &serde_json::Value) -> serde_json::Value {
    // ab envelopes drift across versions - look through data, tolerate flat.
    if let Some(d) = v.get("data") {
        if d.is_object() && d.as_object().map(|m| !m.is_empty()).unwrap_or(false) {
            return d.clone();
        }
    }
    v.clone()
}

fn default_target() -> String {
    // PREFER A BOX THAT IS ACTUALLY DOING SOMETHING (bug found 2026-08-22: the plain
    // dashboard defaulted to targets[0], which was a box with no APS token, so the
    // identity chips read empty while AdomLapper was signed in). Rank connected targets
    // by a cheap per-box readiness probe: ready > running > signed-into-APS > first seen.
    // Only ever a fallback - the frontend passes ?target= once it knows the roster.
    let out = match Command::new("adom-bridge").arg("targets").output() {
        Ok(o) => o.stdout,
        Err(_) => return String::new(),
    };
    let v: serde_json::Value = match serde_json::from_slice(&out) {
        Ok(v) => v,
        Err(_) => return String::new(),
    };
    let targets: Vec<String> = v["targets"].as_array().map(|a| {
        a.iter().filter_map(|t| t["name"].as_str().map(String::from)).collect()
    }).unwrap_or_default();
    if targets.is_empty() { return String::new(); }
    if targets.len() == 1 { return targets[0].clone(); }
    let mut best: Option<(u8, String)> = None;   // (rank, name); higher rank wins
    for name in &targets {
        let rd = unwrap_data(&bridge("fusion-dashboard", name, "fusion_readiness", "{}"));
        // APS signed-in is what the identity chips read, so it breaks the common tie where
        // no box is running Fusion but ONE is signed into APS (the 2026-08-22 bug).
        let aps = unwrap_data(&bridge("fusion-dashboard", name, "fusion_aps_status", "{}"));
        let rank = if rd["ready"].as_bool().unwrap_or(false) { 4u8 }
                   else if rd["running"].as_bool().unwrap_or(false) { 3u8 }
                   else if aps["signedIn"].as_bool().unwrap_or(false) { 2u8 }
                   else { 1u8 };
        if best.as_ref().map(|(r, _)| rank > *r).unwrap_or(true) {
            best = Some((rank, name.clone()));
        }
    }
    best.map(|(_, n)| n).unwrap_or_else(|| targets[0].clone())
}

/// The LED payload: one call per facet, each with state + an AI hint on how to act.
fn gather_status(ai_thread: &str, target: &str) -> serde_json::Value {
    gather_status_ex(ai_thread, target, false)
}

fn gather_status_ex(ai_thread: &str, target: &str, launching: bool) -> serde_json::Value {
    // PARALLEL, not sequential: each verb is a relay round-trip, and six of them in series took
    // 36.7s against a remote box (measured live 2026-08-18) - the Dashboard sat blank that whole
    // time. Fan them out and the wall clock is the slowest single call.
    let verbs = ["fusion_readiness", "fusion_aps_status", "fusion_mcp_status",
                 "fusion_set_auto_update", "fusion_set_seat_takeover", "fusion_set_driving_badge",
                 "fusion_set_activity_indicator"];
    let handles: Vec<_> = verbs.iter().map(|v| {
        let (ai, t, verb) = (ai_thread.to_string(), target.to_string(), v.to_string());
        std::thread::spawn(move || unwrap_data(&bridge(&ai, &t, &verb, "{}")))
    }).collect();
    let mut res: Vec<serde_json::Value> = Vec::with_capacity(verbs.len());
    for h in handles {
        res.push(h.join().unwrap_or(serde_json::json!({})));
    }
    let (rd, aps, mcp) = (res[0].clone(), res[1].clone(), res[2].clone());
    let (auto_upd, seat, badge) = (res[3].clone(), res[4].clone(), res[5].clone());
    let activity = res.get(6).cloned().unwrap_or(serde_json::json!({}));

    let running = rd["fusionRunning"].as_bool().or(rd["running"].as_bool()).unwrap_or(false);
    let ready = rd["ready"].as_bool().unwrap_or(false);
    let needs_signin = rd["needsSignin"].as_bool().unwrap_or(false);
    let addin = rd["addinVersion"].as_str().unwrap_or("").to_string();
    // Configured-but-dormant beats broken-red (John, 2026-08-17): while Fusion is closed,
    // sign-in/add-in/MCP are UNKNOWABLE - paint them amber with the last verified time,
    // never red. Red is reserved for "actually needs setup".
    let last_signed = rd["lastSignedInAt"].as_str().unwrap_or("");
    let last_ready = rd["lastReadyAt"].as_str().unwrap_or("");
    // Is lastReadyAt older than 2h? Parsed leniently from "YYYY-MM-DDTHH:MM:SS..." vs now;
    // unparseable timestamps count as stale (never let garbage read as freshly busy).
    let last_ready_stale = !last_ready.is_empty() && {
        use std::time::{SystemTime, UNIX_EPOCH};
        parse_epoch_lenient(last_ready).map(|t| {
            let now = SystemTime::now().duration_since(UNIX_EPOCH).map(|d| d.as_secs()).unwrap_or(0) as i64;
            now - t > 15 * 60      // status-leds contract: busy past 15 min reads "stuck?"
        }).unwrap_or(true)
    };
    let mcp_last = mcp["lastEnabledAt"].as_str().unwrap_or("");
    // WHO is signed in, per facet (John, 2026-08-22): the auth test matrix runs
    // several accounts, and an anonymous green LED cannot distinguish them.
    let aps_who = aps["signedInAs"]["email"].as_str()
        .or(aps["signedInAs"]["name"].as_str()).unwrap_or("").to_string();
    let fusion_who = rd["fusionUser"]["user"].as_str().unwrap_or("").to_string();
    let aps_ok = aps["signedIn"].as_bool().or(aps["configured"].as_bool()).unwrap_or(false)
        || aps["ready"].as_bool().unwrap_or(false);
    let mcp_on = mcp["enabled"].as_bool().unwrap_or(false);
    let upd_offered = rd["fusionUpdateOffered"].as_bool().unwrap_or(false);
    let upd_handled = rd["fusionUpdateHandled"].as_bool().unwrap_or(false);

    // Did the bridge actually answer? A busy bridge returns an error envelope with no version
    // and no running flag - that is UNKNOWN, not "everything off".
    let answered = rd.get("bridgeVersion").map(|v| !v.is_null()).unwrap_or(false)
        || rd.get("running").is_some() || rd.get("installed").is_some();
    // TOP-LEVEL ROLLUP (dock_manifest 1.2.0, issue #37 checklist item 2): our own verdict
    // of which LEDs deserve to change the card, so the dock never falls back to its blunt
    // worst-facet rule. Fusion being CLOSED is a normal state for a control panel - the
    // card stays green. Only two things are worth a yellow: the bridge not answering
    // (transient - busy or relay hiccup), and Fusion up with the add-in neither serving
    // nor busy (self-healable, so warn, not fail). update/aps/mcp never move the card.
    let rollup = if !answered { "warn" }
                 else if running && !ready && last_ready.is_empty() { "warn" }
                 else { "ok" };
    serde_json::json!({
        "state": rollup,
        "target": target,
        "answered": answered,
        "busyReason": if answered { serde_json::Value::Null }
                      else { serde_json::json!(rd["error"].as_str().unwrap_or("the bridge did not answer (busy running a long verb?)")) },
        "dashboardVersion": VERSION,
        "bridgeVersion": rd["bridgeVersion"],
        "leds": {
            "fusionRunning": {"on": running, "label": "Fusion running",
                // A launch takes ~30-90s. Saying nothing during it (or showing red) leaves the
                // user wondering whether anything is happening at all - so say it plainly.
                "state": if running {"on"} else if launching {"starting"} else {"off"},
                "_hint": if running {"Fusion is up.".to_string()}
                         else if launching {"Opening Fusion right now - launching in the background and waiting for its bridge add-in. This usually takes under a minute.".to_string()}
                         else {"fusion_start launches it in the BACKGROUND (never steals focus).".to_string()}},
            "addinReady": {"on": ready, "label": "Bridge add-in", "version": addin,
                // BUSY is not BROKEN (ralph, 2026-08-18): while a long verb runs - a tour, a 3D
                // build - readiness answers not-ready because the add-in's main thread is
                // occupied. Painting that red said "not serving" while the tour was visibly
                // driving Fusion. Amber + an honest sentence instead.
                "state": if ready {"on"}
                         else if running && !last_ready.is_empty() && !last_ready_stale {"busy"}
                         else if running && !last_ready.is_empty() {"stale"}
                         else if !running && !last_ready.is_empty() {"dormant"}
                         else {"off"},
                "_hint": if ready {"Add-in serving - all fusion_* verbs live.".to_string()}
                         // STALENESS CUTOFF (project-a6 cold-verify flag, 2026-08-24): "busy with a
                         // long verb" citing a DAY-old lastReadyAt is a lie - no verb runs 2h+. Past
                         // the cutoff say what it actually is: the add-in has not answered since
                         // that timestamp and a Fusion restart reloads it (which also picks up a
                         // newer installed add-in version).
                         else if running && !last_ready.is_empty() && last_ready_stale {format!("Stuck? Not serving since {} - a long verb never runs this long. Bounce it: fusion_stop then fusion_start (that also loads any newer installed add-in).", last_ready)}
                         else if running && !last_ready.is_empty() {format!("Busy or still starting - Fusion is up and the add-in was serving at {}. A long verb (a tour, a 3D build) occupies its main thread, so readiness answers not-ready until it finishes.", last_ready)}
                         else if !running && !last_ready.is_empty() {format!("Dormant - resumes when Fusion starts (last verified {}).", last_ready)}
                         else {"Not serving. If needsSignin, run fusion_signin - it self-drives to the password step.".to_string()}},
            "signedIn": {"on": running && !needs_signin, "label": "Autodesk sign-in",
                "who": if fusion_who.is_empty() { serde_json::Value::Null } else { serde_json::json!(fusion_who) },
                "state": if running && !needs_signin {"on"} else if !running && !last_signed.is_empty() {"dormant"} else {"off"},
                "_hint": if needs_signin {"fusion_signin {email} self-drives the whole wall: launches, clicks, types the email, toasts the user ONLY at their password.".to_string()}
                         else if !running && !last_signed.is_empty() {format!("Signed in (verified {}). Unknowable while Fusion is closed - dormant, not broken.", last_signed)}
                         else if running {"Signed in.".to_string()} else {"Unknown until Fusion runs.".to_string()}},
            "aps": {"on": aps_ok, "label": "APS cloud search",
                "who": if aps_who.is_empty() { serde_json::Value::Null } else { serde_json::json!(aps_who) },
                "_hint": if aps_ok {"fusion_aps_search gives ~2s cloud lookups."} else {"Not configured: fusion_aps_set_client_id then fusion_aps_signin (silent when the browser session is warm)."}},
            "mcp": {"on": mcp_on, "label": "Fusion MCP server",
                "state": if mcp_on {"on"} else if !running && !mcp_last.is_empty() {"dormant"} else {"off"},
                "_hint": if mcp_on {"fusion_mcp_call drives Autodesk's own MCP tools.".to_string()}
                         else if !running && !mcp_last.is_empty() {format!("Enabled in Fusion's preferences (verified {}) - resumes when Fusion starts.", mcp_last)}
                         else {"fusion_mcp_enable turns it on (~10s, drives the Preferences dialog with a caption).".to_string()}},
            "datahub": {"on": aps_ok, "label": "Data Hub API",
                "who": if aps_who.is_empty() { serde_json::Value::Null } else { serde_json::json!(aps_who) },
                "_hint": "Data Hub rides the APS auth: file listings, versions, urn resolution via fusion_aps_get."},
            "update": {"on": !upd_offered || upd_handled, "label": "Fusion up to date",
                "pending": upd_offered && !upd_handled,
                "_hint": if upd_offered && !upd_handled {"An update is WAITING. Auto-update normally clicks it silently; offer to run it now."} else {"No pending update (auto-update clicks nags silently by default; sticky opt-out via fusion_set_auto_update)."}},
        },
        "settings": {
            "autoUpdateFusion": auto_upd["autoUpdateFusion"].as_bool().unwrap_or(true),
            "autoTakeOverSeat": seat["autoTakeOverSeat"].as_bool().unwrap_or(true),
            "autoGreenOnLaunch": read_settings()["autoGreenOnLaunch"].as_bool().unwrap_or(true),
            "showDrivingBadge": badge["showDrivingBadge"].as_bool().unwrap_or(true),
            "activityIndicator": activity["activityIndicator"].as_str().unwrap_or("progress")
        },
        "demoPart": read_settings()["demoPart"],
        "_hint": "This is the Dashboard status payload. Every LED carries its own _hint naming the verb that fixes it. POST /api/action {action, args} to act; GET /api/status to re-read; subscribe to /events (SSE) for live pushes; POST /ui/toast {message,kind} to narrate on top of the app."
    })
}

fn action(ai_thread: &str, target: &str, name: &str, args: &serde_json::Value) -> serde_json::Value {
    let a = args.to_string();
    match name {
        "start" => bridge(ai_thread, target, "fusion_start", "{}"),
        "stop" => {
            let r = unwrap_data(&bridge(ai_thread, target, "fusion_stop",
                                        "{\"reason\":\"dashboard: user clicked Close Fusion\"}"));
            if r["success"].as_bool() == Some(true) { return r; }
            // DOCTRINE: when the ONLY things blocking the close are the bridge's own throwaway
            // test docs, escalate for the user instead of handing them a failure to resolve.
            if r["bridgeDocsOnly"].as_bool() == Some(true) {
                let _k = unwrap_data(&bridge(ai_thread, target, "fusion_kill", "{}"));
                // VERIFY the outcome, do not trust the verb's flag: fusion_kill reports
                // success:false in shapes where Fusion did in fact close (ralph, 2026-08-18).
                std::thread::sleep(Duration::from_secs(4));
                let gone = !unwrap_data(&bridge(ai_thread, target, "fusion_readiness", "{}"))["running"]
                    .as_bool().unwrap_or(false);
                return serde_json::json!({"success": gone,
                    "escalated": true, "gracefulError": r["error"].clone(),
                    "_hint": "The graceful close was blocked only by the bridge's own generated test documents, so it was force-closed for you - nothing of yours was at risk."});
            }
            r
        }
        "signin" => bridge(ai_thread, target, "fusion_signin", &a),
        "tour" => {
            // THE REMOTE IS AB'S GLASS PANEL NOW (John, 2026-09-01: "ab's new demo control
            // panel that has play/pause/next/prev and is a floaty glass like interface").
            // remote:true makes the tour show desktop_demo_panel itself and drain its
            // presses onto the same transport flags this page's buttons set, so both work
            // mid-tour. The earlier pup floaty (a 320x420 window of this page's ?remote view)
            // is retired: two remotes on screen confused the story, and the glass panel
            // never steals focus. preset:electronics is the eight-stop showcase: library in,
            // symbol, footprint, 3D chip, ganged view, schematic, 2D board, 3D board.
            bridge(ai_thread, target, "fusion_demo",
                   "{\"mode\":\"tour\",\"preset\":\"electronics\",\"remote\":true}")
        }
        "tour_control" => bridge(ai_thread, target, "fusion_demo_control", &a),
        "tour_progress" => bridge(ai_thread, target, "fusion_demo_progress", &a),
        "set_auto_update" => bridge(ai_thread, target, "fusion_set_auto_update", &a),
        "set_seat_takeover" => bridge(ai_thread, target, "fusion_set_seat_takeover", &a),
        "set_driving_badge" => bridge(ai_thread, target, "fusion_set_driving_badge", &a),
        "mcp_enable" => bridge(ai_thread, target, "fusion_mcp_enable", "{}"),
        "aps_signin" => bridge(ai_thread, target, "fusion_aps_signin", "{}"),
        "import_basic_part" => import_basic_part(ai_thread, target, args),   // readiness handled by the caller
        _ => serde_json::json!({"error": format!("unknown action {}", name),
            "_hint": "Actions: start, stop, signin, tour, tour_progress, set_auto_update, set_seat_takeover, mcp_enable, aps_signin, import_basic_part."}),
    }
}

/// The wiki-to-Fusion demo, end to end from the container: clone the part page,
/// send its .lbr to the box, open it in Fusion's Library Manager. All background.
fn import_basic_part(ai_thread: &str, target: &str, args: &serde_json::Value) -> serde_json::Value {
    let page = args["page"].as_str().unwrap_or("aravk/in-s42ata");
    let tmp = format!("/tmp/dash-part-{}", std::process::id());
    let _ = std::fs::remove_dir_all(&tmp);
    let clone = Command::new("adom-wiki")
        .args(["repo", "clone", page, "--dir", &tmp]).output();
    if clone.is_err() {
        return serde_json::json!({"error": "adom-wiki CLI missing",
            "_hint": "The container ships adom-wiki; check PATH."});
    }
    let lbr = std::fs::read_dir(&tmp).ok().and_then(|rd| {
        rd.filter_map(|e| e.ok()).map(|e| e.path())
            .find(|p| p.extension().map(|x| x == "lbr").unwrap_or(false))
    });
    let Some(lbr) = lbr else {
        return serde_json::json!({"error": format!("{} has no .lbr file", page),
            "_hint": "Pick an adom basic part page (it publishes <PART>.lbr + <PART>.step)."});
    };
    let dest = "C:/Users/john/AppData/Local/Temp/fusion-dashboard";
    let send = bridge(ai_thread, target, "send_files",
        &serde_json::json!({"filePaths": [lbr.to_string_lossy()], "dest": dest}).to_string());
    let name = lbr.file_name().unwrap().to_string_lossy().to_string();
    // BUSY-RETRY (ralph, 2026-08-18): the add-in serializes on its main thread, so an open
    // issued while another session's long verb is running is REJECTED outright. Waiting is the
    // whole fix - never hand that back to the user as a failure.
    let mut open = bridge(ai_thread, target, "fusion_open_lbr",
        &serde_json::json!({"filePath": format!("{}/{}", dest, name)}).to_string());
    for _ in 0..10 {
        let o = unwrap_data(&open);
        let err = o["error"].as_str().unwrap_or("").to_lowercase();
        let busy = o["errorCode"].as_str() == Some("main_thread_busy")
            || err.contains("main thread busy") || err.contains("in progress from another");
        if !busy { break; }
        std::thread::sleep(Duration::from_secs(15));
        open = bridge(ai_thread, target, "fusion_open_lbr",
            &serde_json::json!({"filePath": format!("{}/{}", dest, name)}).to_string());
    }
    serde_json::json!({"page": page, "sent": unwrap_data(&send), "opened": unwrap_data(&open),
        "_hint": "Wiki part -> Fusion Library Manager, all background. Next steps an AI can run: fusion_show_symbol / fusion_show_footprint {name}, fusion_bind_library_3d for the 3D, fusion_make_test_project for a solo demo board. Render any of them with fusion_state."})
}

// ── SSE hub: the live channel every Adom app carries ─────────────────────────────────────
struct Hub {
    clients: Mutex<Vec<Sender<String>>>,
}
impl Hub {
    fn broadcast(&self, event: &str, data: &serde_json::Value) {
        let msg = format!("event: {}\ndata: {}\n\n", event, data);
        self.clients.lock().unwrap().retain(|c| c.send(msg.clone()).is_ok());
    }
    fn client_count(&self) -> usize {
        // Prune dead senders lazily so the count reflects live viewers.
        let mut g = self.clients.lock().unwrap();
        g.retain(|c| c.send(": ping\n\n".into()).is_ok());
        g.len()
    }
}

struct App {
    ai_thread: String,
    target: String,
    hub: Hub,
    console: Mutex<Vec<String>>,
    last_activity: Mutex<Instant>,
    // Auto-green: John's ruling 2026-08-18 - "the meaning of opening the dashboard is that you
    // open fusion and all the other services", with a sticky opt-out toggle.
    // PER TARGET (ralph, 2026-08-18): these were single globals, so an auto-green for one box
    // silently blocked a run for the other and the endpoint still answered "started" - exactly
    // wrong now that multi-box is first-class.
    auto_green_running: Mutex<std::collections::HashSet<String>>,
    auto_green_last: Mutex<std::collections::HashMap<String, Instant>>,
    // Surfaces demo staging, keyed BY TARGET: staging sends files to one specific box, so a
    // single global slot made a switch to another box reuse paths that box never received
    // (caught live 2026-08-18).
    // (libraryPath, deviceset, designReady) - designReady false means the generated test
    // project did NOT land, so the four design surfaces must regenerate it before they run.
    staged: Mutex<std::collections::HashMap<String, (String, String, bool)>>,
    // Last GOOD status per target. While the bridge is busy (a tour, a long verb), readiness
    // calls time out and every field reads false - which used to paint the whole board RED
    // while the tour was visibly running (ralph, 2026-08-18). Unknowable is not broken.
    last_good: Mutex<std::collections::HashMap<String, (Instant, serde_json::Value)>>,
    // Boxes we are actively launching Fusion on, so the LED can say "opening right now"
    // instead of leaving the user guessing (John, 2026-08-18).
    launching: Mutex<std::collections::HashMap<String, Instant>>,
    // Why the last launch attempt ended, so a failed launch explains itself on the LED instead
    // of silently reverting to a bare red "off" (ralph, 2026-08-18).
    last_launch_note: Mutex<std::collections::HashMap<String, String>>,
}

// ── Dashboard-own settings (container-side; bridge prefs stay in the bridge) ──────────────
fn settings_path() -> std::path::PathBuf {
    let home = std::env::var("HOME").unwrap_or_else(|_| "/tmp".into());
    let dir = std::path::Path::new(&home).join(".adom").join("fusion-dashboard");
    let _ = std::fs::create_dir_all(&dir);
    dir.join("settings.json")
}

fn read_settings() -> serde_json::Value {
    let mut v = std::fs::read_to_string(settings_path()).ok()
        .and_then(|s| serde_json::from_str::<serde_json::Value>(&s).ok())
        .unwrap_or_else(|| serde_json::json!({}));
    if v.get("autoGreenOnLaunch").is_none() {
        v["autoGreenOnLaunch"] = serde_json::Value::Bool(true);   // ON by default, per John
    }
    if v.get("demoPart").is_none() {
        // Deliberately NOT web control's IN-S42ATR: a different adom basic part (green 0603 vs
        // red 0402) proves the flow generalizes instead of re-proving one file.
        v["demoPart"] = serde_json::Value::String("aravk/in-s63btg".into());
    }
    v
}

fn write_settings(v: &serde_json::Value) {
    let _ = std::fs::write(settings_path(), serde_json::to_string_pretty(v).unwrap_or_default());
}
impl App {
    fn log(&self, m: String) {
        let mut g = self.console.lock().unwrap();
        g.push(m);
        let overflow = g.len().saturating_sub(500);
        if overflow > 0 {
            g.drain(0..overflow);
        }
    }
    fn tgt(&self) -> String {
        if self.target.is_empty() { default_target() } else { self.target.clone() }
    }
}


// ── AUTO-GREEN (John, 2026-08-18) ────────────────────────────────────────────────────────
// "when i launch the dashboard, the default behavior should be to get all those led's green,
//  meaning if fusion isn't launched, you literally launch it ... but i need a toggle to make
//  this not be the default, and if i turn it off you just show led status and don't act."
//
// So: opening the Dashboard = an INTENT to work, not just to look. The sequence does every
// step it can do WITHOUT the human, narrates each one as a toast on the live channel, and
// stops honestly at any wall that genuinely needs a person (a password, an unconfigured APS
// client id) rather than pretending. All of it background - never steals focus.
/// Is Fusion running on any OTHER bridge target than `here`? Returns that box's name.
/// Used by auto-green's seat courtesy - a page-load must never steal the one Autodesk
/// seat from a box that is actively using it (#50 regression: killed a build matrix).
fn fusion_running_elsewhere(ai: &str, here: &str) -> Option<String> {
    let out = Command::new("adom-bridge").arg("targets").output().ok()?;
    let v: serde_json::Value = serde_json::from_slice(&out.stdout).ok()?;
    for t in v["targets"].as_array()? {
        let name = t["name"].as_str().unwrap_or("");
        if name.is_empty() || name.eq_ignore_ascii_case(here) { continue; }
        let rd = unwrap_data(&bridge(ai, name, "fusion_readiness", "{}"));
        if rd["running"].as_bool() == Some(true) {
            return Some(name.to_string());
        }
    }
    None
}

fn auto_green_forced(app: &Arc<App>, target: &str, force: bool) -> bool {
    {   // single-flight + 3-minute debounce, PER TARGET: several tabs/reloads must not stack
        // launches on the SAME box, but a run for one box must never block another.
        let mut running = app.auto_green_running.lock().unwrap();
        if running.contains(target) { return false; }      // a run is genuinely in flight
        if !force {
            if let Some(t) = app.auto_green_last.lock().unwrap().get(target) {
                if t.elapsed() < Duration::from_secs(180) { return false; }
            }
        }
        running.insert(target.to_string());
    }
    // Mark "launching" SYNCHRONOUSLY, before the worker thread starts: the endpoint returns
    // immediately and the page's very next status must already say "opening Fusion", not "off"
    // (ralph, 2026-08-18 - a 3-second window where the user saw a red LED after clicking).
    {
        let ai0 = app.ai_thread.clone();
        let t0 = target.to_string();
        let running_now = unwrap_data(&bridge(&ai0, &t0, "fusion_readiness", "{}"))["running"]
            .as_bool().unwrap_or(false);
        if !running_now {
            app.launching.lock().unwrap().insert(t0.clone(), Instant::now());
            app.last_launch_note.lock().unwrap().remove(&t0);
        }
    }
    let t = target.to_string();
    let ai = app.ai_thread.clone();
    let app2 = app.clone();
    std::thread::spawn(move || {
        let toast = |msg: &str, kind: &str| {
            app2.hub.broadcast("toast", &serde_json::json!({"message": msg, "kind": kind}));
            app2.log(format!("auto-green: {}", msg));
        };
        let push_state = || {
            let st = gather_status(&ai, &t);
            app2.hub.broadcast("state", &st);
            st
        };
        let mut st = push_state();
        let led = |st: &serde_json::Value, k: &str| st["leds"][k]["on"].as_bool().unwrap_or(false);

        if led(&st, "fusionRunning") && led(&st, "addinReady") && led(&st, "aps") && led(&st, "mcp") {
            toast("Everything is already green on this box", "ok");
        } else {
            toast(&format!("Getting {} ready - launching Fusion and its services in the background", t), "");

            // 1. Fusion itself.
            // ⚠ SEAT COURTESY (#50 regression, 2026-08-23): one Autodesk seat, many boxes.
            // An UNATTENDED auto-green that launches Fusion while the SAME account's Fusion
            // is live on ANOTHER box makes Autodesk suspend that other Fusion - which killed
            // a 20-part build matrix mid-prove on AdomLapper when a dock test opened this
            // dashboard on the conference box. Auto-green never steals the seat: if Fusion
            // runs elsewhere, report it and stand down. A DELIBERATE Start Fusion click
            // still takes the seat (John's "the seat follows automatically" model is for
            // intentional starts, not page-loads).
            if !led(&st, "fusionRunning") {
                if let Some(other) = fusion_running_elsewhere(&ai, &t) {
                    let why = format!(
                        "Fusion is live on {} right now - starting it here would take the one \
                         Autodesk seat and suspend that box mid-work. Press Start Fusion to \
                         take the seat deliberately.", other);
                    toast(&why, "");
                    app2.last_launch_note.lock().unwrap().insert(t.clone(), why);
                    app2.auto_green_last.lock().unwrap().insert(t.clone(), Instant::now());
                    app2.auto_green_running.lock().unwrap().remove(&t);
                    push_state();
                    return;
                }
            }
            if !led(&st, "fusionRunning") {
                app2.launching.lock().unwrap().insert(t.clone(), Instant::now());
                app2.hub.broadcast("state", &gather_status_ex(&ai, &t, true));
                bridge(&ai, &t, "fusion_start", "{}");
                for _ in 0..30 {
                    std::thread::sleep(Duration::from_secs(10));
                    st = push_state();
                    if led(&st, "addinReady") { break; }
                    // A sign-in wall shows up as running-but-never-ready: handle it below.
                    if led(&st, "fusionRunning") && st["leds"]["signedIn"]["on"].as_bool() == Some(false)
                        && st["leds"]["signedIn"]["state"].as_str() == Some("off") { break; }
                }
                app2.launching.lock().unwrap().remove(&t);
                if led(&st, "addinReady") {
                    app2.last_launch_note.lock().unwrap().remove(&t);
                    toast("Fusion is up and the bridge add-in is serving", "ok");
                } else {
                    let why = if led(&st, "fusionRunning") {
                        "Fusion launched but its bridge add-in never came up - press Start Fusion to retry, or check for a sign-in wall."
                    } else {
                        "The launch attempt did not take (Autodesk seat busy elsewhere, or a licensing dialog). Press Start Fusion to try again."
                    };
                    app2.last_launch_note.lock().unwrap().insert(t.clone(), why.to_string());
                    toast(why, "err");
                }
            }

            // 2. Fusion is up but the add-in is not serving. TWO causes, both fixable without
            //    the human: a sign-in wall (drive it), or an add-in that simply did not load
            //    (restart Fusion - it only loads at launch). John's rule: detect and fix, then
            //    continue. Previously this branch only tried sign-in, so a stuck add-in sat red
            //    on the Dashboard forever (caught by the ralph loop, 2026-08-18).
            st = push_state();
            if led(&st, "fusionRunning") && !led(&st, "addinReady") {
                let si = unwrap_data(&bridge(&ai, &t, "fusion_signin", "{}"));
                if si["signinWall"].is_string() {
                    toast("Fusion needs your Autodesk password - I drove the sign-in to that step for you", "err");
                } else {
                    toast("Fusion is up but its add-in is not serving - healing that now", "");
                    wait_ready_with_heal(&app2, &t, 240);
                    st = push_state();
                    if led(&st, "addinReady") { toast("Bridge add-in is serving", "ok"); }
                }
            }

            // 3. Autodesk's MCP server (a Fusion preference; needs Fusion up).
            st = push_state();
            if led(&st, "addinReady") && !led(&st, "mcp") {
                toast("Turning on the Fusion MCP server", "");
                bridge(&ai, &t, "fusion_mcp_enable", "{}");
                st = push_state();
                if led(&st, "mcp") { toast("Fusion MCP server enabled", "ok"); }
            }

            // 4. APS cloud search. Signing in is only attempted when a client id is already
            //    configured (the warm case that can complete silently); an unconfigured APS is
            //    a real setup task for the human, so we say so instead of opening browsers.
            st = push_state();
            if !led(&st, "aps") {
                let aps = unwrap_data(&bridge(&ai, &t, "fusion_aps_status", "{}"));
                if aps["configured"].as_bool().unwrap_or(false) {
                    toast("Signing in to APS cloud search", "");
                    bridge(&ai, &t, "fusion_aps_signin", "{}");
                    st = push_state();
                    if led(&st, "aps") { toast("APS cloud search is live", "ok"); }
                } else {
                    toast("APS cloud search needs a one-time setup (client id) - use Setup when you want it", "err");
                }
            }
        }

        let st = push_state();
        let reds: Vec<String> = st["leds"].as_object().map(|o| o.iter()
            .filter(|(_, v)| !v["on"].as_bool().unwrap_or(false) && v["state"].as_str() != Some("dormant"))
            .map(|(k, _)| k.clone()).collect()).unwrap_or_default();
        if reds.is_empty() { toast("All green - ready to go", "ok"); }
        else { toast(&format!("Ready except: {} (see each card for the exact next step)", reds.join(", ")), ""); }

        app2.auto_green_last.lock().unwrap().insert(t.clone(), Instant::now());
        app2.auto_green_running.lock().unwrap().remove(&t);
    });
    true
}

/// Make the box ready, do not lecture the user about it (John, 2026-08-18: "when i load the
/// dashboard you're supposed to get everything ready for me ... launching the dashboard is like
/// opening fusion and making sure its fully setup and ready for the ai to control it").
/// Any action that needs Fusion calls this FIRST: it returns immediately when ready, otherwise
/// it kicks auto-green (single-flight, so an in-flight launch is joined rather than duplicated)
/// and waits, narrating progress, before the caller proceeds.
/// Wait for readiness while HEALING the fixable states. Does NOT call auto_green (auto_green
/// calls this, so putting the kick here would recurse).
fn wait_ready_with_heal(app: &Arc<App>, target: &str, max_wait_s: u64) -> bool {
    let ai = app.ai_thread.clone();
    let is_ready = || unwrap_data(&bridge(&ai, target, "fusion_readiness", "{}"))["ready"]
        .as_bool().unwrap_or(false);
    let t0 = Instant::now();
    let mut announced = false;
    let mut restarted = false;
    let mut last_start = Instant::now() - Duration::from_secs(120);   // allow an immediate launch
    while t0.elapsed() < Duration::from_secs(max_wait_s) {
        std::thread::sleep(Duration::from_secs(5));
        // SELF-HEAL rather than wait on a broken state (John, 2026-08-18: "even if something is
        // off, you should be detecting that and then auto launching all the needed pieces ...
        // never make the adom user do anything manually"). Two mechanically fixable states:
        //   a) a modal is blocking the add-in -> clear it;
        //   b) Fusion is up but the add-in never came up -> restart it ONCE (the add-in loads at
        //      launch, so stop+start is the actual remedy; more waiting never fixes it).
        // THE LAUNCH ITSELF: never assume someone else is starting Fusion. auto_green may be
        // debounced (a run finished in the last 3 minutes), in which case waiting here would
        // wait forever on a launch nobody performs - which is exactly how the surfaces started
        // failing with "installed but not running" (ralph, 2026-08-18). fusion_start is
        // idempotent, so issuing it directly is safe; throttled to one attempt per 75s.
        {
            let running = unwrap_data(&bridge(&ai, target, "fusion_readiness", "{}"))["running"]
                .as_bool().unwrap_or(false);
            if !running && last_start.elapsed() > Duration::from_secs(75) {
                last_start = Instant::now();
                app.launching.lock().unwrap().insert(target.to_string(), Instant::now());
                app.hub.broadcast("toast", &serde_json::json!({
                    "message": format!("Opening Fusion on {}", target), "kind": ""}));
                app.log(format!("wait_ready_with_heal: launching Fusion on {}", target));
                bridge(&ai, target, "fusion_start", "{}");
            }
        }
        if t0.elapsed() > Duration::from_secs(40) && !is_ready() {
            let st = unwrap_data(&bridge(&ai, target, "fusion_state", "{\"screenshot\":false}"));
            if st["blockingDialogs"].as_array().map(|a| !a.is_empty()).unwrap_or(false) {
                app.log("ensure_ready: clearing blocking dialogs".into());
                bridge(&ai, target, "fusion_dismiss_blocking_dialogs", "{}");
            }
            if !restarted && t0.elapsed() > Duration::from_secs(90)
                && st["running"].as_bool().unwrap_or(false)
                && !st["addinResponding"].as_bool().unwrap_or(false)
                // a loaded add-in that is busy is not "did not start": never restart over it
                && st["addinBusy"].is_null() {
                restarted = true;
                app.hub.broadcast("toast", &serde_json::json!({
                    "message": "Fusion is up but its bridge add-in did not start - restarting Fusion for you", "kind": ""}));
                app.log("ensure_ready: add-in not serving, restarting Fusion".into());
                bridge(&ai, target, "fusion_stop", "{\"reason\":\"dashboard: add-in not serving, restarting to load it\"}");
                std::thread::sleep(Duration::from_secs(4));
                bridge(&ai, target, "fusion_kill", "{}");
                std::thread::sleep(Duration::from_secs(3));
                app.launching.lock().unwrap().insert(target.to_string(), Instant::now());
                bridge(&ai, target, "fusion_start", "{}");
            }
        }
        if is_ready() {
            app.launching.lock().unwrap().remove(target);
            app.hub.broadcast("toast", &serde_json::json!({"message": "Fusion is ready - continuing", "kind": "ok"}));
            app.hub.broadcast("state", &gather_status(&ai, target));
            return true;
        }
        // MEASURED, NEVER A SHRUG (page issue #32, John 2026-08-19): the bridge now returns a
        // `progress` block seeded from THIS machine's rolling launch history, so the wait is
        // reported with a real percent + ETA instead of "a minute or two". Pushed on the live
        // channel every tick so the LED and its caption narrow as the launch proceeds.
        let rd = unwrap_data(&bridge(&ai, target, "fusion_readiness", "{}"));
        if let Some(p) = rd.get("progress").filter(|p| !p.is_null()) {
            app.hub.broadcast("progress", &serde_json::json!({
                "target": target, "phase": p["phase"].clone(), "percent": p["percent"].clone(),
                "etaSec": p["etaSec"].clone(), "elapsedSec": p["elapsedSec"].clone(),
                "stepLabel": p["stepLabel"].clone(), "confidence": p["confidence"].clone(),
                "startKind": p["startKind"].clone()}));
            if !announced {
                announced = true;
                let eta = p["etaSec"].as_i64();
                app.hub.broadcast("toast", &serde_json::json!({
                    "message": match eta {
                        Some(s) => format!("{} - about {}s left ({})",
                                           p["stepLabel"].as_str().unwrap_or("Fusion is starting"), s,
                                           p["confidence"].as_str().unwrap_or("typical")),
                        None => format!("{}", p["stepLabel"].as_str().unwrap_or("Fusion is starting")),
                    }, "kind": ""}));
            }
        }
    }
    false
}

fn auto_green(app: &Arc<App>, target: &str) -> bool { auto_green_forced(app, target, false) }

/// The doctrine entry point: make the box ready, never tell the user to do it.
/// How long to wait for a launch on THIS box, from the bridge's own MEASURED history
/// (page issue #32) rather than a literal. p90 with generous headroom, because giving up
/// early on a genuinely-slow-but-healthy cold start hands the user a failure the machine
/// was about to resolve (ralph, 2026-08-19: a 4-minute cap failed a box that needed more).
/// Falls back to `floor` when this machine has no history yet.
fn ready_budget(ai: &str, target: &str, floor_s: u64) -> u64 {
    let st = unwrap_data(&bridge(ai, target, "fusion_state", "{\"screenshot\":false}"));
    let cold = st["timings"]["launch"]["cold"]["p90"].as_f64()
        .or_else(|| st["timings"]["launch"]["cold"]["p50"].as_f64());
    match cold {
        Some(s) if s > 0.0 => ((s * 2.0) as u64 + 120).max(floor_s),
        _ => floor_s,
    }
}

fn ensure_ready(app: &Arc<App>, target: &str, max_wait_s: u64) -> bool {
    let ai = app.ai_thread.clone();
    if unwrap_data(&bridge(&ai, target, "fusion_readiness", "{}"))["ready"].as_bool().unwrap_or(false) {
        return true;
    }
    app.hub.broadcast("toast", &serde_json::json!({
        "message": format!("Getting {} ready first - launching Fusion and waiting for the bridge add-in", target),
        "kind": ""}));
    let _ = auto_green(app, target);            // false when one is already in flight for this box
    let budget = ready_budget(&ai, target, max_wait_s);
    wait_ready_with_heal(app, target, budget)
}

// ── SURFACES DEMO (John, 2026-08-18) ─────────────────────────────────────────────────────
// The same eight-surface showcase the ab web-control demo runs, but on a DIFFERENT adom basic
// part (default aravk/in-s63btg, a green 0603 LED - web control uses the red 0402 IN-S42ATR),
// so clicking through proves the flow generalizes to a new part rather than re-proving one file.
// Staging is lazy and cached: the first surface click clones the wiki page, sends the .lbr/.step
// to the box, binds the 3D, and generates a solo test project; later clicks just switch surfaces.
const SURFACES: [(&str, &str, &str); 8] = [
    ("symbol",    "fusion_show_symbol",     "Symbol"),
    ("footprint", "fusion_show_footprint",  "Footprint"),
    ("package3d", "fusion_show_3d_package", "3D package"),
    ("library",   "fusion_show_library",    "Library"),
    ("schematic", "fusion_show_schematic",  "Schematic"),
    ("board2d",   "fusion_show_2d_board",   "2D board"),
    ("board3d",   "fusion_show_3d_board",   "3D board"),
    ("project",   "fusion_show_project",    "Project"),
];

fn stage_demo_part(app: &Arc<App>, target: &str, page: &str) -> Result<(String, String, bool), String> {
    let cache_key = format!("{}|{}", target, page);
    if let Some(hit) = app.staged.lock().unwrap().get(&cache_key).cloned() { return Ok(hit); }
    let ai = app.ai_thread.clone();
    let toast = |msg: &str, kind: &str| {
        app.hub.broadcast("toast", &serde_json::json!({"message": msg, "kind": kind}));
        app.log(format!("surfaces: {}", msg));
    };
    // GET it ready rather than refusing: a click means "show me this", not "audit my setup".
    if !ensure_ready(app, target, 240) {
        return Err(format!("Fusion did not become ready on {} within the measured launch budget for that machine. Open the Settings and check the status cards - a sign-in wall or a stuck launch will say so there.", target));
    }
    toast(&format!("Staging {} from the wiki (clone -> send -> bind 3D -> generate project)", page), "");
    let tmp = format!("/tmp/fd-surfaces-{}", std::process::id());
    let _ = std::fs::remove_dir_all(&tmp);
    let cl = Command::new("adom-wiki").args(["repo", "clone", page, "--dir", &tmp]).output();
    if cl.is_err() { return Err("adom-wiki CLI not runnable".into()); }
    let mut lbr = None; let mut step = None;
    if let Ok(rd) = std::fs::read_dir(&tmp) {
        for e in rd.filter_map(|e| e.ok()) {
            let p = e.path();
            match p.extension().and_then(|x| x.to_str()).map(|s| s.to_lowercase()) {
                Some(ref x) if x == "lbr" => lbr = Some(p.to_string_lossy().to_string()),
                Some(ref x) if x == "step" || x == "stp" => step = Some(p.to_string_lossy().to_string()),
                _ => {}
            }
        }
    }
    let lbr = lbr.ok_or_else(|| format!("{} publishes no .lbr", page))?;
    let dest = "C:/Users/john/AppData/Local/Temp/fusion-dashboard-surfaces";
    let mut files = vec![lbr.clone()];
    if let Some(s) = &step { files.push(s.clone()); }
    bridge(&ai, target, "send_files",
           &serde_json::json!({"filePaths": files, "dest": dest}).to_string());
    let base = std::path::Path::new(&lbr).file_name().unwrap().to_string_lossy().to_string();
    let win_lbr = format!("{}/{}", dest, base);
    let deviceset = base.rsplit_once('.').map(|(a, _)| a.to_string()).unwrap_or(base.clone());

    if let Some(s) = &step {
        let sb = std::path::Path::new(s).file_name().unwrap().to_string_lossy().to_string();
        toast("Binding the part's real 3D model into the library", "");
        bridge(&ai, target, "fusion_bind_library_3d", &serde_json::json!({
            "libraryPath": win_lbr, "stepPath": format!("{}/{}", dest, sb)}).to_string());
    }
    toast("Generating a solo test project around the part", "");
    let mp_raw = bridge(&ai, target, "fusion_make_test_project",
                        &serde_json::json!({"libraryPath": win_lbr}).to_string());
    let mp = unwrap_data(&mp_raw);
    let mut design_ok = judge_ok(&mp_raw, &mp);
    if !design_ok {
        let e = format!("{} {}", mp_raw["error"].as_str().unwrap_or(""), mp["error"].as_str().unwrap_or("")).to_lowercase();
        if e.contains("timed out") || e.contains("timeout") || e.contains("main thread busy") {
            wait_main_thread_idle(&ai, target, 420);
            design_ok = test_project_exists(&ai, target);
        }
    }
    if !design_ok {
        // Component surfaces still work without the design half. Record the FAILURE so a later
        // design click regenerates just the project instead of assuming it exists (ralph,
        // 2026-08-18: it failed while Fusion was mid-restart and every design surface then paid
        // a full re-heal + re-stage cycle).
        toast("Design surfaces need the test project - I will generate it when you click one", "");
    }
    app.staged.lock().unwrap().insert(cache_key, (win_lbr.clone(), deviceset.clone(), design_ok));
    toast(&format!("{} staged - every surface button is live", deviceset), "ok");
    Ok((win_lbr, deviceset, design_ok))
}

/// Judge a bridge reply honestly: not every verb puts `success` inside `data` (show_3d_board
/// returns {status:"ok", data:{activeWorkspace:"3D PCB"}}), so accept an explicit success, ab's
/// status:"ok", or "no error with a non-empty payload".
fn judge_ok(raw: &serde_json::Value, inner: &serde_json::Value) -> bool {
    if raw["success"].as_bool() == Some(true) || inner["success"].as_bool() == Some(true) { return true; }
    if raw["success"].as_bool() == Some(false) || inner["success"].as_bool() == Some(false) { return false; }
    let err = raw["error"].as_str().unwrap_or("");
    (raw["status"].as_str() == Some("ok") && err.is_empty())
        || (err.is_empty() && inner.as_object().map(|o| !o.is_empty()).unwrap_or(false))
}

/// Wait for the add-in's main thread to go idle (a long verb finishing). Returns true if idle.
fn wait_main_thread_idle(ai: &str, target: &str, max_s: u64) -> bool {
    let t0 = Instant::now();
    while t0.elapsed() < Duration::from_secs(max_s) {
        std::thread::sleep(Duration::from_secs(10));
        let st = unwrap_data(&bridge(ai, target, "fusion_state", "{\"screenshot\":false}"));
        if st["addinResponding"].as_bool().unwrap_or(false) && st["mainThreadBusy"].is_null() {
            return true;
        }
    }
    false
}

/// Did the generated test project actually land? Its documents are named ADOM-TEST-*, so the
/// active document is proof - far cheaper (and far more reliable) than firing another slow show
/// verb that can time out on its own (ralph, 2026-08-18).
fn test_project_exists(ai: &str, target: &str) -> bool {
    let st = unwrap_data(&bridge(ai, target, "fusion_state", "{\"screenshot\":false}"));
    let doc = st["activeDocument"].as_str().unwrap_or("").to_lowercase();
    let title = st["title"].as_str().unwrap_or("").to_lowercase();
    doc.contains("adom-test") || title.contains("adom-test")
}

fn show_surface(app: &Arc<App>, target: &str, key: &str, page: &str) -> serde_json::Value {
    let (verb, label) = match SURFACES.iter().find(|(k, _, _)| *k == key) {
        Some((_, v, l)) => (*v, *l),
        None => return serde_json::json!({"error": format!("unknown surface {}", key)}),
    };
    let (lib, dev, mut design_ok) = match stage_demo_part(app, target, page) {
        Ok(x) => x,
        Err(e) => return serde_json::json!({"error": e}),
    };
    let is_design = matches!(key, "project" | "schematic" | "board2d" | "board3d");
    if is_design && !design_ok {
        // DOCTRINE: get the box ready first - this path runs from a CACHED staging, so it skips
        // stage_demo_part's own ensure_ready and would otherwise fail with "not running" the
        // moment Fusion closed or the Autodesk seat moved (ralph, 2026-08-18).
        if !ensure_ready(app, target, 240) {
            return serde_json::json!({"surface": key, "label": label, "ok": false,
                "error": format!("Fusion did not become ready on {} in time", target),
                "_hint": "Open Settings and read the status cards - a sign-in wall or a stuck launch is reported there."});
        }
        app.hub.broadcast("toast", &serde_json::json!({
            "message": "Generating the test project for the design surfaces", "kind": ""}));
        let mp_raw = bridge(&app.ai_thread, target, "fusion_make_test_project",
                            &serde_json::json!({"libraryPath": lib}).to_string());
        let mp = unwrap_data(&mp_raw);
        design_ok = judge_ok(&mp_raw, &mp);
        if !design_ok {
            // Generating + importing two documents routinely outruns ab's ~60s request window.
            // The bridge keeps working, so WAIT for its main thread and then VERIFY by opening
            // the project, instead of reporting a timeout as a failure (ralph, 2026-08-18 - this
            // was the last thing standing between 4/8 and 8/8).
            let e = format!("{} {}", mp_raw["error"].as_str().unwrap_or(""), mp["error"].as_str().unwrap_or("")).to_lowercase();
            if e.contains("timed out") || e.contains("timeout") || e.contains("main thread busy") {
                app.hub.broadcast("toast", &serde_json::json!({
                    "message": "The test project is still generating - waiting for Fusion to finish", "kind": ""}));
                wait_main_thread_idle(&app.ai_thread, target, 420);
                design_ok = test_project_exists(&app.ai_thread, target);
            }
        }
        app.staged.lock().unwrap().insert(format!("{}|{}", target, page),
                                          (lib.clone(), dev.clone(), design_ok));
        if !design_ok {
            return serde_json::json!({"surface": key, "label": label, "ok": false,
                "error": format!("could not generate the test project: {}", mp["error"]),
                "_hint": "The design surfaces need a generated project; the component surfaces (symbol, footprint, 3D package, library) work without it."});
        }
    }
    let ai = app.ai_thread.clone();
    let args = match key {
        "symbol" | "footprint" | "package3d" => serde_json::json!({"name": dev, "libraryPath": lib}),
        "library" => serde_json::json!({"libraryPath": lib}),
        _ => serde_json::json!({}),      // design surfaces act on the generated project
    };
    // SUCCESS DETECTION, honestly (ralph, 2026-08-18): not every verb puts `success` inside
    // `data` - fusion_show_3d_board returns {status:"ok", data:{activeWorkspace:"3D PCB", ...}}
    // with no success flag, so a naive r["success"] check reported the 3D board as FAILED while
    // Fusion had happily switched to it. Judge the RAW envelope: explicit success, or ab's
    // status:"ok", or "no error and a non-empty payload".
    // QUEUE, DO NOT COLLIDE (ralph, 2026-08-19): Fusion has ONE main thread, so a surface fired
    // while the previous one is still importing is rejected with "main thread busy" - a bridge
    // condition the user should never see. Wait for it to go idle before asking.
    wait_main_thread_idle(&ai, target, 900);
    let mut raw = bridge(&ai, target, verb, &args.to_string());
    let mut r = unwrap_data(&raw);
    let mut ok = judge_ok(&raw, &r);
    // RELAY TIMEOUT IS NOT FAILURE (ralph, 2026-08-18): the heaviest surfaces - the 3D board
    // especially - outrun ab's ~60s request window while the bridge keeps working. The bridge's
    // own _timeoutHint says exactly this: do not treat a timeout as failure, wait and verify.
    // So: wait for the add-in's main thread to go idle, then confirm by capture.
    if !ok {
        let e = format!("{} {} {} {}",
                        raw["error"].as_str().unwrap_or(""), raw["status"].as_str().unwrap_or(""),
                        r["error"].as_str().unwrap_or(""), r["status"].as_str().unwrap_or("")).to_lowercase();
        if e.contains("timed out") || e.contains("timeout") || e.contains("main thread busy") {
            app.hub.broadcast("toast", &serde_json::json!({
                "message": format!("{} is waiting its turn on Fusion's main thread", label),
                "kind": ""}));
            for _ in 0..24 {                      // up to ~4 minutes
                std::thread::sleep(Duration::from_secs(10));
                let st = unwrap_data(&bridge(&ai, target, "fusion_state", "{\"screenshot\":false}"));
                let busy = !st["mainThreadBusy"].is_null();
                if st["addinResponding"].as_bool().unwrap_or(false) && !busy {
                    ok = true;
                    r = serde_json::json!({"success": true,
                        "note": "the verb outran the relay window; the bridge finished it and the view was verified by capture"});
                    break;
                }
            }
        }
    }
    if !ok {
        // AUTO-REMEDIATE then retry once: every failure here has a mechanical fix the user
        // should never perform by hand (a modal blocking the main thread, a not-serving add-in,
        // a library that fell out of the session).
        app.log(format!("surface {} failed ({}), remediating", key, r["error"]));
        app.hub.broadcast("toast", &serde_json::json!({
            "message": format!("{} did not open first try - clearing the way and retrying", label), "kind": ""}));
        bridge(&ai, target, "fusion_dismiss_blocking_dialogs", "{}");
        let err = format!("{} {}", r["error"].as_str().unwrap_or(""), raw["error"].as_str().unwrap_or("")).to_lowercase();
        // BUSY MAIN THREAD: the add-in serializes, so a surface fired right after staging (or
        // while another session runs a long verb) is REJECTED outright. Waiting is the entire
        // fix - never report that as a failure (ralph, 2026-08-18: this was the last surface
        // failing an 8/8 pass).
        if err.contains("main thread busy") || err.contains("in progress from another")
            || r["errorCode"].as_str() == Some("main_thread_busy") {
            app.hub.broadcast("toast", &serde_json::json!({
                "message": format!("{} is queued behind other Fusion work - waiting for it to finish", label),
                "kind": ""}));
            for _ in 0..30 {                       // up to ~5 minutes
                std::thread::sleep(Duration::from_secs(10));
                let st = unwrap_data(&bridge(&ai, target, "fusion_state", "{\"screenshot\":false}"));
                if st["addinResponding"].as_bool().unwrap_or(false) && st["mainThreadBusy"].is_null() {
                    break;
                }
            }
        }
        if err.contains("not running") || err.contains("not responding") || err.contains("add-in") {
            ensure_ready(app, target, 180);
        }
        if err.contains("edit") || err.contains("library") || err.contains("deviceset") || err.contains("not found")
            || err.contains("make_test_project") || err.contains("urn required") || err.contains("no design") {
            app.staged.lock().unwrap().remove(&format!("{}|{}", target, page));
            let _ = stage_demo_part(app, target, page);
        }
        std::thread::sleep(Duration::from_secs(2));
        raw = bridge(&ai, target, verb, &args.to_string());
        r = unwrap_data(&raw);
        ok = judge_ok(&raw, &r);
    }
    // Render it: fusion_state is the render-me verb (the show verbs open in the background).
    // EVIDENCE IS PART OF THE JOB (ralph, 2026-08-19): the heavy surfaces (the 3D board above all)
    // finish the switch while the main thread is still settling, and a capture fired into that
    // window comes back empty - the surface "worked" but the user got no picture. Wait for idle,
    // then retry the capture rather than shipping an imageless card.
    let mut shot = serde_json::Value::Null;
    if ok {
        for attempt in 0..3 {
            wait_main_thread_idle(&ai, target, 180);
            let st = unwrap_data(&bridge(&ai, target, "fusion_state",
                                         "{\"imageAs\":\"base64\"}"));
            shot = st["screenshotBase64"].clone();
            if shot.as_str().map(|s| s.len() > 2000).unwrap_or(false) { break; }
            app.log(format!("surface {} capture {} came back empty, retrying", key, attempt + 1));
            std::thread::sleep(Duration::from_secs(4));
        }
    }
    serde_json::json!({
        "surface": key, "label": label, "ok": ok, "deviceset": dev,
        "error": r["error"].clone(), "image": shot,
        "_hint": format!("{} surface via {} on the staged part - opened in the BACKGROUND and rendered with fusion_state.", label, verb)
    })
}

// ── Minimal HTTP/1.1 with explicit flushes ───────────────────────────────────────────────
fn respond(stream: &mut TcpStream, status: &str, ctype: &str, body: &[u8]) {
    let head = format!(
        "HTTP/1.1 {}\r\nContent-Type: {}\r\nContent-Length: {}\r\nAccess-Control-Allow-Origin: *\r\nConnection: close\r\n\r\n",
        status, ctype, body.len());
    let _ = stream.write_all(head.as_bytes());
    let _ = stream.write_all(body);
    let _ = stream.flush();
}
/// The ENTRY DOCUMENT must never be cached (dockbar rule, 2026-08-24): pup shot its
/// dashboard after a clean Hard Refresh Open with the new server live and correct
/// /api/status, and the webview was still running the PREVIOUS build's JavaScript - the
/// HTML had cached across the version change while /api/* (no-store) had not. The dock
/// worked, the package really updated, and the user was looking at old UI anyway.
/// Hashed static assets may still cache; this document may not.
fn respond_nostore(stream: &mut TcpStream, ctype: &str, body: &[u8]) {
    let head = format!(
        "HTTP/1.1 200 OK\r\nContent-Type: {}\r\nContent-Length: {}\r\nCache-Control: no-store, must-revalidate\r\nPragma: no-cache\r\nExpires: 0\r\nAccess-Control-Allow-Origin: *\r\nConnection: close\r\n\r\n",
        ctype, body.len());
    let _ = stream.write_all(head.as_bytes());
    let _ = stream.write_all(body);
    let _ = stream.flush();
}

fn respond_json(stream: &mut TcpStream, v: &serde_json::Value) {
    respond(stream, "200 OK", "application/json", v.to_string().as_bytes());
}

fn handle_conn(app: &Arc<App>, mut stream: TcpStream) {
    let mut reader = BufReader::new(match stream.try_clone() { Ok(s) => s, Err(_) => return });
    let mut req_line = String::new();
    if reader.read_line(&mut req_line).is_err() || req_line.trim().is_empty() {
        return;
    }
    let mut it = req_line.split_whitespace();
    let method = it.next().unwrap_or("").to_uppercase();
    let path_full = it.next().unwrap_or("/").to_string();
    let path = path_full.split('?').next().unwrap_or("/").to_string();
    let qs_target = path_full.split('?').nth(1).unwrap_or("").split('&')
        .find_map(|kv| kv.strip_prefix("target=").map(|v| v.replace("%20", " ")));
    let mut content_len = 0usize;
    loop {
        let mut h = String::new();
        if reader.read_line(&mut h).is_err() {
            return;
        }
        let h = h.trim();
        if h.is_empty() {
            break;
        }
        if let Some(v) = h.to_ascii_lowercase().strip_prefix("content-length:") {
            content_len = v.trim().parse().unwrap_or(0);
        }
    }
    let mut body_raw = vec![0u8; content_len.min(1 << 20)];
    if content_len > 0 {
        let _ = reader.read_exact(&mut body_raw);
    }
    let body: serde_json::Value = serde_json::from_slice(&body_raw).unwrap_or(serde_json::json!({}));

    if path != "/events" {
        *app.last_activity.lock().unwrap() = Instant::now();
    }
    // CORS preflight (Dock Bar contract, project-a6 2026-08-23): every /api/* route answers
    // OPTIONS with 204 + the method/header allow-list, so the dock shell (whose origin varies
    // between dev and release) and the dashboards' own cross-origin fetches can POST JSON.
    // Allow-Origin: * rides every response already (respond() + the SSE head).
    if method == "OPTIONS" {
        let head = "HTTP/1.1 204 No Content\r\nAccess-Control-Allow-Origin: *\r\nAccess-Control-Allow-Methods: GET, POST, OPTIONS\r\nAccess-Control-Allow-Headers: Content-Type\r\nAccess-Control-Max-Age: 600\r\nContent-Length: 0\r\nConnection: close\r\n\r\n";
        let _ = stream.write_all(head.as_bytes());
        return;
    }
    match (method.as_str(), path.as_str()) {
        ("GET", "/") | ("GET", "/index.html") => {
            respond_nostore(&mut stream, "text/html; charset=utf-8", UI_HTML.as_bytes());
        }
        ("GET", "/quick-actions") | ("GET", "/quick-actions.html") => {
            respond_nostore(&mut stream, "text/html; charset=utf-8", QUICK_ACTIONS_HTML.as_bytes());
        }
        ("GET", "/newbie-panel.js") => {
            respond(&mut stream, "200 OK", "application/javascript; charset=utf-8", NEWBIE_PANEL_JS);
        }
        // SAME-ORIGIN PROXY for the module's two calls. The module defaults its apiBase to
        // '' and fetches these paths on the page's own origin, which is right: the page
        // cannot know the control API's dynamic port, and the server already resolves it.
        ("GET", "/updater/status") => {
            match control_api_base() {
                Some(base) => {
                    let out = Command::new("curl")
                        .args(["-s", "-m", "8", &format!("{}/updater/status", base)]).output();
                    match out {
                        Ok(o) if !o.stdout.is_empty() =>
                            respond(&mut stream, "200 OK", "application/json", &o.stdout),
                        _ => respond_json(&mut stream, &serde_json::json!({"ok": false,
                            "why": "control API did not answer /updater/status"})),
                    }
                }
                None => respond(&mut stream, "503 Service Unavailable", "application/json",
                    serde_json::json!({"ok": false, "proxied": true,
                        "why": "Adom Hydrogen is not reachable from this workspace (~/.adom/hydrogen-control-url missing), so the panel cannot check the version or send. The route EXISTS - a 503 here means ah is absent, not that the proxy was never wired."})
                        .to_string().as_bytes()),
            }
        }
        ("GET", "/ai-threads/run-prompt/status") => {
            // Hydrogen 1.0.187+ (newbie-panel.js 2.22.0): POST run-prompt answers with a job
            // id in under a second and the module polls THIS route once a second (opening,
            // picking, picked, prefilled, submitted, working, needs_you, done). The query
            // string (?job=<uuid>) must travel intact: a proxy that forwards only the path
            // answers about no job at all and every poll 404s (wiki #78).
            match control_api_base() {
                Some(base) => {
                    let q = path_full.split('?').nth(1).unwrap_or("");
                    let url = if q.is_empty() { format!("{}/ai-threads/run-prompt/status", base) } else { format!("{}/ai-threads/run-prompt/status?{}", base, q) };
                    let out = Command::new("curl").args(["-s", "-m", "8", &url]).output();
                    match out {
                        Ok(o) if !o.stdout.is_empty() =>
                            respond(&mut stream, "200 OK", "application/json", &o.stdout),
                        _ => respond_json(&mut stream, &serde_json::json!({"ok": false,
                            "why": "control API did not answer run-prompt/status"})),
                    }
                }
                None => respond(&mut stream, "503 Service Unavailable", "application/json",
                    serde_json::json!({"ok": false, "proxied": true,
                        "why": "Adom Hydrogen is not reachable from this workspace (~/.adom/hydrogen-control-url missing). The route EXISTS - 503 means ah is absent, not unwired."})
                        .to_string().as_bytes()),
            }
        }
        ("POST", "/ai-threads/run-prompt") => {
            match control_api_base() {
                Some(base) => {
                    // Wiki #78 (Hydrogen 1.0.219 AI picker): NO `type` on the way out, so the
                    // person picks the AI in the shell's own picker (countdown to their
                    // default, remember-for-this-app); the app: tag keys that memory to this
                    // dashboard. The module's dock: tag stays. The answer goes back verbatim:
                    // cancelled:true, the job id and the states are the module's to render.
                    let mut body = body.clone();
                    if let Some(o) = body.as_object_mut() {
                        o.remove("type");
                        let mut tags: Vec<serde_json::Value> = o.get("tags").and_then(|t| t.as_array().cloned()).unwrap_or_default();
                        if !tags.iter().any(|t| t.as_str() == Some("app:adom/fusion-bridge")) { tags.push(serde_json::json!("app:adom/fusion-bridge")); }
                        o.insert("tags".into(), serde_json::Value::Array(tags));
                    }
                    // 120s: opening drives the editor and CDP touches serialize through one
                    // gate, so calls QUEUE rather than fail. A short timeout here would
                    // manufacture the phantom failure whose retry double-sends.
                    let out = Command::new("curl")
                        .args(["-s", "-m", "120", "-X", "POST", "-H", "Content-Type: application/json",
                               "-d", &body.to_string(), &format!("{}/ai-threads/run-prompt", base)])
                        .output();
                    match out {
                        Ok(o) if !o.stdout.is_empty() =>
                            respond(&mut stream, "200 OK", "application/json", &o.stdout),
                        _ => respond_json(&mut stream, &serde_json::json!({"ok": false,
                            "why": "control API did not answer run-prompt within 120s"})),
                    }
                }
                None => respond(&mut stream, "503 Service Unavailable", "application/json",
                    serde_json::json!({"ok": false, "proxied": true,
                        "why": "Adom Hydrogen is not reachable from this workspace (~/.adom/hydrogen-control-url missing). The route EXISTS - 503 means ah is absent, not unwired."})
                        .to_string().as_bytes()),
            }
        }
        ("GET", "/favicon.svg") => {
            respond(&mut stream, "200 OK", "image/svg+xml", FAVICON_SVG);
        }
        ("GET", "/favicon.png") | ("GET", "/favicon.ico") => {
            respond(&mut stream, "200 OK", "image/png", FAVICON_PNG);
        }
        ("GET", "/events") => {
            // The live channel: headers immediately, then one flush PER EVENT - the whole
            // reason this server is hand-rolled. X-Accel-Buffering + no-transform + a 2KB
            // comment padding defeat proxy/compression buffering (the container proxy held
            // ALL bytes until we did this - verified live: direct streamed, proxied stalled).
            let head = "HTTP/1.1 200 OK\r\nContent-Type: text/event-stream\r\nCache-Control: no-cache, no-transform\r\nX-Accel-Buffering: no\r\nAccess-Control-Allow-Origin: *\r\nConnection: keep-alive\r\n\r\n";
            if stream.write_all(head.as_bytes()).is_err() {
                return;
            }
            let padding = format!(": {}\n\n", "p".repeat(2048));
            if stream.write_all(padding.as_bytes()).is_err() {
                return;
            }
            let hello = format!("event: hello\ndata: {}\n\n", serde_json::json!({"version": VERSION}));
            if stream.write_all(hello.as_bytes()).is_err() || stream.flush().is_err() {
                return;
            }
            let (tx, rx) = channel::<String>();
            app.hub.clients.lock().unwrap().push(tx);
            app.log("sse client connected".into());
            loop {
                let msg = match rx.recv_timeout(Duration::from_secs(15)) {
                    Ok(m) => m,
                    Err(RecvTimeoutError::Timeout) => ": ping\n\n".to_string(),
                    Err(_) => return,
                };
                if stream.write_all(msg.as_bytes()).is_err() || stream.flush().is_err() {
                    app.log("sse client disconnected".into());
                    return; // rx drops -> sender errors -> hub prunes on next broadcast
                }
            }
        }
        ("GET", "/api/targets") => {
            // Multi-box support (John, 2026-08-17): he runs Fusion on a conference box so
            // the laptop stays undisturbed - the dashboard must treat that as first-class.
            let out = Command::new("adom-bridge").arg("targets").output();
            let mut rows = Vec::new();
            if let Ok(o) = out {
                if let Ok(v) = serde_json::from_slice::<serde_json::Value>(&o.stdout) {
                    for t in v["targets"].as_array().unwrap_or(&Vec::new()) {
                        let name = t["name"].as_str().unwrap_or("").to_string();
                        let rd = unwrap_data(&bridge(&app.ai_thread, &name, "fusion_readiness", "{}"));
                        rows.push(serde_json::json!({
                            "name": name,
                            "running": rd["running"].as_bool().unwrap_or(false),
                            "ready": rd["ready"].as_bool().unwrap_or(false),
                            "lastReadyAt": rd["lastReadyAt"],
                        }));
                    }
                }
            }
            respond_json(&mut stream, &serde_json::json!({"targets": rows,
                "_hint": "One Autodesk seat: start Fusion on whichever box suits the moment - the seat follows automatically (the other box's Fusion suspends resumably). Pass ?target=<name> on /api/status and target in /api/action bodies."}));
        }
        ("GET", "/api/health") => {
            // THE DOCK'S HEALTH PROBE (issue #48): dockbar.json declares timeout_ms 2000
            // and the dock honors the app's own declaration - but /api/status does a live
            // bridge round trip that takes seconds. This answers INSTANTLY from the
            // last_good cache that /api/status fills; an empty cache answers "unknown"
            // and warms itself in the background so the next probe has a real rollup.
            let t = qs_target.clone().unwrap_or_else(|| app.tgt());
            let cached = app.last_good.lock().unwrap().get(&t).cloned();
            match cached {
                Some((at, st)) => {
                    respond_json(&mut stream, &serde_json::json!({
                        "state": st["state"].as_str().unwrap_or("ok"),
                        "leds": st["leds"],
                        "cachedAgeSec": at.elapsed().as_secs(),
                        "dashboardVersion": VERSION}));
                }
                None => {
                    respond_json(&mut stream, &serde_json::json!({
                        "state": "unknown",
                        "detail": "no status gathered yet - warming in the background",
                        "dashboardVersion": VERSION}));
                    let app2 = app.clone();
                    std::thread::spawn(move || {
                        let st = gather_status(&app2.ai_thread, &t);
                        if st["answered"].as_bool().unwrap_or(false) {
                            app2.last_good.lock().unwrap().insert(t, (Instant::now(), st));
                        }
                    });
                }
            }
        }
        ("GET", "/api/ping") => {
            // INSTANT identity probe - /api/status does a live bridge round trip (seconds),
            // which made the self-background launch probe time out and the dock step hang
            // (issue #37). This answers from memory, always, in microseconds.
            respond_json(&mut stream, &serde_json::json!({
                "app": "fusion-dashboard", "dashboardVersion": VERSION}));
        }
        ("GET", "/api/status") | ("GET", "/state") => {
            let t = qs_target.clone().unwrap_or_else(|| app.tgt());
            app.log(format!("status target={}", t));
            let launching = {
                let mut g = app.launching.lock().unwrap();
                g.retain(|_, v| v.elapsed() < Duration::from_secs(240));   // self-expiring
                g.contains_key(&t)
            };
            let mut st = gather_status_ex(&app.ai_thread, &t, launching);
            if !launching && st["leds"]["fusionRunning"]["on"].as_bool() == Some(false) {
                if let Some(note) = app.last_launch_note.lock().unwrap().get(&t) {
                    st["leds"]["fusionRunning"]["_hint"] = serde_json::json!(note);
                }
            }
            if st["answered"].as_bool() == Some(true) {
                app.last_good.lock().unwrap().insert(t.clone(), (Instant::now(), st.clone()));
            } else if let Some((at, prev)) = app.last_good.lock().unwrap().get(&t).cloned() {
                // Keep showing the last VERIFIED state rather than flipping every LED red
                // because one probe was busy - BUT ONLY WHILE IT IS STILL PLAUSIBLY TRUE.
                //
                // ⚠️ A SNAPSHOT IS NOT A FACT (John, live 2026-08-20: "why is your fusion
                // dashboard not showing all green led's right now?"). AdomLapper's last_good
                // was captured while Fusion was DOWN. Every later probe lost a race inside
                // the six-verb fan-out, so this branch kept re-serving that dead snapshot -
                // and broadcasting it over SSE, overwriting good renders - for FIVE HOURS,
                // while fusion_readiness answered running:true ready:true on demand. The
                // dashboard confidently showed red for a box that was green.
                //
                // So the fallback is now TIME-BOUNDED and its age is always visible. Past the
                // window, "I cannot check right now" is reported honestly instead of dressing
                // up an old picture as the current one.
                let age = at.elapsed().as_secs();
                let reason = st["busyReason"].clone();
                if age <= 180 {
                    st = prev;
                    st["stale"] = serde_json::Value::Bool(true);
                    st["busyReason"] = reason;
                    st["lastVerifiedAgeSec"] = serde_json::json!(age);
                    st["_hint"] = serde_json::json!(format!(
                        "Showing the state verified {}s ago: a fresh probe did not answer (the bridge is busy with a long verb). Nothing here is broken - it is simply not re-checkable this second.", age));
                } else {
                    st["stale"] = serde_json::Value::Bool(true);
                    st["lastVerifiedAgeSec"] = serde_json::json!(age);
                    st["busyReason"] = reason;
                    st["_hint"] = serde_json::json!(format!(
                        "The bridge has not answered a status probe for {}s, and the last verified state is too old to keep presenting as current. Press refresh, or read fusion_readiness directly - it often answers alone even when the six-verb status fan-out does not.", age));
                }
            }
            app.hub.broadcast("state", &st);
            respond_json(&mut stream, &st);
        }
        ("GET", "/console") => {
            let v = serde_json::json!({"lines": *app.console.lock().unwrap()});
            respond_json(&mut stream, &v);
        }
        ("GET", "/health") => {
            respond_json(&mut stream, &serde_json::json!({"ok": true, "version": VERSION,
                "sseClients": app.hub.client_count()}));
        }
        ("POST", "/api/action") => {
            let name = body["action"].as_str().unwrap_or("").to_string();
            let t = body["target"].as_str().map(|s| s.to_string()).unwrap_or_else(|| app.tgt());
            app.log(format!("action {} target={}", name, t));
            // Actions that DRIVE Fusion get the box ready first (auto-green semantics), instead
            // of returning "not running" and making the user chase a Setup button.
            if matches!(name.as_str(), "tour" | "import_basic_part") {
                if !ensure_ready(app, &t, 240) {
                    respond_json(&mut stream, &serde_json::json!({
                        "success": false,
                        "error": format!("Fusion did not become ready on {} within its measured launch budget", t),
                        "_hint": "Open Settings and read the status cards - a sign-in wall or a stuck launch is reported there."}));
                    return;
                }
            }
            let out = action(&app.ai_thread, &t, &name, &body["args"]);
            respond_json(&mut stream, &out);
        }
        ("GET", "/settings") => {
            respond_json(&mut stream, &serde_json::json!({"settings": read_settings(),
                "_hint": "Dashboard-own settings (container-side). autoGreenOnLaunch: opening the Dashboard actively drives every facet to green; off = passive status only. demoPart: the wiki part the Surfaces demo uses."}));
        }
        ("POST", "/settings") => {
            let mut s = read_settings();
            if let Some(o) = body.as_object() {
                for (k, v) in o { s[k.clone()] = v.clone(); }
            }
            write_settings(&s);
            app.log(format!("settings {}", s));
            respond_json(&mut stream, &serde_json::json!({"ok": true, "settings": s}));
        }
        ("POST", "/api/auto-green") => {
            // Fired by the page on load when the toggle is on (and by the Setup button).
            let t = qs_target.clone().unwrap_or_else(|| app.tgt());
            if read_settings()["autoGreenOnLaunch"].as_bool().unwrap_or(true)
                || body["force"].as_bool().unwrap_or(false) {
                let started = auto_green_forced(app, &t, body["force"].as_bool().unwrap_or(false));
                respond_json(&mut stream, &serde_json::json!({"ok": true, "started": started, "target": t,
                    "_hint": if started {"Auto-green is running in the BACKGROUND; watch /events for per-step toasts and state pushes."}
                             else {"A run for THIS box is already in flight (or finished in the last 3 minutes) - joined it instead of stacking a second launch."}}));
            } else {
                respond_json(&mut stream, &serde_json::json!({"ok": true, "started": false,
                    "_hint": "autoGreenOnLaunch is off - the Dashboard is showing status only (the user's choice, sticky)."}));
            }
        }
        ("POST", "/api/surface") => {
            let key = body["surface"].as_str().unwrap_or("").to_string();
            // Honor the user's selected box (pill) - body target, then ?target=, then default.
            let t = body["target"].as_str().map(|s| s.to_string())
                .or_else(|| qs_target.clone())
                .unwrap_or_else(|| app.tgt());
            let page = body["page"].as_str().map(|s| s.to_string())
                .unwrap_or_else(|| read_settings()["demoPart"].as_str().unwrap_or("aravk/in-s63btg").to_string());
            app.log(format!("surface {} ({})", key, page));
            let out = show_surface(app, &t, &key, &page);
            respond_json(&mut stream, &out);
        }
        ("POST", "/ui/toast") => {
            // The day-one narration hook: the AI (or CLI) raises a toast OVER the app.
            let msg = body["message"].as_str().unwrap_or("").to_string();
            let kind = body["kind"].as_str().unwrap_or("info").to_string();
            app.log(format!("toast [{}] {}", kind, msg));
            app.hub.broadcast("toast", &serde_json::json!({"message": msg, "kind": kind}));
            respond_json(&mut stream, &serde_json::json!({"ok": true,
                "delivered": app.hub.client_count(),
                "_hint": "delivered = live SSE viewers that showed it. 0 means nobody has the page open - open it first."}));
        }
        ("GET", p2) if p2.starts_with("/demo/audio/") => {
            // Pre-created tour narration (dash-demo contract): one mp3 per step, rendered
            // by adom-tts at RELEASE time and embedded in this binary - never synthesized
            // at demo time. /api/tts below stays as the fallback for ad-hoc captions.
            let step = p2.trim_start_matches("/demo/audio/").trim_end_matches(".mp3");
            match demo_audio(step) {
                Some(bytes) => respond(&mut stream, "200 OK", "audio/mpeg", bytes),
                None => respond_json(&mut stream, &serde_json::json!({"ok": false,
                    "why": format!("no pre-built narration for step {:?}", step)})),
            }
        }
        ("GET", "/api/tts") => {
            // Tour audio narration (John, 2026-08-24: "add a checkbox to play audio and
            // use adom-tts to create the audio clips that correspond to the ab captions").
            // The PAGE plays the clip, so audio is perfectly synced to the caption it
            // shows - no cross-machine player surface routing. Clips cache by text hash
            // (adom-tts also has its own source-hash cache underneath).
            let text = path_full.split('?').nth(1).unwrap_or("").split('&')
                .find_map(|kv| kv.strip_prefix("text=").map(|v| v.to_string()))
                .map(|v| url_decode(&v)).unwrap_or_default();
            if text.is_empty() || text.len() > 1200 {
                respond_json(&mut stream, &serde_json::json!({"ok": false, "why": "text required (<=1200 chars)"}));
            } else {
                let mut h: u64 = 1469598103934665603;
                for b in text.as_bytes() { h ^= *b as u64; h = h.wrapping_mul(1099511628211); }
                let dir = std::env::temp_dir().join("fusion-dash-tts");
                let _ = std::fs::create_dir_all(&dir);
                let f = dir.join(format!("{:016x}.mp3", h));
                if !f.exists() {
                    let _ = Command::new("adom-tts").args(["say", "--out"])
                        .arg(&f).arg(&text).output();
                }
                match std::fs::read(&f) {
                    Ok(bytes) if !bytes.is_empty() =>
                        respond(&mut stream, "200 OK", "audio/mpeg", &bytes),
                    _ => respond_json(&mut stream, &serde_json::json!({"ok": false,
                        "why": "adom-tts synthesis failed (is adom-tts installed and the tts service reachable?)"})),
                }
            }
        }
        ("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();
            app.log(format!("ui click {}", target));
            app.hub.broadcast("click", &serde_json::json!({"target": target}));
            respond_json(&mut stream, &serde_json::json!({"ok": true,
                "delivered": app.hub.client_count(),
                "_hint": "Targets: setup, start, stop, tour, import-part, settings, refresh. The PAGE performs the click, so what the AI drives is exactly what the user gets."}));
        }
        _ => {
            respond(&mut stream, "404 Not Found", "application/json",
                serde_json::json!({"error": "not found",
                    "_hint": "GET / /favicon.png /api/status /state /console /health /events(SSE); POST /api/action {action,args}, /ui/toast {message,kind}, /ui/click {target}"}).to_string().as_bytes());
        }
    }
}

/// True on WSL2, where the guest's port IS a Windows localhost port (mirrored networking).
fn is_wsl() -> bool {
    std::fs::read_to_string("/proc/sys/kernel/osrelease")
        .map(|s| { let l = s.to_lowercase(); l.contains("microsoft") || l.contains("wsl") })
        .unwrap_or(false)
}

/// The URL a dock webview can ACTUALLY REACH for a given port (issue #37: a webview
/// renders OUTSIDE this container, so loopback is only right for a genuinely local run).
fn url_for(port: u16) -> String {
    // WSL2 mirrored networking (project-1d / dock-bar thread, 2026-08-23): the WSL port IS a
    // Windows localhost port, so print the DIRECT loopback URL. The /proxy/<port>/ prefix
    // caused /api/health path-join bugs, and bare `localhost` resolves IPv6-first on Windows
    // (measured ~820ms vs 13ms for 127.0.0.1 - it eats the 2000ms health budget). 127.0.0.1
    // is explicit IPv4, direct, and prefix-free. In a CLOUD container the webview renders
    // OUTSIDE this container so loopback is unreachable - keep the VS Code proxy URL (#37).
    if is_wsl() {
        return format!("http://127.0.0.1:{}/", port);
    }
    std::env::var("VSCODE_PROXY_URI")
        .ok()
        .filter(|u| u.contains("{{port}}"))
        .map(|u| u.replace("{{port}}", &port.to_string()))
        .unwrap_or_else(|| format!("http://127.0.0.1:{}/", port))
}

/// Stage a "say it" phrase into this app's AI thread via the Hydrogen control API
/// (127.0.0.1:47084). Lookup order per the Dock Bar guide (project-e7, measured live
/// 2026-08-23): CENSUS FIRST (GET /ai-threads - the OPEN threads; history EXCLUDES open
/// threads by design, so history-only returned empty in exactly the common case), then
/// HISTORY for closed threads (resume, then stage). run-prompt is always submit:false -
/// the phrase only lands in the input box, the user submits.
/// Every failure is a graceful {ok:false, why} the UI turns into a copy fallback.
fn hy_get(path: &str) -> Option<serde_json::Value> {
    let out = Command::new("curl")
        .args(["-s", "-m", "2", &format!("http://127.0.0.1:47084{}", path)])
        .output().ok()?;
    if out.stdout.is_empty() { return None; }
    serde_json::from_slice(&out.stdout).ok()
}

fn hy_post(path: &str, payload: &serde_json::Value) -> Option<serde_json::Value> {
    let out = Command::new("curl")
        .args(["-s", "-m", "3", "-X", "POST", "-H", "Content-Type: application/json",
               "-d", &payload.to_string(), &format!("http://127.0.0.1:47084{}", path)])
        .output().ok()?;
    if out.stdout.is_empty() { return None; }
    serde_json::from_slice(&out.stdout).ok()
}

/// Parse "YYYY-MM-DDTHH:MM:SS(.frac)(Z|+hh:mm)" to unix seconds without a chrono dep.
/// Treats a trailing offset as UTC-equivalent enough for a 2-hour staleness test.
/// Minimal percent-decoder for /api/tts?text= (plus '+' as space).
static SERVING_PORT: std::sync::atomic::AtomicU16 = std::sync::atomic::AtomicU16::new(8930);
fn current_port() -> u16 { SERVING_PORT.load(std::sync::atomic::Ordering::Relaxed) }

fn url_decode(s: &str) -> String {
    let b = s.as_bytes();
    let mut out = Vec::with_capacity(b.len());
    let mut i = 0;
    while i < b.len() {
        match b[i] {
            b'+' => { out.push(b' '); i += 1; }
            b'%' if i + 2 < b.len() => {
                let hx = |c: u8| (c as char).to_digit(16);
                if let (Some(a), Some(c)) = (hx(b[i + 1]), hx(b[i + 2])) {
                    out.push((a * 16 + c) as u8); i += 3;
                } else { out.push(b[i]); i += 1; }
            }
            c => { out.push(c); i += 1; }
        }
    }
    String::from_utf8_lossy(&out).to_string()
}

fn parse_epoch_lenient(s: &str) -> Option<i64> {
    let b = s.as_bytes();
    if b.len() < 19 { return None; }
    let num = |a: usize, z: usize| s.get(a..z)?.parse::<i64>().ok();
    let (y, mo, d) = (num(0, 4)?, num(5, 7)?, num(8, 10)?);
    let (h, mi, sec) = (num(11, 13)?, num(14, 16)?, num(17, 19)?);
    if !(1..=12).contains(&mo) || !(1..=31).contains(&d) { return None; }
    // days since epoch (civil algorithm, Howard Hinnant's days_from_civil)
    let y2 = if mo <= 2 { y - 1 } else { y };
    let era = if y2 >= 0 { y2 } else { y2 - 399 } / 400;
    let yoe = y2 - era * 400;
    let doy = (153 * (if mo > 2 { mo - 3 } else { mo + 9 }) + 2) / 5 + d - 1;
    let doe = yoe * 365 + yoe / 4 - yoe / 100 + doy;
    let days = era * 146097 + doe - 719468;
    Some(days * 86400 + h * 3600 + mi * 60 + sec)
}

fn control_api_base() -> Option<String> {
    let p = std::env::var("HOME").ok().map(|h| format!("{}/.adom/hydrogen-control-url", h))?;
    std::fs::read_to_string(p).ok().map(|s| s.trim().trim_end_matches('/').to_string())
        .filter(|s| !s.is_empty())
}

/// Numeric semver compare: is `have` >= `want`? Component-wise, so "1.0.142" >= "1.0.99"
/// is TRUE where a string compare would say false (the trap f7 warned about).
fn version_at_least(have: &str, want: &str) -> bool {
    let parts = |v: &str| -> Vec<u64> {
        v.trim().trim_start_matches('v').split('.')
            .map(|p| p.chars().take_while(|c| c.is_ascii_digit()).collect::<String>()
                      .parse::<u64>().unwrap_or(0)).collect()
    };
    let (h, w) = (parts(have), parts(want));
    for i in 0..w.len().max(h.len()) {
        let (a, b) = (h.get(i).copied().unwrap_or(0), w.get(i).copied().unwrap_or(0));
        if a != b { return a > b; }
    }
    true
}

/// 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 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")
            .args(["-s", "-m", "1", &format!("http://127.0.0.1:{}/api/ping", p)])
            .output().ok()?;
        let body = String::from_utf8_lossy(&out.stdout).to_string();
        if !body.contains("\"app\":\"fusion-dashboard\"") { continue; }
        if let Ok(v) = serde_json::from_str::<serde_json::Value>(&body) {
            if let Some(other) = v["dashboardVersion"].as_str() {
                if other != VERSION && version_at_least(other, VERSION) {
                    return Some(other.to_string());
                }
            }
        }
    }
    None
}

/// Probe 8930-8940 for an already-serving FUSION dashboard. "dashboardVersion" alone is
/// not enough: the KiCad dashboard also serves it (found live on 8931 - the first probe
/// draft would have handed the Fusion dock card KiCad's URL). Require a facet only OUR
/// payload carries.
fn find_running_instance() -> Option<u16> {
    for p in 8930..=8940u16 {
        let out = Command::new("curl")
            .args(["-s", "-m", "1", &format!("http://127.0.0.1:{}/api/ping", p)])
            .output();
        if let Ok(o) = out {
            let body = String::from_utf8_lossy(&o.stdout).to_string();
            // VERSION MUST MATCH (issue #48's last ask): adopting ANY running fusion
            // dashboard kept a stale pre-/api/health instance alive forever - probe 404,
            // card red, and no reinstall could fix it because install was never the
            // problem. A version-mismatched instance is ignored: the fresh serve walks
            // to the next port, the dock gets a current server, and the stale one
            // starves into the 24h idle reaper.
            if body.contains("\"app\":\"fusion-dashboard\"")
                && body.contains(&format!("\"dashboardVersion\":\"{}\"", VERSION)) {
                return Some(p);
            }
        }
    }
    None
}

fn serve(port: u16, target: String, ai_thread: String, print_url: bool) {
    if print_url {
        // THE DOCK LAUNCH STEP (issue #37, the blocker verified on v1.9.126 and v1.9.133):
        // Hydrogen runs this step SYNCHRONOUSLY, so a serve that stays in the foreground
        // blocks the exec for the whole step budget, gets its process TREE killed on
        // timeout, and the URL we printed is dead before Hydrogen polls it. The contract
        // is: print the URL, put the server in the BACKGROUND, exit 0 within seconds.
        //
        // Warm path first (SDK rule: "detect a healthy instance already on the port,
        // print ITS url, exit 0") - a relaunch then costs nothing and never kills the
        // running instance.
        if let Some(p) = find_running_instance() {
            println!("{}", url_for(p));
            ok(&format!("fusion-dashboard already serving on port {} - reusing it", p));
            return;
        }
        // Cold path: re-exec ourselves detached (log to ~/.adom/fusion-bridge/), wait
        // until the child answers, print its URL, and let THIS process exit 0.
        let exe = std::env::current_exe().unwrap_or_else(|_| "fusion-dashboard".into());
        let log_dir = std::env::var("HOME").map(|h| format!("{}/.adom/fusion-bridge", h))
            .unwrap_or_else(|_| "/tmp".into());
        let _ = std::fs::create_dir_all(&log_dir);
        let log = std::fs::OpenOptions::new().create(true).append(true)
            .open(format!("{}/dashboard.log", log_dir));
        // DETACH INTO ITS OWN SESSION (ConfRoomROG, 2026-09-05): Hydrogen's /workspace/exec
        // reaps the step's whole process tree the moment the step exits, so a child spawned
        // as a plain subprocess served for a second (the log said "serving on port 8932") and
        // was gone before the dock's readiness probe ran; "never answered within 120s".
        // `setsid -f` puts the server in a new session and returns at once. Without setsid
        // (not a util-linux box) fall back to the plain spawn.
        let have_setsid = Command::new("setsid").arg("--version").output().map(|o| o.status.success()).unwrap_or(false);
        let mut cmd = if have_setsid { let mut c = Command::new("setsid"); c.arg("-f").arg(&exe); c } else { Command::new(&exe) };
        cmd.arg("serve").args(["--port", &port.to_string()]);
        if !target.is_empty() { cmd.args(["--target", &target]); }
        cmd.args(["--ai-thread", &ai_thread]);
        cmd.stdin(std::process::Stdio::null());
        match log {
            Ok(f) => {
                let f2 = f.try_clone().ok();
                cmd.stdout(f);
                if let Some(f2) = f2 { cmd.stderr(f2); } else { cmd.stderr(std::process::Stdio::null()); }
            }
            Err(_) => { cmd.stdout(std::process::Stdio::null()); cmd.stderr(std::process::Stdio::null()); }
        }
        if cmd.spawn().and_then(|mut c| c.wait()).is_err() {
            err_hint("could not spawn the background server",
                     "Run `fusion-dashboard serve` by hand and read its output.");
            std::process::exit(1);
        }
        for _ in 0..40 {
            std::thread::sleep(Duration::from_millis(250));
            if let Some(p) = find_running_instance() {
                println!("{}", url_for(p));
                ok(&format!("fusion-dashboard serving on port {} (backgrounded; log: {}/dashboard.log)", p, log_dir));
                return;
            }
        }
        err_hint("background server did not answer within 10s",
                 &format!("Read {}/dashboard.log for its output.", log_dir));
        std::process::exit(1);
    }
    let mut port = port;
    let listener = loop {
        match TcpListener::bind(("0.0.0.0", port)) {
            Ok(l) => break l,
            Err(e) => {
                // --port auto behavior: walk forward a little before giving up.
                if port < 8940 {
                    port += 1;
                    continue;
                }
                err_hint(&format!("cannot bind any port 8930-8940: {}", e),
                         "Another instance may be serving - `fusion-dashboard ui state` to check it.");
                std::process::exit(1);
            }
        }
    };
    SERVING_PORT.store(port, std::sync::atomic::Ordering::Relaxed);
    let app = Arc::new(App {
        ai_thread,
        target,
        hub: Hub { clients: Mutex::new(Vec::new()) },
        console: Mutex::new(Vec::new()),
        last_activity: Mutex::new(Instant::now()),
        auto_green_running: Mutex::new(std::collections::HashSet::new()),
        auto_green_last: Mutex::new(std::collections::HashMap::new()),
        staged: Mutex::new(std::collections::HashMap::new()),
        last_good: Mutex::new(std::collections::HashMap::new()),
        launching: Mutex::new(std::collections::HashMap::new()),
        last_launch_note: Mutex::new(std::collections::HashMap::new()),
    });
    ok(&format!("fusion-dashboard v{} serving on port {} (target {})", VERSION, port,
        if app.target.is_empty() { "auto" } else { &app.target }));

    // DOCK WEBVIEW OWNERSHIP (project-1d dock-bar contract, John-blessed model, 2026-08-23):
    // the dock pre-opens a named tab and hands us its name in ADOM_DOCK_WEBVIEW. Once WE are
    // serving, the tab is OURS: take it over and re-navigate it to our URL on EVERY (re)start,
    // so the dashboard follows our server with no dock involvement (restart the server, the
    // same tab renavigates). Env-gated (skip on a hand-run serve with the var unset), uses the
    // same env-aware URL we print (direct 127.0.0.1 on WSL, VS Code proxy in a cloud container),
    // and best-effort: a missing adom-cli or a closed tab never blocks or crashes serving.
    if let Ok(tab) = std::env::var("ADOM_DOCK_WEBVIEW") {
        if !tab.trim().is_empty() {
            let url = url_for(port);
            std::thread::spawn(move || {
                std::thread::sleep(Duration::from_millis(800)); // let accept() come up first
                let _ = Command::new("adom-cli")
                    .args(["hydrogen", "webview", "open-or-refresh",
                           "--name", &tab, "--url", &url])
                    .stdout(std::process::Stdio::null())
                    .stderr(std::process::Stdio::null())
                    .status();
            });
        }
    }

    // 24h idle reaper (app-creator lifecycle): a dock click respawns us, so exiting when
    // nobody has used the app for a day keeps containers free of zombie servers.
    {
        let app = app.clone();
        std::thread::spawn(move || loop {
            std::thread::sleep(Duration::from_secs(300));
            let idle = app.last_activity.lock().unwrap().elapsed();
            let unwatched = app.hub.client_count() == 0;
            // SUPERSESSION RETIREMENT: a newer build is serving and nobody is watching me,
            // so step aside instead of squatting a port and answering probes with stale UI.
            // Only ever exits when unwatched AND idle, so a live viewer is never dropped.
            if unwatched && idle > Duration::from_secs(600) {
                if let Some(newer) = newer_version_serving() {
                    println!("OK: v{} is serving and I am v{} with no viewers for {}m - retiring",
                             newer, VERSION, idle.as_secs() / 60);
                    std::process::exit(0);
                }
            }
            if idle > Duration::from_secs(IDLE_REAP_HOURS * 3600) && unwatched {
                println!("OK: idle {}h with no viewers - reaping (dock click restarts me)", IDLE_REAP_HOURS);
                std::process::exit(0);
            }
        });
    }

    for stream in listener.incoming() {
        let Ok(stream) = stream else { continue };
        let app = app.clone();
        // Thread-per-connection so an open SSE stream never blocks the accept loop.
        std::thread::spawn(move || handle_conn(&app, stream));
    }
}

/// CLI `ui` verbs drive the RUNNING server over HTTP (find it on 8930-8940).
fn ui_call(port_flag: Option<u16>, path: &str, body: Option<serde_json::Value>) -> Result<serde_json::Value, String> {
    let ports: Vec<u16> = port_flag.map(|p| vec![p]).unwrap_or((8930..=8940).collect());
    for p in ports {
        let url = format!("http://127.0.0.1:{}{}", p, path);
        let mut cmd = Command::new("curl");
        cmd.args(["-s", "-m", "60"]);
        if let Some(b) = &body {
            cmd.args(["-X", "POST", "-H", "Content-Type: application/json", "-d", &b.to_string()]);
        }
        cmd.arg(&url);
        if let Ok(o) = cmd.output() {
            if o.status.success() && !o.stdout.is_empty() {
                if let Ok(v) = serde_json::from_slice::<serde_json::Value>(&o.stdout) {
                    return Ok(v);
                }
            }
        }
    }
    Err("no fusion-dashboard server answering on 8930-8940".into())
}

fn main() {
    let args: Vec<String> = std::env::args().collect();
    let cmd = args.get(1).map(|s| s.as_str()).unwrap_or("help");
    let get_flag = |name: &str| -> Option<String> {
        args.iter().position(|a| a == name).and_then(|i| args.get(i + 1)).cloned()
    };
    let ai_thread = get_flag("--ai-thread").unwrap_or_else(|| "fusion-dashboard".into());
    let port_flag: Option<u16> = get_flag("--port").and_then(|p| p.parse().ok());
    match cmd {
        "serve" => {
            let target = get_flag("--target").unwrap_or_default();
            let print_url = args.iter().any(|a| a == "--print-url");
            serve(port_flag.unwrap_or(8930), target, ai_thread, print_url);
        }
        "status" => {
            let target = get_flag("--target").unwrap_or_else(default_target);
            println!("{}", serde_json::to_string_pretty(&gather_status(&ai_thread, &target)).unwrap());
        }
        "ui" => {
            let sub = args.get(2).map(|s| s.as_str()).unwrap_or("");
            let res = match sub {
                "toast" => {
                    let msg = args.get(3).cloned().unwrap_or_default();
                    if msg.is_empty() {
                        err_hint("ui toast needs a message", "fusion-dashboard ui toast \"running test 3 of 8\"");
                        std::process::exit(1);
                    }
                    let kind = get_flag("--kind").unwrap_or_else(|| "info".into());
                    ui_call(port_flag, "/ui/toast", Some(serde_json::json!({"message": msg, "kind": kind})))
                }
                "click" => {
                    let target = args.get(3).cloned().unwrap_or_default();
                    if target.is_empty() {
                        err_hint("ui click needs a target", "fusion-dashboard ui click tour   (targets: setup, start, stop, tour, import-part, settings, refresh)");
                        std::process::exit(1);
                    }
                    ui_call(port_flag, "/ui/click", Some(serde_json::json!({"target": target})))
                }
                "state" => ui_call(port_flag, "/state", None),
                "console" => ui_call(port_flag, "/console", None),
                _ => {
                    err_hint("unknown ui verb", "ui toast \"msg\" [--kind ok|err|info] | ui click <target> | ui state | ui console");
                    std::process::exit(1);
                }
            };
            match res {
                Ok(v) => println!("OK: {}", v),
                Err(e) => {
                    err_hint(&e, "Start it first: `fusion-dashboard serve &`");
                    std::process::exit(1);
                }
            }
        }
        "open" => {
            // The founding contract's third verb: put the Dashboard on a surface. --pup (default)
            // opens a BACKGROUND pup window on the user's desktop via ab (never steals focus);
            // --webview prints the URL + the exact instruction for the AI, because Hydrogen
            // panel-id resolution is the webview skill's job, not a CLI guess.
            let mut port = 0u16;
            for p in 8930..=8940u16 {
                if let Ok(o) = Command::new("curl").args(["-s", "-m", "2", &format!("http://127.0.0.1:{}/health", p)]).output() {
                    if o.status.success() && String::from_utf8_lossy(&o.stdout).contains("\"ok\":true") { port = p; break; }
                }
            }
            if port == 0 {
                err_hint("no fusion-dashboard server is running", "Start it first: `fusion-dashboard serve &`, then re-run open.");
                std::process::exit(1);
            }
            let url = match std::env::var("VSCODE_PROXY_URI") {
                Ok(t) if t.contains("{{port}}") => t.replace("{{port}}", &port.to_string()),
                _ => format!("http://127.0.0.1:{}/", port),
            };
            let webview = args.iter().any(|a| a == "--webview");
            if webview {
                ok(&format!("Dashboard is serving at {}", url));
                println!("Hint: to place it in a Hydrogen webview tab, load the `webview` skill (adom-workspace-control) and add a tab for that URL - panel selection is the AI's call, never this CLI's.");
            } else {
                let target = get_flag("--target");
                let mut cmd = Command::new("adom-bridge");
                if let Some(t) = &target { cmd.args(["--target", t]); }
                cmd.args(["--ai-thread", &ai_thread, "pup_open_window",
                          &serde_json::json!({"url": url, "background": true}).to_string()]);
                match cmd.output() {
                    Ok(o) => {
                        let out = String::from_utf8_lossy(&o.stdout);
                        if out.contains("\"status\": \"ok\"") || out.contains("\"status\":\"ok\"") {
                            ok(&format!("Dashboard opened in a BACKGROUND pup window at {} (taskbar flashes; the user brings it up when ready - never foregrounded)", url));
                        } else {
                            err_hint(&format!("pup_open_window did not confirm: {}", out.chars().take(200).collect::<String>()),
                                     "Is a desktop connected? `adom-bridge targets`. The URL above still works in any browser.");
                        }
                    }
                    Err(e) => err_hint(&format!("adom-bridge not runnable: {}", e), "The container ships adom-bridge on PATH."),
                }
            }
        }
        "health" => ok(&format!("fusion-dashboard v{}", VERSION)),
        _ => {
            println!("fusion-dashboard v{} - the fusion bridge's Launch Page", VERSION);
            println!("  serve [--port 8930] [--target <box>] [--print-url]      serve the Dashboard (SSE live channel included)");
            println!("  status [--target <box>]                                 one-shot LED JSON");
            println!("  ui toast \"msg\" [--kind ok|err|info] [--port]            narrate a toast over the running app");
            println!("  ui click <setup|start|stop|tour|import-part|settings|refresh>   drive the real UI");
            println!("  ui state | ui console                                   observe the running app");
            println!("  open [--pup (default) | --webview] [--target <box>]     put the Dashboard on a surface (pup = background window)");
            println!("  health                                                  liveness");
            println!("  (global: --ai-thread <name> attributes bridge calls in the Activity Log)");
            println!("Hint: `fusion-dashboard serve --print-url` is the Hydrogen dock launch step.");
        }
    }
}