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
v1.7.1: sharp+keytar optional + graceful sharp load — robust to native-build failures (fixes npm install fatal on boxes where install scripts can't find node)
6 files changed
+62−49
adom-bridge-puppeteer-manifest.json+4−4@@ -1,10 +1,10 @@ { "manifest_version": 1, "name": "puppeteer",- "version": "1.7.0",- "url": "https://wiki.adom.inc/download/adom/adom-desktop-puppeteer-bridge/1.7.0/adom-bridge-puppeteer-v1.7.0.zip",- "sha256": "f30de8c514fe1bde4f82b000234968f24d294199051dcee03c89b743d36f1406",- "size": 128274,+ "version": "1.7.1",+ "url": "https://wiki.adom.inc/download/adom/adom-desktop-puppeteer-bridge/1.7.1/adom-bridge-puppeteer-v1.7.1.zip",+ "sha256": "8adc693119f7bd12096605c33bbd58e6ef46b559d026dce1be12d6bbdf9d29db",+ "size": 128688, "verbPrefixes": [ "browser_", "desktop_recorder_",
package.json+1−1@@ -1,6 +1,6 @@ { "slug": "adom-desktop-puppeteer-bridge",- "version": "1.7.0",+ "version": "1.7.1", "type": "app", "description": "CONTAINER-SIDE install \u2014 the everyday `pup` user skill only. This pkg drops the `pup` skill (drive the browser bridge: open/close/tabs/screenshot/record, background-by-default) into ~/.claude/skills/ AND ~/.codex/skills/. It does NOT install the developer/publishing skills (pup-bridge-dev, pup-bridge-publish) \u2014 those are source-only; clone the repo (adom-wiki repo clone adom/adom-desktop-puppeteer-bridge) to get them from dev-skills/. It also does NOT contain the bridge runtime: the bridge (Node + Chrome for Testing) lives on the DESKTOP \u2014 bundled inside Adom Desktop and published here on the Releases tab as adom-bridge-puppeteer-v<ver>.zip, which Adom Desktop streams into its bridges-cache. TL;DR: `pkg install` = the pup user skill; dev skills = clone the source; the bridge = the Release zip + bundled in Adom Desktop.", "keywords": [
src/BRIDGE_VERSION+1−1@@ -1 +1 @@-1.7.0+1.7.1
src/bridge.json+1−1@@ -2,7 +2,7 @@ "manifest_version": 1, "name": "puppeteer", "displayName": "Puppeteer (Chrome for Testing + Native)",- "version": "1.7.0",+ "version": "1.7.1", "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.", "homepage": "https://wiki.adom.inc/adom/adom-desktop", "author": "Adom Inc.",
src/package.json+3−1@@ -7,8 +7,10 @@ "start": "node server.js" }, "dependencies": {+ "puppeteer": "^24.38.0"+ },+ "optionalDependencies": { "keytar": "^7.9.0",- "puppeteer": "^24.38.0", "sharp": "^0.34.5" } }
src/server.js+52−41@@ -15,11 +15,38 @@ const chrome = require('./chrome'); // Chrome-for-Testing self-heal: detect / au // v1.8.40+: previously this was wrapped in try/catch with a `let sharp = null` // fallback. The fallback path silently sent 2559px PNGs to /tmp/adom-desktop-screenshots/, // which CRASHES Claude many-image sessions at the 2000px limit. Hard-require-// sharp now: if it can't load, the bridge fails fast at startup with a clear-// error rather than producing oversized screenshots indefinitely.-const sharp = require('sharp');+// sharp is OPTIONAL — a heavy native dep whose install can fail on a fresh box (no+// prebuild / no build tools / a managed-runtime PATH gap). pup must still spawn and+// work without it: we fall back to serving the screenshot at capture size and reading+// dimensions from the PNG header. When sharp IS present, we downscale to keep images+// under Claude's Read limit (the ideal path). Never let a missing sharp crash the bridge.+let sharp = null;+try { sharp = require('sharp'); }+catch (e) { console.warn('[pup] sharp unavailable — screenshots served at capture size, not downscaled:', e.message); } const MAX_SCREENSHOT_DIM = 1568; // Claude's optimal max dimension +// PNG width/height straight from the IHDR header (bytes 16-23, big-endian). Pure JS,+// no sharp needed. puppeteer screenshots are always PNG.+function pngDimensions(buf) {+ try { return { width: buf.readUInt32BE(16), height: buf.readUInt32BE(20) }; }+ catch { return { width: 0, height: 0 }; }+}+// Downscale a PNG to <= maxDim (long edge) when sharp is available; otherwise pass it+// through at capture size. Returns the (possibly-resized) buffer + dimensions + flags.+async function processScreenshot(buf, maxDim) {+ if (sharp) {+ const orig = await sharp(buf).metadata();+ const needsResize = orig.width > maxDim || orig.height > maxDim;+ const out = await (needsResize+ ? sharp(buf).resize({ width: maxDim, height: maxDim, fit: 'inside' })+ : sharp(buf)).png({ compressionLevel: 9, adaptiveFiltering: true }).toBuffer();+ const fin = await sharp(out).metadata();+ return { buf: out, width: fin.width, height: fin.height, origWidth: orig.width, origHeight: orig.height, resized: needsResize, sharpUsed: true };+ }+ const d = pngDimensions(buf);+ return { buf, width: d.width, height: d.height, origWidth: d.width, origHeight: d.height, resized: false, sharpUsed: false };+}+ const PORT = (() => { const idx = process.argv.indexOf('--port'); return idx !== -1 && process.argv[idx + 1] ? parseInt(process.argv[idx + 1]) : 8851;@@ -2827,22 +2854,12 @@ const server = http.createServer(async (req, res) => { 20_000, 'page.screenshot' ); - // v1.8.40+: sharp is mandatory — no `if (sharp)` guard, no try/catch- // fallback. If the resize fails the request errors out cleanly rather- // than silently sending a 2559px image that would crash a many-image- // Claude session at the 2000px API limit. For the rare "I want the- // real resolution to save to disk" case, use browser_screenshot_full_res.- const origMeta = await sharp(screenshotBuf).metadata();+ // Downscale to keep the image under Claude's Read limit when sharp is+ // available; otherwise serve at capture size (sharp is optional — see the+ // processScreenshot helper). A ≤1568px image is the ideal path. const maxDim = Math.min(maxWidth, MAX_SCREENSHOT_DIM);- const needsResize = origMeta.width > maxDim || origMeta.height > maxDim;- const pipeline = needsResize- ? sharp(screenshotBuf).resize({ width: maxDim, height: maxDim, fit: 'inside' })- : sharp(screenshotBuf);- screenshotBuf = await pipeline.png({ compressionLevel: 9, adaptiveFiltering: true }).toBuffer();- // v1.8.40+: probe the FINAL dimensions (post-resize) so the response- // tells the caller exactly what they're going to Read. Without these- // the AI can only guess from sizeKB.- const finalMeta = await sharp(screenshotBuf).metadata();+ const shot = await processScreenshot(screenshotBuf, maxDim);+ screenshotBuf = shot.buf; const filePath = path.join(SHOTS_DIR, `pup-${ts}.png`); fs.writeFileSync(filePath, screenshotBuf);@@ -2855,11 +2872,12 @@ const server = http.createServer(async (req, res) => { sizeKB, filePath, base64,- width: finalMeta.width,- height: finalMeta.height,- origWidth: origMeta.width,- origHeight: origMeta.height,- resized: needsResize,+ width: shot.width,+ height: shot.height,+ origWidth: shot.origWidth,+ origHeight: shot.origHeight,+ resized: shot.resized,+ ...(shot.sharpUsed ? {} : { sharpUnavailable: true, _hint: 'Screenshot served at CAPTURE size — the sharp resize library is not installed on this box, so the image was NOT downscaled and may exceed Claude\'s ~2000px Read limit. If a Read fails on size, capture a smaller viewport, or (rare) this box\'s native-dep install failed — see browser_readiness.' }), sessionId: resolvedId, tabId, }) });@@ -2885,10 +2903,9 @@ const server = http.createServer(async (req, res) => { tab.page.screenshot({ type: 'png', fullPage }), 20_000, 'page.screenshot' );- // Probe dimensions for the warning payload — cheap (sharp just reads- // the PNG header). No resize, no recompression — the bytes that hit- // disk are exactly what Chrome captured.- const meta = await sharp(screenshotBuf).metadata();+ // Probe dimensions for the warning payload — cheap. No resize, no+ // recompression — the bytes that hit disk are exactly what Chrome captured.+ const meta = sharp ? await sharp(screenshotBuf).metadata() : pngDimensions(screenshotBuf); const filePath = path.join(SHOTS_DIR, `pup-fullres-${ts}.png`); fs.writeFileSync(filePath, screenshotBuf); const sizeKB = Math.round(screenshotBuf.length / 1024);@@ -3886,15 +3903,9 @@ const server = http.createServer(async (req, res) => { const ts = Date.now(); const fp = url.searchParams.get('fullPage') === 'true'; let shotBuf = await session.page.screenshot({ type: 'png', fullPage: fp });- // v1.8.40+: resize is mandatory (no guard, no fallback). See block at- // top of file + browser_screenshot case for context.- const origMeta = await sharp(shotBuf).metadata();- const needsResize = origMeta.width > MAX_SCREENSHOT_DIM || origMeta.height > MAX_SCREENSHOT_DIM;- const pipeline = needsResize- ? sharp(shotBuf).resize({ width: MAX_SCREENSHOT_DIM, height: MAX_SCREENSHOT_DIM, fit: 'inside' })- : sharp(shotBuf);- shotBuf = await pipeline.png({ compressionLevel: 9, adaptiveFiltering: true }).toBuffer();- const finalMeta = await sharp(shotBuf).metadata();+ // 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);@@ -3921,11 +3932,11 @@ const server = http.createServer(async (req, res) => { ok: true, sizeKB, filePath,- width: finalMeta.width,- height: finalMeta.height,- origWidth: origMeta.width,- origHeight: origMeta.height,- resized: needsResize,+ width: shot.width,+ height: shot.height,+ origWidth: shot.origWidth,+ origHeight: shot.origHeight,+ resized: shot.resized, container, }); return;