app
Pup - the AI's own browser
Public Made by Adomby adom
pup gives your AI a real browser on your desktop that it drives completely, in the background, signed in as you: windows that never jump in front of your work, one shared profile that learns your logins, and every window labeled on the taskbar with its AI thread's name.
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
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154
//! 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() }