main
Kyle Bergstedt Publish 3.0.0 259e308 13d ago
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);
}