localharness 0.73.0

Agents that own themselves: one Rust crate that's both an agent SDK (streaming, tools, hooks, policies, triggers, MCP) and a wallet-owning, self-sovereign agent that runs in the browser.
Documentation
//! On-screen diagnostics for devices with NO devtools (iOS Safari/PWA).
//!
//! Two surfaces, both monochrome and zero-cost when idle:
//!
//! * **Panic banner** — [`install_panic_banner`] chains AFTER
//!   `console_error_panic_hook` and paints the panic message (plus the last
//!   breadcrumbs) into a fixed banner at the top of the page. A wasm panic
//!   kills every spawned future, so pre-banner the ONLY mobile symptom was a
//!   silently frozen UI ("stuck on creating identity…" with the 15s timeout
//!   never firing — the timeout future was dead too). The hook also
//!   AUTO-REPORTS via [`send_panic_beacon`] (sendBeacon + a pre-signed token)
//!   so panics reach the telemetry repo without a user screenshot.
//! * **Breadcrumb log** — [`log`] records the last [`CAP`] steps in a ring
//!   buffer. With `?debug=1` in the URL they also paint live into a fixed
//!   overlay (`#lh-debug`), so a hang (no panic, a promise that never
//!   settles) shows HOW FAR the flow got.
//!
//! The banner/overlay are built with maud (auto-escaped) and injected via
//! `insert_adjacent_html` at fixed ids — within the no-imperative-DOM rule.

use std::cell::RefCell;

use maud::html;

/// Max breadcrumbs retained (newest last).
const CAP: usize = 30;

thread_local! {
    static CRUMBS: RefCell<Vec<String>> = const { RefCell::new(Vec::new()) };
    /// Pre-signed `telemetry`-route proxy auth token for the panic beacon.
    static PANIC_TOKEN: RefCell<Option<String>> = const { RefCell::new(None) };
}

/// Cache a fresh auth token for the panic beacon. Refreshed periodically by
/// [`crate::app::telemetry::start_panic_beacon_refresh`] — post-panic the wasm
/// executor is dead, so the hook can only SPEND a token signed earlier.
pub(crate) fn set_panic_token(token: String) {
    PANIC_TOKEN.with(|t| *t.borrow_mut() = Some(token));
}

/// Auto-report a panic via `navigator.sendBeacon` — the one fire-and-forget
/// network call that survives the panic hook (every spawned future dies with
/// the panic; sendBeacon hands the bytes to the BROWSER synchronously).
/// sendBeacon can't set headers, so the pre-signed token rides in the JSON
/// body (`auth`) — `proxy/api/telemetry.ts` accepts body auth for exactly
/// this. Best-effort: no cached token yet (no identity / telemetry off) or a
/// disabled toggle → banner only, exactly as before.
fn send_panic_beacon(msg: &str, crumb_tail: &[String]) {
    if !crate::app::telemetry::enabled() {
        return;
    }
    // `try_borrow` for the same reason as `crumbs_snapshot`: never panic
    // inside the panic hook.
    let Some(token) = PANIC_TOKEN.with(|t| t.try_borrow().ok().and_then(|v| v.clone())) else {
        return;
    };
    let title: String = msg.lines().next().unwrap_or("panic").chars().take(160).collect();
    let fp: String = msg.chars().filter(|c| c.is_ascii_alphanumeric()).take(40).collect();
    let body = crate::app::telemetry::clamp(format!(
        "{}\n\nlast steps:\n· {}\n\napp: v{}",
        crate::app::telemetry::redact(msg),
        crumb_tail.join("\n· "),
        env!("CARGO_PKG_VERSION"),
    ));
    let payload = serde_json::json!({
        "kind": "panic",
        "title": crate::app::telemetry::redact(&title),
        "signature": format!("panic-{fp}"),
        "body": body,
        "auth": token,
    })
    .to_string();
    let url = format!(
        "{}/api/telemetry",
        crate::registry::CREDIT_PROXY_URL.trim_end_matches('/')
    );
    if let Some(nav) = web_sys::window().map(|w| w.navigator()) {
        let _ = nav.send_beacon_with_opt_str(&url, Some(&payload));
    }
}

