← Commit history

Publish 2.0.306

John Lauer ·1c17c34284 ·2mo ago ·parent 3c0b315
4 files changed +103−15
dockbar.json+9−1
@@ -1,5 +1,13 @@ {   "dock_manifest": "1.2.0",+  "say": [+    "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"+  ],   "webview": {     "title": "Pup",     "favicon": "docs/img/dock-pup.svg"@@ -34,4 +42,4 @@     },     "console": "show"   }-}\ No newline at end of file+}
package.json+1−1
@@ -1,6 +1,6 @@ {   "slug": "pup-bridge",-  "version": "2.0.305",+  "version": "2.0.306",   "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.305",+  "version": "2.0.306",   "tags": [     "skills",     "pup",
tools/pup-dashboard+92−12
@@ -1,5 +1,5 @@ #!/usr/bin/env node-/* pup-dashboard — the container-side status page behind pup's Dock Bar card (adom/pup-bridge#66).+/* pup-dashboard: the container-side status page behind pup's Dock Bar card (adom/pup-bridge#66).  *  * Shape follows adom/hydrogen-dock-sdk and the KiCad/Fusion reference bridges. The three rules in  * the SDK exist because they broke BOTH other bridges' first attempts, so they are honored here@@ -10,11 +10,11 @@  *   2. PATH: wiki CLIs land in ~/.local/bin, which is not on the exec shell's PATH. Every child  *      process here gets it prepended, so "works on my machine" cannot happen.  *   3. serve is IDEMPOTENT: Hydrogen runs it on EVERY launch. If an instance is already up, detect- *      it, print ITS url, exit 0 — never a second server, never a port fight.+ *      it, print ITS url, exit 0: never a second server, never a port fight.  *  * /api/status returns the SDK payload: per-LED {label, state, detail, _hint} plus a TOP-LEVEL  * rollup state. It is CACHED and internally timed out: adom/fusion-bridge#37 is the cautionary- * tale — a 3.1s status against a declared 2000ms budget reads to the dock as a grey LED.+ * tale: a 3.1s status against a declared 2000ms budget reads to the dock as a grey LED.  */ 'use strict'; const http = require('http');@@ -33,6 +33,45 @@ const STATE = path.join(os.tmpdir(), 'pup-dashboard.state.json'); // file shipped in (package.json sits one level up in the installed module), published in the state // file and in /api/status as dashboardVersion. A running instance whose version differs from ours // is restarted rather than reused: version-blind reuse pinned fusion's users to a stale server.+// "Say it" (John via the Dock Bar guide): teach users to ASK for pup by its noun. The phrases live+// in dockbar.json (the dock shows them on the interstitial and the card hover); the dashboard shows+// the same list at the very top, copy on click, and "Try it" stages one into the app's own thread.+let SAY = ['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'];+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';+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 }); } }); });+    req.on('error', (e) => resolve({ status: 0, error: e.message })); req.on('timeout', () => { req.destroy(); resolve({ status: 0, error: 'timeout' }); });+  });+}+function hpost(urlPath, body) {+  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) }); }); });+    req.on('error', (e) => resolve({ status: 0, error: e.message })); req.on('timeout', () => { req.destroy(); resolve({ status: 0, error: 'timeout' }); });+    req.end(data);+  });+}+function findSid(j) {+  // the history/census payloads are lists (or {threads:[...]}) of thread records carrying sid/id+  const list = Array.isArray(j) ? j : (j && (j.threads || j.items || j.history || j.results)) || [];+  for (const t of list) { if (t && (t.sid || t.id || t.session_id)) return String(t.sid || t.id || t.session_id); }+  return null;+}+async function stagePhrase(phrase) {+  if (!SAY.includes(phrase)) return { ok: false, error: 'unknown phrase' };+  const h = await hget('/ai-threads/history?tag=' + encodeURIComponent('app:adom/pup-bridge'));+  if (!h.status) return { ok: false, error: 'Hydrogen is not reachable on 127.0.0.1:47084 (' + (h.error || 'no answer') + '). Paste the phrase into any Adom AI thread instead.' };+  let sid = findSid(h.json);+  if (!sid) { const c = await hget('/ai-threads/census'); sid = findSid(c.json); }+  if (!sid) 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.' };+  const r = await hpost('/ai-threads/run-prompt', { sid, prompt: phrase, submit: false });+  if (r.status >= 200 && r.status < 300) return { ok: true, sid, staged: phrase };+  return { ok: false, error: 'Hydrogen refused the stage (' + (r.status || r.error) + ')' + (r.raw ? ': ' + r.raw : '') };+} 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 || '';@@ -124,7 +163,7 @@ async function buildStatus() {   const bad = (d) => !!(d._err || d.error || d.errorCode || d.success === false);   const offline = !tgt.name || (bad(ready) && bad(status)); -  // Chrome for Testing — John's named example: the "ready for action" light.+  // Chrome for Testing: John's named example: the "ready for action" light.   const cft = ready.chrome || ready.chromeForTesting || {};   const cftOk = !bad(ready) && (ready.ready === true || ready.ok === true || cft.installed === true);   leds.push({@@ -138,7 +177,7 @@ async function buildStatus() {       : (cftOk ? null : 'Run pup_prewarm to install and warm Chrome for Testing before the first window.'),   }); -  // Bridge — is the pup verb surface answering at all+  // Bridge: is the pup verb surface answering at all   leds.push({     key: 'bridge',     label: 'Bridge',@@ -148,7 +187,7 @@ async function buildStatus() {     _hint: bad(status) ? 'Check Adom Bridge is running on the desktop and the puppeteer bridge is installed (bridge_install).' : null,   }); -  // Windows — what pup is doing right now+  // Windows: what pup is doing right now   const sessions = Array.isArray(status.sessions) ? status.sessions : [];   const tabs = sessions.reduce((n, s) => n + (Array.isArray(s.tabs) ? s.tabs.length : 0), 0);   leds.push({@@ -162,13 +201,13 @@ async function buildStatus() {     sessions: sessions.map(s => ({ sessionId: s.sessionId, owner: s.owner || null, tabs: Array.isArray(s.tabs) ? s.tabs.length : null })),   }); -  // Profile — the durable jar that makes logins persist+  // Profile: the durable jar that makes logins persist   const prof = (status.settings && status.settings.profile) || null;   leds.push({     key: 'profile',     label: 'Profile',     state: bad(status) ? 'warn' : 'ok',-    detail: bad(status) ? 'unknown' : `durable profile "${prof || 'adom-you'}" — logins persist across windows`,+    detail: bad(status) ? 'unknown' : `durable profile "${prof || 'adom-you'}": logins persist across windows`,     _hint: bad(status) ? null : 'Isolated throwaway instead: pup_open_window {isolated:true}.',   }); @@ -212,11 +251,25 @@ h1 .dot{display:inline-block;width:11px;height:11px;border-radius:50%;margin-rig 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}+.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} </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">     <div class="lbl">See what pup can do</div>@@ -238,6 +291,19 @@ async function load(){     }).join('');   }catch(e){ document.getElementById('topdetail').textContent='status unavailable: '+e; } }+(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 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';}+  });+})(); document.getElementById('demo').onclick=async function(){   const b=this,o=document.getElementById('out'); b.disabled=true; b.textContent='Running…'; o.hidden=false; o.textContent='';   try{ const r=await fetch('api/demo',{method:'POST'}); const d=await r.json(); o.textContent=(d.lines||[]).join('\\n'); }@@ -277,7 +343,7 @@ function alive(port) { }  async function serve() {-  // rule 3: IDEMPOTENT — an instance already up wins; print ITS url and exit 0.+  // rule 3: IDEMPOTENT: an instance already up wins; print ITS url and exit 0.   try {     const st = JSON.parse(fs.readFileSync(STATE, 'utf8'));     if (st.port && await alive(st.port)) {@@ -294,7 +360,7 @@ async function serve() {   // rule 1: SELF-BACKGROUND, done in the only order that is actually correct. The PARENT spawns a   // detached child, waits for the CHILD to publish the port it really bound, prints THAT url, and   // exits 0 so Hydrogen's step completes. My first cut had the parent print its own port and then-  // hand off to a child that bound a DIFFERENT one — the printed URL would have been dead on+  // hand off to a child that bound a DIFFERENT one: the printed URL would have been dead on   // arrival, which is precisely the failure rule 1 exists to prevent.   if (!process.env.PUP_DASH_CHILD) {     const { spawn } = require('child_process');@@ -335,8 +401,22 @@ 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') {+      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);+        res.writeHead(200, { 'content-type': 'application/json', ...CORS });+        res.end(JSON.stringify(out));+      });+      return;+    }+    if (u.endsWith('/api/say')) {+      res.writeHead(200, { 'content-type': 'application/json', 'cache-control': 'no-store', ...CORS });+      return res.end(JSON.stringify({ say: SAY }));+    }     res.writeHead(200, { 'content-type': 'text/html; charset=utf-8' });-    res.end(PAGE);+    res.end(PAGE.replace('__SAY__', JSON.stringify(SAY)));   });   server.listen(0, '127.0.0.1', () => {     const port = server.address().port;@@ -348,6 +428,6 @@ const cmd = process.argv[2]; if (cmd === 'serve') serve(); else if (cmd === 'status') buildStatus().then(b => { console.log(JSON.stringify(b, null, 2)); process.exit(0); }); else {-  console.log('pup-dashboard — pup\'s Dock Bar status page\n\n  pup-dashboard serve --print-url   start (idempotent, self-backgrounding) and print the URL\n  pup-dashboard status              print the /api/status payload and exit');+  console.log('pup-dashboard: pup\'s Dock Bar status page\n\n  pup-dashboard serve --print-url   start (idempotent, self-backgrounding) and print the URL\n  pup-dashboard status              print the /api/status payload and exit');   process.exit(cmd ? 1 : 0); }