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
v2.0.236: A second cleanup pass removes about three hundred and sixty more lines of duplication: the eight legacy curl endpoints that shadowed pup verbs without ownership checks or recovery now answer with a gone status naming the verb to use, the two superseded send-to-back implementations give way to the park every internal caller already shared, the crash race is event-driven instead of polling four times a second for the lifetime of every operation, the twin recording listers and six copies of the session-resolution preamble collapse into one each, and per-session maps are finally reaped on teardown.
4 files changed
+96−293
adom-bridge-puppeteer-manifest.json+5−5@@ -1,10 +1,10 @@ { "manifest_version": 1, "name": "puppeteer",- "version": "2.0.235",- "url": "https://wiki.adom.inc/download/adom/pup-bridge/2.0.235/adom-bridge-puppeteer-v2.0.235.zip",- "sha256": "a88dc58cc91891672ee96e8715fd6f8f5a37ba57da1a3fe3c9f4e896fff0360c",- "size": 945172,+ "version": "2.0.236",+ "url": "https://wiki.adom.inc/download/adom/pup-bridge/2.0.236/adom-bridge-puppeteer-v2.0.236.zip",+ "sha256": "b725ff4890cc35f679379ea2a19967b48bde7441d115e9621c7f5e90ffa8c6c1",+ "size": 943641, "verbPrefixes": [ "browser_", "desktop_recorder_",@@ -12,5 +12,5 @@ ], "healthEndpoint": "/status", "statusVerb": "browser_readiness",- "released_at": "2026-08-18T12:18:16.740453Z"+ "released_at": "2026-08-18T12:53:44.749801Z" }
src/BRIDGE_VERSION+1−1@@ -1 +1 @@-2.0.235+2.0.236
src/bridge.json+1−1@@ -2,7 +2,7 @@ "manifest_version": 1, "name": "puppeteer", "displayName": "Puppeteer (Chrome for Testing + Native)",- "version": "2.0.235",+ "version": "2.0.236", "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. pup_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. pup_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, pup_alert_window posts a native toast whose click focuses the window, pup_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-bridge", "author": "Adom Inc.",
src/server.js+89−286@@ -680,6 +680,8 @@ function attachTab(session, sessionId, page, { makeActive = true, opener = 'user const t = (session.tabs || []).find(x => x.page === page); if (t && !t._crashed) { t._crashed = true; t._crashedAt = Date.now(); t._crashReason = why;+ // v2.0.236: wake anything racing this tab's in-flight verb (replaces a 250ms poll)+ try { const w = t._crashWaiters || []; t._crashWaiters = []; w.forEach(fn => { try { fn(); } catch (e) {} }); } catch (e) {} try { const u = page.url(); if (u && u !== 'about:blank') t._crashUrl = u; } catch (e) {} console.log(`[crash] session "${sessionId}" tab ${t.tabId} renderer CRASHED (${why}) — auto-recovering; verbs fast-fail until it's back`); // v1.9.176 — AUTO-HEAL the crashed renderer ONCE. An 'Aw, Snap' tab is already dead (it shows@@ -1344,15 +1346,20 @@ function _throwIfCrashed(t, sessionId) { // instead of waiting out the full withTimeout budget. The losing op promise is swallowed so a late // rejection can't surface as an unhandledRejection. (Crash detection + pre-op fast-fail via // resolveTabOrError already exist; this only trims the racing-op latency.)+// v2.0.236: this polled every 250ms for the LIFETIME of every screenshot/eval/click — a 4Hz timer+// per in-flight op, watching for a condition the tab already announces. _markCrashed now resolves a+// per-tab waiter, so the race is event-driven: zero timers, and it reacts instantly instead of up+// to 250ms late. (A tab already crashed when the op starts still fast-fails, as before.)+function crashSignal(t) {+ if (!t) return new Promise(() => {}); // nothing to watch+ if (!t._crashWaiters) t._crashWaiters = [];+ return new Promise((resolve) => { t._crashWaiters.push(resolve); });+} function raceCrash(t, sessionId, opPromise) { opPromise.catch(() => {});- let iv;- const crashWatch = new Promise((_, reject) => {- iv = setInterval(() => {- if (t && t._crashed) { try { _throwIfCrashed(t, sessionId); } catch (e) { reject(e); } }- }, 250);- });- return Promise.race([opPromise, crashWatch]).finally(() => clearInterval(iv));+ if (t && t._crashed) { try { _throwIfCrashed(t, sessionId); } catch (e) { return Promise.reject(e); } }+ const crashWatch = crashSignal(t).then(() => { _throwIfCrashed(t, sessionId); });+ return Promise.race([opPromise, crashWatch]); } // Send a structured-or-plain error from resolveTabOrError. If the error@@ -3157,37 +3164,6 @@ function browserPickReport() { }; } -// Push a pup window to the BACK of the z-order WITHOUT activating it. Occluded, NOT-// minimized — it keeps compositing, so screenshots/eval still work while it sits behind-// the user's windows. This is what makes "open in pup" non-disruptive BY DEFAULT: the AI-// drives it invisibly and only an explicit request ever brings it forward. Scoped to THIS-// session's Chrome pid (+ its child chrome/msedge processes) so it never touches the-// user's real browser windows. Windows-only; no-op elsewhere.-async function osSendWindowToBack(profileName) {- // v2.0.27: native + async (was a SYNC PowerShell that blocked the event loop and was MISSED by the- // 2.0.23 sweep — its variable was named psScript, not scriptPath; the honest census found it).- if (process.platform !== 'win32') return false;- const be = browsers.get(profileName);- const pid = (be && be.browser && (be.browser._detachedPid || (be.browser.process() && be.browser.process().pid))) || null;- if (!pid) return false;- try {- const kids = new Set([Number(pid)]);- try {- const pd = abPayload(await chrome.abCommand('process_list', { nameFilter: 'chrome' }, { timeoutMs: 5000 }));- for (const p of ((pd && pd.processes) || [])) if (Number(p.parentPid) === Number(pid)) kids.add(Number(p.pid));- } catch (e) {}- const rows = await enumChromeWidgetWindows();- let sent = false;- for (const r of rows) {- if (!kids.has(r.pid)) continue;- try {- const d = abPayload(await chrome.abCommand('desktop_set_window_state', { hwnd: r.h, state: 'bottom', force: false }, { timeoutMs: 5000 }));- if (d && (d.sentToBack || d.ok)) sent = true;- } catch (e) {}- }- return sent;- } catch (e) { console.error(`[bg] send-to-back failed for "${profileName}": ${e.message}`); return false; }-} // v1.8.34: capture the user's CURRENT foreground window (decimal HWND string) right // before we launch a pup browser, so the backgrounder can hand keyboard focus back to it.@@ -8081,6 +8057,25 @@ async function applyOwnerChipSetting(on) { } } }+// v2.0.236: four per-session Maps were written but never cleaned (_unstickLast, _unstickClickAt,+// _fgToastAt, _extEnabledFor) — small, but they grew for the process lifetime and every future+// per-session map would repeat the mistake. One reaper, called from every teardown path.+function forgetSessionState(sid) {+ for (const m of [_unstickLast, _unstickClickAt, _fgToastAt, _extEnabledFor]) {+ try { m.delete(sid); } catch (e) {}+ }+}+// v2.0.236: this exact 6-line preamble ("resolve the session or 400") was pasted into six verb+// cases. One helper; identical semantics (no auto-recovery — these are the verbs that must act on a+// window that is ALREADY live, and silently relaunching one would be a different verb).+function requireLiveSession(res, args) {+ const sessionId = args.sessionId || activeSessionId;+ if (!sessionId || !sessions.has(sessionId)) {+ sendJSON(res, { success: false, error: `Session "${sessionId}" not found` });+ return null;+ }+ return { sessionId, session: sessions.get(sessionId) };+} async function reconcileModel(reason) { if (process.platform !== 'win32' || _reconcileBusy) return; _reconcileBusy = true;@@ -8123,7 +8118,7 @@ async function reconcileModel(reason) { try { if (s._curAppId && s._curAppId !== PUP_APP_ID) await unregisterPupAumid(s._curAppId); } catch (e) {} try { fs.unlinkSync(path.join(require('os').tmpdir(), 'pup-badge-debug', sid + '.png')); } catch (e) {} try { _tabFavCache && s.tabs && s.tabs.forEach(() => {}); } catch (e) {}- sessions.delete(sid);+ sessions.delete(sid); forgetSessionState(sid); try { deleteSessionFile(sid); } catch (e) {} console.log(`[reconcile] reaped closed session "${sid}" (no open windows${reason ? ', ' + reason : ''}) — removed its badge + taskbar identity`); }@@ -8273,7 +8268,7 @@ async function reclaimUnmanagedWindows() { if (alive) continue; // Part B will reconnect + rescanProfile reattaches this same session recordCleanup(`dropped zombie session "${sid}" (0 tabs, its Chrome was gone) and unregistered its taskbar identity — it had been showing up in pup_list_windows for every thread`); try { if (s._curAppId && s._curAppId !== PUP_APP_ID) await unregisterPupAumid(s._curAppId); } catch (e) {}- sessions.delete(sid);+ sessions.delete(sid); forgetSessionState(sid); try { deleteSessionFile(sid); } catch (e) {} } @@ -8592,32 +8587,6 @@ async function badgePupTaskbar(sessionId) { // the browser's out-of-process window). A pup-distinct badge (white Adom leaves) is an AD // feature request on desktop_taskbar; pup will call that verb via direct-API once it exists. -// v1.8.26: THE real "background by default" enforcement, done RIGHT. A freshly-launched-// --start-maximized browser grabs foreground; osSendWindowToBack only lowers z-order and-// SetForegroundWindow(userWindow) is BLOCKED by Windows' foreground lock — so the earlier-// attempts left pup on top, stealing focus. The reliable primitive: minimize the pup-// window (ShowWindow SW_MINIMIZE) — Windows then AUTOMATICALLY hands foreground to the-// user's previous window, no lock to fight — then re-show it WITHOUT activating-// (SW_SHOWNOACTIVATE) so it still composites for screenshots, and drop it to z-bottom.-// We only ever touch OUR OWN window, which is always allowed. SELF-VERIFYING: returns-// 'backgrounded' | 'still-foreground' | 'no-window' | 'error' by re-reading the-// foreground window after, so callers (and tests) know for CERTAIN whether it worked.-// cdpPort (the browser's CDP debug port) resolves the real browser via its LISTENING-// process — reliable across the launcher→browser pid relaunch that Edge/Chrome do.-async function osBackgroundWindowByPid(pid, cdpPort, restoreHwnd, respectUser, targetHwnd) {- // v2.0.27: native. Resolve the pid tree's real frame (or use the caller's hwnd) and z-bottom it via- // ab. respectUser maps to ab's force flag: an explicit lower verb may demote a foreground window- // (deliberate user-requested action through the AI), a respectful one skips it. restoreHwnd/cdpPort- // kept for signature compatibility; the native path never steals focus so there is nothing to restore.- if (process.platform !== 'win32') return 'error';- try {- let target = Number(targetHwnd) || null;- if (!target && pid) target = await largestChromeWindowOfPidTree(pid);- if (!target) return 'no-window';- const d = abPayload(await chrome.abCommand('desktop_set_window_state', { hwnd: Number(target), state: 'bottom', force: !respectUser }, { timeoutMs: 5000 }));- return (d && (d.sentToBack || d.ok)) ? 'backgrounded' : 'error';- } catch (e) { return 'error'; }-} // v2.0.27: osBackgroundWatchdog DELETED — zero callers (superseded by the park + re-assert // doctrine in dev-skills/pup-user-foreground).@@ -10448,32 +10417,6 @@ function isSessionAlive(s) { return false; } -// Get active session, fallback to first, or return null.-// v1.9.92 (issue #16): NON-DESTRUCTIVE. This is a RESOLVER, not a reaper — it must never delete from-// the session map. The old version deleted every session whose isSessionAlive() was false, with no-// `_lostBrowser` guard (unlike resolveSession, which deliberately keeps disconnected entries for-// rescan). So after a transient disconnect, ONE call to this function silently wiped the ENTIRE-// session map — destroying the exact entries handleBrowserDisconnect had just preserved and told the-// user to recover with pup_rescan. That is the reported bug: pup_list_windows returns 0-// windows while the windows are still open and drivable by sessionId, and rescan can't help because-// the sessionId→profile records it needs are gone from memory. Mark, don't delete; removal belongs to-// the close and rescan paths.-function getActiveSession() {- if (activeSessionId && sessions.has(activeSessionId)) {- const s = sessions.get(activeSessionId);- if (isSessionAlive(s)) return s;- s._lostBrowser = true; // keep the entry so pup_rescan can recover it- }- for (const [id, s] of sessions) {- if (isSessionAlive(s)) {- activeSessionId = id;- return s;- }- s._lostBrowser = true; // was: sessions.delete(id) — the map-wiping bug- }- activeSessionId = null;- return null;-} // Resolve a session by explicit sessionId or fall back to active.@@ -15174,12 +15117,9 @@ const server = http.createServer(async (req, res) => { return; } case 'pup_switch_tab': {- const sessionId = args.sessionId || activeSessionId;- if (!sessionId || !sessions.has(sessionId)) {- sendJSON(res, { success: false, error: `Session "${sessionId}" not found` });- return;- }- const session = sessions.get(sessionId);+ const _live = requireLiveSession(res, args);+ if (!_live) return;+ const { sessionId, session } = _live; const tab = getTabById(session, args.tabId); if (!tab) { const available = session.tabs.map(t => t.tabId).join(', ');@@ -15207,12 +15147,9 @@ const server = http.createServer(async (req, res) => { return; } case 'pup_close_tab': {- const sessionId = args.sessionId || activeSessionId;- if (!sessionId || !sessions.has(sessionId)) {- sendJSON(res, { success: false, error: `Session "${sessionId}" not found` });- return;- }- const session = sessions.get(sessionId);+ const _live = requireLiveSession(res, args);+ if (!_live) return;+ const { sessionId, session } = _live; if (!args.tabId) { sendJSON(res, { success: false, error: 'tabId is required for pup_close_tab' }); return;@@ -15238,12 +15175,9 @@ const server = http.createServer(async (req, res) => { } case 'pup_list_tabs': { drainWindowOpenedEvents().catch(() => {}); // v1.9.32: cheap event-driven drag-out check- const sessionId = args.sessionId || activeSessionId;- if (!sessionId || !sessions.has(sessionId)) {- sendJSON(res, { success: false, error: `Session "${sessionId}" not found` });- return;- }- const session = sessions.get(sessionId);+ const _live = requireLiveSession(res, args);+ if (!_live) return;+ const { sessionId, session } = _live; const tabs = []; for (const t of session.tabs) { const info = await describeTab(t, session.activeTabId);@@ -15259,12 +15193,9 @@ const server = http.createServer(async (req, res) => { // {stop:true} (alias {clear:true}) CLEARS a lingering orange highlight instead. // (Pre-1.8.35 this wrongly called bringToFront — it raised the window instead of // flashing. Now it uses the real FlashWindowEx path, same as the auto-flash.)- const sessionId = args.sessionId || activeSessionId;- if (!sessionId || !sessions.has(sessionId)) {- sendJSON(res, { success: false, error: `Session "${sessionId}" not found` });- return;- }- const session = sessions.get(sessionId);+ const _live = requireLiveSession(res, args);+ if (!_live) return;+ const { sessionId, session } = _live; try { const stop = !!(args.stop || args.clear); // ── macOS ── there is no taskbar to flash and macOS has no API to bounce ANOTHER@@ -15326,12 +15257,9 @@ const server = http.createServer(async (req, res) => { // Send a pup window to the BACKGROUND (behind the user's windows) without // stealing focus. It stays fully drivable + screenshottable. This is the // "get it off the user's screen" verb — the counterpart to raise/focus.- const sessionId = args.sessionId || activeSessionId;- if (!sessionId || !sessions.has(sessionId)) {- sendJSON(res, { success: false, error: `Session "${sessionId}" not found` });- return;- }- const session = sessions.get(sessionId);+ const _live = requireLiveSession(res, args);+ if (!_live) return;+ const { sessionId, session } = _live; // v1.8.47b: park on-screen at z-bottom (screenshots keep working). An explicit lower is // a deliberate re-park, so clear the user-foreground latch too. const be = browsers.get(session.profileName);@@ -15343,7 +15271,11 @@ const server = http.createServer(async (req, res) => { ok = await macSetAppHiddenByPid(macSessionPid(session), true); } else { if (!session._hwnd) { try { await resolveSessionHwndByBounds(session, sessionId); } catch (e) {} }- ok = (be && be.browser) ? ((await osBackgroundWindowByPid(be.browser._detachedPid, be.browser._cdpPort, be.browser._priorForeground, false, session._hwnd)) === 'backgrounded') : await osSendWindowToBack(session.profileName);+ // v2.0.236: route through the SAME park every internal caller uses instead of the two+ // legacy one-off implementations (osBackgroundWindowByPid took parameters its body no+ // longer read, and osSendWindowToBack re-derived the window by profile). One park now.+ const _pg = (session.tabs && session.tabs[0] && session.tabs[0].page) || session.page || null;+ ok = _pg ? !!(await parkSessionWindowDirect(_pg, sessionId)) : false; } session._foreground = false; session._fgWatchUntil = 0; // v1.9.260: a deliberate lower is not a "surfaced without pup" event - z-bottom keeps OS focus and the no-fault watch was mislabeling it@@ -15353,12 +15285,9 @@ const server = http.createServer(async (req, res) => { case 'pup_raise_os_window': case 'pup_focus_window': { // Actually brings the Chrome window to the foreground using Win32 SetForegroundWindow- const sessionId = args.sessionId || activeSessionId;- if (!sessionId || !sessions.has(sessionId)) {- sendJSON(res, { success: false, error: `Session "${sessionId}" not found` });- return;- }- const session = sessions.get(sessionId);+ const _live = requireLiveSession(res, args);+ if (!_live) return;+ const { sessionId, session } = _live; // v1.8.70: THE FOREGROUND GATE (mirrors abe's userRequestedForeground+foregroundReason // dual key — same contract across Adom's browser surfaces). Raising steals the user's // screen and keyboard; it is allowed ONLY as a user-requested show-me, and the caller@@ -17839,126 +17768,26 @@ const server = http.createServer(async (req, res) => { } } - // Legacy HTTP endpoints (for direct curl access) — operate on active session-- // POST /launch- if (pathname === '/launch' && req.method === 'POST') {- const body = JSON.parse(await readBody(req));- if (body.pupshotUrl) pupshotUrl = body.pupshotUrl;- const session = await launchSession(body.sessionId || 'default', body.url);- session.errors.length = 0;- sendJSON(res, { ok: true, url: body.url, sessionId: activeSessionId });- return;- }-- // POST /navigate- if (pathname === '/navigate' && req.method === 'POST') {- const body = JSON.parse(await readBody(req));- const session = (() => { const s = getActiveSession(); if (!s) throw new Error('No session. Use /launch first.'); return s; })();- await session.page.goto(body.url, { waitUntil: 'domcontentloaded', timeout: 30000 });- session.errors.length = 0;- sendJSON(res, { ok: true, url: body.url });- return;- }-- // POST /reload- if (pathname === '/reload' && req.method === 'POST') {- const session = (() => { const s = getActiveSession(); if (!s) throw new Error('No session. Use /launch first.'); return s; })();- session.errors.length = 0;- await session.page.reload({ waitUntil: 'domcontentloaded', timeout: 30000 });- sendJSON(res, { ok: true });- return;- }-- // GET /screenshot- if (pathname === '/screenshot') {- const session = (() => { const s = getActiveSession(); if (!s) throw new Error('No session. Use /launch first.'); return s; })();- const ts = Date.now();- const fp = url.searchParams.get('fullPage') === 'true';- let shotBuf = await session.page.screenshot({ type: 'png', fullPage: fp, captureBeyondViewport: fp });- // Downscale when sharp is available; else serve at capture size (sharp optional).- const shot = await processScreenshot(shotBuf, MAX_SCREENSHOT_DIM);- shotBuf = shot.buf;- const filePath = path.join(SHOTS_DIR, `pup-${ts}.png`);- fs.writeFileSync(filePath, shotBuf);- const sizeKB = Math.round(shotBuf.length / 1024);- session.latestShot = filePath;-- let container = null;- if (pupshotUrl) {- try {- const buf = fs.readFileSync(filePath);- const b64 = buf.toString('base64');- const postBody = JSON.stringify({ image: 'data:image/png;base64,' + b64 });- const resp = await fetch(pupshotUrl, {- method: 'POST',- headers: { 'Content-Type': 'application/json' },- body: postBody,- });- container = await resp.json();- } catch (e) {- container = { error: e.message };- }- }-- sendJSON(res, {- ok: true,- sizeKB,- filePath,- width: shot.width,- height: shot.height,- origWidth: shot.origWidth,- origHeight: shot.origHeight,- resized: shot.resized,- container,- });- return;- }-- // GET /screenshot.png (serves latest shot regardless of format)- if (pathname === '/screenshot.png') {- const session = getActiveSession();- const shot = session?.latestShot;- if (shot && fs.existsSync(shot)) {- const buf = fs.readFileSync(shot);- res.writeHead(200, { 'Content-Type': 'image/png', 'Access-Control-Allow-Origin': '*' });- res.end(buf);- } else {- res.writeHead(404);- res.end('No screenshot yet');- }- return;- }-- // GET /errors- if (pathname === '/errors') {- const session = getActiveSession();- const errs = session ? session.errors : [];- sendJSON(res, { errors: errs, count: errs.length });- return;- }-- // POST /eval- if (pathname === '/eval' && req.method === 'POST') {- const body = JSON.parse(await readBody(req));- const expr = String(body.expr || '');- const session = (() => { const s = getActiveSession(); if (!s) throw new Error('No session. Use /launch first.'); return s; })();- let result;- try {- result = await session.page.evaluate(new Function('return (' + expr + ')'));- } catch (e) {- result = { __error: e.message };+ // v2.0.236: the legacy curl endpoints (/launch /navigate /reload /screenshot /screenshot.png+ // /errors /eval /wait) are GONE. Each duplicated a pup_* verb on the "active session" with none+ // of the ownership gate, session recovery, or foreground etiquette the verbs have — so any+ // caller reaching them got subtly different (worse) behavior, and they were the last consumer of+ // getActiveSession(). Nothing in this repo, the skills, or the recorder referenced them. They+ // answer 410 with the verb to use instead, so a forgotten script fails loudly and fixably.+ {+ const LEGACY = {+ '/launch': 'pup_open_window', '/navigate': 'pup_navigate', '/reload': 'pup_reload',+ '/screenshot': 'pup_screenshot', '/screenshot.png': 'pup_screenshot', '/errors': 'pup_errors',+ '/eval': 'pup_eval', '/wait': 'pup_wait',+ };+ if (LEGACY[pathname]) {+ res.writeHead(410, { 'Content-Type': 'application/json' });+ res.end(JSON.stringify({+ ok: false, gone: pathname, use: LEGACY[pathname],+ _hint: `${pathname} was a legacy curl shim that acted on whatever window happened to be "active" — no owner check, no recovery. Call the verb ${LEGACY[pathname]} through adom-bridge-cli instead (it targets YOUR thread's window and recovers a lost one).`,+ }));+ return; }- sendJSON(res, { ok: true, result });- return;- }-- // GET /wait?ms=N- if (pathname === '/wait') {- const ms = parseInt(url.searchParams.get('ms') || '3000');- await new Promise(r => setTimeout(r, ms));- sendJSON(res, { ok: true, waited: ms });- return; } // GET /status@@ -19416,57 +19245,31 @@ function tabRecordStatusImpl({ sessionId } = {}) { return active; } -function tabRecordListImpl() {- // Scan RECORDINGS_DIR for completed tab recordings (.webm files matching- // the tab pattern). Each has an optional sidecar .json with metadata.- let entries = [];+// v2.0.236: these were two copies of the same directory scan differing only in the filename prefix+// and two sidecar fields. One implementation, two thin wrappers so the call sites read the same.+function listRecordings(prefix, extra) { try {- entries = fs.readdirSync(RECORDINGS_DIR)- .filter(f => f.startsWith('rec-tab-') && f.endsWith('.webm'))+ return fs.readdirSync(RECORDINGS_DIR)+ .filter(f => f.startsWith(prefix) && f.endsWith('.webm')) .map(f => { const fp = path.join(RECORDINGS_DIR, f); const stat = fs.statSync(fp);- const sidecarPath = fp.replace(/\.webm$/, '.json'); let sidecar = {};- try { sidecar = JSON.parse(fs.readFileSync(sidecarPath, 'utf8')); } catch {}- return {+ try { sidecar = JSON.parse(fs.readFileSync(fp.replace(/\.webm$/, '.json'), 'utf8')); } catch {}+ const base = { recordingId: sidecar.recordingId || f.replace(/\.webm$/, ''), filePath: fp, sizeKB: Math.round(stat.size / 1024), durationMs: sidecar.durationMs || null,- fps: sidecar.fps || null,- sessionId: sidecar.sessionId || null,- tabId: sidecar.tabId || null, mtime: stat.mtime.toISOString(), };+ for (const k of extra) base[k] = sidecar[k] !== undefined ? sidecar[k] : null;+ return base; });- } catch {}- return entries;-}--function desktopRecordListImpl() {- let entries = [];- try {- entries = fs.readdirSync(RECORDINGS_DIR)- .filter(f => f.startsWith('rec-desktop-') && f.endsWith('.webm'))- .map(f => {- const fp = path.join(RECORDINGS_DIR, f);- const stat = fs.statSync(fp);- const sidecarPath = fp.replace(/\.webm$/, '.json');- let sidecar = {};- try { sidecar = JSON.parse(fs.readFileSync(sidecarPath, 'utf8')); } catch {}- return {- recordingId: sidecar.recordingId || f.replace(/\.webm$/, ''),- filePath: fp,- sizeKB: Math.round(stat.size / 1024),- durationMs: sidecar.durationMs || null,- reason: sidecar.reason || null,- mtime: stat.mtime.toISOString(),- };- });- } catch {}- return entries;+ } catch { return []; } }+const tabRecordListImpl = () => listRecordings('rec-tab-', ['fps', 'sessionId', 'tabId']);+const desktopRecordListImpl = () => listRecordings('rec-desktop-', ['reason']); // ════════════════════════════════════════════════════════════════════════════