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.
← Commit history
Clean up zombie sessions automatically and REPORT the cleanup to the next caller (John's design). Two parts. (1) Zombie detection was gated on _lostBrowser, a flag only set when a DISCONNECT EVENT fires — a Chrome killed outright (the bridge-restart tree kill) never emits one, so those sessions sat with 0 tabs, no flag, and were never dropped. That is exactly the four zombies John found cluttering browser_list_windows for every thread. Zero tabs plus a dead CDP port is now sufficient, with a _launchInFlight guard so a session mid-launch is never mistaken for one. (2) A cleanup REPORT QUEUE: each janitorial action (zombie session dropped, orphaned browser processes reaped) is recorded and attached to the NEXT verb response as _cleanupReport, then dropped — report-once. Per John, the receiving thread is usually NOT the one whose window was cleaned; that is accepted, it can ignore the note, and the information still reaches the user eventually instead of never. Verb-scoped so AD's health poll cannot swallow it, capped at 12 entries, and added to the output-merge list because anything left on the outer envelope is silently dropped on a SUCCESSFUL call.
4 files changed
+49−10
adom-bridge-puppeteer-manifest.json+4−4@@ -1,10 +1,10 @@ { "manifest_version": 1, "name": "puppeteer",- "version": "1.9.118",- "url": "https://wiki.adom.inc/download/adom/adom-desktop-puppeteer-bridge/1.9.118/adom-bridge-puppeteer-v1.9.118.zip",- "sha256": "a8c315668ee43c59093701a75269589806d0603ccbd2b921bfdb39ccb343add7",- "size": 385723,+ "version": "1.9.119",+ "url": "https://wiki.adom.inc/download/adom/adom-desktop-puppeteer-bridge/1.9.119/adom-bridge-puppeteer-v1.9.119.zip",+ "sha256": "5aa1c58f7dc91e9f6f12e21b87d90dec27a51e13793b3ceb6fabd85157635598",+ "size": 386733, "verbPrefixes": [ "browser_", "desktop_recorder_",
src/BRIDGE_VERSION+1−1@@ -1 +1 @@-1.9.118+1.9.119
src/bridge.json+1−1@@ -2,7 +2,7 @@ "manifest_version": 1, "name": "puppeteer", "displayName": "Puppeteer (Chrome for Testing + Native)",- "version": "1.9.118",+ "version": "1.9.119", "description": "Drives a browser ALREADY on the machine \u2014 installed Chrome, else Microsoft Edge (every Windows PC ships with Edge) \u2014 launched with a FRESH isolated profile, so the common case has NO ~150 MB Chrome-for-Testing download and it never touches the user's real tabs/logins. Binds its local server to LOOPBACK (127.0.0.1) only, so no Windows Firewall 'allow Node through the firewall' prompt ever pops. Chrome for Testing is fetched only as a LAST RESORT if the machine has no Chromium browser at all. SPAWN-VERIFIED fallback: pup tries each detected browser (Chrome \u2192 Edge \u2192 CfT) and actually launches it, falling through to the next if one won't start (and handling browsers like Edge that relaunch onto another PID), so a corrupt/partial install never hard-fails an open. The browser that launches is cached as the default (persisted across restarts) so it isn't re-probed each time. On spawn the bridge SELF-WARMS: detects browsers + disk, and only if the box has no Chrome/Edge and no cached CfT does it background-fetch CfT so the first open is instant. browser_use pins a specific browser (chrome | edge | cft | auto); {browser:'chrome',install:true} downloads & installs REAL Google Chrome (user-scoped, no admin) on an Edge-only box. browser_readiness (READ-ONLY) reports which browser will be used + the full candidate list + the default + free disk. Every failure returns a specific errorCode + _hint (chrome_install_no_disk, chrome_download_network, chrome_install_permission, chrome_stable_not_detected, \u2026). Launches with FULL browser permissions by default for full debug (strictPermissions:true opts out). Headful automation: open/close windows+tabs, navigate, screenshot, eval JS, record. pup is a GENERIC, non-profile browser only \u2014 every session is a fresh isolated Chrome/Edge process, never the user's real signed-in browser; profile defaults to sessionId. To drive the user's REAL Chrome/Edge (their profile, cookies, SSO), use the Adom browser EXTENSION's nbrowser_* verbs \u2014 a separate bridge, not pup's job. macOS (v1.9.74+): full parity with mac-native mechanics \u2014 background-by-default is the app-level hide (windows stay drivable/screenshottable while hidden), raise/lower map to unhide+activate/hide, browser_alert_window posts a native toast whose click focuses the window, browser_use {browser:'chrome',install:true} installs Chrome from the official dmg, and taskbar identity/badging remains Windows-only.", "homepage": "https://wiki.adom.inc/adom/adom-desktop", "author": "Adom Inc.",
src/server.js+43−4@@ -260,6 +260,30 @@ const _launchInFlight = new Map(); // implies "retry and it's yours". Cleared on close. Ownership is still self-reported attribution, never // authorization — it exists to stop threads trampling each other, not to secure anything. const _sessionOwnerClaims = new Map(); // sessionId -> owner string+// v1.9.119 (John's design) — CLEANUP REPORT QUEUE. pup should tidy up on its own AND tell somebody it+// did. Silent janitorial work is invisible: John found four zombie sessions cluttering every thread's+// browser_list_windows and had to ask about them. So each cleanup is recorded here and attached to the+// NEXT verb response as `_cleanupReport`, then DROPPED (report-once). The thread that receives it is+// whichever one happens to call next, which is usually NOT the thread whose window was cleaned — that is+// accepted by design: it may be irrelevant to that caller and it can ignore it, but it reaches the user+// eventually, which beats never surfacing at all. Capped so a storm cannot bloat a response.+const _cleanupReports = [];+const CLEANUP_REPORT_MAX = 12;+function recordCleanup(msg) {+ try {+ if (!msg) return;+ _cleanupReports.push(String(msg).slice(0, 240));+ while (_cleanupReports.length > CLEANUP_REPORT_MAX) _cleanupReports.shift();+ console.log(`[janitor] ${msg}`);+ } catch (e) {}+}+function drainCleanupReports() {+ if (!_cleanupReports.length) return null;+ const items = _cleanupReports.splice(0, _cleanupReports.length);+ return `pup CLEANED UP after itself since your last call (reported once, then forgotten — it may not `+ + `concern YOUR windows, in which case ignore it, but please surface it to the user if it looks `+ + `relevant): ${items.map((m, i) => `(${i + 1}) ${m}`).join(' ')}`;+} // ── Tab helpers ────────────────────────────────────────────────────@@ -1544,7 +1568,9 @@ async function reapOrphanPupChromes() { if (orphans.length) { await _taskkillPids(orphans.map(p => p.pid)); await new Promise(r => setTimeout(r, 350)); } let remaining = (await _psProcs(where)).filter(isOrphan); if (remaining.length) { await _taskkillPids(remaining.map(p => p.pid)); await new Promise(r => setTimeout(r, 300)); remaining = (await _psProcs(where)).filter(isOrphan); }- if (killed || remaining.length) console.log(`[reap-orphan] killed ${killed} orphan pup process(es), remaining ${remaining.length} (spared ${liveDirs.size} live profile(s))`);+ if (killed || remaining.length) {+ recordCleanup(`reaped ${killed} orphaned pup browser process(es) that no live session owned${remaining.length ? ` (${remaining.length} survived and may still be using CPU)` : ''} — ${liveDirs.size} live profile(s) were spared`);+ } return { killed, remaining: remaining.length }; } @@ -3405,12 +3431,17 @@ async function reclaimUnmanagedWindows() { // Chrome is truly gone, drop the session AND unregister its AUMID so no stale taskbar identity is // left; if its Chrome is alive, Part B below reconnects and rescanProfile reattaches it by title. for (const [sid, s] of [...sessions]) {- if (!s._lostBrowser || (s.tabs || []).length) continue;+ // v1.9.119: do NOT require _lostBrowser. That flag is only set when a DISCONNECT EVENT fires;+ // a Chrome killed outright (the bridge-restart tree kill) never emits one, so the session sat+ // with 0 tabs and no flag and was never dropped. Zero tabs plus a dead port IS a zombie,+ // however it got that way. (A session mid-launch has no tabs yet, so respect _launchInFlight.)+ if ((s.tabs || []).length) continue;+ if (_launchInFlight.has(s.profileName)) continue; let port = null; try { port = parseInt(fs.readFileSync(path.join(PROFILES_DIR, s.profileName, 'DevToolsActivePort'), 'utf8').trim().split('\n')[0], 10) || null; } catch (e) {} const alive = port && await fetch(`http://127.0.0.1:${port}/json/version`).then(r => r && r.ok).catch(() => false); if (alive) continue; // Part B will reconnect + rescanProfile reattaches this same session- console.log(`[reclaim] zombie session "${sid}" (_lostBrowser, 0 tabs, Chrome gone) — dropping it and its taskbar identity`);+ recordCleanup(`dropped zombie session "${sid}" (0 tabs, its Chrome was gone) and unregistered its taskbar identity — it had been showing up in browser_list_windows for every thread`); try { if (s._curAppId && s._curAppId !== PUP_APP_ID) await unregisterPupAumid(s._curAppId); } catch (e) {} sessions.delete(sid); try { deleteSessionFile(sid); } catch (e) {}@@ -6015,6 +6046,14 @@ function maybeAttachBugInvite(obj, verb) { let mode = 'on'; try { mode = (pupSettings().reportIssues === 'off') ? 'off' : 'on'; } catch (e) {} if (mode === 'off') return;+ // v1.9.119: hand over any pending janitorial report. Verb-scoped for the same reason the invite is:+ // AD's /health poll and its statusVerb probe would otherwise consume it before any AI saw it.+ try {+ if (!isProbe && obj._cleanupReport === undefined) {+ const rep = drainCleanupReports();+ if (rep) obj._cleanupReport = rep;+ }+ } catch (e) {} const isError = obj.success === false || !!obj.errorCode || (!!obj.error && obj.error !== ''); // A FAILURE always earns the invitation, even on a probe — that is the highest-value moment. if (isError) { obj._reportIssues = bugReportInvite(true); return; }@@ -6091,7 +6130,7 @@ function sendJSON(res, obj, status = 200) { touched = true; } } catch (e) {}- for (const k of ['_reportIssues', '_hint', '_next', 'related', 'pitfalls']) {+ for (const k of ['_reportIssues', '_cleanupReport', '_hint', '_next', 'related', 'pitfalls']) { if (obj[k] !== undefined && inner[k] === undefined) { inner[k] = obj[k]; touched = true; } } if (touched) obj.output = JSON.stringify(inner);