master
John Lauer pup demo tour driver (tools/pup-demo.py) + the 8-beat Play-the-demo prompt; consent toast reason/aiThread; aumidShortcu… 7d4c4a4 2mo ago
//! The local Hydrogen (ah) control-API proxy: discovery of the dynamic control port, the
//! byte-faithful same-origin relays the dashboard's Send-to-AI module calls (/updater/status,
//! /ai-threads/run-prompt, /ai-threads/run-prompt/status), the release-version send gate, and the
//! /api/demo-send action that opens a "Pup - demo" thread and waits for Hydrogen to report it sent.
//! Ports server.js v2.0.351/388: the port is DYNAMIC and re-probed on every failure (ah restarts
//! move it), the relays forward status + content-type + body untouched, and an unreadable version
//! gate FAILS OPEN.

use crate::Reply;
use serde_json::{json, Value};
use std::io::Read;
use std::sync::Mutex;
use std::time::{Duration, Instant};

const DISCOVERY_PORTS: [u16; 4] = [47080, 48080, 49180, 50280];
const SEND_MIN_RELEASE: [u64; 3] = [1, 0, 142];
pub const DEMO_PROMPT: &str = "Run the pup demo tour on my machine and narrate it. It is eight beats; use ab's demo kit (desktop_demo_panel for pacing, desktop_caption for narration) and keep every window in the background unless a beat says otherwise. tools/pup-demo.py in the pup-bridge repo is the reference script.\n\n\
1. THE PUP WINDOW: pup_open_window {foreground:true, foregroundReason} to https://wiki.adom.inc/adom/pup-bridge. Tell me it is a real Chrome for Testing in a profile pup owns, driven over CDP, normally born in the background. Point out the Adom toolbar and the owner chip.\n\
2. TASKBAR IDENTITY: point at the taskbar button: the AI thread name on the Adom tile plus the page favicon badge. Then pup_progress {mode:value, value:30, note}, then 75, then indeterminate, then off, and explain a long task paints the button.\n\
3. JUMP LISTS: tell me to right-click that button: switch wiki view, annotate, open the dashboard, close all pup windows. Every task is a pup verb.\n\
4. TOOLBAR, ANNOTATE, ADOM-YOU: pup_annotate {tool:pen}, draw a stroke with pup_input_dispatch, and explain the capture goes to the clipboard and the owning AI. Then explain adom-you: one durable profile every window shares, whose logins grow a keychain vault.\n\
5. GOOGLE LOGIN: navigate to accounts.google.com/signin, pup_click #identifierId, pup_type the vault username, pup_click #identifierNext, then pup_login {submit:true, submitReason}. If it answers blocked_on_user with a wall, say so honestly: pup filled and submitted, Google asked to verify, pup hands me the wheel.\n\
6. SCREENSHOTS FOR THE AI: pup_screenshot and pup_screenshot_element {selector:h1}; explain these are how you read what I see, resized for a vision model.\n\
7. ULTRALIBRARIAN LOGIN: navigate to https://app.ultralibrarian.com/Account/Login?returnUrl=%252fsearch and pup_login {submit:true, submitReason}; read the Hello line back to me.\n\
8. CLOSE ALL FROM THE JUMP LIST: explain the Close ALL Adom Pup windows task, then pup_close {all:true, everyone:true}. Confirm nothing is left on my desktop.\n\n\
Never ask me for a password: every login comes from the adom-you vault (pup_credentials lists it; pup_vault_sync brings another desktop's logins over).";

static CTL: Mutex<Option<(String, Instant)>> = Mutex::new(None);
static GATE: Mutex<Option<(Instant, bool, String)>> = Mutex::new(None);

pub struct Resp { pub status: u16, pub raw: String, pub content_type: Option<String>, pub error: Option<String> }
impl Resp {
    fn dead(e: impl Into<String>) -> Resp { Resp { status: 0, raw: String::new(), content_type: None, error: Some(e.into()) } }
    pub fn json(&self) -> Option<Value> { serde_json::from_str(&self.raw).ok() }
}

fn home() -> std::path::PathBuf { std::path::PathBuf::from(std::env::var("USERPROFILE").or_else(|_| std::env::var("HOME")).unwrap_or_default()) }
fn agent(timeout: Duration) -> ureq::Agent { ureq::AgentBuilder::new().timeout(timeout.max(Duration::from_millis(300))).build() }

