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.336",+ "version": "2.0.337", "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.336",+ "version": "2.0.337", "tags": [ "skills", "pup",
tools/pup-dashboard+21−337@@ -168,7 +168,18 @@ function instanceVersion(port) { // readiness gate consumes this URL from a local WSL2 workspace with mirrored networking, // where the 'localhost' NAME resolves IPv6-first and costs ~800ms; 127.0.0.1 is instant. // The :7380/proxy/<port>/ hop is unnecessary for a local consumer.-const PROXY = (port) => `http://127.0.0.1:${port}/`;+// THE dashboard URL (v2.0.337). John: "we can't have pup having 2 different dashboards."+// This helper used to SERVE its own launch page and hand the dock that URL, so the dock opened a+// header, six prompts and four pills while pup's real UI, the live-windows dashboard with the+// taskbar strip, window tree, icon provenance and ownership badges, sat undiscovered on the+// bridge's own port. The species rule is that a full app's MAIN UI is the dock target, so the dock+// now gets the bridge's dashboard and this process serves no page at all.+// The bridge listens on the DESKTOP; his container reaches it fine (measured: curl from inside the+// container to 127.0.0.1:8851/health returned 200 under mirrored networking), and so does the+// Hydrogen shell, so ONE url works for the webview AND for the dock's health poll.+const BRIDGE_PORT = Number(process.env.PUP_BRIDGE_PORT || 8851);+const BRIDGE_DASHBOARD = `http://127.0.0.1:${BRIDGE_PORT}/dashboard`;+const PROXY = () => BRIDGE_DASHBOARD; // ── the box's pup bridge, via the ab CLI ──────────────────────────────────────────────────────── // Which desktop are we reporting on? A target-less call FAILS outright when more than one Adom@@ -306,340 +317,11 @@ async function buildStatus() { return body; } -// ── page ────────────────────────────────────────────────────────────────────────────────────────-// NOTE: PAGE is a TEMPLATE LITERAL. Every regex escape inside it must be DOUBLED (\\b, \\d,-// \\s), or JS consumes them as string escapes before the browser ever sees the regex: \\b becomes a-// backspace char, \\d becomes 'd', \\s becomes 's'. That shipped live (2.0.326-2.0.329): the status-// pills rendered 'pup 2' / 'in talled and pinned' and the say-chip teal noun highlight never applied.-// NOTE: PAGE is a TEMPLATE LITERAL. Every regex escape inside it must be DOUBLED (\\b, \\d,-// \\s), or JS consumes them as string escapes before the browser sees the regex: \b becomes a-// backspace char, \d becomes 'd', \s becomes 's'. That shipped live (2.0.326-2.0.329): the status-// pills rendered 'pup 2' / 'in talled and pinned' and the say-chip teal noun highlight never applied.-// The app mark below is the SAME artifact as docs/img/dock-pup.svg (webview.favicon), inlined at-// 18x18 per dash-header; if you tune one, tune the file and re-verify BOTH 16px tab and 18px header.-const PAGE = `<!doctype html><meta charset="utf-8"><title>Pup</title>-<style>-:root{--surface:#161b22;--surface2:#21262d;--line:#30363d;--teal:#00b8b1;--on-accent:#04262a;--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)}-/* ── header: 44px, EDGE TO EDGE, padding inside the bar (dash-header) ── */-.hdr{height:44px;display:flex;align-items:center;padding:0 16px;background:var(--surface);border-bottom:1px solid var(--line);position:sticky;top:0;z-index:20}-.hdr .brand{display:flex;align-items:center;position:relative;cursor:default}-.hdr .mark{width:18px;height:18px;flex:none;color:var(--text);display:flex}-.hdr .mark svg{width:18px;height:18px;display:block}-.hdr .bname{font-family:var(--sans);font-weight:600;font-size:15px;color:var(--text);letter-spacing:-.01em;margin-left:9px}-.hdr .bsub{font-size:13px;font-weight:400;color:var(--muted);margin-left:8px}-.hdr .pop{position:absolute;top:36px;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 .6s;z-index:30}-.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 .right{margin-left:auto;display:flex;align-items:center;gap:6px}-.chip{display:inline-flex;align-items:center;gap:7px;border:1px solid var(--line);border-radius:999px;padding:3px 10px;font:12px ui-monospace,monospace;color:var(--muted)}-.chip b{color:var(--text);font-weight:400}-.chip .dot{width:8px;height:8px;border-radius:50%;background:#6e7681}-.chip[data-state=ok] .dot{background:var(--led-green)}.chip[data-state=busy] .dot{background:var(--led-amber)}.chip[data-state=bad] .dot{background:var(--led-red)}-.ibtn{width:28px;height:28px;display:flex;align-items:center;justify-content:center;background:none;border:1px solid transparent;border-radius:6px;color:var(--muted);cursor:pointer;padding:0}-.ibtn svg{width:18px;height:18px}-.ibtn.busy{opacity:.55;pointer-events:none;animation:ibtnpulse 1.1s ease-in-out infinite}-@keyframes ibtnpulse{0%,100%{opacity:.55}50%{opacity:.9}}-.ibtn:hover{color:var(--teal);border-color:var(--line);background:rgba(0,184,177,.08)}-.ibtn:focus-visible{outline:2px solid var(--teal);outline-offset:1px}-/* ── page body ── */-.wrap{margin:0 auto;padding:20px 20px 60px}-/* NO MAX-WIDTH on the layout (adom-ui-design 1.17.0, John saw the dead band on the right when he- maximised the browser: "its like they hard coded their max width"). A dashboard is an APP- SURFACE, not an article: it fills whatever it is given, a 700px webview pane or a 2500px- maximised window, and extra width becomes more room for the prompts. The reading measure that- prose genuinely needs is capped on the PARAGRAPH instead (.expl max-width:70ch), which is the- one place a measure belongs. The only other caps left in this sheet are .led-detail's popover- box and the two breakpoint media queries, neither of which constrains the page. */-/* item 6 (John, 2026-08-24: the collapsed gap did not match the open one). The CONTENT REGION- owns the page padding, NOT the newbie panel. Before, .wrap had 0 top padding and the panel's- own margin-top:16px supplied the gap under the header -- so the closed rule display:none- took that margin with it and the next block sat FLUSH against the header. Now the 20px lives- on .wrap, where it is identical whether the panel is open or closed. */-/* ── newbie panel: ONE full-width band, TWO columns ── */-.newbie{background:var(--surface);border:1px solid var(--line);border-left:3px solid var(--teal);border-radius:12px;padding:18px 20px 20px;margin:0 0 20px;position:relative}-.newbie h2{font:600 15px var(--sans);color:var(--text);margin:0}-.newbie .expl{font-size:13.5px;color:var(--muted);margin:4px 0 0;max-width:70ch}-.newbie .expl b.noun{color:var(--teal);font-weight:700}-.nb-x{position:absolute;top:12px;right:14px;width:28px;height:28px;display:flex;align-items:center;justify-content:center;background:none;border:1px solid transparent;border-radius:6px;color:var(--muted);font-size:17px;line-height:1;cursor:pointer}-.nb-x:hover{color:var(--teal);border-color:var(--line)}-.nb-cols{display:grid;grid-template-columns:220px 1fr;gap:24px;align-items:start;margin-top:14px}-.nb-right{border-left:1px solid var(--line);padding-left:24px}-.play{display:inline-flex;align-items:center;gap:8px;background:var(--teal);color:var(--on-accent);border:0;border-radius:8px;padding:10px 18px;font:600 14px var(--sans);cursor:pointer}-.play svg{width:16px;height:16px}-.play:disabled{opacity:.55;cursor:default}-.play-sub{font-size:12px;color:var(--muted);margin-top:8px}-/* prompts TABLE, not pills */-table.prompts{width:100%;border-collapse:collapse;table-layout:fixed}-table.prompts tr{height:40px;border-bottom:1px solid var(--line);cursor:pointer;display:grid;grid-template-columns:minmax(0,max-content) 72px;justify-content:start;align-items:center}-/* The TABLE still spans the full width; the ROW packs its content to the left so Copy and- Send sit just after the text they act on. Without this, deleting the layout cap put the- icons ~2000px from their phrase at a 2500px viewport (measured). minmax(0,max-content),- not plain max-content: the 0 floor lets the phrase cell shrink below its natural width- at narrow viewports so the ellipsis still works, while max-content stops it from- stretching to the far edge when there is room to spare. */-table.prompts tr:last-child{border-bottom:0}-table.prompts tr:hover{background:rgba(255,255,255,.03)}-table.prompts td.phrase{font-size:14px;color:var(--text);white-space:nowrap;overflow:hidden;text-overflow:ellipsis;padding-right:12px}-table.prompts td.phrase em{color:var(--teal);font-style:normal}-table.prompts td.acts{width:72px;text-align:right;white-space:nowrap;justify-self:end}-table.prompts td.acts .ibtn{display:inline-flex;vertical-align:middle;margin-left:6px}-.newbie.closed{display:none}-/* 1.13.0: the panel FULLY closes (no residual one-liner) and the app UI moves up to fill the space;- the header button is the only way back, and it is a real toggle. */-.ibtn[aria-pressed=true]{color:var(--teal);background:rgba(0,184,177,.10);border-color:var(--line);box-shadow:inset 0 1px 2px rgba(0,0,0,.35)}-/* ── status pills ── */-.leds{display:flex;flex-wrap:wrap;gap:8px;margin-bottom:20px}-.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;text-align:left}-.led-detail .fix{color:var(--teal);margin-top:6px}-/* ── demo transport ── */-/* 1.13.0: the demo toolbar lives INSIDE the panel, under the Play button, never floating bottom-centre.- Body face throughout (NO monospace anywhere in the demo UI); tabular-nums so 3/11 does not jitter. */-.transport{margin-top:12px}-.tstep{font:12.5px var(--sans);font-variant-numeric:tabular-nums;color:var(--body);margin-bottom:8px}-.tstep #tstepn{color:var(--teal);font-weight:600;font-variant-numeric:tabular-nums}-.tstep #tsteptitle{color:var(--body)}.tstep #tstate{color:var(--muted);margin-left:6px}-.tbtns{display:flex;gap:6px;margin-bottom:10px;align-items:center}-.tbtn{width:28px;height:28px;display:flex;align-items:center;justify-content:center;background:none;border:1px solid transparent;border-radius:6px;color:var(--muted);cursor:pointer;padding:0}-.tbtn svg{width:16px;height:16px}-.tbtn:hover{color:var(--teal);border-color:var(--line);background:rgba(0,184,177,.08)}-.tbtn:active{transform:translateY(1px)}-.tbtn:focus-visible{outline:2px solid var(--teal);outline-offset:1px}-.tbtn.texit:hover{color:var(--led-red);border-color:rgba(248,81,73,.5);background:rgba(248,81,73,.1)}-.tsep{width:1px;height:18px;background:var(--line);margin:0 3px}-.tbar{height:6px;background:var(--surface2);border-radius:3px;overflow:hidden}.tfill{height:100%;width:0;background:var(--teal);transition:width .3s ease}-/* ── tooltip + toast ── */-#tip{position:fixed;background:var(--surface2);border:1px solid var(--line);border-radius:6px;padding:5px 9px;font-size:12px;color:var(--body);pointer-events:none;z-index:99;box-shadow:0 6px 18px rgba(0,0,0,.5)}-#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);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:760px){.nb-cols{grid-template-columns:1fr}.nb-right{border-left:0;border-top:1px solid var(--line);padding-left:0;padding-top:16px}.play{width:100%;justify-content:center}}-@media(max-width:720px){.hdr .bsub{display:none}}-</style>-<header class="hdr">- <div class="brand" tabindex="0">- <span class="mark"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><rect x="2.75" y="4.5" width="18.5" height="15" rx="2.5"/><path d="M2.75 8.75h18.5"/><circle cx="12" cy="14.2" r="3.1"/></svg></span>- <span class="bname">Pup</span>- <span class="bsub">AI browser control</span>- <div class="pop" id="pop">loading…</div>- </div>- <div class="right">- <span class="chip" id="target" hidden><span class="dot"></span><span id="targetbox">…</span></span>- <button class="ibtn" id="recall" aria-pressed="true" aria-label="Hide the prompts panel" data-tip="Hide the prompts panel">- <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="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" aria-label="Settings" data-tip="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 0 1-4 0v-.09A1.65 1.65 0 0 0 9 19.4a1.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 0 1 0-4h.09A1.65 1.65 0 0 0 4.6 9a1.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.33H9a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 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.82V9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 0 4h-.09a1.65 1.65 0 0 0-1.51 1z"/></svg>- </button>- </div>-</header>-<div class="wrap">- <section class="newbie" id="newbie">- <button class="nb-x" id="nbx" aria-label="Collapse this panel" data-tip="Collapse this panel">×</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="nb-cols">- <div class="nb-left">- <button class="play" id="demo">- <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><path d="M7 4.8v14.4a.6.6 0 0 0 .92.5l11.3-7.2a.6.6 0 0 0 0-1l-11.3-7.2a.6.6 0 0 0-.92.5z"/></svg>- <span id="demolbl">Play the demo</span>- </button>- <div class="play-sub" id="demosub">11 steps, about 2 minutes</div>- <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" aria-label="Previous step" data-tip="Previous step"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><path d="M18.5 5.5v13L9 12z"/><path d="M6 5.5v13"/></svg></button>- <button class="tbtn" id="tplay" aria-label="Pause" data-tip="Pause"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><path d="M9 5.5v13"/><path d="M15 5.5v13"/></svg></button>- <button class="tbtn" id="tnext" aria-label="Next step" data-tip="Next step"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><path d="M5.5 5.5v13L15 12z"/><path d="M18 5.5v13"/></svg></button>- <span class="tsep"></span>- <button class="tbtn" id="tmute" aria-label="Mute the narration" data-tip="Mute the narration"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><path d="M4 9.5v5h3.5L12 18.5v-13L7.5 9.5z"/><path d="M16 9.8a3 3 0 0 1 0 4.4"/><path d="M18.6 7.4a6.5 6.5 0 0 1 0 9.2"/></svg></button>- <span class="tsep"></span>- <button class="tbtn texit" id="texit" aria-label="Exit the demo" data-tip="Exit the demo"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><path d="M18 6 6 18"/><path d="m6 6 12 12"/></svg></button>- </div>- <div class="tbar"><div id="tfill" class="tfill"></div></div>- </div>- </div>- <div class="nb-right"><table class="prompts"><tbody id="prompts"></tbody></table></div>- </div>- </section>- <div class="leds" id="leds"></div>-</div>-<div id="toast"></div>-<script>-var SAY=__SAY__;-var ICON_COPY='<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><rect x="9" y="9" width="12" height="12" rx="2.5"/><path d="M6 15H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h8a2 2 0 0 1 2 2v1"/></svg>';-var ICON_SEND='<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><path d="M21.5 2.5 10.5 13.5"/><path d="M21.5 2.5 14.8 21.5l-4.3-8-8-4.3z"/></svg>';-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);}-/* tooltips: 600ms, body-appended, viewport-clamped */-(function(){var tip=null,timer=null;- function show(el){var t=el.getAttribute('data-tip');if(!t)return;tip=document.createElement('div');tip.id='tip';tip.textContent=t;document.body.appendChild(tip);- var r=el.getBoundingClientRect(),tr=tip.getBoundingClientRect();- var x=Math.min(Math.max(6,r.left+r.width/2-tr.width/2),innerWidth-tr.width-6);- var y=r.bottom+6; if(y+tr.height>innerHeight-6) y=r.top-tr.height-6;- tip.style.left=x+'px';tip.style.top=y+'px';}- function hide(){if(timer){clearTimeout(timer);timer=null;}if(tip){tip.remove();tip=null;}}- document.addEventListener('mouseover',function(e){var el=e.target.closest('[data-tip]');if(!el)return;hide();timer=setTimeout(function(){show(el);},600);});- document.addEventListener('mouseout',function(e){if(e.target.closest('[data-tip]'))hide();});- document.addEventListener('click',hide,true);})();-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,quiet){var done=function(){if(!quiet)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();}-/* ── prompts TABLE ── */-(function(){- var tb=document.getElementById('prompts');- tb.innerHTML=SAY.map(function(p){- var q=p.replace(/\\bpup\\b/,'<em>pup</em>');- return '<tr data-say="'+p.replace(/"/g,'"')+'">'- +'<td class="phrase" title="'+p.replace(/"/g,'"')+'">“'+q+'”</td>'- +'<td class="acts">'- +'<button class="ibtn act-copy" aria-label="Copy this prompt to your clipboard" data-tip="Copy this prompt to your clipboard">'+ICON_COPY+'</button>'- +'<button class="ibtn act-send" aria-label="Send this prompt to a new AI thread" data-tip="Send this prompt to a new AI thread">'+ICON_SEND+'</button>'- +'</td></tr>';- }).join('');- tb.addEventListener('click',async function(ev){- var row=ev.target.closest('tr'); if(!row)return;- var phrase=row.dataset.say;- if(ev.target.closest('.act-send')){- var b=ev.target.closest('.act-send'); b.disabled=true; b.classList.add('busy');- /* Opening a thread drives the editor and legitimately takes 10-30s on a busy workbench, so- SAY so at click time. A silently disabled button for half a minute reads as a hang. */- toast('','Opening a new AI thread. This can take up to half a minute.');- var r=await fetch('api/say-send',{method:'POST',headers:{'content-type':'application/json'},body:JSON.stringify({phrase:phrase})}).then(function(x){return x.json();}).catch(function(){return null;});- b.disabled=false; b.classList.remove('busy');- if(r&&r.ok){toast('success','Sent to a new AI thread.');}- else{copyPhrase(phrase,true);toast('',(r&&r.why)||'Copied instead. The AI panel was unreachable.');}- return;- }- copyPhrase(phrase); /* the row itself, and the copy icon, both copy */- });-})();-/* ── panel open/closed: ONE state, driven by both the X and the header toggle (1.13.0) ──- The X removes the panel entirely (no residual one-liner) and the app UI moves up. The header- button is a real toggle: aria-pressed, a teal glyph + inset background when open, muted when- closed, and a label that swaps. The two controls can never drift because they call one setter.- Graduation (newbie-graduation): the panel also folds after 3 successful launches or the first- time the bridge sees the user's AI drive pup. The localStorage key is SLUG-NAMESPACED because a- proxied dashboard shares one origin with every other app (measured: six other apps' keys sat- beside mine), so the namespace is what provides isolation, not the origin. */-var NB_KEY='pup-bridge:newbieClosed', NB_LAUNCH='pup-bridge:launches', NB_GRAD='pup-bridge:graduated';-function setPanel(open){- var panel=document.getElementById('newbie'), btn=document.getElementById('recall');- panel.classList.toggle('closed',!open);- btn.setAttribute('aria-pressed',String(open));- var lbl=open?'Hide the prompts panel':'Show the prompts panel';- btn.setAttribute('aria-label',lbl); btn.setAttribute('data-tip',lbl);- try{ open?localStorage.removeItem(NB_KEY):localStorage.setItem(NB_KEY,'1'); }catch(e){}-}-(function(){- var closed=false; try{ closed=localStorage.getItem(NB_KEY)==='1'; }catch(e){}- setPanel(!closed);- document.getElementById('nbx').onclick=function(){setPanel(false);};- document.getElementById('recall').onclick=function(){- setPanel(document.getElementById('newbie').classList.contains('closed'));- };- document.getElementById('gear').onclick=function(){fetch('api/open-settings',{method:'POST'}).then(function(){toast('','Opening pup settings on your desktop…');}).catch(function(){});};-})();-/* graduation triggers: 3 green launches, or the first AI-driven action pup reports */-function graduate(why){- try{ if(localStorage.getItem(NB_GRAD)==='1') return; localStorage.setItem(NB_GRAD,'1'); }catch(e){}- setPanel(false);- toast('','Prompts panel tucked away ('+why+'). The ? in the header brings it back any time.');-}-function noteLaunch(state){- if(state!=='ok') return;- var n=0; try{ n=(parseInt(localStorage.getItem(NB_LAUNCH),10)||0)+1; localStorage.setItem(NB_LAUNCH,String(n)); }catch(e){}- if(n>=3) graduate('you have launched pup '+n+' times');-}-/* ── status ── */-function ledState(s){return s==='ok'?'ok':(s==='fail'||s==='bad'?'bad':(s==='warn'||s==='busy'?'busy':'unknown'));}-async function load(){- try{- var d=await (await fetch('api/status',{cache:'no-store'})).json();- 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');- tgt.hidden=false;- document.getElementById('targetbox').innerHTML='target: <b>'+(d.desktop||'this machine')+'</b>';- tgt.setAttribute('data-state',ledState(d.state));- if(!window.__pupCounted){ window.__pupCounted=true; noteLaunch(d.state); }- // "the bridge saw the user's AI drive the app": a window owned by an AI thread other than the- // dashboard's own demo session is the honest signal that they are past needing the prompts.- try{- var w=(d.leds||[]).find(function(l){return l.key==='windows';});- var driven=(w&&w.sessions||[]).some(function(s2){return s2.owner && s2.sessionId!=='pup-dock-demo';});- if(driven) graduate('your AI is driving pup now');- }catch(e){}- var leds=d.leds||[];- document.getElementById('leds').innerHTML=leds.map(function(l,i){- var st=ledState(l.state);- var short=l.short||'';- if(!short){ short=(l.detail||'').replace(/\\b\\d[\\d.]*\\b/g,'').replace(/ on [^.,;(]+/i,'').replace(/[.\\u2014(].*$/,'').replace(/\\s+/g,' ').trim();- if(short.length>26){ var cut=short.slice(0,26); var sp=cut.lastIndexOf(' '); short=(sp>10?cut.slice(0,sp):cut)+'\\u2026'; } }- 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('');- 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 ── */-(function(){- var demoBtn=document.getElementById('demo'), lbl=document.getElementById('demolbl'), sub=document.getElementById('demosub'), 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('pup-bridge:demoMuted')==='1';}catch(e){}- var SPK='<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><path d="M4 9.5v5h3.5L12 18.5v-13L7.5 9.5z"/><path d="M16 9.8a3 3 0 0 1 0 4.4"/><path d="M18.6 7.4a6.5 6.5 0 0 1 0 9.2"/></svg>';- var MUTED='<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><path d="M4 9.5v5h3.5L12 18.5v-13L7.5 9.5z"/><path d="m16.5 9.5 5 5"/><path d="m21.5 9.5-5 5"/></svg>';- var mb=document.getElementById('tmute');- function paintMute(){ mb.innerHTML=muted?MUTED:SPK; var l=muted?'Unmute the narration':'Mute the narration'; mb.setAttribute('aria-label',l); mb.setAttribute('data-tip',l); }- paintMute();- mb.onclick=function(){muted=!muted;try{localStorage.setItem('pup-bridge:demoMuted',muted?'1':'0');}catch(e){}paintMute();ctl(muted?'mute':'unmute');};- var poll=null;- function stopPoll(){if(poll){clearInterval(poll);poll=null;}tp.hidden=true;demoBtn.disabled=false;lbl.textContent='Play the demo';sub.textContent='11 steps, about 2 minutes';}- async function tick(){- try{- var d=await (await fetch('api/demo/state',{cache:'no-store'})).json();- if(!d.running){stopPoll();return;}- tp.hidden=false; demoBtn.disabled=true; lbl.textContent='Demo running';- sub.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;- var pb=document.getElementById('tplay');- pb.innerHTML=paused- ? '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><path d="M7 4.8v14.4a.6.6 0 0 0 .92.5l11.3-7.2a.6.6 0 0 0 0-1l-11.3-7.2a.6.6 0 0 0-.92.5z"/></svg>'- : '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><path d="M9 5.5v13"/><path d="M15 5.5v13"/></svg>';- var pl=paused?'Resume':'Pause'; pb.setAttribute('aria-label',pl); pb.setAttribute('data-tip',pl);- 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; lbl.textContent='Starting\\u2026';- var mm=false; try{mm=localStorage.getItem('pup-bridge:demoMuted')==='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();- };- 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>`;+// The launch page (its PAGE template and the header/newbie/pill CSS that went with it) was+// DELETED in 2.0.337 when the dock moved to the bridge's live-windows dashboard. The+// doubled-escape warning that lived here moved with the markup, into DASHBOARD_HTML in+// src/server.js, because that is the template literal it now applies to.+ // ── demo ──────────────────────────────────────────────────────────────────────────────────────── async function demoTarget() {@@ -891,8 +573,10 @@ async function serve() { // The API routes already sent no-store; the HTML did not, so the webview could serve a cached // page across a version change and silently mask a redeploy. A dashboard that lies about its own // version is worse than one that reloads slowly.- res.writeHead(200, { 'content-type': 'text/html; charset=utf-8', 'cache-control': 'no-store, must-revalidate', 'pragma': 'no-cache' });- res.end(PAGE.replace('__SAY__', JSON.stringify(SAY)));+ // RETIRED (v2.0.337): this process no longer renders a dashboard. Anything probing this port+ // gets pointed at the real one instead of being handed a second page to be confused by.+ res.writeHead(410, { 'content-type': 'text/plain; charset=utf-8', 'cache-control': 'no-store', ...CORS });+ res.end('pup serves ONE dashboard, and it is not this one.\nOpen ' + BRIDGE_DASHBOARD + ' instead.\n'); }); server.listen(0, '127.0.0.1', () => { const port = server.address().port;