← Commit history

adom/pup-bridge#66: ship the Dock Bar card. dockbar.json (manifest 1.2.0) at the repo root, a real favicon at the declared path, and pup-dashboard — the container-side status page serving /api/status in the SDK payload shape (per-LED label/state/detail/_hint plus our own top-level rollup, cached with an internal budget under the declared 2000ms). Honors the three SDK rules that broke both other bridges: serve self-backgrounds by spawning a detached child and waiting for the port the CHILD actually bound before printing it, every child process gets ~/.local/bin prepended, and a second serve detects the live instance and prints ITS url.

John Lauer ·0284087e7f ·2mo ago ·parent 5775b77
5 files changed +362−1
dockbar.jsonadded+36
@@ -0,0 +1,36 @@+{+  "dock_manifest": "1.2.0",+  "webview": {+    "title": "Pup",+    "favicon": "docs/img/dock-pup.svg"+  },+  "health": {+    "url": "/api/status",+    "poll_seconds": 15,+    "timeout_ms": 2000+  },+  "launch": {+    "mode": "container_command",+    "steps": [+      {+        "label": "Installing pup",+        "run": "sh -c 'PATH=\"$HOME/.local/bin:$PATH\"; adom-wiki pkg install adom/pup-bridge'",+        "skip_if": "sh -c 'PATH=\"$HOME/.local/bin:$PATH\"; command -v pup-dashboard'",+        "timeout_seconds": 180+      },+      {+        "label": "Starting the pup dashboard",+        "run": "sh -c 'PATH=\"$HOME/.local/bin:$PATH\"; exec pup-dashboard serve --print-url'",+        "await": "url",+        "timeout_seconds": 60+      }+    ],+    "on_ready": "navigate_placeholder",+    "ai": {+      "prompt": "Run the pup demo and show me what pup can do on my machine.\n\n1. 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.\n2. Screenshot it and show me the result, so I can see you can read the page without touching my browser.\n3. 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.\n4. 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.\n5. Read the pup dashboard's 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\nThen close the demo window so nothing is left on my desktop.",+      "thread": "existing",+      "thread_fallback": "new",+      "submit": "staged"+    }+  }+}
docs/img/dock-pup.svgadded+8
@@ -0,0 +1,8 @@+<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 128 128" width="128" height="128" role="img" aria-label="pup">+  <rect width="128" height="128" rx="26" fill="#00b8b1"/>+  <rect x="24" y="24" width="80" height="80" rx="16" fill="#053b38"/>+  <circle cx="52" cy="56" r="8" fill="#7fe7e2"/>+  <circle cx="80" cy="50" r="6" fill="#7fe7e2"/>+  <circle cx="82" cy="76" r="7" fill="#7fe7e2"/>+  <path d="M52 56 L80 50 M52 56 L82 76" stroke="#7fe7e2" stroke-width="5" stroke-linecap="round" fill="none"/>+</svg>
install.sh+10
@@ -34,4 +34,14 @@ deploy_into() { echo "Installing adom-desktop-puppeteer-bridge skills:" deploy_into "$HOME/.claude/skills"                          # Claude Code [ -d "$HOME/.codex" ] && deploy_into "$HOME/.codex/skills"  # Codex (if present)++# adom/pup-bridge#66 — the Dock Bar card. dockbar.json's first step is skipped when `pup-dashboard`+# is already on PATH, so this install is what makes the card launchable at all. ~/.local/bin is where+# every wiki CLI lands; the manifest's steps prepend it because it is NOT on the exec shell's PATH.+if [ -f "$PKG_DIR/tools/pup-dashboard" ]; then+  mkdir -p "$HOME/.local/bin"+  cp "$PKG_DIR/tools/pup-dashboard" "$HOME/.local/bin/pup-dashboard"+  chmod +x "$HOME/.local/bin/pup-dashboard"+  echo "  -> pup-dashboard installed to ~/.local/bin (the Dock Bar card's status page)"+fi echo "Done. The pup skill calls the adom-desktop CLI; if the bridge isn't installed on Adom Desktop it self-installs on first use."
package.json+4−1
@@ -35,7 +35,10 @@     "install.sh",     "uninstall.sh",     "README.md",-    "package.json"+    "package.json",+    "tools/pup-dashboard",+    "dockbar.json",+    "docs/img/dock-pup.svg"   ],   "scripts": {     "install": "./install.sh",
tools/pup-dashboardadded+304
@@ -0,0 +1,304 @@+#!/usr/bin/env node+/* 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+ * explicitly rather than discovered again:+ *+ *   1. serve SELF-BACKGROUNDS: print the URL, fork, exit 0. Hydrogen waits for the step to exit;+ *      a foreground server is killed at the timeout and its URL is dead before the readiness probe.+ *   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.+ *+ * /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.+ */+'use strict';+const http = require('http');+const fs = require('fs');+const os = require('os');+const path = require('path');+const { execFile } = require('child_process');++const HOME = os.homedir();+const BIN = path.join(HOME, '.local', 'bin');+// rule 2 (PATH), plus caller identity: pup REFUSES verbs from an unnamed caller (supplyIdentity),+// so a dashboard that omits ADOM_AI_THREAD gets an error back and paints a false "not installed".+const ENV = { ...process.env, PATH: `${BIN}:${process.env.PATH || ''}`, ADOM_AI_THREAD: process.env.ADOM_AI_THREAD || 'pup dashboard' };+const STATE = path.join(os.tmpdir(), 'pup-dashboard.state.json');+const PROXY = (port) => `http://localhost:7380/proxy/${port}/`;++// ── the box's pup bridge, via the ab CLI ────────────────────────────────────────────────────────+// Which desktop are we reporting on? A target-less call FAILS outright when more than one Adom+// Bridge is connected ("you didn't say which"), and that failure looked identical to "Chrome not+// installed" in the first cut. Resolve once, cache, and name the box in the payload so a two-desktop+// user can see which one the lights describe.+let _target = { at: 0, name: null, all: [] };+async function resolveTarget() {+  if (_target.name && Date.now() - _target.at < 60000) return _target;+  const d = await new Promise((resolve) => {+    execFile('adom-bridge', ['targets'], { env: ENV, timeout: 8000 }, (err, stdout) => {+      const raw = String(stdout || ''); const i = raw.indexOf('{');+      if (i < 0) return resolve({});+      try { resolve(JSON.parse(raw.slice(i))); } catch (e) { resolve({}); }+    });+  });+  const all = (d.targets || []).map(t => t.name).filter(Boolean);+  _target = { at: Date.now(), name: all[0] || null, all };+  return _target;+}++function ab(verb, args, timeoutMs, target) {+  return new Promise((resolve) => {+    const argv = target ? ['--target', target, verb, JSON.stringify(args || {})] : [verb, JSON.stringify(args || {})];+    const child = execFile('adom-bridge', argv,+      { env: ENV, timeout: timeoutMs, maxBuffer: 1 << 22 },+      (err, stdout) => {+        const raw = String(stdout || '');+        const i = raw.indexOf('{');+        if (i < 0) return resolve({ _err: err ? String(err.message).slice(0, 120) : 'no json' });+        try {+          let d = JSON.parse(raw.slice(i));+          if (typeof d.output === 'string' && d.output.trim().startsWith('{')) {+            try { d = { ...d, ...JSON.parse(d.output) }; } catch (e) {}+          }+          resolve(d);+        } catch (e) { resolve({ _err: 'unparseable' }); }+      });+    child.on('error', () => resolve({ _err: 'adom-bridge not found on PATH' }));+  });+}++// ── status, cached ──────────────────────────────────────────────────────────────────────────────+let _cache = { at: 0, body: null };+const CACHE_MS = 10000;+const BUDGET_MS = 1800;   // under the manifest's declared 2000ms timeout, on purpose++async function buildStatus() {+  if (_cache.body && Date.now() - _cache.at < CACHE_MS) return _cache.body;++  const tgt = await resolveTarget();+  const [ready, status] = await Promise.all([+    ab('pup_readiness', {}, BUDGET_MS, tgt.name),+    ab('pup_status', {}, BUDGET_MS, tgt.name),+  ]);++  const leds = [];+  // An ERROR payload is not a healthy answer. pup returns {error, errorCode} in-band, so treating+  // "we got JSON back" as success is how a refused call paints a green-ish card.+  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.+  const cft = ready.chrome || ready.chromeForTesting || {};+  const cftOk = !bad(ready) && (ready.ready === true || ready.ok === true || cft.installed === true);+  leds.push({+    key: 'chrome',+    label: 'Chrome for Testing',+    state: offline ? 'fail' : (cftOk ? 'ok' : 'warn'),+    detail: offline ? (tgt.name ? 'the desktop bridge is not answering' : 'no Adom Bridge is connected')+      : (cftOk ? (cft.version ? `installed, pinned ${cft.version}` : 'installed and pinned')+               : (bad(ready) ? `readiness could not be read (${ready.error || ready.errorCode || ready._err})` : (ready.reason || 'not installed yet'))),+    _hint: offline ? 'Start Adom Bridge on the desktop, then reload this page.'+      : (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+  leds.push({+    key: 'bridge',+    label: 'Bridge',+    state: bad(status) ? 'fail' : 'ok',+    detail: bad(status) ? `pup_* verbs not answering (${status._err || status.error || status.errorCode})`+      : `pup ${status.version || '?'} answering on ${tgt.name}${tgt.all.length > 1 ? ` (of ${tgt.all.length} desktops: ${tgt.all.join(', ')})` : ''}`,+    _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+  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({+    key: 'windows',+    label: 'Windows',+    state: bad(status) ? 'warn' : 'ok',+    detail: bad(status) ? 'unknown' : (sessions.length+      ? `${sessions.length} window(s), ${tabs} tab(s): ` + sessions.map(s => `${s.sessionId}${s.owner ? ` (${s.owner})` : ''}`).join(', ')+      : 'no pup windows open right now'),+    _hint: bad(status) ? null : 'Ask your AI to open one: pup_open_window {url}. It opens in the BACKGROUND and never steals your screen.',+    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+  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`,+    _hint: bad(status) ? null : 'Isolated throwaway instead: pup_open_window {isolated:true}.',+  });++  // TOP-LEVEL rollup: OUR call, so a warn facet does not yellow the card unless we mean it.+  // Only a dead bridge is a red card; "Chrome not installed yet" is a real warn (it self-heals on+  // first use), and "no windows open" is perfectly normal and must never colour the card.+  const bridgeLed = leds.find(l => l.key === 'bridge');+  const chromeLed = leds.find(l => l.key === 'chrome');+  const state = bridgeLed.state === 'fail' ? 'fail' : (chromeLed.state === 'ok' ? 'ok' : 'warn');++  const body = {+    state,+    label: 'pup',+    detail: state === 'ok' ? 'ready for action' : (state === 'fail' ? 'desktop bridge not answering' : 'ready after a one-time Chrome install'),+    version: status.version || null,+    desktop: tgt.name || null,+    desktops: tgt.all,+    leds,+    generated_at: new Date().toISOString(),+  };+  _cache = { at: Date.now(), body };+  return body;+}++// ── 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}+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 id="leds"></div>+  <div class="demo">+    <div class="lbl">See what pup can do</div>+    <div class="det">Runs a short self-demo on your desktop: opens a window in the background, screenshots it, records a clip, and reports every light above.</div>+    <p><button id="demo">Run the demo</button></p>+    <pre id="out" hidden></pre>+  </div>+</div>+<script>+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>';+    }).join('');+  }catch(e){ document.getElementById('topdetail').textContent='status unavailable: '+e; }+}+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'); }+  catch(e){ o.textContent='demo failed: '+e; }+  b.disabled=false; b.textContent='Run the demo';+};+load(); setInterval(load,15000);+</script>`;++// ── demo ────────────────────────────────────────────────────────────────────────────────────────+async function runDemo() {+  const lines = [];+  const sid = 'pup-dock-demo';+  lines.push('Opening a window in the BACKGROUND (pup never steals your screen)…');+  const tgt = await resolveTarget();+  const open = await ab('pup_open_window', { sessionId: sid, url: 'https://wiki.adom.inc/adom/pup-bridge', owner: 'pup dock demo' }, 120000, tgt.name);+  lines.push(open._err ? `  could not open: ${open._err}` : `  open: ${open.sessionId || sid}`);+  if (!open._err) {+    const shot = await ab('pup_screenshot', { sessionId: sid }, 60000, tgt.name);+    lines.push(shot._err ? `  screenshot failed: ${shot._err}` : `  screenshot: ${shot.width}x${shot.height}`);+    lines.push('Its taskbar button carries this thread\'s name and the page favicon.');+    lines.push('Ask your AI for the rest: recording (highFps for full-rate demo video), annotation, and the gliding cursor.');+    lines.push(`Close it when you are done: pup_close_window {"sessionId":"${sid}"}`);+  }+  return lines;+}++// ── serve ───────────────────────────────────────────────────────────────────────────────────────+function alive(port) {+  return new Promise((resolve) => {+    const req = http.get({ host: '127.0.0.1', port, path: '/api/status', timeout: 1200 }, (res) => {+      res.resume(); resolve(res.statusCode === 200);+    });+    req.on('error', () => resolve(false));+    req.on('timeout', () => { req.destroy(); resolve(false); });+  });+}++async function serve() {+  // 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)) { process.stdout.write(PROXY(st.port) + '\n'); process.exit(0); }+  } catch (e) {}++  // 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+  // arrival, which is precisely the failure rule 1 exists to prevent.+  if (!process.env.PUP_DASH_CHILD) {+    const { spawn } = require('child_process');+    try { fs.unlinkSync(STATE); } catch (e) {}+    const child = spawn(process.execPath, [__filename, 'serve'],+      { detached: true, stdio: 'ignore', env: { ...ENV, PUP_DASH_CHILD: '1' } });+    child.unref();+    for (let i = 0; i < 60; i++) {                       // up to ~15s, well inside the 60s step budget+      await new Promise(r => setTimeout(r, 250));+      try {+        const st = JSON.parse(fs.readFileSync(STATE, 'utf8'));+        if (st.port && await alive(st.port)) { process.stdout.write(PROXY(st.port) + '\n'); process.exit(0); }+      } catch (e) {}+    }+    process.stderr.write('pup-dashboard: the server did not come up in time\n');+    process.exit(1);+  }++  // child: bind, publish the real port, and live on.+  const server = http.createServer(async (req, res) => {+    const u = (req.url || '/').split('?')[0].replace(/\/+$/, '') || '/';+    if (u.endsWith('/api/status')) {+      let body; try { body = await buildStatus(); }+      catch (e) { body = { state: 'fail', label: 'pup', detail: 'status build failed: ' + e.message, leds: [] }; }+      res.writeHead(200, { 'content-type': 'application/json', 'cache-control': 'no-store' });+      return res.end(JSON.stringify(body));+    }+    if (u.endsWith('/api/demo') && req.method === 'POST') {+      let lines; try { lines = await runDemo(); } catch (e) { lines = ['demo failed: ' + e.message]; }+      res.writeHead(200, { 'content-type': 'application/json' });+      return res.end(JSON.stringify({ lines }));+    }+    res.writeHead(200, { 'content-type': 'text/html; charset=utf-8' });+    res.end(PAGE);+  });+  server.listen(0, '127.0.0.1', () => {+    const port = server.address().port;+    try { fs.writeFileSync(STATE, JSON.stringify({ port, pid: process.pid, at: Date.now() })); } catch (e) {}+  });+}++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');+  process.exit(cmd ? 1 : 0);+}