/// Where the control API lives right now: the container-side file when present, else the discovery responder.
pub fn control_url(force: bool) -> Option<String> {
    if !force { if let Some((u, at)) = CTL.lock().unwrap().clone() { if at.elapsed() < Duration::from_secs(300) { return Some(u); } } }
    if let Ok(u) = std::fs::read_to_string(home().join(".adom").join("hydrogen-control-url")) {
        let u = u.trim().trim_end_matches('/').to_string();
        if !u.is_empty() { *CTL.lock().unwrap() = Some((u.clone(), Instant::now())); return Some(u); }
    }
    for port in DISCOVERY_PORTS {
        let r = agent(Duration::from_millis(1200)).get(&format!("http://127.0.0.1:{port}/")).call();
        if let Ok(r) = r { if let Ok(j) = r.into_json::<Value>() { if let Some(u) = j["control_url"].as_str() {
            let u = u.trim_end_matches('/').to_string();
            pup_core::log(format!("[hydrogen] control API discovered at {u} (via responder on {port})"));
            *CTL.lock().unwrap() = Some((u.clone(), Instant::now())); return Some(u);
        } } }
    }
    *CTL.lock().unwrap() = None;
    None
}

fn read(r: Result<ureq::Response, ureq::Error>) -> Resp {
    match r {
        Ok(res) | Err(ureq::Error::Status(_, res)) => {
            let status = res.status(); let ct = res.header("content-type").map(|s| s.to_string());
            let mut raw = String::new(); let _ = res.into_reader().take(8 << 20).read_to_string(&mut raw);
            Resp { status, raw, content_type: ct, error: None }
        }
        Err(e) => Resp::dead(e.to_string()),
    }
}
fn get_once(base: Option<String>, path: &str, timeout: Duration) -> Resp {
    let Some(b) = base else { return Resp::dead("hydrogen control API not discovered") };
    read(agent(timeout).get(&format!("{b}{path}")).call())
}
fn post_once(base: Option<String>, path: &str, body: &str, timeout: Duration) -> Resp {
    let Some(b) = base else { return Resp::dead("hydrogen control API not discovered") };
    read(agent(timeout).post(&format!("{b}{path}")).set("content-type", "application/json").send_string(body))
}
/// A dead port means the cached URL is stale (ah restarted), not that Hydrogen is gone: re-probe once.
pub fn get(path: &str, timeout: Duration) -> Resp { let r = get_once(control_url(false), path, timeout); if r.status == 0 { get_once(control_url(true), path, timeout) } else { r } }
pub fn post(path: &str, body: &str, timeout: Duration) -> Resp { let r = post_once(control_url(false), path, body, timeout); if r.status == 0 { post_once(control_url(true), path, body, timeout) } else { r } }

/// Release-version gate on updater.current_version (the ONE endpoint with a release version). Unreadable = allowed.
pub fn shell_supports_send() -> (bool, String) {
    if let Some((at, ok, why)) = GATE.lock().unwrap().clone() { if at.elapsed() < Duration::from_secs(60) { return (ok, why); } }
    let r = get("/updater/status", Duration::from_millis(2500));
    let j = r.json().unwrap_or(Value::Null);
    let u = if j["updater"].is_object() { j["updater"].clone() } else { j };
    let raw = u["current_version"].as_str().unwrap_or("").trim().to_string();
    let mut out = (true, String::new());
    let v: Vec<u64> = raw.split(|c: char| !c.is_ascii_digit()).take(3).filter_map(|p| p.parse().ok()).collect();
    if v.len() == 3 && v.iter().zip(SEND_MIN_RELEASE.iter()).find_map(|(a, b)| if a != b { Some(a < b) } else { None }).unwrap_or(false) {
        out = (false, format!("Your Hydrogen ({raw}) predates the Send to AI fix. Update to 1.0.142 or newer to enable it; the phrase was copied instead."));
    }
    *GATE.lock().unwrap() = Some((Instant::now(), out.0, out.1.clone()));
    out
}

fn relay(r: Resp, hint: &str) -> Reply {
    if r.status == 0 { return Reply::Bytes(502, "application/json".into(), json!({ "error": r.error.unwrap_or_else(|| "hydrogen unreachable".into()), "_hint": hint }).to_string().into_bytes(), Some("no-store".into())); }
    Reply::Bytes(r.status, r.content_type.unwrap_or_else(|| "application/json".into()), r.raw.into_bytes(), Some("no-store".into()))
}

const UNREACHABLE_HINT: &str = "Hydrogen (ah) is not reachable on this desktop: its discovery responder answered on none of 47080/48080/49180/50280 and ~/.adom/hydrogen-control-url is absent. Start Hydrogen, then retry.";

