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.388 (insiders): Send to AI for Hydrogen 1.0.187+ (dock-bar broadcast): re-vendored newbie-panel.js 2.22.0 from adom/adom-ui-design 3.1.0 (job/state contract, status polling with a deadline, onProgress fix), added the third proxy route GET /ai-threads/run-prompt/status with the query string forwarded intact, and taught the dashboard's server-side demo-send the same job/state contract while keeping the old delivered_to shape working. Not promoted.
5 files changed
+149−17
package.json+1−1@@ -1,6 +1,6 @@ { "slug": "pup-bridge",- "version": "2.0.387",+ "version": "2.0.388", "type": "app", "description": "The pup SKILL PACK \u2014 the AI skills that teach a cloud/container AI how to drive the Adom Desktop Puppeteer (pup) bridge (browser_* verbs): the everyday `pup` skill plus focused sub-skills for windows/sessions/tabs, screenshots+recording, browsers+Chrome install, and driving the Adom wiki. Installs into ~/.claude/skills + ~/.codex/skills. DOCS ONLY \u2014 this is NOT the bridge runtime. The bridge itself (the Node program that drives the browser) runs on the DESKTOP: Adom Desktop bundles it and auto-installs/updates it, and it's published separately on the Releases tab as adom-bridge-puppeteer-v<ver>.zip. The pkg version is kept in lockstep with the Release version so the page's max version row always carries BOTH a pkg tarball (skills) and a runtime zip (bridge) \u2014 a skills-only or runtime-only bump on the top row breaks constraint installs. TL;DR: pkg install = the skills for your AI; the bridge = the Release zip, which Adom Desktop manages for you.", "keywords": [
src/BRIDGE_VERSION+1−1@@ -1 +1 @@-2.0.387+2.0.388
src/bridge.json+1−1@@ -2,7 +2,7 @@ "manifest_version": 1, "name": "puppeteer", "displayName": "Puppeteer (Chrome for Testing + Native)",- "version": "2.0.387",+ "version": "2.0.388", "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/newbie-panel.js+113−14@@ -112,6 +112,23 @@ var TWO_COL_MIN = 700, PHRASE_BUDGET = 336; var MIN_API = [1, 0, 142]; + /* Hydrogen 1.0.187+ answers a send with a JOB and reports progress on+ * GET /ai-threads/run-prompt/status?job=<uuid>. Measured on the VM: tab visible ~0.1s,+ * session id ~2.5s, sent by 3-5s, a trivial answer done ~8s. One poll a second is the+ * right cadence for that shape, and it is an in-memory read on the other end. */+ var PHASE = {+ opening: 'Opening a new AI chat…',+ prefilled: 'Prompt ready…',+ submitted: 'Sent, waiting for the AI…',+ working: 'The AI is working…',+ needs_you: 'The AI needs you. Open the tab.',+ done: 'Done.',+ submit_failed: 'The prompt is in the tab. Press Enter there to send it.',+ submitted_unidentified:'Sent, but the AI never checked in.',+ submit_unconfirmed: 'Sent, but delivery was not confirmed.',+ still_working: 'The AI is still working. Open the tab.'+ };+ function css() { if (document.getElementById('anp-css')) return; var s = document.createElement('style');@@ -174,6 +191,9 @@ if (!toastHost) { toastHost = el('div', 'anp-toasts'); document.body.appendChild(toastHost); } var t = el('div', 'anp-toast ' + (kind || 'info'), esc(msg)); toastHost.appendChild(t);+ /* retextable, so a long send can narrate its phases in ONE toast instead of+ * stacking six of them on top of each other. */+ t.setText = function (next) { t.innerHTML = esc(next); return t; }; setTimeout(function () { t.remove(); }, ms || (kind === 'err' ? 7000 : 4000)); return t; }@@ -224,7 +244,7 @@ }) .catch(function () { return { ok: true, version: null, open: true }; }); }- function send(base, phrase, appName, slug) {+ function send(base, phrase, appName, slug, onProgress) { return gate(base).then(function (g) { if (!g.ok) throw new Error('Adom needs updating to send prompts (found ' + g.version + ', needs 1.0.142). Run: adom update'); var ctl = new AbortController(), timer = setTimeout(function () { ctl.abort(); }, 120000);@@ -241,12 +261,55 @@ .then(function (res) { clearTimeout(timer); var j = res.j || {};- /* THREE states, never two. delivered_to is an ARRAY; a thread that opened but did- * not confirm delivery is NOT a failure, and retrying it double-sends. */+ if (!(res.status >= 200 && res.status < 300))+ throw new Error((j._hints && j._hints.note) || j.error || ('AI threads returned ' + res.status));+ /* PRE-1.0.187 SHAPE, kept because older Hydrogen builds still answer this way.+ * delivered_to is an ARRAY; a thread that opened but did not confirm delivery is+ * NOT a failure, and retrying it double-sends. */ var dt = j.delivered_to, arr = Array.isArray(dt) ? dt : (dt ? [dt] : []); if (arr.length || (j.inject && j.inject.delivered_in_conversation)) return { state: 'delivered' };- if (res.status >= 200 && res.status < 300) return { state: 'opened', hint: j._hints && j._hints.note };- throw new Error((j._hints && j._hints.note) || j.error || ('AI threads returned ' + res.status));+ /* 1.0.187+ returns in well under a second with a JOB, not a delivery. ok:true means+ * the job STARTED. delivered_to is never filled on this path any more, so without+ * the poll below every send would report "delivery not confirmed" forever. */+ if (!j.job) return { state: 'opened', hint: j._hints && j._hints.note };+ var started = Date.now(), missing = false;+ return new Promise(function (resolve) {+ (function tick() {+ /* the deadline is checked in BOTH paths. Checking it only after a successful+ * poll leaves a dashboard whose server stops answering polling forever. */+ function again() {+ if (Date.now() - started > 120000)+ return resolve({ state: 'opened', hint: 'no confirmation within 120s' });+ setTimeout(tick, 1000);+ }+ fetch(base + '/ai-threads/run-prompt/status?job=' + encodeURIComponent(j.job))+ .then(function (r) {+ /* THE STATUS ROUTE IS A THIRD PROXY ROUTE. Dashboards proxy /updater/status+ * and POST /ai-threads/run-prompt only. If this one was never added, every+ * poll 404s and the user watches "Opening…" for the full 120s before a+ * hedge, which is worse than the behaviour this replaced. Fail FAST and say+ * exactly which route is missing instead of hanging. */+ if (r.status === 404) { missing = true; return null; }+ return r.json();+ })+ .then(function (st) {+ if (missing) return resolve({ state: 'opened', phase: 'no_status_route',+ hint: 'Your AI chat opened. This app does not proxy GET ' ++ '/ai-threads/run-prompt/status yet, so progress cannot be shown.' });+ var state = (st.job && st.job.state) || '';+ if (typeof onProgress === 'function') { try { onProgress(state, st.job); } catch (e) {} }+ /* working or later IS sent, for the user-facing toast. */+ if (state === 'working' || state === 'done' || state === 'needs_you' ||+ state === 'still_working') return resolve({ state: 'delivered', phase: state });+ if (state === 'submit_failed' || state === 'submitted_unidentified' ||+ state === 'submit_unconfirmed')+ return resolve({ state: 'opened', phase: state,+ hint: (st.job && (st.job.error || st.job.note)) || state });+ again();+ })+ .catch(again);+ })();+ }); }) .catch(function (e) { clearTimeout(timer);@@ -316,7 +379,10 @@ if (sd.disabled) return; sd.disabled = true; var pending = toast('Opening a new AI thread…', 'info', 120000);- send(base, phrase, appName, slug).then(function (r) {+ send(base, phrase, appName, slug, function (state, job) {+ if (PHASE[state]) pending.setText(PHASE[state]);+ if (typeof cfg.onProgress === 'function') { try { cfg.onProgress(state, job); } catch (e) {} }+ }).then(function (r) { pending.remove(); if (r.state === 'delivered') toast('Sent. Your AI thread has the prompt.', 'ok'); else toast('Thread opened, delivery not confirmed. Check your AI threads before sending again.', 'info', 8000);@@ -419,11 +485,17 @@ * as a specific layout regression rather than an invalid run, and project-manager nearly * filed it as a bug. A refusal cannot be misread. (project-manager, 2026-08-25.) */ var w0 = Math.round(panel.getBoundingClientRect().width);- if (w0 === 0) return { ok: false, invalid: true,- error: 'panel measures 0px wide: this tab is not foregrounded, so every layout row would ' ++ /* rows: [] is deliberate. The refusal previously omitted the key entirely, and EVERY+ * caller does r.rows.map(...) because every healthy result has rows, so a correct refusal+ * arrived at the caller as "Cannot read properties of undefined (reading 'map')" and read+ * like harness noise. kicad hit exactly that and logged it as its own crash. A guard whose+ * failure mode is a TypeError in someone else's stack is a guard that will be misread. */+ if (w0 === 0) return { ok: false, invalid: true, rows: [],+ error: 'panel measures 0px wide: this tab is not foregrounded (or the panel is hidden), ' ++ 'so every layout row would ' + 'be computed from a collapsed pane while the style rows pass normally. Activate ' +- 'the tab by name and re-run. No rows reported, because a partial result from an ' +- 'unrendered pane looks like a real bug.' };+ 'the tab by name (or open the panel) and re-run. No rows reported, because a ' ++ 'partial result from an unrendered pane looks like a real bug.' }; var cs = getComputedStyle(panel), rows = [], ok = true; /* Chrome reports the USED border-width, snapped to whole device pixels, so a declared * 1px reads as 0.666667px at dpr 1.5. Compare against the snapped value, not the@@ -579,14 +651,41 @@ }) .catch(function (e) { add('gate route wired', '200 with JSON', 'FETCH FAILED: ' + e.message, false); }) .then(function () {- return fetch(base + '/ai-threads/run-prompt', {method: 'OPTIONS'})- .then(function (r) { add('send route wired', 'not 404', String(r.status), r.status !== 404); })- .catch(function (e) { add('send route wired', 'not 404', 'FETCH FAILED: ' + e.message, false); });+ /* PROBE A NONSENSE PATH TOO, AND REQUIRE THE TWO TO DIFFER.+ * "not 404" was too weak an assertion. kicad's do_OPTIONS returned 204 for EVERY path+ * unconditionally, so this row was green for any string at all, including routes that+ * exist in no method. An app could fix its GET catch-all, still have a blanket+ * preflight, and keep a green row that tests nothing. Comparing against a path that+ * cannot exist makes the row self-validating: if they answer identically, the server+ * is answering the SHAPE of the question rather than the question. */+ var nonsense = '/__anp_probe_' + String(Math.floor(1e9 * 0.6180339887)) + '__';+ return Promise.all([+ fetch(base + '/ai-threads/run-prompt', {method: 'OPTIONS'}).then(function (r) { return r.status; },+ function () { return 'ERR'; }),+ fetch(base + nonsense, {method: 'OPTIONS'}).then(function (r) { return r.status; },+ function () { return 'ERR'; })+ ]).then(function (both) {+ var real = both[0], fake = both[1];+ add('send route wired', 'answers, and differently from a path that cannot exist',+ 'route ' + real + ', nonsense ' + fake,+ real !== 'ERR' && real !== 404 && real !== fake);+ });+ })+ .then(function () {+ /* THE STATUS ROUTE, added for Hydrogen 1.0.187+. It is a THIRD proxy route and no+ * dashboard had it before 2.22.0, so it is the row most likely to be red. A GET with+ * a job id that cannot exist must still be ANSWERED (any non-404), because a 404 here+ * means the route was never proxied and progress can never be reported. */+ return fetch(base + '/ai-threads/run-prompt/status?job=__anp_probe__')+ .then(function (r) { return r.status; }, function () { return 'ERR'; })+ .then(function (st) {+ add('progress route wired', 'answers (not 404)', String(st), st !== 'ERR' && st !== 404);+ }); }) .then(function () { return {ok: ok, rows: rows}; }); } root.AdomNewbiePanel = { mount: mount, toast: toast, selfCheck: selfCheck, copyText: copyText,- checkSend: checkSend, version: '2.19.1',+ checkSend: checkSend, version: '2.22.0', TWO_COL_MIN: TWO_COL_MIN, PHRASE_BUDGET: PHRASE_BUDGET }; })(typeof window !== 'undefined' ? window : this);
src/server.js+33@@ -20056,6 +20056,26 @@ const server = http.createServer(async (req, res) => { }, 120000); if (!r.status) { sendJSON(res, { ok: false, why: 'Could not reach the AI panel (' + (r.error || 'no response') + ').' }); return; } const j = r.json || {};+ // v2.0.388 (Hydrogen 1.0.187+ Send-to-AI contract, dock-bar broadcast): run-prompt now returns+ // in under a second with {ok, job, state:'opening'} and delivered_to is never filled on that+ // path; progress comes from polling /ai-threads/run-prompt/status?job=<job>. working or later+ // counts as sent; submit_failed means the prompt sits in the tab unsent (tell the user to press+ // Enter there). Older builds still answer the delivered_to shape, handled below unchanged.+ if (j && j.job && j.state) {+ const t0 = Date.now(); let st = j.state, last = j;+ const SENT = new Set(['working', 'needs_you', 'done']);+ const FAIL = new Set(['submit_failed', 'submitted_unidentified', 'submit_unconfirmed', 'still_working']);+ while (Date.now() - t0 < 30000 && !SENT.has(st) && !FAIL.has(st)) {+ await new Promise(r2 => setTimeout(r2, 1000));+ const pr = await hydrogenGet('/ai-threads/run-prompt/status?job=' + encodeURIComponent(j.job), 5000);+ if (pr.status === 404) { sendJSON(res, { ok: false, why: 'This Hydrogen build has no run-prompt status route; the prompt may still be sending in the new tab.' }); return; }+ if (pr.json && pr.json.state) { last = pr.json; st = pr.json.state; }+ }+ if (SENT.has(st)) { sendJSON(res, { ok: true, state: st, job: j.job }); return; }+ if (st === 'submit_failed') { sendJSON(res, { ok: false, state: st, why: 'The demo prompt is sitting in the new AI tab but send never clicked: press Enter there to run it.' }); return; }+ if (FAIL.has(st)) { sendJSON(res, { ok: false, state: st, why: 'Hydrogen reported ' + st + ' for the new thread.' }); return; }+ sendJSON(res, { ok: true, state: st, job: j.job, why: 'The new AI tab opened; still ' + st + ' after 30s.' }); return;+ } // Three states, matching the module: delivered_to is an ARRAY, and a thread that opened // without confirming delivery is NOT a failure. Reporting it as one invites a retry, and a // retry double-sends the demo into a second thread.@@ -20118,6 +20138,19 @@ const server = http.createServer(async (req, res) => { }); return; }+ // v2.0.388 (Hydrogen 1.0.187+ Send-to-AI): the THIRD proxied route. run-prompt now returns a job+ // id and the page polls this once a second for {state}. The query string (?job=<uuid>) MUST be+ // forwarded intact: a path-only proxy answers about no job at all. Same byte-faithful relay as+ // the two routes above; a missing ah answers 502-with-a-hint, never a 404 the module reads as+ // "route not wired".+ if (pathname === '/ai-threads/run-prompt/status' && req.method === 'GET') {+ const qs = (() => { try { const u = new URL(req.url, 'http://127.0.0.1'); return u.search || ''; } catch (e) { return ''; } })();+ const r = await hydrogenGet('/ai-threads/run-prompt/status' + qs, 8000);+ if (!r.status) { res.writeHead(502, { 'Content-Type': 'application/json', 'Cache-Control': 'no-store' }); res.end(JSON.stringify({ error: r.error || 'hydrogen unreachable', _hint: 'Hydrogen (ah) is not reachable from pup; the AI-threads progress route needs the local Hydrogen control API.' })); return; }+ res.writeHead(r.status, { 'Content-Type': r.contentType || 'application/json', 'Cache-Control': 'no-store' });+ res.end(r.raw);+ return;+ } if (pathname === '/api/status') { const warming = !chrome || !chrome.readiness; const rd = warming ? null : chrome.readiness();