← Commit history

Publish 2.0.325

John Lauer ·a28697ef14 ·1mo ago ·parent 3108855
3 files changed +197−113
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&hellip;</div>+    </div>+    <div class="sp"></div>+    <div class="target" id="target" hidden><span class="dot"></span><span id="targetbox">&hellip;</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">&times;</button>+    <h2>Say it</h2>+    <p class="expl">You never need this page&rsquo;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">&#9198;</button>         <button class="tbtn" id="tplay" title="pause / resume">&#9208;</button>         <button class="tbtn" id="tnext" title="next step">&#9197;</button>-        <button class="tbtn tstop" id="tstop" title="stop the demo">&#9209;</button>+        <span class="tsep"></span>+        <button class="tbtn" id="tmute" title="mute / unmute narration">&#128266;</button>+        <span class="tsep"></span>+        <button class="tbtn texit" id="texit" title="exit the demo">&#10005;</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,'&quot;')+'">&ldquo;'+q+'&rdquo; <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||'?')+' &middot; 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 }));