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.
master
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573
#!/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');
// Dock contract: version-aware reuse + webview takeover. TOOL_VERSION is the skillpack version this
// 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.
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 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, timeoutMs) {
return new Promise((resolve) => {
const data = JSON.stringify(body);
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);
});
}
// Thread lookup against the live Hydrogen control API (shapes measured by the dock thread,
// 2026-08-23, now in the Dock Bar guide):
// CENSUS (open threads) GET /ai-threads -> {ok, groups:[{threads:[{tid, type, label, sid|null, tags:[...]}]}]}
// HISTORY (closed) GET /ai-threads/history?tag=... -> {ok, items:[{ai, id, title, updated_ms}], open_excluded}
// Census FIRST: history EXCLUDES open threads by design, so history-first comes back empty exactly
// when the thread is open. A closed thread is resumed (POST /ai-threads/resume {ai,id}) before staging.
const APP_TAG = 'app:adom/pup-bridge';
function censusThread(j) {
for (const g of (j && j.groups) || []) for (const t of (g && g.threads) || []) {
if (t && Array.isArray(t.tags) && t.tags.includes(APP_TAG)) return t;
}
return null;
}
// 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.
// 2026-09-03 (dock-bar broadcast, Hydrogen 1.0.187+ Send-to-AI contract): the container-side
// send path (sendToNewThread -> /ai-threads/run-prompt, verified via delivered_to) is REMOVED. On
// 1.0.187+ delivered_to is never filled, so this path would have called every successful send a
// failure. Send to AI lives in the bridge-served dashboard (newbie-panel.js 2.22.0, job/state
// contract, polled through the bridge's /ai-threads/run-prompt/status proxy). The routes below
// answer 410 so a stale caller gets a pointer instead of a phantom failure.
// RELEASE-VERSION GATE, on the ONE endpoint that actually carries a release version.
// Corrected twice, so the record matters:
// build_sha - LIES on incremental builds (the build thread had to verify by code marker).
// build_epoch - ACTIVELY HARMFUL as a gate: it pins the feature to one machine's build clock,
// so every user on a stock signed release (older epoch) is refused forever.
// /version, /health, /buildinfo, page globals - carry NO release version (probed 2026-08-24).
// GET /updater/status -> updater.current_version - DOES. Measured on AdomLapper: "1.0.142".
// The gate exists because a pre-1.0.142 shell still carries the crash family that took John's
// session out this morning, and the user on a stock old release is exactly the person least able
// to diagnose it. UNREADABLE FAILS OPEN: refusing on a field we cannot read would block legitimate
// users, and the send path already fails honestly (toast + copy) when the shell cannot serve it.
const SEND_MIN_RELEASE = [1, 0, 142];
function cmpver(a, b) {
for (let i = 0; i < 3; i++) { const d = (a[i] || 0) - (b[i] || 0); if (d) return d < 0 ? -1 : 1; }
return 0;
}
let _shellGate = { at: 0, ok: true, why: '' };
async function shellSupportsSend() {
if (Date.now() - _shellGate.at < 60000) return _shellGate;
const r = await hget('/updater/status');
const b = (r && r.json) || null;
const u = (b && (b.updater || b)) || null;
const raw = u && typeof u.current_version === 'string' ? u.current_version.trim() : '';
const m = raw.match(/^(\d+)\.(\d+)\.(\d+)/);
let out;
if (!m) out = { ok: true, why: '' }; // endpoint down or version unparseable: FAIL OPEN
else if (cmpver([+m[1], +m[2], +m[3]], SEND_MIN_RELEASE) < 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.' };
} else out = { ok: true, why: '' };
_shellGate = { at: Date.now(), ...out };
return _shellGate;
}
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 || '';
const DOCK_APP = process.env.ADOM_DOCK_APP || '';
// Once the dashboard is serving, take over the tab the dock pre-opened (named ADOM_DOCK_WEBVIEW):
// navigate it to our direct URL and own it from then on. Skipped when the var is unset (hand-run).
function takeOverDockWebview(url) {
if (!DOCK_WEBVIEW) return;
try {
const { spawn } = require('child_process');
const c = spawn('adom-cli', ['hydrogen', 'webview', 'open-or-refresh', '--name', DOCK_WEBVIEW, '--url', url],
{ detached: true, stdio: 'ignore', env: ENV });
c.on('error', () => {});
c.unref();
} catch (e) {}
}
function instanceVersion(port) {
return new Promise((resolve) => {
const req = http.get({ host: '127.0.0.1', port, path: '/api/status', timeout: 1500 }, (res) => {
let b = ''; res.on('data', d => { b += d; }); res.on('end', () => { try { resolve(String(JSON.parse(b).dashboardVersion || '')); } catch (e) { resolve(''); } });
});
req.on('error', () => resolve('')); req.on('timeout', () => { req.destroy(); resolve(''); });
});
}
// Dock contract (ah build thread, 2026-08-23): print the DIRECT loopback form. The dock's
// readiness gate consumes this URL from a local WSL2 workspace with mirrored networking,
// where the 'localhost' NAME resolves IPv6-first and costs ~800ms; 127.0.0.1 is instant.
// The :7380/proxy/<port>/ hop is unnecessary for a local consumer.
// THE dashboard URL (v2.0.337). John: "we can't have pup having 2 different dashboards."
// This helper used to SERVE its own launch page and hand the dock that URL, so the dock opened a
// header, six prompts and four pills while pup's real UI, the live-windows dashboard with the
// taskbar strip, window tree, icon provenance and ownership badges, sat undiscovered on the
// bridge's own port. The species rule is that a full app's MAIN UI is the dock target, so the dock
// now gets the bridge's dashboard and this process serves no page at all.
// The bridge listens on the DESKTOP; his container reaches it fine (measured: curl from inside the
// container to 127.0.0.1:8851/health returned 200 under mirrored networking), and so does the
// Hydrogen shell, so ONE url works for the webview AND for the dock's health poll.
const BRIDGE_PORT = Number(process.env.PUP_BRIDGE_PORT || 8851);
const BRIDGE_DASHBOARD = `http://127.0.0.1:${BRIDGE_PORT}/dashboard`;
const PROXY = () => BRIDGE_DASHBOARD;
// ── 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 (process.env.ADOM_DESKTOP_TARGET) return { name: process.env.ADOM_DESKTOP_TARGET, all: [process.env.ADOM_DESKTOP_TARGET], at: Date.now() };
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'))),
short: offline ? 'bridge offline' : (cftOk ? 'installed' : 'not installed'),
_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(', ')})` : ''}`,
short: bad(status) ? 'not answering' : 'answering',
_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'),
short: bad(status) ? 'unknown' : (sessions.length ? `${sessions.length} open` : 'none open'),
_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`,
short: bad(status) ? 'unknown' : (prof || 'adom-you'),
_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,
dashboardVersion: TOOL_VERSION,
desktop: tgt.name || null,
desktops: tgt.all,
leds,
generated_at: new Date().toISOString(),
};
_cache = { at: Date.now(), body };
return body;
}
// The launch page (its PAGE template and the header/newbie/pill CSS that went with it) was
// DELETED in 2.0.337 when the dock moved to the bridge's live-windows dashboard. The
// doubled-escape warning that lived here moved with the markup, into DASHBOARD_HTML in
// src/server.js, because that is the template literal it now applies to.
// ── demo ────────────────────────────────────────────────────────────────────────────────────────
async function demoTarget() {
// v2.0.318 (John ran the demo and saw NOTHING: with two desktops connected the demo drove the
// FIRST target, which was the other machine). The user is at the desktop they just clicked
// "Run the demo" on, and that machine's input-idle is seconds while the other's is minutes:
// pick the target with the LOWEST desktop_user_idle_ms.
const t = await resolveTarget();
if (!t.all || t.all.length <= 1) return t;
const idles = await Promise.all(t.all.map(async (name) => {
const r = await ab('desktop_user_idle_ms', {}, 6000, name);
const ms = Number(r.idleMs != null ? r.idleMs : (r.ms != null ? r.ms : NaN));
return { name, idle: Number.isFinite(ms) ? ms : Infinity };
}));
idles.sort((a, b) => a.idle - b.idle);
return { name: idles[0].name, all: t.all, at: Date.now(), idles };
}
// ── Controllable demo (John: "a play/pause/next/prev/exit ... easy way to stop", "pre-create the
// mp3 audio files so you don't generate them on the fly"). The demo is a list of STEPS; a stateful
// runner plays them and honors pause / next / prev / stop between and inside steps. Narration audio
// is pre-rendered to a cache at serve time so playback is instant. State + controls are exposed at
// /api/demo/{start,control,state}; the dashboard shows a transport bar while it runs.
const DEMO_AUDIO_DIR = path.join(os.tmpdir(), 'pup-demo-audio');
let demo = null; // { running, paused, idx, total, title, target, abort, jump, log, startedAt }
function demoSteps(sid) {
// Each: { title, say, run(ctx) }. ctx: { ab, sid, tgt, set(k,v), get(k) }. Narration is the `say`
// string (spoken + captioned by the runner before run()); keep run() to the ACTION.
return [
{ title: 'Intro', say: 'Hi, this is the pup demo: an A I browser living on your desktop. I will narrate everything I do. You can pause, skip, or stop me any time from the controls.', run: async () => {} },
{ title: 'Open in background', say: 'Opening a window in the BACKGROUND: nothing pops over your work. Look at your taskbar for a new teal button wearing my thread name, with the page icon badged on it.',
run: async (c) => { const o = await c.ab('pup_open_window', { sessionId: sid, url: 'https://wiki.adom.inc/adom/pup-bridge', owner: 'pup dock demo' }, 120000); if (o._err) throw new Error('open failed: ' + o._err); } },
{ title: 'Progress bar', say: 'Watch that button: I am driving its PROGRESS BAR by hand. pup runs one of these for the length of ANY verb or long operation, sized to how long it usually takes.',
run: async (c) => { let hwnd = null; for (let i = 0; i < 4 && !hwnd; i++) { const wi = await c.ab('pup_window_info', { sessionId: sid }, 20000); hwnd = wi && wi.hwnd; if (!hwnd) await c.sleep(1200); } c.set('hwnd', hwnd); if (!hwnd) return; for (const v of [15, 40, 65, 85, 100]) { if (c.stopping()) break; await c.ab('desktop_taskbar', { hwnd, progress: { state: 'normal', value: v } }, 8000); await c.sleep(500); } await c.ab('desktop_taskbar', { hwnd, progress: { state: 'none' } }, 8000); } },
{ title: 'Foreground (captioned)', say: 'Bringing the window up so you can watch. pup always captions a foreground first: who, and why. Here it comes.',
run: async (c) => { await c.ab('pup_raise_os_window', { sessionId: sid, foregroundReason: 'the pup demo you started: bringing the window up so you can watch me drive it' }, 30000); await c.sleep(900); } },
{ title: 'The cursor', say: 'That teal cursor is me: it glides to where I am about to act and says why.',
run: async (c) => { await c.ab('pup_hover', { sessionId: sid, x: 700, y: 300, intent: 'this is my cursor', steps: 16 }, 20000); } },
{ title: 'Toolbar tiles', say: 'Top right, next to the address bar: three teal tiles. The pup dots are my menu. The pencil starts annotation. The avatar is your adom you identity: this window is signed in as YOU.',
run: async (c) => { await c.ab('pup_hover', { sessionId: sid, x: 1180, y: 8, intent: 'look UP at the toolbar', steps: 14 }, 20000); } },
{ title: 'Screenshot', say: 'Screenshot now: the same progress bar runs on its own while I capture, even with the window buried in the background.',
run: async (c) => { const sh = await c.ab('pup_screenshot', { sessionId: sid }, 60000); c.log('screenshot: ' + (sh._err ? sh._err : (sh.width + 'x' + sh.height))); } },
{ title: 'Annotate (feedback)', say: 'Now why annotation exists: FEEDBACK to your A I. Circle the thing you mean, and the capture goes to the A I thread that owns this window, and onto your clipboard for any A I chat. Watch.',
run: async (c) => { const arm = await c.ab('pup_annotate', { sessionId: sid, tool: 'pen' }, 30000); if (arm._err) { c.log('annotate did not arm: ' + arm._err); return; } const draw = "(async()=>{const r=document.getElementById('__pup_annotate');if(!r)return 'no overlay';const cv=r.querySelector('canvas');if(!cv)return 'no canvas';const mk=(t,x,y)=>new PointerEvent(t,{bubbles:true,cancelable:true,clientX:x,clientY:y,pointerId:1,isPrimary:true,button:0,buttons:t==='pointerup'?0:1});const cx=Math.round(innerWidth*0.35),cy=Math.round(innerHeight*0.4),rad=Math.min(150,Math.round(innerHeight*0.2));cv.dispatchEvent(mk('pointerdown',cx+rad,cy));for(let a=0.12;a<=6.55;a+=0.12){cv.dispatchEvent(mk('pointermove',cx+rad*Math.cos(a),cy+rad*Math.sin(a)));await new Promise(q=>setTimeout(q,55));}cv.dispatchEvent(mk('pointerup',cx+rad,cy));return 'drew';})()"; const dr = await c.ab('pup_evaluate', { sessionId: sid, expression: draw }, 40000); c.log('draw: ' + (dr._err || dr.result || '?')); } },
{ title: 'Capture landed', say: 'Pen up: captured. It is on your clipboard with a strip naming this thread and page, and the owning A I thread sees it. Your A I now knows exactly what you meant.', run: async () => {} },
{ title: 'Jump list', say: 'Every pup button has a right-click menu. Opening it for you now, watch the bottom of your screen.',
run: async (c) => { const uia = "Add-Type -AssemblyName UIAutomationClient; $tb=[System.Windows.Automation.AutomationElement]::RootElement.FindFirst('Children',(New-Object System.Windows.Automation.PropertyCondition([System.Windows.Automation.AutomationElement]::ClassNameProperty,'Shell_TrayWnd'))); $btn=$tb.FindAll('Descendants',(New-Object System.Windows.Automation.PropertyCondition([System.Windows.Automation.AutomationElement]::ControlTypeProperty,[System.Windows.Automation.ControlType]::Button))) | Where-Object { $_.Current.Name -like '*pup dock demo*' } | Select-Object -First 1; if ($btn) { $r=$btn.Current.BoundingRectangle; [int]($r.X+$r.Width/2); [int]($r.Y+$r.Height/2) }"; const rc = await c.ab('desktop_run_script', { language: 'powershell', body: uia, timeoutSeconds: 30 }, 35000); const nums = String(rc.stdout || '').trim().split(/\r?\n/).map(Number).filter(Number.isFinite); if (nums.length >= 2) { await c.ab('desktop_right_click', { x: nums[0], y: nums[1] }, 15000); await c.sleep(1100); await c.ab('desktop_right_click', { x: nums[0], y: nums[1] }, 15000); await c.sleep(2200); await c.ab('desktop_press_key', { key: 'Escape' }, 10000); c.log('opened jump list'); } else c.log('jump-list rect not found'); } },
{ title: 'Cleanup + close', say: 'And when I am done, I clean up: cursor off, and closing the window now. Watch the button leave your taskbar. That is pup. Say, show me in pup, to any Adom A I.',
run: async (c) => { await c.ab('pup_evaluate', { sessionId: sid, expression: "['__adom_cursor__','__pupCursor','__adom_cursor_ring__','__adom_cursor_label__'].forEach(id=>{const e=document.getElementById(id);if(e)e.remove();});1" }, 15000).catch(() => {}); await c.ab('pup_close_window', { sessionId: sid }, 30000); c.log('closed the demo window'); } },
];
}
function audioFileFor(text) {
let h = 0; for (let i = 0; i < text.length; i++) { h = (h * 31 + text.charCodeAt(i)) | 0; }
return path.join(DEMO_AUDIO_DIR, 'a' + (h >>> 0).toString(16) + '.mp3');
}
function renderLine(text) {
const f = audioFileFor(text);
if (fs.existsSync(f)) return Promise.resolve(f);
return new Promise((resolve) => {
execFile('adom-tts', ['say', text, '--out', f, '--thread', 'pup demo', '--desc', 'pup demo narration'], { env: ENV, timeout: 45000 }, (err) => resolve(err ? null : f));
});
}
async function preRenderDemoAudio() { // warm the cache at serve time so playback never generates on the fly
try { fs.mkdirSync(DEMO_AUDIO_DIR, { recursive: true }); } catch (e) {}
for (const st of demoSteps('pup-dock-demo')) { await renderLine(st.say); }
}
let _demoAudioChild = null;
function stopDemoAudio() { const c = _demoAudioChild; _demoAudioChild = null; if (c) { try { c.kill(); } catch (e) {} } }
function playLine(text) { // play the (already-rendered) mp3 on the shared player; returns when it finishes
return new Promise((resolve) => {
const c = execFile('adom-tts', ['say', text, '--out', audioFileFor(text), '--thread', 'pup demo', '--desc', 'pup demo narration', '--play'], { env: ENV, timeout: 40000 }, () => { if (_demoAudioChild === c) _demoAudioChild = null; resolve(); });
_demoAudioChild = c;
});
}
async function runDemoControllable(opts) {
const sid = 'pup-dock-demo';
const startMuted = !!(opts && opts.muted);
const sleep = (ms) => new Promise(r => setTimeout(r, ms));
const tgt = await demoTarget();
const steps = demoSteps(sid);
demo = { running: true, paused: false, idx: 0, total: steps.length, title: steps[0].title, target: tgt.name, abort: false, jump: null, log: [], startedAt: Date.now(), muted: startMuted };
await preRenderDemoAudio(); // fast if already warm
const store = {};
const capId = 'pup-demo';
const ctx = {
sid, tgt, sleep,
ab: (verb, a, t) => ab(verb, a, t, tgt.name),
set: (k, v) => { store[k] = v; }, get: (k) => store[k],
log: (m) => demo.log.push(m),
stopping: () => demo.abort || demo.jump != null,
};
// abort/jump-aware sleep so controls feel instant
ctx.sleep = async (ms) => { const end = Date.now() + ms; while (Date.now() < end) { if (demo.abort || demo.jump != null) return; while (demo.paused && !demo.abort) await sleep(120); await sleep(Math.min(120, Math.max(1, end - Date.now()))); } };
const stepBanner = () => { const s = steps[demo.idx]; return (demo.idx + 1) + '/' + steps.length + ': ' + s.title; };
while (demo.idx >= 0 && demo.idx < steps.length && !demo.abort) {
while (demo.paused && !demo.abort) { await sleep(150); }
if (demo.abort) break;
const step = steps[demo.idx];
demo.title = step.title; demo.jump = null;
// narrate: caption + spoken audio together (the pre-rendered mp3). The audio is RACED against an
// abort/jump watcher so Stop and Next feel instant: if either fires, kill the clip and move on.
ab('desktop_caption', { text: step.say, duration: Math.min(12000, Math.max(2500, 900 + step.say.split(/\s+/).length * 350)), id: capId }, 8000, tgt.name).catch(() => {}); // bridge default size/position (dash-demo fleet ruling)
if (demo.muted) { await Promise.race([ sleep(Math.min(6000, Math.max(1500, step.say.split(/\s+/).length * 240))), (async () => { while (!demo.abort && demo.jump == null) await sleep(120); })() ]); }
else { await Promise.race([ playLine(step.say), (async () => { while (!demo.abort && demo.jump == null) await sleep(120); stopDemoAudio(); })() ]); }
if (!demo.abort && demo.jump == null) { try { await step.run(ctx); } catch (e) { demo.log.push('step ' + stepBanner() + ' failed: ' + e.message); } }
if (demo.abort) break;
if (demo.jump != null) { demo.idx = Math.max(0, Math.min(steps.length - 1, demo.jump)); demo.jump = null; }
else demo.idx++;
}
const aborted = demo.abort;
// always clean up the demo window and any lit progress bar
try { await ab('pup_close_window', { sessionId: sid }, 20000, tgt.name); } catch (e) {}
try { await ab('desktop_caption', { text: aborted ? 'pup demo stopped.' : 'pup demo done. Say, show me in pup, to any Adom AI.', duration: 3000, id: capId }, 8000, tgt.name); } catch (e) {}
demo.running = false;
return { done: true, aborted, log: demo.log };
}
function demoState() {
if (!demo) return { running: false };
return { running: demo.running, paused: demo.paused, muted: demo.muted, idx: demo.idx, total: demo.total, title: demo.title, target: demo.target, startedAt: demo.startedAt, log: demo.log.slice(-8) };
}
function demoControl(action) {
if (!demo || !demo.running) return { ok: false, error: 'no demo is running' };
if (action === 'pause') demo.paused = true;
else if (action === 'resume') demo.paused = false;
else if (action === 'next') { demo.jump = Math.min(demo.total - 1, demo.idx + 1); demo.paused = false; }
else if (action === 'prev') { demo.jump = Math.max(0, demo.idx - 1); demo.paused = false; }
else if (action === 'stop') { demo.abort = true; demo.paused = false; try { stopDemoAudio(); } catch (e) {} }
else if (action === 'mute') { demo.muted = true; try { stopDemoAudio(); } catch (e) {} }
else if (action === 'unmute') { demo.muted = false; }
if (action === 'next' || action === 'prev') { try { stopDemoAudio(); } catch (e) {} }
else return { ok: false, error: 'unknown action' };
return { ok: true, ...demoState() };
}
// legacy synchronous entry (kept so the old button/callers still work): start + wait.
async function runDemo() { const r = await runDemoControllable(); return (demo && demo.log ? demo.log : []).concat(r.aborted ? ['(stopped)'] : ['(done)']); }
// ── 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)) {
const v = await instanceVersion(st.port);
if (v === TOOL_VERSION) { takeOverDockWebview(PROXY(st.port)); process.stdout.write(PROXY(st.port) + '\n'); process.exit(0); }
// a running instance of another version: stop it (by the pid it published) and start ours
process.stderr.write(`pup-dashboard: running instance is ${v || 'unversioned'}, this is ${TOOL_VERSION}; restarting\n`);
try { if (st.pid) process.kill(st.pid); } catch (e) {}
try { fs.unlinkSync(STATE); } catch (e) {}
await new Promise(r => setTimeout(r, 400));
}
} 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)) { takeOverDockWebview(PROXY(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.
// Dock contract (ah build thread, 2026-08-23): the dock's health probe is a CROSS-ORIGIN fetch
// from the Hydrogen shell page, so without CORS the browser discards a perfectly good 200 and the
// card's LED reads red "not answering". Every /api/* and /health answer carries
// Access-Control-Allow-Origin: * (same as kicad 1.0.45 / fusion-dashboard), and OPTIONS preflight
// is answered for the POST endpoints.
const CORS = { 'access-control-allow-origin': '*', 'access-control-allow-methods': 'GET, POST, OPTIONS', 'access-control-allow-headers': 'content-type' };
const server = http.createServer(async (req, res) => {
const u = (req.url || '/').split('?')[0].replace(/\/+$/, '') || '/';
const isApi = u.includes('/api/') || u.endsWith('/health');
if (isApi && req.method === 'OPTIONS') { res.writeHead(204, CORS); return res.end(); }
if (u.endsWith('/api/status') || u.endsWith('/health')) {
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', ...CORS });
return res.end(JSON.stringify(body));
}
if (u.endsWith('/api/demo/start') && req.method === 'POST') {
if (demo && demo.running) { res.writeHead(200, { 'content-type': 'application/json', ...CORS }); return res.end(JSON.stringify({ started: false, alreadyRunning: true, ...demoState() })); }
let body = ''; req.on('data', d => { body += d; });
req.on('end', () => { let muted = false; try { muted = !!JSON.parse(body || '{}').muted; } catch (e) {} runDemoControllable({ muted }).catch((e) => { if (demo) demo.log.push('runner crashed: ' + e.message); if (demo) demo.running = false; }); res.writeHead(200, { 'content-type': 'application/json', ...CORS }); res.end(JSON.stringify({ started: true })); });
return;
}
if (u.endsWith('/api/demo/control') && req.method === 'POST') {
let body = ''; req.on('data', d => { body += d; });
req.on('end', () => { let action = ''; try { action = String(JSON.parse(body).action || ''); } catch (e) {} const r = demoControl(action); res.writeHead(200, { 'content-type': 'application/json', ...CORS }); res.end(JSON.stringify(r)); });
return;
}
if (u.endsWith('/api/demo/state')) {
res.writeHead(200, { 'content-type': 'application/json', 'cache-control': 'no-store', ...CORS }); return res.end(JSON.stringify(demoState()));
}
if (u.endsWith('/api/demo') && req.method === 'POST') { // legacy synchronous run (kept)
let lines; try { lines = await runDemo(); } catch (e) { lines = ['demo failed: ' + e.message]; }
res.writeHead(200, { 'content-type': 'application/json', ...CORS });
return res.end(JSON.stringify({ lines }));
}
if ((u.endsWith('/api/say-send') || u.endsWith('/api/try')) && req.method === 'POST') {
res.writeHead(410, { 'content-type': 'application/json', ...CORS });
return res.end(JSON.stringify({ ok: false, gone: true, why: 'Send to AI moved to the bridge dashboard (newbie-panel 2.22.0, Hydrogen 1.0.187+ job contract); this container-side route no longer sends.' }));
}
if (u.endsWith('/api/open-settings') && req.method === 'POST') {
const tgt = await resolveTarget();
ab('pup_dashboard', {}, 15000, tgt.name).catch(() => {});
res.writeHead(200, { 'content-type': 'application/json', ...CORS }); return res.end(JSON.stringify({ ok: true }));
}
if (u.endsWith('/api/say')) {
res.writeHead(200, { 'content-type': 'application/json', 'cache-control': 'no-store', ...CORS });
return res.end(JSON.stringify({ say: SAY }));
}
// v2.0.329: NO-STORE on the page itself. Caught on AdomLapper during the fleet dashboard sweep:
// /api/status reported dashboardVersion 2.0.328 while the rendered page was still running an
// OLDER build's JS (status pills showed "pup 2" / "6 window", the pre-2.0.326 short-state logic).
// The API routes already sent no-store; the HTML did not, so the webview could serve a cached
// page across a version change and silently mask a redeploy. A dashboard that lies about its own
// version is worse than one that reloads slowly.
// RETIRED (v2.0.337): this process no longer renders a dashboard. Anything probing this port
// gets pointed at the real one instead of being handed a second page to be confused by.
res.writeHead(410, { 'content-type': 'text/plain; charset=utf-8', 'cache-control': 'no-store', ...CORS });
res.end('pup serves ONE dashboard, and it is not this one.\nOpen ' + BRIDGE_DASHBOARD + ' instead.\n');
});
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(), version: TOOL_VERSION, dockWebview: DOCK_WEBVIEW || null, dockApp: DOCK_APP || null })); } catch (e) {}
// v2.0.322: pre-render the demo narration to mp3 now, so a later "Run the demo" plays instantly
// (John: "pre-create these mp3 audio files so you don't generate them on the fly").
preRenderDemoAudio().then(() => console.error('pup-dashboard: demo audio pre-rendered')).catch(() => {});
});
}
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);
}