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.643 (insiders): landed with a DOM probe (Log in link = not signed in; login form = sign-in page), on open/navigate…
5598ee3
1mo ago
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116
//! Tab verbs: pup_open_tab, pup_list_tabs, pup_switch_tab, pup_close_tab, pup_switch_window.
//! Shapes follow docs/contract/pup_{open_tab,list_tabs,switch_tab,close_tab,switch_window}.json.
use crate::{attach_tab, browser_for, refresh_tab, resolve, session_row, touch, Ctx};
use pup_core::{err, ok, Caller};
use serde_json::{json, Value};
use std::time::Duration;
fn tab_row(ctx: &Ctx, s: &pup_session::Session, t: &pup_session::Tab, active: bool) -> Value {
let errs = ctx.tabstate.lock().unwrap().get(&t.cdp_session).map(|x| x.errors.len()).unwrap_or(0);
json!({ "tabId": t.tab_id, "url": t.url, "title": t.title, "active": active, "errorCount": errs, "fav": t.fav, "opener": "agent", "openerTabId": Value::Null, "sessionId": s.id, "window": s.id, "aiThread": s.owner,
// 2.0.458: true only for the tabs pup keeps focus-emulated (the active one, plus the one a verb is
// driving). A tab WITHOUT it is throttled and discardable by Chrome, which is what keeps a many-tab
// pup window off the user's CPU and out of their RAM.
"focusEmulated": crate::focus_emulated(ctx, &s.id, &t.tab_id) })
}
pub fn open_tab(ctx: &Ctx, caller: &Caller, args: &Value) -> Value {
let s = match resolve(ctx, caller, args) { Ok(s) => s, Err(e) => return e };
if crate::transport::is_ext(&s) {
let lane = match crate::transport::lane_for(ctx, &s) { Ok(l) => l, Err(e) => return e };
let url = args.get("url").and_then(|u| u.as_str()).map(|u| u.trim()).filter(|u| !u.is_empty()).unwrap_or("about:blank").to_string();
// 2.0.537: the same URL-dedup rule the CDP lane has had since 2.0.478. A recovery relaunch that raced a
// caller's opens re-added a saved tab list on top of them (ti.com twice; 33 tabs for 20 sites, John,
// 2026-09-13). A second open of a URL the window already holds activates that tab instead.
if url != "about:blank" {
if let Some(t) = s.tabs.iter().find(|t| t.url.trim_end_matches('/') == url.trim_end_matches('/')).cloned() {
let _ = crate::transport::activate(&lane, &t);
ctx.sessions.with_mut(&s.id, |x| { if let Some(i) = x.tabs.iter().position(|tt| tt.tab_id == t.tab_id) { x.active_tab = i; } });
return ok(json!({ "sessionId": s.id, "window": s.id, "aiThread": s.owner, "tab": tab_row(ctx, &s, &t, true), "tabId": t.tab_id, "count": s.tabs.len(), "transport": "ext", "reused": true, "landed": crate::landed_ext(ctx, &s, &t, &t.url, &t.title), "_hint": "This window already had a tab at that URL; it was activated instead of opening a duplicate." }));
}
}
let mut sess = s.clone();
let t = match crate::transport::open_tab(&lane, &mut sess, &url) { Ok(t) => t, Err(e) => return e };
sess.tabs.push(t.clone()); sess.active_tab = sess.tabs.len() - 1;
let _ = crate::transport::activate(&lane, &t);
ctx.sessions.insert(sess.clone()); ctx.sessions.save_file(&sess);
crate::identity::overlay(ctx, &sess.id, caller);
touch(ctx, &sess.id, args.get("updateNote").and_then(|n| n.as_str()));
return ok(json!({ "sessionId": sess.id, "window": sess.id, "aiThread": sess.owner, "tab": tab_row(ctx, &sess, &t, true), "tabId": t.tab_id, "count": sess.tabs.len(), "transport": "ext", "landed": crate::landed_ext(ctx, &sess, &t, &t.url, &t.title) }));
}
let b = match browser_for(ctx, &s.profile) { Some(b) => b, None => return err("browser_gone", "the browser behind this window is not connected", None) };
let url = args.get("url").and_then(|u| u.as_str()).map(|u| u.trim()).filter(|u| !u.is_empty()).unwrap_or("about:blank").to_string();
let target = match b.cdp.create_target(&url, false, None, None) { Ok(t) => t, Err(e) => return err("cdp_create_target_failed", e.to_string(), None) };
let mut sess = s.clone();
let tab_id = match attach_tab(&mut sess, &b.cdp, &target) { Ok(t) => t, Err(e) => return e };
// 2.0.467: the new tab is tracked the moment it is attached, BEFORE the activate and the load wait (up to
// 15 s). Until now the session was written back only at the end, so the events pump's tab adoption (which
// looks 400 ms after Target.targetCreated) saw pup's own new target as "opened outside pup" and attached it
// a second time: after a crash recovery or pup_restart_browser a 3-tab window came back with 6 entries,
// half of them duplicates on the same targets (ConfRoomROG and arav-rog soaks, 2026-09-11).
ctx.sessions.insert(sess.clone());
if let Some(cs) = sess.tabs.last().map(|t| t.cdp_session.clone()) { crate::inject_page_scripts(ctx, &sess, &b.cdp, &cs); } // owner chip + floaty ride every new document from here on
let _ = b.cdp.send(None, "Target.activateTarget", json!({ "targetId": target }), Duration::from_secs(5));
if url != "about:blank" { if let Some(t) = sess.tabs.last_mut() { let rx = b.cdp.subscribe(Some(&t.cdp_session), Some("Page.loadEventFired")); let _ = pup_cdp::Cdp::wait_event(&rx, Duration::from_secs(15)); refresh_tab(&b.cdp, t); } }
if let Some(cs) = sess.tabs.last().map(|t| t.cdp_session.clone()) { crate::inject_page_scripts(ctx, &sess, &b.cdp, &cs); }
let row = sess.tabs.last().map(|t| tab_row(ctx, &sess, t, true)).unwrap_or(Value::Null);
let count = sess.tabs.len();
sess.active_tab = sess.tabs.len().saturating_sub(1); // Target.activateTarget above put the new tab in front; the badge follows it
ctx.sessions.insert(sess.clone()); ctx.sessions.save_file(&sess);
crate::focus_emulate(ctx, &sess.id, None); // 2.0.458: the new tab is the active one now
touch(ctx, &sess.id, args.get("updateNote").and_then(|n| n.as_str()));
crate::identity::overlay(ctx, &sess.id, caller);
if let Some(a) = ctx.arc() { crate::identity::overlay_retries(a, sess.id.clone(), caller.clone()); }
pup_core::merge(ok(row), json!({ "tabCount": count, "activeTabId": tab_id, "memory": crate::memory_report(ctx, Some(&sess.profile)), "renderCheck": sess.tabs.last().map(|t| crate::render_check(&b.cdp, &t.cdp_session, None)).unwrap_or(Value::Null), "_verifyRender": crate::VERIFY_RENDER, "_hint": "New tab opened and activated in the existing window (no new OS window). Screenshot to verify the render.", "_next": ["pup_screenshot", "pup_switch_tab", "pup_close_tab"] }))
}
pub fn list_tabs(ctx: &Ctx, caller: &Caller, args: &Value) -> Value {
let mut s = match resolve(ctx, caller, args) { Ok(s) => s, Err(e) => return e };
if let Ok(lane) = crate::transport::lane_for(ctx, &s) { crate::transport::refresh_all(&lane, &mut s); ctx.sessions.with_mut(&s.id, |x| { x.tabs = s.tabs.clone(); x.active_tab = s.active_tab; }); }
let active = s.active().map(|t| t.tab_id.clone());
let rows: Vec<Value> = s.tabs.iter().map(|t| tab_row(ctx, &s, t, Some(&t.tab_id) == active.as_ref())).collect();
ok(json!({ "sessionId": s.id, "window": s.id, "aiThread": s.owner, "count": rows.len(), "tabs": rows, "activeTabId": active, "lastAgentUpdate": s.last_agent_update() }))
}
pub fn switch_tab(ctx: &Ctx, caller: &Caller, args: &Value) -> Value {
let s = match resolve(ctx, caller, args) { Ok(s) => s, Err(e) => return e };
let Some(tab_id) = args.get("tabId").and_then(|t| t.as_str()) else { return err("tab_id_required", "pup_switch_tab needs {tabId}", Some("pup_list_tabs shows the ids.")) };
let Some(t) = s.tab_by_id(tab_id).cloned() else { return err("tab_not_found", format!("no tab \"{tab_id}\" in window \"{}\"", s.id), Some("pup_list_tabs shows the ids.")) };
if crate::transport::is_ext(&s) {
let lane = match crate::transport::lane_for(ctx, &s) { Ok(l) => l, Err(e) => return e };
let activated = crate::transport::activate(&lane, &t).is_ok();
ctx.sessions.with_mut(&s.id, |x| { if let Some(i) = x.tabs.iter().position(|tt| tt.tab_id == t.tab_id) { x.active_tab = i; } });
if let Some(x) = ctx.sessions.get(&s.id) { ctx.sessions.save_file(&x); }
crate::identity::overlay(ctx, &s.id, caller);
return ok(json!({ "sessionId": s.id, "window": s.id, "aiThread": s.owner, "tabId": t.tab_id, "activated": activated, "transport": "ext" }));
}
let b = match browser_for(ctx, &s.profile) { Some(b) => b, None => return err("browser_gone", "the browser behind this window is not connected", None) };
let activated = b.cdp.send(None, "Target.activateTarget", json!({ "targetId": t.target_id }), Duration::from_secs(5)).is_ok();
ctx.sessions.with_mut(&s.id, |x| { if let Some(i) = x.tabs.iter().position(|tt| tt.tab_id == t.tab_id) { x.active_tab = i; } });
crate::focus_emulate(ctx, &s.id, None);
touch(ctx, &s.id, args.get("updateNote").and_then(|n| n.as_str()));
let mut t2 = t.clone(); refresh_tab(&b.cdp, &mut t2);
crate::identity::overlay(ctx, &s.id, caller); // Node: the badge follows the ACTIVE tab
pup_core::merge(ok(tab_row(ctx, &s, &t2, true)), json!({ "activated": activated, "_hint": "Tab activated within its window (the window itself was NOT raised)." }))
}
pub fn close_tab(ctx: &Ctx, caller: &Caller, args: &Value) -> Value {
let s = match resolve(ctx, caller, args) { Ok(s) => s, Err(e) => return e };
let t = match args.get("tabId").and_then(|t| t.as_str()).map(|id| s.tab_by_id(id).cloned()).unwrap_or_else(|| s.active().cloned()) { Some(t) => t, None => return err("tab_not_found", "no such tab", None) };
if s.tabs.len() <= 1 { return crate::close_window(ctx, caller, &json!({ "window": s.id })); }
if let Ok(lane) = crate::transport::lane_for(ctx, &s) { crate::transport::close_tab(&lane, &t); }
let remaining = ctx.sessions.with_mut(&s.id, |x| { x.tabs.retain(|tt| tt.tab_id != t.tab_id); if x.active_tab >= x.tabs.len() { x.active_tab = x.tabs.len().saturating_sub(1); } x.tabs.len() }).unwrap_or(0);
ctx.tabstate.lock().unwrap().remove(&t.cdp_session);
let active = ctx.sessions.get(&s.id).and_then(|x| x.active().map(|tt| tt.tab_id.clone()));
if let Some(x) = ctx.sessions.get(&s.id) { ctx.sessions.save_file(&x); }
crate::focus_emulate(ctx, &s.id, None); // 2.0.458: a different tab is active now
crate::identity::overlay(ctx, &s.id, caller); // the active tab changed
ok(json!({ "sessionId": s.id, "window": s.id, "aiThread": s.owner, "closedTab": t.tab_id, "remaining": remaining, "activeTabId": active }))
}
pub fn switch_window(ctx: &Ctx, caller: &Caller, args: &Value) -> Value {
let s = match resolve(ctx, caller, args) { Ok(s) => s, Err(e) => return e };
ctx.sessions.set_active(&s.id);
pup_core::merge(ok(session_row(ctx, &s)), json!({ "activeTabId": s.active().map(|t| t.tab_id.clone()), "background": if s.foreground { "foreground" } else { "backgrounded" }, "ownerSource": "session", "emulatedViewport": Value::Null, "errorCount": 0, "lastAgentUpdate": s.last_agent_update(), "_hint": "This window is now the active one for verbs that default to it. It was NOT raised." }))
}