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
rust: Phase 3 complete (vault, cards, wiki auto-sign + view flip, annotate, tab + screen recording, highFps lease, down…
4869577
2mo ago
12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667
//! The human-consent registry behind pup's approval toasts (port of src/consent.js, pup-bridge#92).
//! A verb parked on the human's click waits in-verb for at most IN_VERB (45 s, under every ab
//! budget), then answers blocked_on_user with stillRunning:true while the toast stays up for
//! TOTAL_SEC; a re-call with the same key re-attaches (no second toast) and collects the decision
//! exactly once; a timed-out toast is retracted (notify_dismiss).
use pup_core::{AbClient, Caller};
use serde_json::{json, Value};
use std::collections::HashMap;
use std::sync::{Arc, Condvar, Mutex};
use std::time::{Duration, Instant};
pub const IN_VERB: Duration = Duration::from_secs(45);
pub const TOTAL_SEC: u64 = 120;
const PICKUP: Duration = Duration::from_secs(120);
pub struct Rec { pub id: String, pub key: String, pub verb: String, pub what: String, pub since: Instant, pub since_ms: u64, pub decided: Option<Instant>, pub decision: Option<String>, pub picked_up: bool, pub attached: u32 }
pub struct Registry { map: Mutex<HashMap<String, Arc<(Mutex<Rec>, Condvar)>>> }
pub enum Ask { Pending { consent_id: String, remaining_sec: u64, attached: u32 }, Decided { consent_id: String, decision: Option<String> } }
impl Registry {
pub fn new() -> Registry { Registry { map: Mutex::new(HashMap::new()) } }
fn find_pending(&self, key: &str) -> Option<Arc<(Mutex<Rec>, Condvar)>> {
self.map.lock().unwrap().values().find(|r| { let g = r.0.lock().unwrap(); g.key == key && !g.picked_up }).cloned()
}
/// Show the toast (or attach to the pending one) and wait up to IN_VERB for the click.
pub fn ask(&self, ab: Arc<dyn AbClient>, caller: &Caller, key: &str, verb: &str, what: &str, id_prefix: &str, toast: Value) -> Ask {
let rec = match self.find_pending(key) {
Some(r) => { r.0.lock().unwrap().attached += 1; r }
None => {
let id = format!("{id_prefix}-{:x}-{}", pup_core::now_ms(), std::process::id() % 1000);
let r = Arc::new((Mutex::new(Rec { id: id.clone(), key: key.into(), verb: verb.into(), what: what.into(), since: Instant::now(), since_ms: pup_core::now_ms(), decided: None, decision: None, picked_up: false, attached: 0 }), Condvar::new()));
self.map.lock().unwrap().insert(id.clone(), r.clone());
let (r2, ab2, caller2, id2) = (r.clone(), ab.clone(), caller.clone(), id.clone());
std::thread::Builder::new().name("consent".into()).spawn(move || {
let mut args = toast; args["id"] = json!(id2); args["wait"] = json!(true); args["timeoutSec"] = json!(TOTAL_SEC);
let decision = ab2.call("notify_user", args, &caller2, Duration::from_secs(TOTAL_SEC + 10)).ok().and_then(|v| v["action"].as_str().map(|s| s.to_string()));
if decision.is_none() { let _ = ab2.call("notify_dismiss", json!({ "id": id2 }), &caller2, Duration::from_secs(8)); }
{ let mut g = r2.0.lock().unwrap(); g.decision = decision.clone(); g.decided = Some(Instant::now()); pup_core::log(format!("[consent] {} {} ({}, attached {}x)", g.verb, decision.as_ref().map(|d| format!("ANSWERED \"{d}\"")).unwrap_or_else(|| "TIMED OUT (toast retracted)".into()), g.id, g.attached)); }
r2.1.notify_all();
}).ok();
r
}
};
// wait for a decision, up to IN_VERB
let deadline = Instant::now() + IN_VERB;
let mut g = rec.0.lock().unwrap();
while g.decided.is_none() { let left = deadline.saturating_duration_since(Instant::now()); if left.is_zero() { break; } let (ng, _) = rec.1.wait_timeout(g, left).unwrap(); g = ng; }
if g.decided.is_none() { let remaining = TOTAL_SEC.saturating_sub(g.since.elapsed().as_secs()); return Ask::Pending { consent_id: g.id.clone(), remaining_sec: remaining, attached: g.attached }; }
g.picked_up = true;
let out = Ask::Decided { consent_id: g.id.clone(), decision: g.decision.clone() };
let id = g.id.clone(); drop(g);
self.map.lock().unwrap().remove(&id);
out
}
/// Rows for pup_readiness.active[]; decided-but-uncollected records expire after PICKUP.
pub fn rows(&self) -> Vec<Value> {
let mut map = self.map.lock().unwrap();
map.retain(|_, r| { let g = r.0.lock().unwrap(); !(g.decided.map(|d| d.elapsed() > PICKUP).unwrap_or(false)) });
map.values().map(|r| { let g = r.0.lock().unwrap(); let decided = g.decided.is_some(); json!({ "id": g.id, "verb": g.verb, "phase": if decided { "consent_decided" } else { "consent" }, "stepLabel": if decided { format!("The user answered; re-call {} with the same args to collect the decision", g.verb) } else { g.what.clone() }, "percent": Value::Null, "elapsedSec": g.since.elapsed().as_secs(), "estimatedSec": Value::Null, "etaSec": Value::Null, "confidence": "measured", "blockedOnUser": !decided, "blockedBy": if decided { Value::Null } else { json!("your Approve/Deny click on the desktop toast") }, "reattach": format!("{} again with the SAME args", g.verb) }) }).collect()
}
}