/// Record a breadcrumb (and mirror it to the console). Call this at every
/// stage of a flow that has EVER hung on mobile — the crumbs are what the
/// panic banner / `?debug=1` overlay show.
pub(crate) fn log(msg: &str) {
    web_sys::console::log_1(&wasm_bindgen::JsValue::from_str(&format!("[lh] {msg}")));
    CRUMBS.with(|c| {
        let mut v = c.borrow_mut();
        v.push(msg.to_string());
        if v.len() > CAP {
            let drop = v.len() - CAP;
            v.drain(..drop);
        }
    });
    if overlay_enabled() {
        paint_overlay();
    }
}

fn crumbs_snapshot() -> Vec<String> {
    // `try_borrow`, not `borrow`: this is called from the PANIC hook, and a
    // panic raised while `log()` held `borrow_mut()` would otherwise double-
    // borrow and panic INSIDE the panic hook (aborting with no banner). A
    // contended borrow just yields no crumbs — the panic message still paints.
    CRUMBS.with(|c| c.try_borrow().map(|v| v.clone()).unwrap_or_default())
}

/// `?debug=1` anywhere in the query turns the live overlay on.
fn overlay_enabled() -> bool {
    web_sys::window()
        .and_then(|w| w.location().search().ok())
        .map(|s| s.contains("debug=1"))
        .unwrap_or(false)
}

/// (Re)paint the `#lh-debug` overlay with the current breadcrumbs.
fn paint_overlay() {
    let Some(doc) = web_sys::window().and_then(|w| w.document()) else { return };
    let crumbs = crumbs_snapshot();
    let markup = html! {
        div id="lh-debug" style="position:fixed;left:0;right:0;bottom:0;z-index:2147483646;\
            background:rgba(0,0,0,0.92);color:#9a9a9a;border-top:1px solid #444;\
            font:10px/1.5 monospace;padding:6px 10px;max-height:38vh;overflow-y:auto;\
            pointer-events:none;white-space:pre-wrap;" {
            @for line in &crumbs {
                div { (line) }
            }
        }
    };
    if let Some(el) = doc.get_element_by_id("lh-debug") {
        el.set_outer_html(&markup.into_string());
    } else if let Some(body) = doc.body() {
        let _ = body.insert_adjacent_html("beforeend", &markup.into_string());
    }
}

/// Install the visible panic banner. Chains the PREVIOUS hook first (so
/// `console_error_panic_hook` keeps logging a proper stack to the console),
/// then paints the message + breadcrumbs into `#lh-panic-banner`. Must be
/// called AFTER `console_error_panic_hook::set_once`.
pub(crate) fn install_panic_banner() {
    let prev = std::panic::take_hook();
    std::panic::set_hook(Box::new(move |info| {
        prev(info);
        let msg = info.to_string();
        let crumbs = crumbs_snapshot();
        let tail_start = crumbs.len().saturating_sub(8);
        // Auto-report FIRST — the beacon is handed off synchronously, so a
        // paint hiccup below can't lose it. Banner stays for the human.
        send_panic_beacon(&msg, &crumbs[tail_start..]);
        let Some(doc) = web_sys::window().and_then(|w| w.document()) else { return };
        let markup = html! {
            div id="lh-panic-banner" style="position:fixed;top:0;left:0;right:0;\
                z-index:2147483647;background:#000;color:#fff;border-bottom:1px solid #fff;\
                font:11px/1.5 monospace;padding:12px 14px;max-height:50vh;overflow-y:auto;\
                white-space:pre-wrap;" {
                div { "⚠ the app crashed — screenshot this and send it:" }
                div style="margin-top:6px" { (msg) }
                @if !crumbs.is_empty() {
                    div style="margin-top:6px;color:#9a9a9a" { "last steps:" }
                    @for line in &crumbs[tail_start..] {
                        div style="color:#9a9a9a" { "· " (line) }
                    }
                }
            }
        };
        if let Some(el) = doc.get_element_by_id("lh-panic-banner") {
            el.set_outer_html(&markup.into_string());
        } else if let Some(body) = doc.body() {
            let _ = body.insert_adjacent_html("afterbegin", &markup.into_string());
        }
    }));
}