← Commit history

Publish 2.0.334

John Lauer ·16d60e69ed ·1mo ago ·parent 6631ff2
3 files changed +56−47
package.json+1−1
@@ -1,6 +1,6 @@ {   "slug": "pup-bridge",-  "version": "2.0.333",+  "version": "2.0.334",   "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.333",+  "version": "2.0.334",   "tags": [     "skills",     "pup",
tools/pup-dashboard+54−45
@@ -48,10 +48,10 @@ function hget(urlPath) {     req.on('error', (e) => resolve({ status: 0, error: e.message })); req.on('timeout', () => { req.destroy(); resolve({ status: 0, error: 'timeout' }); });   }); }-function hpost(urlPath, body) {+function hpost(urlPath, body, timeoutMs) {   return new Promise((resolve) => {     const data = JSON.stringify(body);-    const req = http.request(HYDROGEN_CTL + urlPath, { method: 'POST', timeout: 4000, headers: { 'content-type': 'application/json', 'content-length': Buffer.byteLength(data) } }, (res) => { let b = ''; res.on('data', d => { b += d; }); res.on('end', () => { let j = null; try { j = JSON.parse(b); } catch (e) {} resolve({ status: res.statusCode, json: j, raw: b.slice(0, 300) }); }); });+    const req = http.request(HYDROGEN_CTL + urlPath, { method: 'POST', timeout: timeoutMs || 4000, headers: { 'content-type': 'application/json', 'content-length': Buffer.byteLength(data) } }, (res) => { let b = ''; res.on('data', d => { b += d; }); res.on('end', () => { let j = null; try { j = JSON.parse(b); } catch (e) {} resolve({ status: res.statusCode, json: j, raw: b.slice(0, 300) }); }); });     req.on('error', (e) => resolve({ status: 0, error: e.message })); req.on('timeout', () => { req.destroy(); resolve({ status: 0, error: 'timeout' }); });     req.end(data);   });@@ -72,50 +72,49 @@ function censusThread(j) { // 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.+//+// ONE CALL, NOT TWO (corrected 2026-08-24 against the authoritative hydrogen-ai-threads-api skill,+// after John pressed Send and got a tab with no prompt in it). The old code did+// POST /ai-threads/new -> read sid -> POST /ai-threads/run-prompt {sid}. That can NEVER work:+//+//   "THE RESPONSE CARRIES NO THREAD ID, EVER (sid: null, tid: null, explicit since 1.0.143): a new+//    thread has no identity until its first submitted prompt ... Do NOT open-then-target: `new`+//    followed by `run-prompt {sid}` always fails because there is no sid to pass."+//+// So /new always opened the tab (which is why John SAW a thread appear) and the second call always+// bailed on the missing sid. The composite below opens the thread AND delivers the prompt in one+// request; `name` and `tags` stage exactly as they do on /new and attach when the sid appears. async function sendToNewThread(phrase) {   // UN-PARKED 2026-08-24 (John: "we are re-enabling the send to ai"). The ah build thread certified   // the crash family dead on the gate-fixed build (33ac2074, six raw-CDP typing helpers that ran-  // outside the concurrency gate), ~20 min of the kill condition with zero process deaths, and the-  // dock thread verified the CODE MARKER in the installed exe rather than trusting a version stamp.-  // Contract (say-strip): open a NEW thread named for the app, tagged dock:<WIKI PAGE SLUG>, submit-  // the phrase. NEW threads only: never resume, never target a tid from the census. On any failure-  // the page copies the phrase and toasts honestly, so the user is never stranded.+  // outside the concurrency gate), ~20 min of the kill condition with zero process deaths.   if (!SAY.includes(phrase)) return { ok: false, why: 'unknown phrase' };-  // The fix shipped after published Hydrogen 1.0.141, so refuse HONESTLY on a pre-fix shell instead-  // of driving it into the crash we just cleared. 1.0.142+ (or a build reporting the marker) passes.-  const guard = await shellSupportsSend();-  if (!guard.ok) return { ok: false, why: guard.why };-  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) + ')' };+  // 45s, per the skill: "Opening drives the editor (extension command, dual detection, tab+  // placement) and legitimately takes 10-30s on a busy workbench; every CDP touch also serializes+  // through one gate ... A 5-10s client timeout manufactures phantom failures." The old code used+  // hpost's 4s default, so even a correct call would have reported failure on a busy workbench.+  const run = await hpost('/ai-threads/run-prompt', {+    new: true, prompt: phrase, submit: true, name: 'Pup - say it', tags: ['dock:pup-bridge'],+  }, 45000);+  if (!run.status) return { ok: false, why: 'Could not reach the AI panel (' + (run.error || 'no response') + '); the phrase was copied instead.' };+  if (!(run.status >= 200 && run.status < 300)) return { ok: false, why: 'The AI panel refused the prompt (HTTP ' + run.status + '); the phrase was copied instead.' };+  const j = run.json || {};+  if (j.ok === false) return { ok: false, why: 'The AI panel refused the prompt (' + (j.error || j.why || 'no reason given') + '); the phrase was copied instead.' };+  // The response verifies delivery against the on-disk transcript, so report what it actually+  // says rather than treating HTTP 200 as proof the prompt landed in the editor.+  return { ok: true, sid: j.sid || null, deliveredTo: j.delivered_to || null }; } -// Pre-fix shells carry the crash this feature had parked for it, so refuse with a next step rather-// than risking the user's editor. NOTE (measured 2026-08-24): Hydrogen's control API exposes NO-// semver. GET /version returns an endpoint list; GET /health returns build_sha / build_epoch /-// build_dirty. The build thread also verified the fix by CODE MARKER because the version stamp lies-// on incremental builds. So the gate is build_epoch: the certified gate-fixed build on AdomLapper-// reports 1787569730 (Aug 24 06:08), and stock 1.0.141 predates it. Anything at or after that epoch-// passes; anything older gets the honest refusal; an unreadable epoch FAILS OPEN (never block on a-// field we cannot read) and is logged so it can be corrected rather than silently trusted.-const SEND_FIX_EPOCH = 1787569730;-let _shellGate = { at: 0, ok: true, why: '' };-async function shellSupportsSend() {-  if (Date.now() - _shellGate.at < 60000) return _shellGate;-  const r = await hget('/health');-  const b = (r && r.json) || null;-  let out;-  if (!r.status || !b) out = { ok: false, why: 'Could not reach Hydrogen to check its build; the phrase was copied instead.' };-  else if (typeof b.build_epoch !== 'number') out = { ok: true, why: '' };   // fail open on a field we cannot read-  else if (b.build_epoch < SEND_FIX_EPOCH) out = { ok: false, why: 'Your Hydrogen predates the Send to AI fix (build ' + (b.build_local || b.build_epoch) + '). Update to 1.0.142 or newer to enable it; the phrase was copied instead.' };-  else out = { ok: true, why: '' };-  _shellGate = { at: Date.now(), ...out };-  return _shellGate;-}+// NO VERSION GATE (removed 2026-08-24). The previous build_epoch gate was measured, not guessed,+// but it gated on the wrong thing and would have refused honest sends forever: Hydrogen's control+// API exposes NO release version anywhere the dashboard can read. Measured today on AdomLapper:+// GET /version returns an endpoint list; GET /health and GET /buildinfo return only build_sha /+// build_epoch / build_stamp / build_local; and the shell page carries no version global. So+// "gate on release version >= 1.0.142" is not implementable from the browser, and gating on+// build_epoch instead pins the feature to ONE machine's incremental build clock -- every user on a+// stock signed release (whose epoch is older) would be refused even though their shell is fine.+// The failure path already handles a shell that cannot serve the call: the request fails, the toast+// says why, and the phrase is copied. That is a capability probe, and it is honest on every build.  let TOOL_VERSION = '0'; try { TOOL_VERSION = String(require(path.join(__dirname, '..', 'package.json')).version || '0'); } catch (e) {}@@ -316,12 +315,19 @@ body{margin:0;background:#0d1117;color:var(--text);font:14px/1.5 var(--sans)} .chip[data-state=ok] .dot{background:var(--led-green)}.chip[data-state=busy] .dot{background:var(--led-amber)}.chip[data-state=bad] .dot{background:var(--led-red)} .ibtn{width:28px;height:28px;display:flex;align-items:center;justify-content:center;background:none;border:1px solid transparent;border-radius:6px;color:var(--muted);cursor:pointer;padding:0} .ibtn svg{width:18px;height:18px}+.ibtn.busy{opacity:.55;pointer-events:none;animation:ibtnpulse 1.1s ease-in-out infinite}+@keyframes ibtnpulse{0%,100%{opacity:.55}50%{opacity:.9}} .ibtn:hover{color:var(--teal);border-color:var(--line);background:rgba(0,184,177,.08)} .ibtn:focus-visible{outline:2px solid var(--teal);outline-offset:1px} /* ── page body ── */-.wrap{max-width:1180px;margin:0 auto;padding:0 20px 60px}+.wrap{max-width:1180px;margin:0 auto;padding:20px 20px 60px}+/* item 6 (John, 2026-08-24: the collapsed gap did not match the open one). The CONTENT REGION+   owns the page padding, NOT the newbie panel. Before, .wrap had 0 top padding and the panel's+   own margin-top:16px supplied the gap under the header -- so the closed rule display:none+   took that margin with it and the next block sat FLUSH against the header. Now the 20px lives+   on .wrap, where it is identical whether the panel is open or closed. */ /* ── newbie panel: ONE full-width band, TWO columns ── */-.newbie{background:var(--surface);border:1px solid var(--line);border-left:3px solid var(--teal);border-radius:12px;padding:18px 20px 20px;margin:16px 0 20px;position:relative}+.newbie{background:var(--surface);border:1px solid var(--line);border-left:3px solid var(--teal);border-radius:12px;padding:18px 20px 20px;margin:0 0 20px;position:relative} .newbie h2{font:600 15px var(--sans);color:var(--text);margin:0} .newbie .expl{font-size:13.5px;color:var(--muted);margin:4px 0 0;max-width:70ch} .newbie .expl b.noun{color:var(--teal);font-weight:700}@@ -462,11 +468,14 @@ function copyPhrase(text,quiet){var done=function(){if(!quiet)toast('success','C     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;+      var b=ev.target.closest('.act-send'); b.disabled=true; b.classList.add('busy');+      /* Opening a thread drives the editor and legitimately takes 10-30s on a busy workbench, so+         SAY so at click time. A silently disabled button for half a minute reads as a hang. */+      toast('','Opening a new AI thread. This can take up to half a minute.');       var r=await fetch('api/say-send',{method:'POST',headers:{'content-type':'application/json'},body:JSON.stringify({phrase:phrase})}).then(function(x){return x.json();}).catch(function(){return null;});-      b.disabled=false;+      b.disabled=false; b.classList.remove('busy');       if(r&&r.ok){toast('success','Sent to a new AI thread.');}-      else{copyPhrase(phrase,true);toast('', (r&&r.parked)?'Copied instead. Send to AI is paused while we finish a fix.':((r&&r.why)||'Copied instead. The AI panel was unreachable.'));}+      else{copyPhrase(phrase,true);toast('',(r&&r.why)||'Copied instead. The AI panel was unreachable.');}       return;     }     copyPhrase(phrase);   /* the row itself, and the copy icon, both copy */