/// The four routes. `query` is the raw query string (forwarded intact for the status poll).
pub fn route(method: &str, path: &str, query: &str, body: &str) -> Option<Reply> {
    match (method, path) {
        ("GET", "/updater/status") => Some(relay(get("/updater/status", Duration::from_secs(8)), UNREACHABLE_HINT)),
        ("POST", "/ai-threads/run-prompt") => {
            if serde_json::from_str::<Value>(body).is_err() { return Some(Reply::Bytes(400, "application/json".into(), b"{\"error\":\"bad json\"}".to_vec(), None)); }
            // 120s, matching the module's own AbortController: a loaded workbench can take 60s just to create a tab.
            Some(relay(post("/ai-threads/run-prompt", body, Duration::from_secs(120)), UNREACHABLE_HINT))
        }
        ("GET", "/ai-threads/run-prompt/status") => {
            let qs = if query.is_empty() { String::new() } else { format!("?{query}") };
            Some(relay(get(&format!("/ai-threads/run-prompt/status{qs}"), Duration::from_secs(8)), UNREACHABLE_HINT))
        }
        ("POST", "/api/demo-send") => Some(Reply::Json(200, demo_send())),
        _ => None,
    }
}

/// Open the "Pup - demo" thread and wait (30s) for Hydrogen to say the prompt was sent.
pub fn demo_send() -> Value {
    let (ok, why) = shell_supports_send();
    if !ok { return json!({ "ok": false, "why": why }); }
    let payload = json!({ "new": true, "prompt": DEMO_PROMPT, "submit": true, "name": "Pup - demo", "tags": ["dock:pup-bridge"] }).to_string();
    let r = post("/ai-threads/run-prompt", &payload, Duration::from_secs(120));
    if r.status == 0 { return json!({ "ok": false, "why": format!("Could not reach the AI panel ({}).", r.error.unwrap_or_else(|| "no response".into())) }); }
    let j = r.json().unwrap_or(json!({}));
    // Hydrogen 1.0.187+: {ok, job, state:'opening'} comes back in under a second; progress is the status poll.
    if let (Some(job), Some(mut st)) = (j["job"].as_str().map(|s| s.to_string()), j["state"].as_str().map(|s| s.to_string())) {
        const SENT: [&str; 3] = ["working", "needs_you", "done"];
        const FAIL: [&str; 4] = ["submit_failed", "submitted_unidentified", "submit_unconfirmed", "still_working"];
        let t0 = Instant::now();
        while t0.elapsed() < Duration::from_secs(30) && !SENT.contains(&st.as_str()) && !FAIL.contains(&st.as_str()) {
            std::thread::sleep(Duration::from_secs(1));
            let pr = get(&format!("/ai-threads/run-prompt/status?job={}", urlencode(&job)), Duration::from_secs(5));
            if pr.status == 404 { return json!({ "ok": false, "why": "This Hydrogen build has no run-prompt status route; the prompt may still be sending in the new tab." }); }
            if let Some(s) = pr.json().and_then(|v| v["state"].as_str().map(|s| s.to_string())) { st = s; }
        }
        if SENT.contains(&st.as_str()) { return json!({ "ok": true, "state": st, "job": job }); }
        if st == "submit_failed" { return json!({ "ok": false, "state": st, "why": "The demo prompt is sitting in the new AI tab but send never clicked: press Enter there to run it." }); }
        if FAIL.contains(&st.as_str()) { return json!({ "ok": false, "state": st, "why": format!("Hydrogen reported {st} for the new thread.") }); }
        return json!({ "ok": true, "state": st, "job": job, "why": format!("The new AI tab opened; still {st} after 30s.") });
    }
    // Older builds: delivered_to is an ARRAY, and an opened-but-unconfirmed thread is NOT a failure (a retry double-sends).
    let dt = &j["delivered_to"];
    let delivered = dt.as_array().map(|a| !a.is_empty()).unwrap_or(!dt.is_null()) || j["inject"]["delivered_in_conversation"] == json!(true);
    if delivered { return json!({ "ok": true, "state": "delivered" }); }
    if (200..300).contains(&r.status) { return json!({ "ok": true, "state": "opened", "why": j["_hints"]["note"] }); }
    json!({ "ok": false, "why": j["_hints"]["note"].as_str().or(j["error"].as_str()).map(|s| s.to_string()).unwrap_or_else(|| format!("AI threads returned {}", r.status)) })
}

fn urlencode(s: &str) -> String { s.bytes().map(|b| if b.is_ascii_alphanumeric() || b"-_.~".contains(&b) { (b as char).to_string() } else { format!("%{b:02X}") }).collect() }