← Commit history

v2.0.337: One dashboard, not two: the dock now opens the live-windows dashboard, which wears the contract regions (44px header with the prompts toggle, the Say it band, the status pill row) and serves its own /api/status and /api/say-send; the container-side launch page is deleted.

John Lauer ·6ea2c6303d ·1mo ago ·parent 65f1f8d
4 files changed +313−8
adom-bridge-puppeteer-manifest.json+5−5
@@ -1,10 +1,10 @@ {   "manifest_version": 1,   "name": "puppeteer",-  "version": "2.0.336",-  "url": "https://wiki.adom.inc/download/adom/pup-bridge/2.0.336/adom-bridge-puppeteer-v2.0.336.zip",-  "sha256": "892b63f2ac707c7349d6f4bcdbf05ea94b3f2ad394c030a7295be63e6601c1f2",-  "size": 1439179,+  "version": "2.0.337",+  "url": "https://wiki.adom.inc/download/adom/pup-bridge/2.0.337/adom-bridge-puppeteer-v2.0.337.zip",+  "sha256": "cad7f339ec2012924c7e7ee8cd3fd678db05368a1cefdf616bcc16f4accf8f46",+  "size": 1446645,   "verbPrefixes": [     "browser_",     "desktop_recorder_",@@ -12,5 +12,5 @@   ],   "healthEndpoint": "/status",   "statusVerb": "browser_readiness",-  "released_at": "2026-08-24T14:47:17.537042Z"+  "released_at": "2026-08-24T15:02:25.363104Z" }
src/BRIDGE_VERSION+1−1
@@ -1 +1 @@-2.0.336\ No newline at end of file+2.0.337\ No newline at end of file
src/bridge.json+1−1
@@ -2,7 +2,7 @@   "manifest_version": 1,   "name": "puppeteer",   "displayName": "Puppeteer (Chrome for Testing + Native)",-  "version": "2.0.336",+  "version": "2.0.337",   "description": "Drives a browser ALREADY on the machine \u2014 installed Chrome, else Microsoft Edge (every Windows PC ships with Edge) \u2014 launched with a FRESH isolated profile, so the common case has NO ~150 MB Chrome-for-Testing download and it never touches the user's real tabs/logins. Binds its local server to LOOPBACK (127.0.0.1) only, so no Windows Firewall 'allow Node through the firewall' prompt ever pops. Chrome for Testing is fetched only as a LAST RESORT if the machine has no Chromium browser at all. SPAWN-VERIFIED fallback: pup tries each detected browser (Chrome \u2192 Edge \u2192 CfT) and actually launches it, falling through to the next if one won't start (and handling browsers like Edge that relaunch onto another PID), so a corrupt/partial install never hard-fails an open. The browser that launches is cached as the default (persisted across restarts) so it isn't re-probed each time. On spawn the bridge SELF-WARMS: detects browsers + disk, and only if the box has no Chrome/Edge and no cached CfT does it background-fetch CfT so the first open is instant. pup_use pins a specific browser (chrome | edge | cft | auto); {browser:'chrome',install:true} downloads & installs REAL Google Chrome (user-scoped, no admin) on an Edge-only box. pup_readiness (READ-ONLY) reports which browser will be used + the full candidate list + the default + free disk. Every failure returns a specific errorCode + _hint (chrome_install_no_disk, chrome_download_network, chrome_install_permission, chrome_stable_not_detected, \u2026). Launches with FULL browser permissions by default for full debug (strictPermissions:true opts out). Headful automation: open/close windows+tabs, navigate, screenshot, eval JS, record. pup is a GENERIC, non-profile browser only \u2014 every session is a fresh isolated Chrome/Edge process, never the user's real signed-in browser; profile defaults to sessionId. To drive the user's REAL Chrome/Edge (their profile, cookies, SSO), use the Adom browser EXTENSION's nbrowser_* verbs \u2014 a separate bridge, not pup's job. macOS (v1.9.74+): full parity with mac-native mechanics \u2014 background-by-default is the app-level hide (windows stay drivable/screenshottable while hidden), raise/lower map to unhide+activate/hide, pup_alert_window posts a native toast whose click focuses the window, pup_use {browser:'chrome',install:true} installs Chrome from the official dmg, and taskbar identity/badging remains Windows-only.",   "homepage": "https://wiki.adom.inc/adom/adom-bridge",   "author": "Adom Inc.",
src/server.js+306−1
@@ -12350,6 +12350,85 @@ async function dashboardSnapshot() {  // v1.9.224 — the dashboard page. Single self-contained document (no CDN, no build step) because it // is served by the bridge itself and must work on a machine with no network. Adom dark theme.+// The six "say it" phrases the dock shows on its card and this dashboard lists at the top. They+// teach the user to ASK for pup by its noun instead of hunting for buttons (John, via the Dock Bar+// guide), so they are deliberately verbs a person would actually say out loud.+const SAY_PHRASES = [+  'open your dashboard in pup',+  'open my wiki page in pup',+  'open my app in pup',+  'screenshot this site in pup',+  'record a clip of this page in pup',+  'show me the logged-out view in pup',+];+// "Play the demo" hands the dock card's demo script to a fresh AI thread rather than running a+// scripted sequence from here. The scripted runner lived in the container-side helper, and the+// desktop cannot reach the container's loopback (measured: a pup window opened on a container port+// came up with zero tabs), so a button on this desktop-served page could never have called it.+// Driving it through the AI is also the honest demonstration: pup's whole point is that you ASK.+const DEMO_PROMPT = 'Run the pup demo and show me what pup can do on my machine.\n\n'+  + '1. Open a pup window to https://wiki.adom.inc/adom/pup-bridge. Tell me it opened in the BACKGROUND, pup never steals my screen, and that I can ask you to open one in the foreground any time I want to watch, or just click its taskbar button myself.\n'+  + '2. Screenshot it and show me the result, so I can see you can read the page without touching my browser.\n'+  + '3. Record a short clip of the window. Mention highFps mode, which records demo video at full frame rate while the window stays in the background.\n'+  + '4. Annotate the page: draw on it and capture it, and show me the gliding cursor with its intent narration so I can see what you are doing and why.\n'+  + '5. Read the pup dashboard LED status and tell me each light. If anything is not green, name the exact verb that fixes it and offer to run it.\n\n'+  + 'Then close the demo window so nothing is left on my desktop.';++// The local Hydrogen control API. The bridge runs ON the desktop, so this is a plain loopback call+// with nothing to route and no desktop to disambiguate.+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 hydrogenGet(urlPath, timeoutMs) {+  return new Promise((resolve) => {+    const req = http.get(HYDROGEN_CTL + urlPath, { timeout: timeoutMs || 2500 }, (res2) => {+      let b = ''; res2.on('data', (d) => { b += d; });+      res2.on('end', () => { let j = null; try { j = JSON.parse(b); } catch (e) {} resolve({ status: res2.statusCode, json: j }); });+    });+    req.on('error', (e) => resolve({ status: 0, error: e.message }));+    req.on('timeout', () => { req.destroy(); resolve({ status: 0, error: 'timeout' }); });+  });+}+function hydrogenPost(urlPath, bodyObj, timeoutMs) {+  return new Promise((resolve) => {+    const data = JSON.stringify(bodyObj);+    const req = http.request(HYDROGEN_CTL + urlPath, { method: 'POST', timeout: timeoutMs || 4000,+      headers: { 'content-type': 'application/json', 'content-length': Buffer.byteLength(data) } }, (res2) => {+      let b = ''; res2.on('data', (d) => { b += d; });+      res2.on('end', () => { let j = null; try { j = JSON.parse(b); } catch (e) {} resolve({ status: res2.statusCode, json: j }); });+    });+    req.on('error', (e) => resolve({ status: 0, error: e.message }));+    req.on('timeout', () => { req.destroy(); resolve({ status: 0, error: 'timeout' }); });+    req.end(data);+  });+}+// Release-version gate. GET /updater/status -> updater.current_version is the ONE endpoint that+// carries a release version: /version returns an endpoint list, and /health and /buildinfo carry+// only build_sha / build_epoch, which are worse than useless as gates (build_sha lies on+// incremental builds; build_epoch pins the feature to one machine's build clock, so every user on+// a stock signed release is refused forever). A pre-1.0.142 shell still carries the crash family+// this feature was parked for. UNREADABLE FAILS OPEN: never block on a field we cannot read.+const SEND_MIN_RELEASE = [1, 0, 142];+let _shellGate = { at: 0, ok: true, why: '' };+async function shellSupportsSend() {+  if (Date.now() - _shellGate.at < 60000) return _shellGate;+  const r = await hydrogenGet('/updater/status');+  const u = (r && r.json && (r.json.updater || r.json)) || null;+  const raw = u && typeof u.current_version === 'string' ? u.current_version.trim() : '';+  const m = raw.match(/^(\d+)\.(\d+)\.(\d+)/);+  let out = { ok: true, why: '' };+  if (m) {+    const v = [+m[1], +m[2], +m[3]];+    let cmp = 0;+    for (let i = 0; i < 3; i++) { const d = (v[i] || 0) - (SEND_MIN_RELEASE[i] || 0); if (d) { cmp = d < 0 ? -1 : 1; break; } }+    if (cmp < 0) out = { ok: false, why: 'Your Hydrogen (' + raw + ') predates the Send to AI fix. Update to 1.0.142 or newer to enable it; the phrase was copied instead.' };+  }+  _shellGate = { at: Date.now(), ...out };+  return _shellGate;+}+ const DASHBOARD_HTML = `<!doctype html><html lang="en"><head> <meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"> <title>Pup live windows</title>@@ -12365,7 +12444,43 @@ const DASHBOARD_HTML = `<!doctype html><html lang="en"><head> ::-webkit-scrollbar-thumb:hover{background:#484f58} ::-webkit-scrollbar-corner{background:transparent} a{color:#64abff}-header{position:sticky;top:0;z-index:10;background:#161b22;border-bottom:1px solid #30363d;height:44px;padding:0 12px;display:flex;gap:10px;align-items:center}+header{position:sticky;top:0;z-index:10;background:#161b22;border-bottom:1px solid #30363d;height:44px;padding:0 16px;display:flex;gap:10px;align-items:center}+/* ── DOCK CONTRACT regions (v2.0.337) ────────────────────────────────────────────────────────+   The dock opens THIS page now, so the contract's three regions live here: the 44px edge-to-edge+   header above, the "Say it" newbie band, and the status pill row. They are FRAME around the live+   window tree, never a substitute for it -- which is exactly the bug being fixed: pup used to open+   a separate page that had only the frame, so clicking the dock icon landed the user in a waiting+   room while the real UI sat undiscovered on another port.+   NO max-width on any of it (adom-ui-design 1.17.0): a dashboard fills the surface it is given. */+.nbband{background:#161b22;border-bottom:1px solid #30363d;padding:16px 16px 18px;position:relative}+.nbband.closed{display:none}+.nbband h2{font:600 15px "Familjen Grotesk",Satoshi,sans-serif;color:#e6edf3;margin:0}+.nbband .expl{font-size:13.5px;color:#8b949e;margin:4px 0 0;max-width:70ch}+.nbband .expl b.noun{color:#00b8b1;font-weight:700}+.nb-x{position:absolute;top:10px;right:12px;background:none;border:0;color:#8b949e;font-size:18px;line-height:1;cursor:pointer;padding:2px 6px;border-radius:6px}+.nb-x:hover{background:#21262d;color:#e6edf3}+.nb-cols{display:grid;grid-template-columns:220px 1fr;gap:24px;margin-top:14px;align-items:start}+.nb-right{border-left:1px solid #30363d;padding-left:24px}+.play{display:inline-flex;align-items:center;gap:8px;background:#00b8b1;color:#08211f;border:0;border-radius:8px;padding:10px 18px;font:600 14px Satoshi,sans-serif;cursor:pointer}+.play:hover{filter:brightness(1.08)}+.nb-sub{font-size:12px;color:#8b949e;margin-top:8px}+table.prompts{width:100%;border-collapse:collapse}+table.prompts tr{height:40px;border-bottom:1px solid #30363d;cursor:pointer;display:grid;grid-template-columns:minmax(0,max-content) 72px;justify-content:start;align-items:center}+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:#e6edf3;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;padding-right:12px}+table.prompts td.phrase em{color:#00b8b1;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}+.ibtn.busy{opacity:.55;pointer-events:none;animation:ibtnpulse 1.1s ease-in-out infinite}+@keyframes ibtnpulse{0%,100%{opacity:.55}50%{opacity:.9}}+.ledrow{display:flex;flex-wrap:wrap;gap:10px;padding:12px 16px;border-bottom:1px solid #30363d}+.led-pill{display:inline-flex;align-items:center;gap:7px;background:#161b22;border:1px solid #30363d;border-radius:999px;padding:4px 12px;font-size:12.5px;cursor:default}+.led-pill .d{width:8px;height:8px;border-radius:50%;flex:none}+.led-pill .d.ok{background:#3fb950}.led-pill .d.warn{background:#d29922}.led-pill .d.fail{background:#f85149}+.led-pill .lb{font-weight:600;color:#e6edf3}.led-pill .sh{color:#8b949e}+#recall[aria-pressed="true"]{color:#00b8b1;background:#21262d}+@media(max-width:760px){.nb-cols{grid-template-columns:1fr}.nb-right{border-left:0;border-top:1px solid #30363d;padding-left:0;padding-top:16px}.play{width:100%;justify-content:center}} .brand{position:relative;display:flex;align-items:center;gap:7px;color:#00b8b1;font-family:"Familjen Grotesk",Satoshi,sans-serif;font-weight:600;font-size:14px;cursor:default} .brand .bicon{width:16px;height:16px;color:#e6edf3} .brand .bsub{color:#8b949e;font-weight:500}@@ -12624,6 +12739,7 @@ footer{color:#5c6c7a;font-size:11px;padding:8px 18px 22px}     <span class="smeta"><b id="cW">0</b> windows &middot; <b id="cT">0</b> tabs &middot; <b id="cB">0</b> icons &middot; <span id="cAY" title="windows surfing on the shared adom-you profile / total work windows. More adom-you = a richer shared login store = pup auto-fills more sites for you over time.">?</span> adom-you &middot; <span id="cLed" title="green: every taskbar button wears its real page-favicon overlay and no untracked pup windows exist. red: a window is bare or unmodeled."><span class="led red" id="cLedDot"></span> <b id="cLedTxt">?</b></span> &middot; <span id="age"></span></span>   </div>   <div class="acts2">+    <button class="ibtn" id="recall" aria-pressed="true" title="Hide the prompts panel" aria-label="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="9"/><path d="M9.6 9.2a2.5 2.5 0 1 1 3.2 3.2c-.5.2-.8.7-.8 1.3v.3"/><path d="M12 17.2h.01"/></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>     <button class="ibtn" data-act="regen-overlays" data-tip="Regenerate ALL overlay icons: re-scan every open window and tab and re-run the from-scratch overlay algorithm (re-resolve each window handle, re-pick the favicon, recompose + repaint the taskbar badge) and refresh these dashboard icons to match. A clean sweep to verify correctness and surface any drift." title="regenerate all overlay icons (clean sweep)" aria-label="regenerate overlays"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><path d="M23 4v6h-6"/><path d="M1 20v-6h6"/><path d="M3.51 9a9 9 0 0 1 14.85-3.36L23 10M1 14l4.64 4.36A9 9 0 0 0 20.49 15"/></svg></button>     <button class="ibtn" data-act="flash-all" title="flash every pup taskbar button (the Windows taskbar flash, accent-colored on your theme)" aria-label="flash all"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><path d="M18 8a6 6 0 0 0-12 0c0 7-3 9-3 9h18s-3-2-3-9"/><path d="M13.7 21a2 2 0 0 1-3.4 0"/></svg></button>@@ -12631,6 +12747,19 @@ footer{color:#5c6c7a;font-size:11px;padding:8px 18px 22px}     <button class="ibtn danger" data-act="close-all" data-confirm="Close ALL pup windows?" data-tip="Close ALL pup windows on this desktop (every window in the list below, not this dashboard page). Asks before doing it." aria-label="close all pup windows"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round"><rect x="8" y="8" width="13" height="11" rx="1.6"/><path d="M4 15V6a1.5 1.5 0 0 1 1.5-1.5H16"/><path d="M12.2 11.2l4.6 4.6M16.8 11.2l-4.6 4.6"/></svg></button>   </div> </header>+<section class="nbband" id="nbband">+  <button class="nb-x" id="nbx" title="Collapse this panel" aria-label="Collapse this panel">&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="nb-cols">+    <div class="nb-left">+      <button class="play" id="demo"><svg viewBox="0 0 24 24" width="15" height="15" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polygon points="6 3 20 12 6 21 6 3"/></svg>Play the demo</button>+      <div class="nb-sub">11 steps, about 2 minutes</div>+    </div>+    <div class="nb-right"><table class="prompts"><tbody id="prompts"></tbody></table></div>+  </div>+</section>+<div class="ledrow" id="leds"></div> <div id="bar">   <span class="pill" id="cM" style="display:none">minimal-touch</span>   <select id="f"><option value="">all AI threads</option></select>@@ -13293,6 +13422,89 @@ _q('#f').onchange=render; _q('#q').oninput=render; const es=new EventSource('/dashboard/events'); es.onmessage=e=>{const d=JSON.parse(e.data);if(d.error){_q('#m').innerHTML='<div class="empty">snapshot error: '+esc(d.error)+'</div>';_q('#live').classList.add('stale');return;}SNAP=d;_q('#live').classList.remove('stale');render();}; es.onerror=()=>_q('#live').classList.add('stale');++/* ── DOCK CONTRACT behaviour: say-strip, panel toggle, status pills (v2.0.337) ────────────────+   WARNING: this whole page is a JS TEMPLATE LITERAL in server.js, so every regex escape must be+   DOUBLED. A single-backslash \\b is eaten as a BACKSPACE character before the browser ever sees+   it, and the regex then silently matches the wrong thing. That exact bug once rendered the+   status pills as "pup 2" and "in talled and pinned", and cost three wrong diagnoses to find. */+const SAY = ${JSON.stringify(SAY_PHRASES)};+(function(){+  var tb=document.getElementById('prompts'); if(!tb) return;+  tb.innerHTML=SAY.map(function(p){+    var q=p.replace(/\\bpup\\b/,'<em>pup</em>');+    return '<tr data-say="'+p.replace(/"/g,'&quot;')+'">'+      +'<td class="phrase" title="'+p.replace(/"/g,'&quot;')+'">&ldquo;'+q+'&rdquo;</td>'+      +'<td class="acts">'+      +'<button class="ibtn act-copy" aria-label="Copy this prompt to your clipboard" title="Copy this prompt to your clipboard"><svg viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><rect x="9" y="9" width="11" height="11" rx="2"/><path d="M5 15V5a2 2 0 0 1 2-2h10"/></svg></button>'+      +'<button class="ibtn act-send" aria-label="Send this prompt to a new AI thread" title="Send this prompt to a new AI thread"><svg viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><path d="M22 2 11 13"/><path d="M22 2l-7 20-4-9-9-4 20-7z"/></svg></button>'+      +'</td></tr>';+  }).join('');+  function copyPhrase(text,quiet){+    var done=function(){ if(!quiet) toast('Copied to clipboard. Paste it into any AI thread.','ok'); };+    if(document.hasFocus&&document.hasFocus()){ navigator.clipboard.writeText(text).then(done,function(){ done(); }); }+    else { var ta=document.createElement('textarea'); ta.value=text; ta.style.cssText='position:fixed;opacity:0';+           document.body.appendChild(ta); ta.select(); try{document.execCommand('copy');}catch(e){} ta.remove(); done(); }+  }+  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.','info');+      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('Sent to a new AI thread.','ok');+      else { copyPhrase(phrase,true); toast((r&&r.why)||'Copied instead. The AI panel was unreachable.','error'); }+      return;+    }+    copyPhrase(phrase);+  });+})();+/* ONE setter drives both the X and the header toggle, so the two controls can never disagree. */+var NB_KEY='pup-bridge:newbieClosed';+function setPanel(open){+  var panel=document.getElementById('nbband'), btn=document.getElementById('recall');+  if(!panel) return;+  panel.classList.toggle('closed',!open);+  if(btn){ btn.setAttribute('aria-pressed',open?'true':'false');+    btn.title=open?'Hide the prompts panel':'Show the prompts panel';+    btn.setAttribute('aria-label',btn.title); }+  try{ localStorage.setItem(NB_KEY, open?'0':'1'); }catch(e){}+}+(function(){+  var closed=false; try{ closed=localStorage.getItem(NB_KEY)==='1'; }catch(e){}+  setPanel(!closed);+  var x=document.getElementById('nbx'), r=document.getElementById('recall');+  if(x) x.addEventListener('click',function(){ setPanel(false); });+  if(r) r.addEventListener('click',function(){ setPanel(r.getAttribute('aria-pressed')!=='true'); });+})();+/* Status pills, straight from /api/status so the pills and the LED feed cannot drift. */+async function paintLeds(){+  var host=document.getElementById('leds'); if(!host) return;+  try{+    var j=await (await fetch('/api/status',{cache:'no-store'})).json();+    host.innerHTML=(j.leds||[]).map(function(l){+      var esc=function(t){ return String(t==null?'':t).replace(/&/g,'&amp;').replace(/</g,'&lt;'); };+      return '<span class="led-pill" title="'+esc(l.detail)+'"><span class="d '+esc(l.state)+'"></span>'+        +'<span class="lb">'+esc(l.label)+'</span><span class="sh">'+esc(l.short)+'</span></span>';+    }).join('');+  }catch(e){}+}+(function(){+  var d=document.getElementById('demo'); if(!d) return;+  d.addEventListener('click',async function(){+    d.disabled=true; d.classList.add('busy');+    toast('Handing the demo script to a new AI thread. This can take up to half a minute.','info');+    var r=await fetch('/api/say-send',{method:'POST',headers:{'content-type':'application/json'},body:JSON.stringify({phrase:'__demo__'})}).then(function(x){return x.json();}).catch(function(){return null;});+    d.disabled=false; d.classList.remove('busy');+    if(r&&r.ok) toast('The demo is running in a new AI thread.','ok');+    else toast((r&&r.why)||'Could not reach the AI panel.','error');+  });+})();+paintLeds(); setInterval(paintLeds,15000); </script></body></html>`;  const server = http.createServer(async (req, res) => {@@ -18956,6 +19168,99 @@ const server = http.createServer(async (req, res) => {     }      // GET /status+    // The DOCK CONTRACT status payload (v2.0.337). John, via the Dock Bar guide: "we can't have+    // pup having 2 different dashboards." pup used to serve a second, near-empty "launch page" from+    // a container-side helper on its own port, and the dock pointed at THAT while this dashboard,+    // the one with the taskbar strip, the window tree and icon provenance, went undiscovered. The+    // dock now opens THIS page, so the contract's status feed has to come from here.+    //+    // Building it in the bridge is not just relocation, it removes a class of bug: the helper+    // reached pup over ab and had to GUESS which desktop to ask when several were connected, which+    // is why John's laptop dashboard rendered a "target: ConfRoomROG" chip. The bridge IS the+    // desktop, so there is nothing to resolve and nothing to get wrong.+    // "Say it" -> a NEW AI thread, the dock's say-strip contract, now served from the bridge+    // because the bridge is what the dock opens (v2.0.337). Ported verbatim from the retired+    // launch page along with both of its hard-won corrections:+    //   ONE CALL. run-prompt {new:true}. A new thread carries NO sid (the API is explicit), so the+    //   old /ai-threads/new -> run-prompt {sid} flow could never deliver: it opened the tab, which+    //   is why the thread appeared, then bailed on the missing id.+    //   45 SECONDS. Opening drives the editor and takes 10-30s on a busy workbench; the previous+    //   4000ms client timeout manufactured failures no matter how correct the call was.+    if (pathname === '/api/say-send' && req.method === 'POST') {+      let body = '';+      req.on('data', (d) => { body += d; if (body.length > 4096) req.destroy(); });+      req.on('end', async () => {+        let phrase = '';+        try { phrase = String(JSON.parse(body).phrase || ''); } catch (e) {}+        let outgoing = phrase, tname = 'Pup - say it';+        if (phrase === '__demo__') { outgoing = DEMO_PROMPT; tname = 'Pup - demo'; }+        else if (!SAY_PHRASES.includes(phrase)) { sendJSON(res, { ok: false, why: 'unknown phrase' }); return; }+        const guard = await shellSupportsSend();+        if (!guard.ok) { sendJSON(res, { ok: false, why: guard.why }); return; }+        const r = await hydrogenPost('/ai-threads/run-prompt', {+          new: true, prompt: outgoing, submit: true, name: tname, tags: ['dock:pup-bridge'],+        }, 45000);+        if (!r.status) { sendJSON(res, { ok: false, why: 'Could not reach the AI panel (' + (r.error || 'no response') + '); the phrase was copied instead.' }); return; }+        if (!(r.status >= 200 && r.status < 300)) { sendJSON(res, { ok: false, why: 'The AI panel refused the prompt (HTTP ' + r.status + '); the phrase was copied instead.' }); return; }+        const j = r.json || {};+        // The response verifies delivery against the on-disk transcript, so ok:false is a REAL+        // failure even on HTTP 200. Measured on AdomLapper 2026-08-24: the thread opened+        // (new_thread.created 1) while inject reported "typing into the new thread timed out after+        // 40s" and delivered_to came back null. Trusting the status code would have told the user+        // their prompt was sent when nothing was ever typed into it.+        if (j.ok === false) {+          const why = (j.inject && j.inject.error) || j.error || 'no reason given';+          sendJSON(res, { ok: false, why: 'The thread opened but the prompt was not delivered (' + why + '); the phrase was copied instead.' });+          return;+        }+        sendJSON(res, { ok: true, tid: j.tid || null, deliveredTo: j.delivered_to || null });+      });+      return;+    }+    if (pathname === '/api/status') {+      const warming = !chrome || !chrome.readiness;+      const rd = warming ? null : chrome.readiness();+      const leds = [];+      const cftOk = !!(rd && (rd.ready === true || rd.installed === true) && rd.installPhase !== 'failed');+      leds.push({+        key: 'chrome', label: 'Chrome for Testing',+        state: warming ? 'warn' : (rd.installPhase === 'failed' ? 'fail' : (cftOk ? 'ok' : 'warn')),+        detail: warming ? 'the bridge is still warming up'+          : (rd.installPhase === 'failed' ? `install FAILED: ${rd.lastError || 'unknown error'}`+          : (rd.installing ? `downloading (${rd.installProgressPct || 0}%)`+          : (cftOk ? (rd.version ? `installed, pinned ${rd.version}` : 'installed and pinned') : (rd.reason || 'not installed yet')))),+        short: warming ? 'warming' : (cftOk ? 'installed' : (rd.installing ? 'installing' : 'not installed')),+        _hint: cftOk ? null : 'Run pup_prewarm to install and warm Chrome for Testing before the first window.',+      });+      // Answering by construction: this response IS the bridge answering, so claiming anything+      // else here would be theatre. The honest variable is the version, not the state.+      leds.push({ key: 'bridge', label: 'Bridge', state: 'ok', detail: `pup ${BRIDGE_VERSION} answering on ${os.hostname()}`, short: 'answering', _hint: null });+      let tabCount = 0, notYou = 0;+      for (const [, s2] of sessions) {+        tabCount += Array.isArray(s2.tabs) ? s2.tabs.length : 0;+        if ((s2.profileName || DEFAULT_PROFILE) !== DEFAULT_PROFILE) notYou++;+      }+      leds.push({+        key: 'windows', label: 'Windows', state: 'ok',+        detail: sessions.size ? `${sessions.size} window(s), ${tabCount} tab(s) open` : 'no pup windows open right now',+        short: `${sessions.size} open`, _hint: null,+      });+      leds.push({+        key: 'profile', label: 'Profile',+        state: notYou ? 'warn' : 'ok',+        detail: notYou ? `${notYou} window(s) are NOT on the shared ${DEFAULT_PROFILE} profile, so their logins build nothing`+                       : `every window is surfing as ${DEFAULT_PROFILE}, so logins accumulate`,+        short: notYou ? `${notYou} not ${DEFAULT_PROFILE}` : DEFAULT_PROFILE, _hint: null,+      });+      const worst = leds.some(l => l.state === 'fail') ? 'fail' : (leds.some(l => l.state === 'warn') ? 'warn' : 'ok');+      sendJSON(res, {+        state: worst, label: 'pup',+        detail: worst === 'ok' ? 'ready for action' : (leds.find(l => l.state !== 'ok') || {}).detail || 'needs attention',+        version: BRIDGE_VERSION, dashboardVersion: BRIDGE_VERSION,+        desktop: os.hostname(), desktops: [os.hostname()], leds,+      });+      return;+    }     if (pathname === '/status') {       // v2.0.13 (John): /status no longer triggers ensureHeavyInit, so on a cold spawn `chrome` may       // not be loaded yet. Answer the chip probe INSTANTLY with a warming state rather than throwing