app
Pup - the AI's own browser
Public Made by Adomby adom
pup gives your AI a real browser on your desktop that it drives completely, in the background, signed in as you: windows that never jump in front of your work, one shared profile that learns your logins, and every window labeled on the taskbar with its AI thread's name.
← Commit history
v2.0.342: The newbie panel is now the shared adom-ui-design module instead of a hand-rolled copy: pup serves newbie-panel.js verbatim as a file, relays the two Hydrogen endpoints same-origin so the module needs no CORS, and passes only its phrases, noun and demo. Every hand-rolled panel rule, icon and send path is deleted rather than left beside the module.
4 files changed
+94−170
adom-bridge-puppeteer-manifest.json+5−5@@ -1,10 +1,10 @@ { "manifest_version": 1, "name": "puppeteer",- "version": "2.0.341",- "url": "https://wiki.adom.inc/download/adom/pup-bridge/2.0.341/adom-bridge-puppeteer-v2.0.341.zip",- "sha256": "bdda302a37f04d243e2e9baebaebcbd5acc27cda284e35254c40e2e1ffeaca5f",- "size": 1447904,+ "version": "2.0.342",+ "url": "https://wiki.adom.inc/download/adom/pup-bridge/2.0.342/adom-bridge-puppeteer-v2.0.342.zip",+ "sha256": "6faaa9f8a91717a86a80cfbfacedb285548f2f3b8d67d5a13e5991839352ecb3",+ "size": 1516421, "verbPrefixes": [ "browser_", "desktop_recorder_",@@ -12,5 +12,5 @@ ], "healthEndpoint": "/status", "statusVerb": "browser_readiness",- "released_at": "2026-08-25T10:51:28.628439Z"+ "released_at": "2026-08-25T13:44:34.665091Z" }
src/BRIDGE_VERSION+1−1@@ -1 +1 @@-2.0.341+2.0.342
src/bridge.json+1−1@@ -2,7 +2,7 @@ "manifest_version": 1, "name": "puppeteer", "displayName": "Puppeteer (Chrome for Testing + Native)",- "version": "2.0.341",+ "version": "2.0.342", "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+87−163@@ -12445,37 +12445,13 @@ const DASHBOARD_HTML = `<!doctype html><html lang="en"><head> ::-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 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:minmax(180px,220px) 1fr;gap:24px;margin-top:14px;align-items:start}-/* The demo column SHRINKS to 180px at the narrow end instead of collapsing the layout- (adom-ui-design 1.25.0). A hard 220px forced an all-or-nothing choice at every width. */-.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}}+/* ── DOCK CONTRACT regions ───────────────────────────────────────────────────────────────────+ The newbie panel is NO LONGER hand-rolled here. adom-ui-design 2.0.0 ships it as ONE shared+ module and pup mounts it (see /dashboard/newbie-panel.js). Its CSS, markup, icons, clipboard+ path, send path, toasts, tooltips and open/closed memory all live in the module. Every rule the+ old hand-rolled panel declared is DELETED rather than left beside the module: a stray .btn or+ .ibtn declared after the module's rules is exactly how other apps silently lost their styling to+ source order. What remains here is the status pill row, which is its own contract region. */ .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}@@ -12753,18 +12729,7 @@ 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">×</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" 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 id="newbie"></div> <div class="ledrow" id="leds"></div> <div id="bar"> <span class="pill" id="cM" style="display:none">minimal-touch</span>@@ -12786,6 +12751,7 @@ footer{color:#5c6c7a;font-size:11px;padding:8px 18px 22px} <main id="m"><div class="empty">connecting...</div></main> <div id="loops" class="loops"></div> <footer id="ft"></footer>+<script src="/dashboard/newbie-panel.js"></script> <script> const _q=s=>document.querySelector(s); let SNAP=null; const COLL=new Set(); // v1.9.258 (John hovered a tooltip that his stale page could not render): the page's code is baked@@ -13429,64 +13395,32 @@ 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,'"')+'">'- +'<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" 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. On a busy workbench this can take up to two minutes.','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;+/* ── the SHARED newbie panel (adom-ui-design 2.0.0) ───────────────────────────────────────────+ pup owns exactly three things here: the phrases, the noun, and what the demo button does.+ Everything else (card, grid, table, icons, clipboard, send with its version gate and+ three-state verification, toasts, tooltips, the X and the header toggle) belongs to the module.+ apiBase is left at its default so the two Hydrogen calls go SAME-ORIGIN and this bridge relays+ them; see the /updater/status and /ai-threads/run-prompt routes in server.js. */+AdomNewbiePanel.mount({+ mount: '#newbie',+ app: 'pup',+ appName: 'Pup',+ slug: 'pup-bridge',+ toggleButton: '#recall',+ demo: {+ label: 'Play the demo',+ meta: '5 steps, about 2 minutes',+ onPlay: function () {+ AdomNewbiePanel.toast('Handing the demo script to a new AI thread. This can take up to two minutes.');+ fetch('/api/demo-send', {method:'POST'})+ .then(function(r){ return r.json(); })+ .then(function(j){ AdomNewbiePanel.toast(j.ok ? 'The demo is running in a new AI thread.' : (j.why || 'Could not reach the AI panel.'), j.ok ? '' : 'err'); })+ .catch(function(){ AdomNewbiePanel.toast('Could not reach the AI panel.', 'err'); }); }- 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'); });-})();+ },+ phrases: ${JSON.stringify(SAY_PHRASES)}+});+ /* 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;@@ -13499,17 +13433,6 @@ async function paintLeds(){ }).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 two minutes.','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>`; @@ -19180,7 +19103,29 @@ const server = http.createServer(async (req, res) => { } } - // GET /status+ // DEMO ONLY (v2.0.342). The say-strip's phrase sending moved into the shared newbie-panel+ // module, which does it client-side against the same-origin relays above, so the old+ // /api/say-send is gone rather than left as a second, drifting implementation of the same+ // contract. What stays is the demo, because the demo script is pup's own and the module only+ // knows how to call back into onPlay.+ if (pathname === '/api/demo-send' && req.method === 'POST') {+ 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: DEMO_PROMPT, submit: true, name: 'Pup - demo', tags: ['dock:pup-bridge'],+ }, 120000);+ if (!r.status) { sendJSON(res, { ok: false, why: 'Could not reach the AI panel (' + (r.error || 'no response') + ').' }); return; }+ const j = r.json || {};+ // Three states, matching the module: delivered_to is an ARRAY, and a thread that opened+ // without confirming delivery is NOT a failure. Reporting it as one invites a retry, and a+ // retry double-sends the demo into a second thread.+ const dt = j.delivered_to;+ const arr = Array.isArray(dt) ? dt : (dt ? [dt] : []);+ if (arr.length || (j.inject && j.inject.delivered_in_conversation)) { sendJSON(res, { ok: true, state: 'delivered' }); return; }+ if (r.status >= 200 && r.status < 300) { sendJSON(res, { ok: true, state: 'opened', why: (j._hints && j._hints.note) || null }); return; }+ sendJSON(res, { ok: false, why: (j._hints && j._hints.note) || j.error || ('AI threads returned ' + r.status) });+ return;+ } // 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,@@ -19191,59 +19136,37 @@ const server = http.createServer(async (req, res) => { // 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') {+ if (pathname === '/dashboard/newbie-panel.js') {+ try {+ const js = fs.readFileSync(path.join(__dirname, 'newbie-panel.js'));+ res.writeHead(200, { 'Content-Type': 'application/javascript; charset=utf-8', 'Cache-Control': 'no-store' });+ res.end(js);+ } catch (e) { res.writeHead(500); res.end('newbie-panel.js missing'); }+ return;+ }+ // Same-origin proxies for the two Hydrogen endpoints the module calls. The module defaults+ // apiBase to '' ON PURPOSE so the dashboard's own server relays them: a browser page on+ // 127.0.0.1:8851 fetching 127.0.0.1:47084 is cross-origin, and a missing CORS header makes the+ // browser discard a perfectly good 200. The bridge already talks to Hydrogen server-side, so+ // relaying costs nothing and removes CORS from the picture entirely.+ if (pathname === '/updater/status' && req.method === 'GET') {+ const r = await hydrogenGet('/updater/status', 8000);+ res.writeHead(r.status || 502, { 'Content-Type': 'application/json', 'Cache-Control': 'no-store' });+ res.end(JSON.stringify(r.json || { error: r.error || 'hydrogen unreachable' }));+ return;+ }+ if (pathname === '/ai-threads/run-prompt' && req.method === 'POST') { let body = '';- req.on('data', (d) => { body += d; if (body.length > 4096) req.destroy(); });+ req.on('data', (d) => { body += d; if (body.length > 65536) 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; }- // 120 SECONDS, not 45 (ah build thread, 2026-08-24). The tab CREATE alone can take 60s on- // a loaded workbench now that the composite adopts an idle empty tab and the server create- // cap moved 30s -> 60s, so a 45s client timeout would manufacture a failure on exactly the- // slow path this has to survive.- const r = await hydrogenPost('/ai-threads/run-prompt', {- new: true, prompt: outgoing, submit: true, name: tname, tags: ['dock:pup-bridge'],- }, 120000);- 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.- // PROOF IS delivered_to, NEVER THE NAME. Staged names can lag or miss entirely on an- // ADOPTED tab, so a correctly-named tab appearing proves nothing about the prompt; the API- // verifies delivery against the on-disk transcript and reports it here. Measured on- // AdomLapper before the editor fixes landed: HTTP 200 with new_thread.created 1 and a- // perfectly named tab, while inject said "typing into the new thread timed out after 40s"- // and delivered_to was null. Trusting the status code would have told the user their- // prompt was sent when nothing was ever typed into it.- const delivered = j.delivered_to || j.delivered || null;- if (j.ok === false || !delivered) {- // Surface the API's OWN recovery text rather than inventing wording, and never retry a- // create timeout blind: that duplicates the thread. The hints teach polling for the- // staged name/tags and delivering by tid.- const hint = (j._hints && (j._hints.note || j._hints.text)) || '';- const why = (j.inject && j.inject.error) || j.error || (j.ok === false ? 'no reason given' : 'the editor did not confirm delivery');- sendJSON(res, { ok: false, tid: j.tid || null,- why: 'The thread opened but delivery was not confirmed (' + why + '); the phrase was copied instead.' + (hint ? ' ' + hint : '') });- return;- }- sendJSON(res, { ok: true, tid: j.tid || null, deliveredTo: delivered });+ let payload = null;+ try { payload = JSON.parse(body); } catch (e) { res.writeHead(400); res.end('{"error":"bad json"}'); return; }+ // 120s, matching the module's own AbortController: the editor can take 60s just to create+ // a tab on a loaded workbench, and a shorter relay timeout would manufacture a failure the+ // module would then report as "opened" when it actually delivered.+ const r = await hydrogenPost('/ai-threads/run-prompt', payload, 120000);+ res.writeHead(r.status || 502, { 'Content-Type': 'application/json', 'Cache-Control': 'no-store' });+ res.end(JSON.stringify(r.json || { error: r.error || 'hydrogen unreachable' })); }); return; }@@ -19291,6 +19214,7 @@ const server = http.createServer(async (req, res) => { }); return; }+ // GET /status 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