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
v2.0.694 (insiders): No more Chrome for Testing icons on the taskbar: a window that appears in pup's browser without pu…
4f934f5
1mo ago
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171
//! pup-session: the window/tab model and its on-disk session files. ONE place that knows what a
//! pup window is (Node had the truth spread over the sessions Map, session files, _hwnd caches
//! and six recovery paths). Phase 1 carries the model + files; reconnect/rescan/recovery and the
//! health loop land in Phase 2.
use serde_json::{json, Value};
use std::fs;
use std::path::{Path, PathBuf};
use std::sync::Mutex;
pub const WIN_PROP: &str = "AdomPupSid";
#[derive(Debug, Clone)]
pub struct Tab {
pub tab_id: String,
pub target_id: String,
pub cdp_session: String,
pub url: String,
pub title: String,
pub error_count: u32,
pub fav: Option<Value>,
}
#[derive(Debug, Clone)]
pub struct Session {
pub id: String,
pub profile: String,
/// "cdp" (a DevTools port drives it) or "ext" (pup's extension channel drives it, no port). The window is
/// a first-class session either way: identity, overlay, progress, park, recovery all key on this struct.
pub transport: String,
pub owner: Option<String>,
pub owner_container: Option<String>,
pub created_ms: u64,
pub created_iso: String,
pub hwnd: Option<i64>,
/// The CDP Browser window id (Browser.getWindowForTarget), the key that ties user-opened tabs and a
/// replaced last tab back to THIS window (2.0.453).
pub window_id: Option<i64>,
/// When the last tab target was destroyed while the OS window may still be alive (2.0.453 grace).
pub tabless_since_ms: u64,
pub pid: Option<u32>,
pub cdp_port: u16,
pub tabs: Vec<Tab>,
pub active_tab: usize,
pub foreground: bool,
pub app_id: Option<String>,
pub last_agent_update_ms: u64,
pub next_tab_seq: u32,
pub emulated: Option<Value>,
pub last_note: Option<String>,
pub pending_cred_hint: Option<String>,
pub wiki_authed: Option<bool>,
pub wiki_view: Option<String>,
pub last_annotation: Option<Value>,
pub last_verb: Option<String>,
pub last_tab: Option<String>,
pub last_foreground: Option<Value>,
pub overlay_desc: Option<String>,
pub flash_count: u32,
pub progress_count: u32,
pub progress_ms: u64,
pub icon_applied: bool,
pub born_at_ms: u64,
pub user_active_at_ms: u64,
pub raise_granted: bool,
pub overlay_why: Option<Value>,
pub overlay_led: Option<String>,
pub last_paint_sig: Option<String>,
pub last_real_fav: Option<(String, String)>,
pub generic_retried_url: Option<String>,
pub owner_from_caller: bool,
pub disruption_notice: Option<String>,
pub fg_watch_until_ms: u64,
pub last_outer_width: Option<u64>,
pub last_url: Option<String>,
pub last_title: Option<String>,
}
impl Session {
/// Node: session._lastAgentUpdate = {tabId, verb, note, ts} | null.
pub fn last_agent_update(&self) -> Value { match &self.last_verb { Some(v) => json!({ "tabId": self.last_tab, "verb": v, "note": self.last_note, "ts": self.last_agent_update_ms }), None => Value::Null } }
pub fn new(id: &str, profile: &str, owner: Option<&str>, owner_container: Option<&str>, cdp_port: u16, pid: Option<u32>) -> Session {
Session { id: id.into(), profile: profile.into(), transport: "cdp".into(), owner: owner.map(|s| s.into()), owner_container: owner_container.filter(|s| !s.is_empty()).map(|s| s.into()), created_ms: pup_core::now_ms(), created_iso: pup_core::iso_now(), hwnd: None, window_id: None, tabless_since_ms: 0, pid, cdp_port, tabs: Vec::new(), active_tab: 0, foreground: false, app_id: None, last_agent_update_ms: pup_core::now_ms(), next_tab_seq: 1, emulated: None, last_note: None, pending_cred_hint: None, wiki_authed: None, wiki_view: None, last_annotation: None, last_verb: None, last_tab: None, last_foreground: None, overlay_desc: None, flash_count: 0, progress_count: 0, progress_ms: 0, icon_applied: false, born_at_ms: pup_core::now_ms(), user_active_at_ms: 0, raise_granted: false, overlay_why: None, overlay_led: None, last_paint_sig: None, last_real_fav: None, generic_retried_url: None, owner_from_caller: false, disruption_notice: None, fg_watch_until_ms: 0, last_outer_width: None, last_url: None, last_title: None }
}
/// 2.0.694: never hands out an id a tab in this window already holds (a window rebuilt after a restart
/// keeps its saved ids, and an AI that held "tab-3" must not get a different tab under that name).
pub fn next_tab_id(&mut self) -> String {
loop { let id = format!("tab-{}", self.next_tab_seq); self.next_tab_seq += 1; if !self.tabs.iter().any(|t| t.tab_id == id) { return id; } }
}
pub fn active(&self) -> Option<&Tab> { self.tabs.get(self.active_tab) }
pub fn tab_by_id(&self, id: &str) -> Option<&Tab> { self.tabs.iter().find(|t| t.tab_id == id) }
pub fn age_minutes(&self) -> u64 { pup_core::now_ms().saturating_sub(self.created_ms) / 60000 }
pub fn tag_string(&self) -> String { format!("{}={}", WIN_PROP, sanitize_sid(&self.id)) }
/// The on-disk shape (identical to the Node build's saveSessionFile, so either build can
/// recover the other's windows).
pub fn file_json(&self) -> Value {
json!({
"sessionId": self.id, "profile": self.profile, "transport": self.transport, "url": self.active().map(|t| t.url.clone()),
"pid": self.pid, "cdpPort": self.cdp_port, "foreground": self.foreground, "owner": self.owner,
"appId": self.app_id, "_hwnd": self.hwnd, "windowId": self.window_id, "createdAt": self.created_iso, "updatedAt": pup_core::iso_now(),
"targetId": self.active().map(|t| t.target_id.clone()), "shell": "rust",
// 2.0.461: EVERY tab, not just the active one. The file used to carry a single url, so when a
// window came back (a Chrome restart, a wedged browser, a crash) the other tabs were simply gone:
// John lost 19 of 20 component tabs that way on 2026-09-10 and I had to retype them from a log.
// 2.0.694: and each tab's pup id plus the browser's own tab id, so a window re-adopted after a restart
// keeps the ids the AI already holds instead of renumbering from tab-1.
"tabs": self.tabs.iter().map(|t| json!({ "url": t.url, "title": t.title, "tabId": t.tab_id, "browserTabId": t.target_id })).collect::<Vec<_>>(),
"activeTab": self.active_tab,
})
}
}
pub fn sanitize_sid(s: &str) -> String { s.chars().map(|c| if c.is_ascii_alphanumeric() || c == '-' || c == '_' || c == '.' { c } else { '-' }).collect() }
/// A thread's own window is keyed on its --ai-thread name (Node: threadDefaultSessionId).
pub fn thread_default_session_id(thread: &str) -> String { let s = sanitize_sid(thread.trim()); if s.is_empty() { "default".into() } else { s } }
pub struct Sessions { list: Mutex<Vec<Session>>, active: Mutex<Option<String>>, pub dir: PathBuf }
impl Sessions {
pub fn new(dir: &Path) -> Sessions { let _ = fs::create_dir_all(dir); Sessions { list: Mutex::new(Vec::new()), active: Mutex::new(None), dir: dir.to_path_buf() } }
pub fn insert(&self, s: Session) { let id = s.id.clone(); self.list.lock().unwrap().retain(|x| x.id != id); self.list.lock().unwrap().push(s); *self.active.lock().unwrap() = Some(id); }
pub fn get(&self, id: &str) -> Option<Session> { self.list.lock().unwrap().iter().find(|s| s.id == id).cloned() }
pub fn exists(&self, id: &str) -> bool { self.get(id).is_some() }
pub fn with_mut<R>(&self, id: &str, f: impl FnOnce(&mut Session) -> R) -> Option<R> { self.list.lock().unwrap().iter_mut().find(|s| s.id == id).map(f) }
pub fn remove(&self, id: &str) -> Option<Session> {
let mut l = self.list.lock().unwrap();
let pos = l.iter().position(|s| s.id == id)?;
let s = l.remove(pos);
let mut a = self.active.lock().unwrap();
if a.as_deref() == Some(id) { *a = l.last().map(|x| x.id.clone()); }
Some(s)
}
pub fn all(&self) -> Vec<Session> { self.list.lock().unwrap().clone() }
pub fn count(&self) -> usize { self.list.lock().unwrap().len() }
pub fn active_id(&self) -> Option<String> { self.active.lock().unwrap().clone() }
pub fn set_active(&self, id: &str) { *self.active.lock().unwrap() = Some(id.into()); }
pub fn for_profile(&self, profile: &str) -> Vec<Session> { self.all().into_iter().filter(|s| s.profile == profile).collect() }
pub fn owned_by(&self, thread: &str) -> Vec<Session> { self.all().into_iter().filter(|s| s.owner.as_deref() == Some(thread)).collect() }
pub fn file_path(&self, id: &str) -> PathBuf { self.dir.join(format!("{}.json", sanitize_sid(id))) }
pub fn save_file(&self, s: &Session) { let _ = fs::write(self.file_path(&s.id), serde_json::to_string_pretty(&s.file_json()).unwrap_or_default()); }
pub fn delete_file(&self, id: &str) { let _ = fs::remove_file(self.file_path(id)); }
/// Every session file on disk (Node and Rust builds share the folder and the shape).
pub fn read_files(&self) -> Vec<Value> {
let mut out = Vec::new();
if let Ok(rd) = fs::read_dir(&self.dir) { for e in rd.flatten() { if e.path().extension().map(|x| x == "json").unwrap_or(false) { if let Ok(s) = fs::read_to_string(e.path()) { if let Ok(v) = serde_json::from_str::<Value>(&s) { out.push(v); } } } } }
out
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test] fn tab_ids_never_repeat() {
let mut s = Session::new("t", "adom-you", None, None, 0, None);
for id in ["tab-1", "tab-3"] { s.tabs.push(Tab { tab_id: id.into(), target_id: id.into(), cdp_session: String::new(), url: String::new(), fav: None, title: String::new(), error_count: 0 }); }
assert_eq!(s.next_tab_id(), "tab-2"); assert_eq!(s.next_tab_id(), "tab-4");
}
#[test] fn ids() { assert_eq!(thread_default_session_id("my thread/x"), "my-thread-x"); assert_eq!(thread_default_session_id(" "), "default"); }
#[test] fn files_roundtrip() {
let dir = std::env::temp_dir().join(format!("pup-sess-{}", std::process::id())); let ss = Sessions::new(&dir);
let mut s = Session::new("t1", "adom-you", Some("thread"), None, 9222, Some(42));
let tid = s.next_tab_id(); s.tabs.push(Tab { tab_id: tid, target_id: "T".into(), cdp_session: "S".into(), url: "https://x".into(), fav: None, title: "X".into(), error_count: 0 });
ss.insert(s.clone()); ss.save_file(&s);
let files = ss.read_files(); assert_eq!(files.len(), 1); assert_eq!(files[0]["sessionId"], json!("t1")); assert_eq!(files[0]["cdpPort"], json!(9222)); assert_eq!(files[0]["url"], json!("https://x"));
assert_eq!(ss.active_id().as_deref(), Some("t1")); ss.remove("t1"); assert_eq!(ss.count(), 0); assert!(ss.active_id().is_none());
ss.delete_file("t1"); assert!(ss.read_files().is_empty()); let _ = fs::remove_dir_all(&dir);
}
}