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
package.json+1−1@@ -1,6 +1,6 @@ { "slug": "pup-bridge",- "version": "2.0.324",+ "version": "2.0.325", "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": [
page.json+1−1@@ -4,7 +4,7 @@ "slug": "pup-bridge", "title": "Pup - Puppeteer Bridge", "brief": "pup is the AI's own browser: a real, full Chrome on the user's desktop that the AI fully controls (a sandbox, not the user's signed-in browser). Rides Bridge; pup_* verbs open windows and tabs, navigate, screenshot, and eval JS.",- "version": "2.0.324",+ "version": "2.0.325", "tags": [ "skills", "pup",
tools/pup-dashboard+195−111@@ -40,7 +40,8 @@ let SAY = ['open your dashboard in pup', 'open my wiki page in pup', 'open my ap try { const dj = require(path.join(__dirname, '..', 'dockbar.json')); if (Array.isArray(dj.say) && dj.say.length) SAY = dj.say.slice(0, 6); } catch (e) {} // Hydrogen control API (local ah; reachable from WSL2 under mirrored networking): find this app's // thread by its app tag, then stage (never submit) a prompt into it.-const HYDROGEN_CTL = 'http://127.0.0.1:47084';+let HYDROGEN_CTL = 'http://127.0.0.1:47084';+try { const u = fs.readFileSync(path.join(os.homedir(), '.adom', 'hydrogen-control-url'), 'utf8').trim(); if (u) HYDROGEN_CTL = u.replace(/\/$/, ''); } catch (e) {} function hget(urlPath) { return new Promise((resolve) => { const req = http.get(HYDROGEN_CTL + urlPath, { timeout: 2500 }, (res) => { let b = ''; res.on('data', d => { b += d; }); res.on('end', () => { try { resolve({ status: res.statusCode, json: JSON.parse(b) }); } catch (e) { resolve({ status: res.statusCode, json: null }); } }); });@@ -68,28 +69,20 @@ function censusThread(j) { } return null; }-async function stagePhrase(phrase) {- if (!SAY.includes(phrase)) return { ok: false, error: 'unknown phrase' };- const c = await hget('/ai-threads');- if (!c.status) return { ok: false, error: 'Hydrogen is not reachable on 127.0.0.1:47084 (' + (c.error || 'no answer') + '). Paste the phrase into any Adom AI thread instead.' };- const open = censusThread(c.json);- if (open) {- const target = open.sid ? { sid: open.sid } : { tid: open.tid };- const r = await hpost('/ai-threads/run-prompt', { ...target, prompt: phrase, submit: false });- if (r.status >= 200 && r.status < 300 && !(r.json && r.json.ok === false)) return { ok: true, ...target, staged: phrase, where: 'open thread' + (open.label ? ' "' + open.label + '"' : '') };- return { ok: false, error: 'Hydrogen refused the stage (' + (r.status || r.error) + ')' + (r.raw ? ': ' + r.raw : '') };- }- const h = await hget('/ai-threads/history?tag=' + encodeURIComponent(APP_TAG));- const item = h.json && Array.isArray(h.json.items) ? h.json.items.find(i => i && i.id) : null;- if (item) {- const rs = await hpost('/ai-threads/resume', { ai: item.ai || 'claude', id: item.id });- if (!(rs.status >= 200 && rs.status < 300)) return { ok: false, error: 'Found your pup thread "' + (item.title || item.id) + '" but Hydrogen could not reopen it (' + (rs.status || rs.error) + '). Paste the phrase into any Adom AI thread instead.' };- const r = await hpost('/ai-threads/run-prompt', { sid: item.id, prompt: phrase, submit: false });- if (r.status >= 200 && r.status < 300 && !(r.json && r.json.ok === false)) return { ok: true, sid: item.id, staged: phrase, where: 'reopened thread' + (item.title ? ' "' + item.title + '"' : '') };- return { ok: false, error: 'Reopened your pup thread but the stage was refused (' + (r.status || r.error) + ')' + (r.raw ? ': ' + r.raw : '') };- }- return { ok: false, error: 'No pup AI thread exists yet. Click "Run the demo" once (it creates one), or paste the phrase into any Adom AI thread.' };+// say-strip "Send to AI" (John's 2026-08-24 ruling): open a NEW AI thread named for the app,+// tagged dock:pup-bridge, and SUBMIT the phrase. New threads ONLY; never resume/target an existing+// tid. On any failure the page falls back to copying, so the user is never stranded.+async function sendToNewThread(phrase) {+ if (!SAY.includes(phrase)) return { ok: false, why: 'unknown phrase' };+ const mk = await hpost('/ai-threads/new', { name: 'Pup - say it', tags: ['dock:pup-bridge'] });+ if (!(mk.status >= 200 && mk.status < 300)) return { ok: false, why: 'Could not reach the AI panel (' + (mk.status || mk.error) + ')' };+ const sid = mk.json && (mk.json.sid || mk.json.id);+ if (!sid) return { ok: false, why: 'AI panel did not return a thread id' };+ const run = await hpost('/ai-threads/run-prompt', { sid, prompt: phrase, submit: true });+ if (run.status >= 200 && run.status < 300 && !(run.json && run.json.ok === false)) return { ok: true, sid };+ return { ok: false, why: 'Thread opened but the prompt was refused (' + (run.status || run.error) + ')' }; }+ let TOOL_VERSION = '0'; try { TOOL_VERSION = String(require(path.join(__dirname, '..', 'package.json')).version || '0'); } catch (e) {} const DOCK_WEBVIEW = process.env.ADOM_DOCK_WEBVIEW || '';@@ -255,115 +248,199 @@ async function buildStatus() { // ── page ──────────────────────────────────────────────────────────────────────────────────────── const PAGE = `<!doctype html><meta charset="utf-8"><title>pup</title> <style>-:root{color-scheme:dark}-body{margin:0;background:#0d1117;color:#e6edf3;font:14px/1.5 "Familjen Grotesk",system-ui,sans-serif}-.wrap{max-width:760px;margin:0 auto;padding:28px 20px 60px}-h1{font-size:22px;margin:0 0 2px;letter-spacing:-.01em}-h1 .dot{display:inline-block;width:11px;height:11px;border-radius:50%;margin-right:9px;vertical-align:1px}-.sub{color:#8b949e;margin:0 0 22px}-.led{display:flex;gap:12px;align-items:flex-start;padding:13px 14px;border:1px solid #30363d;border-radius:9px;margin-bottom:9px;background:#161b22}-.led .d{width:10px;height:10px;border-radius:50%;margin-top:5px;flex:none}-.ok{background:#3fb950}.warn{background:#d29922}.fail{background:#f85149}-.lbl{font-weight:600}-.det{color:#8b949e;margin-top:2px;word-break:break-word}-.hint{color:#00b8b1;margin-top:6px;font-size:13px}-button{background:#00b8b1;color:#04262a;border:0;border-radius:7px;padding:9px 15px;font:600 14px inherit;cursor:pointer}-button:disabled{opacity:.5;cursor:default}-.demo{margin-top:22px;padding:15px;border:1px solid #30363d;border-radius:9px;background:#161b22}.transport{margin-top:12px;padding:12px;border:1px solid #30363d;border-radius:9px;background:#0d1117}.tstep{font:13px ui-monospace,monospace;color:#c9d1d9;margin-bottom:8px}.tstep #tstepn{color:#00b8b1;font-weight:600}.tstep #tstate{color:#8b949e;margin-left:8px}.tbtns{display:flex;gap:8px;margin-bottom:10px}.tbtn{background:#21262d;color:#e6edf3;border:1px solid #30363d;border-radius:7px;padding:6px 14px;font-size:16px;line-height:1;cursor:pointer}.tbtn:hover{border-color:#00b8b1;color:#00b8b1}.tbtn:active{transform:translateY(1px)}.tbtn.tstop{border-color:rgba(248,81,73,.5);color:#f85149}.tbtn.tstop:hover{background:rgba(248,81,73,.1)}.tbar{height:6px;background:#21262d;border-radius:3px;overflow:hidden}.tfill{height:100%;width:0;background:#00b8b1;transition:width .3s ease}-.say{margin:0 0 14px;padding:13px 14px;border:1px solid #30363d;border-left:3px solid #00b8b1;border-radius:9px;background:#161b22}-.chips{display:flex;flex-wrap:wrap;gap:8px;margin-top:10px}-.chip{display:inline-flex;align-items:center;gap:6px;border:1px solid #30363d;border-radius:999px;padding:5px 6px 5px 12px;background:#0d1117;color:#e6edf3;font:500 13.5px "Familjen Grotesk",system-ui,sans-serif}-.chip .ph{cursor:pointer}-.chip .ph:hover{color:#00b8b1}-.chip .try{background:transparent;color:#00b8b1;border:1px solid #00b8b1;border-radius:999px;padding:2px 9px;font-size:12px}-.chip .try:hover{background:#00b8b1;color:#04262a}-.chip.copied .ph{color:#3fb950}-pre{white-space:pre-wrap;background:#0d1117;border:1px solid #30363d;border-radius:7px;padding:11px;color:#c9d1d9;font-size:12.5px}+:root{--surface:#161b22;--surface2:#21262d;--line:#30363d;--teal:#00b8b1;--muted:#8b949e;--text:#e6edf3;--body:#c9d1d9;--led-green:#3fb950;--led-amber:#d29922;--led-red:#f85149;--sans:"Familjen Grotesk",system-ui,sans-serif;color-scheme:dark}+*{box-sizing:border-box}+body{margin:0;background:#0d1117;color:var(--text);font:14px/1.5 var(--sans)}+.wrap{max-width:820px;margin:0 auto;padding:0 20px 60px}+/* ── 44px three-zone header (dash-header) ── */+.hdr{height:44px;display:flex;align-items:center;gap:12px;border-bottom:1px solid var(--line);position:sticky;top:0;background:#0d1117;z-index:5}+.hdr .brand{display:flex;align-items:center;gap:9px;position:relative;cursor:default}+.hdr .bicon{width:18px;height:18px;border-radius:5px;background:var(--teal);display:flex;align-items:center;justify-content:center;flex:none}+.hdr .bicon svg{width:12px;height:12px}+.hdr .bname{font-weight:700;font-size:15px;letter-spacing:-.01em}+.hdr .bsub{color:var(--muted);font-size:12.5px}+.hdr .pop{position:absolute;top:34px;left:0;background:var(--surface);border:1px solid var(--line);border-radius:8px;padding:9px 12px;font-size:12px;color:var(--muted);white-space:nowrap;box-shadow:0 8px 24px rgba(0,0,0,.5);opacity:0;pointer-events:none;transition:opacity .12s ease .35s;z-index:9}+.hdr .brand:hover .pop{opacity:1}+.hdr .pop b{color:var(--text);font-weight:600}.hdr .pop a{color:var(--teal);text-decoration:none}+.hdr .sp{flex:1}+.hdr .target{display:inline-flex;align-items:center;gap:7px;background:var(--surface);border:1px solid var(--line);border-radius:999px;padding:3px 11px;font:12px ui-monospace,monospace;color:var(--body)}+.hdr .target .dot{width:8px;height:8px;border-radius:50%;background:var(--led-green)}+.hdr .target[data-state=bad] .dot{background:var(--led-red)}.hdr .target[data-state=busy] .dot{background:var(--led-amber)}+.hdr .ibtn{background:none;border:0;color:var(--muted);cursor:pointer;padding:6px;border-radius:6px;display:flex;align-items:center}+.hdr .ibtn:hover{color:var(--teal);background:var(--surface)}+.hdr .ibtn svg{width:18px;height:18px}+/* ── say strip (say-strip) ── */+.say{margin:18px 0 0;background:var(--surface);border:1px solid var(--line);border-left:3px solid var(--teal);border-radius:10px;padding:14px 16px;position:relative}+.say h2{font:600 15px var(--sans);margin:0}+.say .expl{color:var(--muted);font-size:13.5px;margin:4px 0 0}.say .expl b.noun{color:var(--teal);font-weight:700}+.say-x{position:absolute;top:10px;right:12px;background:none;border:0;color:var(--muted);font-size:18px;line-height:1;cursor:pointer}.say-x:hover{color:var(--text)}+.say-chips{display:flex;flex-wrap:wrap;gap:8px;margin-top:10px}+.say-chip{display:inline-flex;align-items:center;gap:8px;background:#0d1117;border:1px solid var(--line);border-radius:999px;padding:5px 6px 5px 12px;font-size:13.5px;cursor:pointer}+.say-chip em{color:var(--teal);font-style:normal}+.say-chip .copy,.say-chip .send{font:500 11px var(--body);background:none;border:1px solid var(--line);border-radius:999px;padding:2px 9px;cursor:pointer}+.say-chip .copy{color:var(--muted)}.say-chip .send{color:var(--teal);border-color:rgba(0,184,177,.45)}+.say-chip:hover .copy{color:var(--teal);border-color:var(--teal)}.say-chip .send:hover{background:rgba(0,184,177,.12)}+/* ── status pill row (status-leds) ── */+.leds{display:flex;flex-wrap:wrap;gap:8px;margin-top:14px}+.led-pill{display:inline-flex;align-items:center;gap:7px;background:var(--surface);border:1px solid var(--line);border-radius:999px;padding:4px 12px;font-size:12.5px;cursor:pointer;position:relative}+.led-pill .dot{width:8px;height:8px;border-radius:50%;background:#6e7681}+.led-pill[data-state=ok] .dot{background:var(--led-green)}.led-pill[data-state=busy] .dot{background:var(--led-amber)}.led-pill[data-state=bad] .dot{background:var(--led-red)}+.led-pill .st{color:var(--muted)}+.led-detail{position:absolute;top:28px;left:0;min-width:240px;max-width:360px;background:var(--surface);border:1px solid var(--line);border-radius:8px;padding:10px 12px;font-size:12.5px;color:var(--body);box-shadow:0 8px 24px rgba(0,0,0,.5);z-index:9;white-space:normal}+.led-detail .fix{color:var(--teal);margin-top:6px}+/* ── demo (dash-demo) ── */+.demo{margin-top:20px;padding:15px;border:1px solid var(--line);border-radius:10px;background:var(--surface)}+.demo .lbl{font-weight:600}.demo .det{color:var(--muted);font-size:13px;margin:4px 0 10px}+.demo button.play{background:var(--teal);color:#04262a;border:0;border-radius:7px;padding:9px 15px;font:600 14px var(--sans);cursor:pointer}+.demo button.play:disabled{opacity:.5;cursor:default}+.transport{margin-top:12px;padding:12px;border:1px solid var(--line);border-radius:9px;background:#0d1117}+.tstep{font:13px ui-monospace,monospace;color:var(--body);margin-bottom:8px}.tstep #tstepn{color:var(--teal);font-weight:600}.tstep #tstate{color:var(--muted);margin-left:8px}+.tbtns{display:flex;gap:8px;margin-bottom:10px;align-items:center}+.tbtn{background:var(--surface2);color:var(--text);border:1px solid var(--line);border-radius:7px;padding:6px 14px;font-size:16px;line-height:1;cursor:pointer}+.tbtn:hover{border-color:var(--teal);color:var(--teal)}.tbtn:active{transform:translateY(1px)}+.tbtn.texit{border-color:rgba(248,81,73,.5);color:var(--led-red)}.tbtn.texit:hover{background:rgba(248,81,73,.1)}+.tsep{width:1px;height:20px;background:var(--line);margin:0 2px}+.tbar{height:6px;background:var(--surface2);border-radius:3px;overflow:hidden}.tfill{height:100%;width:0;background:var(--teal);transition:width .3s ease}+/* ── toast (toasts) ── */+#toast{position:fixed;bottom:24px;left:50%;transform:translateX(-50%);display:flex;flex-direction:column;gap:8px;align-items:center;z-index:99}+.toast{background:var(--surface2);border:1px solid var(--line);border-left:3px solid var(--teal);color:var(--body);border-radius:8px;padding:9px 15px;font-size:13px;box-shadow:0 8px 24px rgba(0,0,0,.5);opacity:1;transition:opacity .3s}+.toast.success{border-left-color:var(--led-green)}.toast.error{border-left-color:var(--led-red)}.toast.out{opacity:0}+.hidden{display:none!important}+@media(max-width:480px){.hdr .bsub{display:none}} </style> <div class="wrap">- <h1><span class="dot" id="top"></span>pup</h1>- <p class="sub" id="topdetail">loading…</p>- <div class="say">- <div class="lbl">Say it</div>- <div class="det">pup is the AI's own browser on your desktop. Ask for it by name. Click a phrase to copy it, or Try it to stage it in your pup thread.</div>- <div id="saylist"></div>- <div class="det" id="saynote" hidden></div>- </div>- <div id="leds"></div>- <div class="demo">+ <header class="hdr">+ <div class="brand" tabindex="0">+ <span class="bicon"><svg viewBox="0 0 24 24" fill="#04262a"><circle cx="8" cy="9" r="3"/><circle cx="15" cy="14" r="4"/></svg></span>+ <span class="bname">Pup</span>+ <span class="bsub">AI browser control</span>+ <div class="pop" id="pop">loading…</div>+ </div>+ <div class="sp"></div>+ <div class="target" id="target" hidden><span class="dot"></span><span id="targetbox">…</span></div>+ <button class="ibtn" id="recall" title="Show getting-started help" aria-label="Show help">+ <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="10"/><path d="M9.09 9a3 3 0 0 1 5.83 1c0 2-3 3-3 3"/><line x1="12" y1="17" x2="12.01" y2="17"/></svg>+ </button>+ <button class="ibtn" id="gear" title="Settings" aria-label="Settings">+ <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="3"/><path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 1 1-2.83 2.83l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 1 1-4 0v-.09a1.65 1.65 0 0 0-1-1.51 1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 1 1-2.83-2.83l.06-.06a1.65 1.65 0 0 0 .33-1.82 1.65 1.65 0 0 0-1.51-1H3a2 2 0 1 1 0-4h.09a1.65 1.65 0 0 0 1.51-1 1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 1 1 2.83-2.83l.06.06a1.65 1.65 0 0 0 1.82.33h0a1.65 1.65 0 0 0 1-1.51V3a2 2 0 1 1 4 0v.09a1.65 1.65 0 0 0 1 1.51h0a1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 1 1 2.83 2.83l-.06.06a1.65 1.65 0 0 0-.33 1.82v0a1.65 1.65 0 0 0 1.51 1H21a2 2 0 1 1 0 4h-.09a1.65 1.65 0 0 0-1.51 1z"/></svg>+ </button>+ </header>++ <section class="say" id="say">+ <button class="say-x" id="sayx" aria-label="Collapse">×</button>+ <h2>Say it</h2>+ <p class="expl">You never need this page’s buttons: just tell your AI what you want, using the word <b class="noun">pup</b>. Copy a phrase, or send it straight to a new AI thread.</p>+ <div class="say-chips" id="saychips"></div>+ </section>++ <div class="leds" id="leds"></div>++ <div class="demo" id="demo-card"> <div class="lbl">See what pup can do</div>- <div class="det">A guided, narrated tour on your desktop (spoken and captioned): background window, taskbar tile and progress bar, screenshot, annotation, jump list. It plays on whichever desktop you are actively using, and you can pause, skip, or stop it any time.</div>- <p><button id="demo">Run the demo</button></p>+ <div class="det">A guided, narrated tour on your desktop (spoken and captioned): background window, taskbar tile and progress bar, screenshot, annotation, jump list. It plays on whichever desktop you are actively using.</div>+ <button class="play" id="demo">Play the demo</button> <div id="transport" class="transport" hidden> <div class="tstep"><span id="tstepn">1/11</span> <span id="tsteptitle">Intro</span> <span id="tstate"></span></div> <div class="tbtns"> <button class="tbtn" id="tprev" title="previous step">⏮</button> <button class="tbtn" id="tplay" title="pause / resume">⏸</button> <button class="tbtn" id="tnext" title="next step">⏭</button>- <button class="tbtn tstop" id="tstop" title="stop the demo">⏹</button>+ <span class="tsep"></span>+ <button class="tbtn" id="tmute" title="mute / unmute narration">🔊</button>+ <span class="tsep"></span>+ <button class="tbtn texit" id="texit" title="exit the demo">✕</button> </div> <div class="tbar"><div id="tfill" class="tfill"></div></div> </div>- <pre id="out" hidden></pre> </div> </div>+<div id="toast"></div> <script>+var SAY=__SAY__;+function toast(kind,msg){var w=document.getElementById('toast');var t=document.createElement('div');t.className='toast '+(kind||'');t.textContent=msg;w.appendChild(t);setTimeout(function(){t.classList.add('out');setTimeout(function(){t.remove();},300);},4000);}+function legacyCopy(text){var ta=document.createElement('textarea');ta.value=text;ta.style.cssText='position:fixed;opacity:0';document.body.appendChild(ta);ta.select();var ok=false;try{ok=document.execCommand('copy');}catch(e){}ta.remove();return ok;}+function copyPhrase(text){var done=function(){toast('success','Copied to clipboard. Paste it into any AI thread.');};if(document.hasFocus&&document.hasFocus()){navigator.clipboard.writeText(text).then(done,function(){if(legacyCopy(text))done();});}else if(legacyCopy(text))done();}+// ── say strip ──+(function(){+ var chips=document.getElementById('saychips');+ chips.innerHTML=SAY.map(function(p){var q=p.replace(/\bpup\b/,'<em>pup</em>');return '<span class="say-chip" data-say="'+p.replace(/"/g,'"')+'">“'+q+'” <button class="copy">Copy</button><button class="send">Send to AI</button></span>';}).join('');+ chips.querySelectorAll('.say-chip').forEach(function(c){+ c.addEventListener('click',function(ev){ if(ev.target.closest('.send'))return; copyPhrase(c.dataset.say); });+ c.querySelector('.send').addEventListener('click',async function(ev){+ ev.stopPropagation(); var b=ev.target; b.disabled=true; b.textContent='…';+ var r=await fetch('api/say-send',{method:'POST',headers:{'content-type':'application/json'},body:JSON.stringify({phrase:c.dataset.say})}).then(function(x){return x.json();}).catch(function(){return null;});+ b.disabled=false; b.textContent='Send to AI';+ if(r&&r.ok) toast('success','Sent to a new AI thread.');+ else { copyPhrase(c.dataset.say); toast('error',(r&&r.why)||'Could not reach the AI panel; copied instead.'); }+ });+ });+ document.getElementById('sayx').onclick=function(){ document.getElementById('say').classList.add('hidden'); try{localStorage.setItem('pupSayHidden','1');}catch(e){} };+ try{ if(localStorage.getItem('pupSayHidden')==='1') document.getElementById('say').classList.add('hidden'); }catch(e){}+ document.getElementById('recall').onclick=function(){ document.getElementById('say').classList.remove('hidden'); try{localStorage.removeItem('pupSayHidden');}catch(e){} };+ document.getElementById('gear').onclick=function(){ fetch('api/open-settings',{method:'POST'}).then(function(){toast('','Opening pup settings on your desktop…');}).catch(function(){}); };+})();+// ── status: header meta + pill row ──+function ledState(s){return s==='ok'?'ok':(s==='fail'||s==='bad'?'bad':(s==='warn'||s==='busy'?'busy':'unknown'));} async function load(){ try{- const r = await fetch('api/status',{cache:'no-store'}); const d = await r.json();- document.getElementById('top').className='dot '+d.state;- document.getElementById('topdetail').textContent=d.detail+(d.version?' · pup '+d.version:'');- document.getElementById('leds').innerHTML=(d.leds||[]).map(function(l){- return '<div class="led"><span class="d '+l.state+'"></span><div><div class="lbl">'+l.label+'</div>'- +'<div class="det">'+(l.detail||'')+'</div>'- +(l.state!=='ok'&&l._hint?'<div class="hint">'+l._hint+'</div>':'')+'</div></div>';+ var d=await (await fetch('api/status',{cache:'no-store'})).json();+ // header popover (versions live here, never in the bar)+ document.getElementById('pop').innerHTML='<b>pup</b> '+(d.version||'?')+' · dashboard '+(d.dashboardVersion||'?')+'<br><a href="https://wiki.adom.inc/adom/pup-bridge" target="_blank">wiki.adom.inc/adom/pup-bridge</a>';+ var tgt=document.getElementById('target');+ if(d.desktop){ tgt.hidden=false; document.getElementById('targetbox').textContent='target: '+d.desktop; tgt.setAttribute('data-state',ledState(d.state)); }+ var leds=d.leds||[];+ document.getElementById('leds').innerHTML=leds.map(function(l,i){+ var st=ledState(l.state);+ var short=(l.detail||'').split(/[.—(]/)[0].trim().slice(0,26);+ return '<span class="led-pill" data-state="'+st+'" data-i="'+i+'"><span class="dot"></span><b>'+l.label+'</b> <span class="st">'+short+'</span></span>'; }).join('');- }catch(e){ document.getElementById('topdetail').textContent='status unavailable: '+e; }+ document.querySelectorAll('.led-pill').forEach(function(pill){+ pill.onclick=function(){+ document.querySelectorAll('.led-detail').forEach(function(e){e.remove();});+ var l=leds[+pill.dataset.i]; if(!l)return;+ var d2=document.createElement('div'); d2.className='led-detail';+ var body=(l.detail||'no detail');+ if(l.state!=='ok'&&l._hint) body+='<div class="fix">'+l._hint+'</div>';+ d2.innerHTML=body; pill.appendChild(d2);+ setTimeout(function(){document.addEventListener('click',function h(e){ if(!d2.contains(e.target)&&e.target!==pill){ d2.remove(); document.removeEventListener('click',h);} });},0);+ };+ });+ }catch(e){} }+// ── demo transport (dash-demo) ── (function(){- const say=__SAY__; const list=document.getElementById('saylist'), note=document.getElementById('saynote');- list.innerHTML='<div class="chips">'+say.map(function(p,i){return '<span class="chip" data-i="'+i+'"><span class="ph" title="click to copy">“'+p+'”</span><button class="try" data-i="'+i+'" title="stage this phrase in your pup AI thread (not sent until you press Enter)">Try it</button></span>';}).join('')+'</div>';- list.addEventListener('click',async function(ev){- const ph=ev.target.closest('.ph'); const tr=ev.target.closest('.try');- if(ph){const chip=ph.closest('.chip'); const p=say[+chip.dataset.i]; try{await navigator.clipboard.writeText(p);}catch(e){} chip.classList.add('copied'); ph.textContent='copied'; setTimeout(function(){chip.classList.remove('copied'); ph.textContent='“'+p+'”';},1200); return;}- if(tr){tr.disabled=true; tr.textContent='…'; note.hidden=false; note.textContent='staging…';- try{const r=await fetch('api/try',{method:'POST',headers:{'content-type':'application/json'},body:JSON.stringify({phrase:say[+tr.dataset.i]})}); const d=await r.json();- note.textContent=d.ok?('Staged in your '+(d.where||'pup thread')+': “'+d.staged+'”. Press Enter there to send it.'):(d.error||'could not stage');}- catch(e){note.textContent='could not reach the dashboard: '+e;}- tr.disabled=false; tr.textContent='Try it';}- });-})();-(function(){- const demoBtn=document.getElementById('demo'), tp=document.getElementById('transport');- const ctl=(action)=>fetch('api/demo/control',{method:'POST',headers:{'content-type':'application/json'},body:JSON.stringify({action})}).catch(function(){});- document.getElementById('tprev').onclick=()=>ctl('prev');- document.getElementById('tnext').onclick=()=>ctl('next');- document.getElementById('tstop').onclick=()=>ctl('stop');- let paused=false;- document.getElementById('tplay').onclick=()=>{ paused=!paused; ctl(paused?'pause':'resume'); };- let poll=null;- function stopPoll(){ if(poll){clearInterval(poll);poll=null;} tp.hidden=true; demoBtn.disabled=false; demoBtn.textContent='Run the demo'; }+ var demoBtn=document.getElementById('demo'), tp=document.getElementById('transport');+ var ctl=function(action){return fetch('api/demo/control',{method:'POST',headers:{'content-type':'application/json'},body:JSON.stringify({action:action})}).catch(function(){});};+ document.getElementById('tprev').onclick=function(){ctl('prev');};+ document.getElementById('tnext').onclick=function(){ctl('next');};+ document.getElementById('texit').onclick=function(){ctl('stop');};+ var paused=false;+ document.getElementById('tplay').onclick=function(){ paused=!paused; ctl(paused?'pause':'resume'); };+ var muted=false; try{muted=localStorage.getItem('pupDemoMuted')==='1';}catch(e){}+ var mb=document.getElementById('tmute'); mb.innerHTML=muted?'🔇':'🔊';+ mb.onclick=function(){ muted=!muted; try{localStorage.setItem('pupDemoMuted',muted?'1':'0');}catch(e){} mb.innerHTML=muted?'🔇':'🔊'; ctl(muted?'mute':'unmute'); };+ var poll=null;+ function stopPoll(){ if(poll){clearInterval(poll);poll=null;} tp.hidden=true; demoBtn.disabled=false; demoBtn.textContent='Play the demo'; } async function tick(){ try{- const d=await (await fetch('api/demo/state',{cache:'no-store'})).json();+ var d=await (await fetch('api/demo/state',{cache:'no-store'})).json(); if(!d.running){ stopPoll(); return; }- tp.hidden=false; demoBtn.disabled=true; demoBtn.textContent='Playing on '+(d.target||'your desktop')+'\u2026';+ tp.hidden=false; demoBtn.disabled=true; demoBtn.textContent='Playing on '+(d.target||'your desktop')+'…'; document.getElementById('tstepn').textContent=(d.idx+1)+'/'+d.total; document.getElementById('tsteptitle').textContent=d.title||''; paused=!!d.paused;- document.getElementById('tplay').innerHTML=paused?'\u25B6':'\u23F8';+ document.getElementById('tplay').innerHTML=paused?'▶':'⏸'; document.getElementById('tstate').textContent=paused?'(paused)':''; document.getElementById('tfill').style.width=Math.round(100*(d.idx+1)/(d.total||1))+'%'; }catch(e){} } demoBtn.onclick=async function(){- demoBtn.disabled=true; demoBtn.textContent='Starting\u2026';- try{ await fetch('api/demo/start',{method:'POST'}); }catch(e){}+ demoBtn.disabled=true; demoBtn.textContent='Starting…';+ var mm=false; try{mm=localStorage.getItem('pupDemoMuted')==='1';}catch(e){}+ try{ await fetch('api/demo/start',{method:'POST',headers:{'content-type':'application/json'},body:JSON.stringify({muted:mm})}); }catch(e){} if(poll) clearInterval(poll); poll=setInterval(tick,700); tick(); };- // attach to a demo that is running (or starts) regardless of who launched it: a light 3s heartbeat- // flips on the fast poller whenever a demo is live, so the transport bar always appears.- setInterval(function(){ if(poll) return; fetch('api/demo/state',{cache:'no-store'}).then(r=>r.json()).then(d=>{ if(d.running){ poll=setInterval(tick,700); tick(); } }).catch(function(){}); }, 3000);+ setInterval(function(){ if(poll) return; fetch('api/demo/state',{cache:'no-store'}).then(function(r){return r.json();}).then(function(d){ if(d.running){ poll=setInterval(tick,700); tick(); } }).catch(function(){}); }, 3000); })(); load(); setInterval(load,15000); </script>`;@@ -444,12 +521,13 @@ function playLine(text) { // play the (already-rendered) mp3 on the shared pla }); } -async function runDemoControllable() {+async function runDemoControllable(opts) { const sid = 'pup-dock-demo';+ const startMuted = !!(opts && opts.muted); const sleep = (ms) => new Promise(r => setTimeout(r, ms)); const tgt = await demoTarget(); const steps = demoSteps(sid);- demo = { running: true, paused: false, idx: 0, total: steps.length, title: steps[0].title, target: tgt.name, abort: false, jump: null, log: [], startedAt: Date.now() };+ demo = { running: true, paused: false, idx: 0, total: steps.length, title: steps[0].title, target: tgt.name, abort: false, jump: null, log: [], startedAt: Date.now(), muted: startMuted }; await preRenderDemoAudio(); // fast if already warm const store = {}; const capId = 'pup-demo';@@ -471,11 +549,9 @@ async function runDemoControllable() { demo.title = step.title; demo.jump = null; // narrate: caption + spoken audio together (the pre-rendered mp3). The audio is RACED against an // abort/jump watcher so Stop and Next feel instant: if either fires, kill the clip and move on.- ab('desktop_caption', { text: step.say, duration: Math.min(12000, Math.max(2500, 900 + step.say.split(/\s+/).length * 350)), id: capId, position: 'bottom', size: 'medium' }, 8000, tgt.name).catch(() => {});- await Promise.race([- playLine(step.say),- (async () => { while (!demo.abort && demo.jump == null) await sleep(120); stopDemoAudio(); })(),- ]);+ ab('desktop_caption', { text: step.say, duration: Math.min(12000, Math.max(2500, 900 + step.say.split(/\s+/).length * 350)), id: capId }, 8000, tgt.name).catch(() => {}); // bridge default size/position (dash-demo fleet ruling)+ if (demo.muted) { await Promise.race([ sleep(Math.min(6000, Math.max(1500, step.say.split(/\s+/).length * 240))), (async () => { while (!demo.abort && demo.jump == null) await sleep(120); })() ]); }+ else { await Promise.race([ playLine(step.say), (async () => { while (!demo.abort && demo.jump == null) await sleep(120); stopDemoAudio(); })() ]); } if (!demo.abort && demo.jump == null) { try { await step.run(ctx); } catch (e) { demo.log.push('step ' + stepBanner() + ' failed: ' + e.message); } } if (demo.abort) break; if (demo.jump != null) { demo.idx = Math.max(0, Math.min(steps.length - 1, demo.jump)); demo.jump = null; }@@ -484,14 +560,14 @@ async function runDemoControllable() { const aborted = demo.abort; // always clean up the demo window and any lit progress bar try { await ab('pup_close_window', { sessionId: sid }, 20000, tgt.name); } catch (e) {}- try { await ab('desktop_caption', { text: aborted ? 'pup demo stopped.' : 'pup demo done. Say, show me in pup, to any Adom AI.', duration: 3000, id: capId, position: 'bottom', size: 'medium' }, 8000, tgt.name); } catch (e) {}+ try { await ab('desktop_caption', { text: aborted ? 'pup demo stopped.' : 'pup demo done. Say, show me in pup, to any Adom AI.', duration: 3000, id: capId }, 8000, tgt.name); } catch (e) {} demo.running = false; return { done: true, aborted, log: demo.log }; } function demoState() { if (!demo) return { running: false };- return { running: demo.running, paused: demo.paused, idx: demo.idx, total: demo.total, title: demo.title, target: demo.target, startedAt: demo.startedAt, log: demo.log.slice(-8) };+ return { running: demo.running, paused: demo.paused, muted: demo.muted, idx: demo.idx, total: demo.total, title: demo.title, target: demo.target, startedAt: demo.startedAt, log: demo.log.slice(-8) }; } function demoControl(action) { if (!demo || !demo.running) return { ok: false, error: 'no demo is running' };@@ -500,6 +576,8 @@ function demoControl(action) { else if (action === 'next') { demo.jump = Math.min(demo.total - 1, demo.idx + 1); demo.paused = false; } else if (action === 'prev') { demo.jump = Math.max(0, demo.idx - 1); demo.paused = false; } else if (action === 'stop') { demo.abort = true; demo.paused = false; try { stopDemoAudio(); } catch (e) {} }+ else if (action === 'mute') { demo.muted = true; try { stopDemoAudio(); } catch (e) {} }+ else if (action === 'unmute') { demo.muted = false; } if (action === 'next' || action === 'prev') { try { stopDemoAudio(); } catch (e) {} } else return { ok: false, error: 'unknown action' }; return { ok: true, ...demoState() };@@ -575,8 +653,9 @@ async function serve() { } if (u.endsWith('/api/demo/start') && req.method === 'POST') { if (demo && demo.running) { res.writeHead(200, { 'content-type': 'application/json', ...CORS }); return res.end(JSON.stringify({ started: false, alreadyRunning: true, ...demoState() })); }- runDemoControllable().catch((e) => { if (demo) demo.log.push('runner crashed: ' + e.message); if (demo) demo.running = false; });- res.writeHead(200, { 'content-type': 'application/json', ...CORS }); return res.end(JSON.stringify({ started: true, total: (demo && demo.total) || 0 }));+ let body = ''; req.on('data', d => { body += d; });+ req.on('end', () => { let muted = false; try { muted = !!JSON.parse(body || '{}').muted; } catch (e) {} runDemoControllable({ muted }).catch((e) => { if (demo) demo.log.push('runner crashed: ' + e.message); if (demo) demo.running = false; }); res.writeHead(200, { 'content-type': 'application/json', ...CORS }); res.end(JSON.stringify({ started: true })); });+ return; } if (u.endsWith('/api/demo/control') && req.method === 'POST') { let body = ''; req.on('data', d => { body += d; });@@ -591,16 +670,21 @@ async function serve() { res.writeHead(200, { 'content-type': 'application/json', ...CORS }); return res.end(JSON.stringify({ lines })); }- if (u.endsWith('/api/try') && req.method === 'POST') {+ if ((u.endsWith('/api/say-send') || u.endsWith('/api/try')) && req.method === 'POST') { let body = ''; req.on('data', d => { body += d; }); req.on('end', async () => { let phrase = ''; try { phrase = String(JSON.parse(body).phrase || ''); } catch (e) {}- const out = await stagePhrase(phrase);+ const out = await sendToNewThread(phrase); res.writeHead(200, { 'content-type': 'application/json', ...CORS }); res.end(JSON.stringify(out)); }); return; }+ if (u.endsWith('/api/open-settings') && req.method === 'POST') {+ const tgt = await resolveTarget();+ ab('pup_dashboard', {}, 15000, tgt.name).catch(() => {});+ res.writeHead(200, { 'content-type': 'application/json', ...CORS }); return res.end(JSON.stringify({ ok: true }));+ } if (u.endsWith('/api/say')) { res.writeHead(200, { 'content-type': 'application/json', 'cache-control': 'no-store', ...CORS }); return res.end(JSON.stringify({ say: SAY }));