← Commit history
SKILL.mdadded+81
@@ -0,0 +1,81 @@+---+name: svcwatch+description: >+  The Services dashboard for Adom service containers: which are healthy, which are+  down, why, and a restart that runs the watchdog's own start command. Reads the+  watchdog's on-disk verdicts, so it never re-probes a vendor API or spends quota.+  Use when someone asks whether their services are up, which one is down, why a+  service went down, or wants one restarted. Trigger words: are my services up,+  service containers, service dashboard, is mouser down, is digikey down, is jlcpcb+  down, service health, restart a service, why is my service down, svcwatch.+---++# svcwatch++One dashboard for every Adom service container.++## Where the data comes from++svcwatch does **not** probe anything. `service-watchdog.sh` (cron, every 5 minutes)+and `kicad-watchdog-of-watchdogs.sh` already probe every container and write one+verdict per check into `$WD_STATE_DIR` (default `~/.watchdog-state`). svcwatch reads+those files.++That is deliberate. Mouser's `?live=true` burns a search slot exactly like a real+query, so a dashboard that re-probed on every page load would spend the very quota+the service exists to spend on real work.++It also means a stale dashboard is a real signal: if the newest verdict is more than+15 minutes old, the page shows a banner saying the watchdog itself has stopped. That+is the failure mode that once hid a 7.3 hour OAuth Gateway outage.++## Commands++```+svcwatch serve [--print-url]   start the server (self-backgrounds, prints the URL)+svcwatch show                  open or refresh the Services webview tab+svcwatch status                one-line health of every service container+svcwatch health                is svcwatch itself up+svcwatch ls                    running instances, with version drift+svcwatch stop                  shut every instance down+svcwatch ui toast "..."        raise a toast on the page+svcwatch ui sweep              run a watchdog sweep now+svcwatch ui restart <svc> --reason "..."    restart one service+```++`ui restart` refuses without a written `--reason`. Restarting a shared service+interrupts whoever is using it, so the reason is recorded, not decorative.++## Endpoints++| Route | Purpose |+|---|---|+| `GET /state` | Full state: services, per-check verdicts, log tail, counts |+| `GET /console` | The app's own log ring buffer |+| `GET /health` | Liveness plus a count rollup. Sends `Access-Control-Allow-Origin: *` because the dock bar probes it cross-origin |+| `GET /api/messages?since=` | The live channel. Polled, not SSE: the container's ingress proxy buffers `text/event-stream`, so an EventSource through a `/proxy/<port>/` URL never leaves CONNECTING |+| `POST /api/sweep` | Run a watchdog sweep |+| `POST /api/restart` | Restart one service. Requires a `reason` |+| `POST /shutdown` | Stop the server |++## Displaying this app (REQUIRED, read before showing anything)++To SHOW this app, use `svcwatch show` (it opens the proper Hydrogen webview tab+itself). For manual display, FIRST load the `webview` skill (adom-workspace-control)+and follow it.++- **NEVER open this app in VS Code's "Simple Browser"**, which is not a webview in+  the Adom ecosystem: no live channel, no console read-back, no AI drivability.+  Using it is a bug.+- Surfaces: **wv**, the Hydrogen webview panel (`webview` skill); **pup** (`pup`+  skill); the user's **real signed-in browser** (Adom browser extension); and+  **hdbw**, Hydrogen's standalone Tauri browser window (only under Hydrogen).+  Simple Browser is NEVER one of them.++## Adding a service++svcwatch has no service list of its own. It reads the `TARGETS` rows in+`~/bin/service-watchdog.sh`, so adding a row there makes the card appear, with its+public host, port, SSH target and start command already filled in. A service with no+start command in `TARGETS` gets a disabled Restart button and a tooltip saying so,+rather than a button that would fail.
app/cli.jsadded+300
@@ -0,0 +1,300 @@+#!/usr/bin/env node+// svcwatch CLI.+//+// Output contract: every response starts with OK: or ERROR:, and every error+// carries at least one Hint: naming the exact next command.+//+// The dock bar runs `svcwatch serve --print-url` as a launch step and waits for+// that process to EXIT before reading its stdout. So serve must self-background:+// it spawns the server detached, waits for the server to answer, prints the+// direct URL, and returns. A serve that stays in the foreground blocks the exec+// for the whole timeout, gets killed, and the dock reports "started, but <url>+// never answered".++import { spawn, execFile } from 'child_process';+import { readFileSync, readdirSync, unlinkSync, existsSync, mkdirSync } from 'fs';+import { join, dirname } from 'path';+import { fileURLToPath } from 'url';++const DIR = dirname(fileURLToPath(import.meta.url));+const SLUG = 'svcwatch';+const VERSION = JSON.parse(readFileSync(join(DIR, 'package.json'), 'utf8')).version;+const INSTANCES = join(process.env.HOME, '.adom', 'instances', SLUG);++// Strip color when stdout is not a TTY.+const TTY = process.stdout.isTTY;+const dim = s => (TTY ? `\x1b[2m${s}\x1b[0m` : s);++const argv = process.argv.slice(2);+const cmd = argv[0] && !argv[0].startsWith('-') ? argv[0] : 'help';+function flag(name) { return argv.includes(name); }+function val(name, fallback = null) {+  const i = argv.indexOf(name);+  return i > -1 && argv[i + 1] && !argv[i + 1].startsWith('--') ? argv[i + 1] : fallback;+}++const AI_THREAD = val('--ai-thread', process.env.ADOM_AI_THREAD || 'dock');++function ok(msg) { console.log('OK: ' + msg); }+function hint(msg) { console.log('Hint: ' + msg); }+function fail(msg, ...hints) {+  console.error('ERROR: ' + msg);+  for (const h of hints) console.error('Hint: ' + h);+  process.exit(1);+}++function alive(pid) { try { process.kill(pid, 0); return true; } catch { return false; } }++function instances() {+  const out = [];+  if (!existsSync(INSTANCES)) return out;+  for (const f of readdirSync(INSTANCES)) {+    if (!f.endsWith('.json')) continue;+    const p = join(INSTANCES, f);+    let e;+    try { e = JSON.parse(readFileSync(p, 'utf8')); } catch { continue; }+    if (!e.pid || !alive(e.pid)) { try { unlinkSync(p); } catch {} continue; }+    out.push(e);+  }+  return out;+}++async function probe(port, ms = 1200) {+  try {+    const c = new AbortController();+    const t = setTimeout(() => c.abort(), ms);+    const r = await fetch(`http://127.0.0.1:${port}/health`, { signal: c.signal });+    clearTimeout(t);+    return r.ok ? await r.json() : null;+  } catch { return null; }+}++async function liveInstance() {+  for (const e of instances()) {+    if (await probe(e.port)) return e;+  }+  return null;+}++// The direct URL, never the :7380/proxy/<port>/ form. The workspace is local+// WSL2 with mirrored networking, so a distro port IS a Windows localhost port.+// 127.0.0.1 rather than the name localhost: IPv6-first resolution costs about+// 800ms per request on Windows.+const directUrl = port => `http://127.0.0.1:${port}/`;++// The proxy URL is what a webview OUTSIDE the container must load. A webview+// cannot reach 127.0.0.1 in here.+function proxyUrl(port) {+  const t = process.env.VSCODE_PROXY_URI;+  return t ? t.replace('{{port}}', String(port)) : directUrl(port);+}++async function waitForPort(port, budgetMs = 30000) {+  const deadline = Date.now() + budgetMs;+  while (Date.now() < deadline) {+    const h = await probe(port);+    if (h) return h;+    await new Promise(r => setTimeout(r, 400));+  }+  return null;+}++async function startDetached() {+  mkdirSync(INSTANCES, { recursive: true });+  const port = val('--port', 'auto');+  const child = spawn(process.execPath, [+    join(DIR, 'server.js'), 'serve', '--ai-thread', AI_THREAD, '--port', port,+  ], { detached: true, stdio: ['ignore', 'ignore', 'ignore'] });+  child.unref();++  // The server picks its own port when asked for auto, so discover it through+  // the registry rather than guessing.+  const deadline = Date.now() + 30000;+  while (Date.now() < deadline) {+    const e = (instances()).find(i => i.pid === child.pid);+    if (e) {+      const h = await waitForPort(e.port, 15000);+      if (h) return e;+    }+    await new Promise(r => setTimeout(r, 300));+  }+  return null;+}++// Once serving, the app takes over the tab the dock pre-opened for it.+function claimDockWebview(port) {+  const name = process.env.ADOM_DOCK_WEBVIEW;+  if (!name) return;+  execFile('adom-cli', [+    'hydrogen', 'webview', 'open-or-refresh',+    '--name', name, '--url', proxyUrl(port),+  ], () => {});+}++async function main() {+  switch (cmd) {++    case 'serve': {+      // Reuse instead of respawn: a second instance would fight for the same+      // registry entry and leave a zombie behind.+      let inst = await liveInstance();+      if (!inst) inst = await startDetached();+      if (!inst) {+        fail('svcwatch did not come up within 30 seconds.',+             'Run it in the foreground to see why: node ' + join(DIR, 'server.js') + ' serve --port 8901',+             'Check the watchdog state directory exists: ls /home/adom/.watchdog-state');+      }+      claimDockWebview(inst.port);+      if (flag('--print-url')) {+        // The dock scrapes the first https?://\S+ on stdout. Keep it on its+        // own line and on stdout, never stderr.+        console.log(directUrl(inst.port));+        return;+      }+      ok(`svcwatch v${VERSION} serving on port ${inst.port}`);+      console.log('URL: ' + directUrl(inst.port));+      console.log('Webview URL: ' + proxyUrl(inst.port));+      return;+    }++    case 'show': {+      let inst = await liveInstance();+      if (!inst) inst = await startDetached();+      if (!inst) fail('Could not start svcwatch.', 'Try: svcwatch serve');+      const name = process.env.ADOM_DOCK_WEBVIEW || 'Service Containers';+      execFile('adom-cli', ['hydrogen', 'webview', 'open-or-refresh',+        '--name', name, '--url', proxyUrl(inst.port),+        '--panel-id', val('--panel-id', '') || 'f11c55d1-1007-4520-9769-d8f3b2c0a333',+      ], (err) => {+        if (err) {+          console.error('ERROR: could not open the webview tab.');+          console.error('Hint: find a pane id with: adom-cli hydrogen workspace tabs');+          process.exit(1);+        }+        ok(`Services tab open on port ${inst.port}`);+      });+      return;+    }++    case 'status': {+      const inst = await liveInstance();+      if (!inst) {+        fail('svcwatch is not running.', 'Start it with: svcwatch serve');+      }+      const h = await probe(inst.port, 4000);+      const c = h.counts;+      ok(`${c.ok}/${c.total} services healthy` ++         (c.down ? `, ${c.down} down` : '') ++         (c.pending ? `, ${c.pending} watching` : ''));+      console.log(dim(`port ${inst.port}  thread "${inst.thread}"  v${inst.version}` ++        (h.sweepAgeS != null ? `  watchdog wrote ${h.sweepAgeS}s ago` : '')));+      if (c.down) hint('Open the dashboard to see which: svcwatch show');+      return;+    }++    case 'health': {+      const inst = await liveInstance();+      if (!inst) fail('svcwatch is not running.', 'Start it with: svcwatch serve');+      ok(`svcwatch v${inst.version} answering on port ${inst.port}`);+      return;+    }++    case 'ls': {+      const list = instances();+      if (!list.length) { ok('no svcwatch instances running'); return; }+      for (const e of list) {+        const idleM = Math.round((Date.now() - new Date(e.lastActivity).getTime()) / 60000);+        const drift = e.version !== VERSION+          ? `  WARN: runs ${e.version}, disk has ${VERSION}` : '';+        console.log(`port ${e.port}  thread "${e.thread}"  idle ${idleM}m  v${e.version}${drift}`);+      }+      ok(`${list.length} instance${list.length === 1 ? '' : 's'}`);+      return;+    }++    case 'stop': {+      const list = instances();+      if (!list.length) { ok('nothing to stop'); return; }+      for (const e of list) {+        try {+          await fetch(`http://127.0.0.1:${e.port}/shutdown`, { method: 'POST' });+        } catch {}+      }+      ok(`stopped ${list.length} instance${list.length === 1 ? '' : 's'}`);+      return;+    }++    // Drive the page the same way a person would, so the AI can walk every+    // interaction and assert the result.+    case 'ui': {+      const inst = await liveInstance();+      if (!inst) fail('svcwatch is not running.', 'Start it with: svcwatch serve');+      const sub = argv[1];+      const base = `http://127.0.0.1:${inst.port}`;+      const post = async (path, body) => {+        const r = await fetch(base + path, {+          method: 'POST', headers: { 'content-type': 'application/json' },+          body: JSON.stringify(body),+        });+        return { status: r.status, json: await r.json().catch(() => ({})) };+      };++      if (sub === 'toast') {+        // Drop each --flag AND the value that follows it, or "--kind success"+        // leaves the word "success" stuck on the end of the message.+        const words = [];+        for (let i = 2; i < argv.length; i++) {+          if (argv[i].startsWith('--')) { i++; continue; }+          words.push(argv[i]);+        }+        const message = words.join(' ');+        if (!message) fail('No message given.', 'Try: svcwatch ui toast "sweep finished"');+        await post('/api/toast', { kind: val('--kind', 'info'), message });+        ok(`toast raised: ${message}`);+        return;+      }+      if (sub === 'sweep') {+        const r = await post('/api/sweep', {});+        if (r.status !== 200) fail('Sweep failed.', 'Read the log: tail /tmp/service-watchdog.log');+        ok(`sweep complete: ${r.json.counts.ok}/${r.json.counts.total} healthy`);+        return;+      }+      if (sub === 'restart') {+        const label = argv[2];+        const reason = val('--reason');+        if (!label) fail('No service given.', 'Try: svcwatch ui restart oauth --reason "recovering the gateway"');+        if (!reason) {+          fail('Restarting a shared service needs a written reason.',+               `Try: svcwatch ui restart ${label} --reason "why you are restarting it"`);+        }+        const r = await post('/api/restart', { label, reason });+        if (r.status !== 200) {+          fail(r.json.error || 'Restart failed.',+               r.json.hint || 'List valid names with: svcwatch status');+        }+        ok(`${r.json.service} restart dispatched`);+        return;+      }+      fail(`Unknown ui command: ${sub || '(none)'}`,+           'Try: svcwatch ui toast "..." | svcwatch ui sweep | svcwatch ui restart <service> --reason "..."');+      return;+    }++    default: {+      console.log(`svcwatch v${VERSION}: the Adom service container dashboard`);+      console.log('');+      console.log('  svcwatch serve [--print-url]     start the server (self-backgrounds)');+      console.log('  svcwatch show                    open or refresh the Services webview tab');+      console.log('  svcwatch status                  one-line health of every service container');+      console.log('  svcwatch health                  is svcwatch itself up');+      console.log('  svcwatch ls                      running instances, with version drift');+      console.log('  svcwatch stop                    shut every instance down');+      console.log('  svcwatch ui toast "..."          raise a toast on the page');+      console.log('  svcwatch ui sweep                run a watchdog sweep now');+      console.log('  svcwatch ui restart <svc> --reason "..."   restart one service');+      return;+    }+  }+}++main().catch(e => fail(e.message, 'This is a bug in svcwatch. Run with: node cli.js ' + cmd));
app/fonts/familjen-grotesk-latin-500-normal.woff2added
⋯ 1 unchanged line ⋯
app/fonts/familjen-grotesk-latin-600-normal.woff2added
⋯ 1 unchanged line ⋯
app/fonts/familjen-grotesk-latin-700-normal.woff2added
⋯ 1 unchanged line ⋯
app/fonts/jetbrains-mono-400.woff2added
⋯ 1 unchanged line ⋯
app/fonts/jetbrains-mono-500.woff2added
⋯ 1 unchanged line ⋯
app/fonts/satoshi-400-normal.woff2added
⋯ 1 unchanged line ⋯
app/fonts/satoshi-500-normal.woff2added
⋯ 1 unchanged line ⋯
app/fonts/satoshi-700-normal.woff2added
⋯ 1 unchanged line ⋯
app/icon.svgadded+5
@@ -0,0 +1,5 @@+<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="#e6edf3" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round">+  <rect x="3" y="4" width="18" height="6" rx="2"/>+  <rect x="3" y="14" width="18" height="6" rx="2"/>+  <path d="M7 7h.01M7 17h.01"/>+</svg>
app/index.htmladded+766
@@ -0,0 +1,766 @@+<!doctype html>+<html lang="en">+<head>+<meta charset="utf-8">+<meta name="viewport" content="width=device-width, initial-scale=1">+<title>Service Containers</title>+<link rel="icon" href="icon.svg" type="image/svg+xml">+<style>+  /* Brand faces, served from this app rather than adom.inc: the font host+     sends no Access-Control-Allow-Origin, so a cross-origin @font-face from+     the proxy URL is blocked and silently falls back. Same-origin fixes it. */+  @font-face { font-family:'Familjen Grotesk'; src:url('fonts/familjen-grotesk-latin-500-normal.woff2') format('woff2'); font-weight:500; font-display:swap; }+  @font-face { font-family:'Familjen Grotesk'; src:url('fonts/familjen-grotesk-latin-600-normal.woff2') format('woff2'); font-weight:600; font-display:swap; }+  @font-face { font-family:'Familjen Grotesk'; src:url('fonts/familjen-grotesk-latin-700-normal.woff2') format('woff2'); font-weight:700; font-display:swap; }+  @font-face { font-family:'Satoshi'; src:url('fonts/satoshi-400-normal.woff2') format('woff2'); font-weight:400; font-display:swap; }+  @font-face { font-family:'Satoshi'; src:url('fonts/satoshi-500-normal.woff2') format('woff2'); font-weight:500; font-display:swap; }+  @font-face { font-family:'Satoshi'; src:url('fonts/satoshi-700-normal.woff2') format('woff2'); font-weight:700; font-display:swap; }+  @font-face { font-family:'JetBrains Mono'; src:url('fonts/jetbrains-mono-400.woff2') format('woff2'); font-weight:400; font-display:swap; }+  @font-face { font-family:'JetBrains Mono'; src:url('fonts/jetbrains-mono-500.woff2') format('woff2'); font-weight:500; font-display:swap; }++  :root {+    /* Adom core theme tokens. Copied from the design system table, not retyped. */+    --bg:#0d1117;+    --surface:#161b22;+    --elevated:#1c2128;+    --overlay:#21262d;+    --border:#30363d;+    --text:#e6edf3;+    --text-body:#c9d1d9;+    --text-2:#8b949e;+    --text-3:#484f58;          /* borders and disabled chrome only, never text */+    --accent:#00b8b1;+    --accent-hover:#00d4cb;+    --accent-bright:#00e6dc;+    --on-accent:#05221f;+    --green:#3fb950;+    --red:#f85149;+    --yellow:#d29922;+    --blue:#64abff;++    --hdr:44px;+    --r-btn:8px;+    --r-card:12px;+    --r-pill:999px;+    --shadow:0 1px 2px rgba(0,0,0,.4), 0 8px 24px -14px rgba(0,0,0,.75);+  }++  * { box-sizing:border-box; }++  /* Scrollbars: barely visible. The content is the subject; the bar is a hint.+     Firefox and the standard: */+  * { scrollbar-width:thin; scrollbar-color:rgba(139,148,158,.38) transparent; }+  /* Chromium and WebKit: */+  ::-webkit-scrollbar { width:10px; height:10px; }+  ::-webkit-scrollbar-track { background:transparent; }+  ::-webkit-scrollbar-thumb {+    background:rgba(139,148,158,.32);+    border-radius:999px;              /* a pill, never a rectangle */+    border:2px solid transparent;     /* insets the pill off the edge... */+    background-clip:padding-box;      /* ...so the border reads as padding */+  }+  ::-webkit-scrollbar-thumb:hover {+    background:rgba(0,184,177,.55); background-clip:padding-box;+  }+  ::-webkit-scrollbar-corner { background:transparent; }+  html, body { height:100%; }+  body {+    margin:0; background:var(--bg); color:var(--text-body);+    font-family:'Satoshi', ui-sans-serif, system-ui, sans-serif;+    font-size:15px; line-height:1.5; -webkit-font-smoothing:antialiased;+  }+  button { font:inherit; color:inherit; cursor:pointer; }+  :focus-visible { outline:2px solid var(--accent-bright); outline-offset:2px; border-radius:var(--r-btn); }+  .mono { font-family:'JetBrains Mono', ui-monospace, monospace; }++  /* ── header: 44px, three zones ─────────────────────────────── */+  header.app-header {+    position:sticky; top:0; z-index:40; height:var(--hdr);+    display:flex; align-items:center; gap:12px; padding:0 12px;+    background:var(--surface); border-bottom:1px solid var(--border);+  }+  .brand {+    display:flex; align-items:center; gap:8px; flex:none;+    font-family:'Familjen Grotesk', sans-serif; font-weight:600;+    color:var(--accent); font-size:.95rem; white-space:nowrap;+  }+  .brand svg { width:16px; height:16px; fill:none; stroke:currentColor; stroke-width:1.8; flex:none; }+  .brand .app-name { color:var(--accent); }+  .brand .sub { color:var(--text-2); font-weight:500; }+  .vsep { width:1px; height:20px; background:var(--border); flex:none; }++  .subject { display:flex; align-items:center; gap:8px; min-width:0; }+  .led-hdr { width:8px; height:8px; border-radius:50%; background:var(--green); flex:none; }+  .led-hdr.bad { background:var(--red); box-shadow:0 0 0 3px rgba(248,81,73,.18); }+  .led-hdr.warn { background:var(--yellow); box-shadow:0 0 0 3px rgba(210,153,34,.18); }+  .subject .name { font-weight:700; color:var(--text); white-space:nowrap;+                   overflow:hidden; text-overflow:ellipsis; }+  .subject .meta { font-family:'JetBrains Mono', monospace; font-size:.75rem;+                   color:var(--text-2); white-space:nowrap; font-variant-numeric:tabular-nums; }+  header.app-header { overflow:hidden; }+  /* The header stays ONE row at 44px at every width. As space runs out the+     subject sheds its parts in order of least value: the meta line first,+     then the brand subtitle, then the subject name. The status LED and the+     healthy/down count are the last things to go, because they are the+     reason someone opens this page. */+  @media (max-width:720px) { .brand .sub { display:none; } }+  @media (max-width:600px) {+    .subject .name { display:none; }+    .vsep { display:none; }+  }+  @media (max-width:420px) { .hbtn { padding:6px 10px; font-size:.8rem; } }++  .actions { margin-left:auto; display:flex; align-items:center; gap:8px; }+  .hbtn {+    background:var(--elevated); border:1px solid var(--border); border-radius:var(--r-btn);+    padding:6px 12px; font-size:.85rem; color:var(--text-body); white-space:nowrap;+    display:inline-flex; align-items:center; gap:6px; transition:background .15s ease, border-color .15s ease;+  }+  .hbtn:hover { background:var(--overlay); border-color:var(--text-3); color:var(--text); }+  .hbtn:active { background:var(--surface); }+  .hbtn[disabled] { opacity:.5; cursor:progress; }+  .icon-btn {+    background:none; border:1px solid transparent; border-radius:var(--r-btn);+    padding:6px; line-height:0; transition:background .15s ease;+  }+  .icon-btn:hover { background:var(--elevated); border-color:var(--border); }+  .icon-btn:active { background:var(--overlay); }+  .icon-btn svg { width:20px; height:20px; stroke:var(--text-2); fill:none; stroke-width:1.8; }+  .icon-btn:hover svg { stroke:var(--text); }++  /* Fingers are not cursors. */+  @media (hover:none), (pointer:coarse) {+    .hbtn, .icon-btn { min-height:44px; min-width:44px; justify-content:center; }+    .sbtn { min-height:44px; }+    .chip { min-height:44px; display:inline-flex; align-items:center; }+  }++  /* ── layout ────────────────────────────────────────────────── */+  /* Full bleed: the dashboard fills the window at every width. The card grid+     is auto-fill, so a wider window buys more columns rather than a wider+     empty margin. */+  main { width:100%; padding:16px 20px 64px;+         display:flex; flex-direction:column; gap:16px; }++  .banner {+    display:none; gap:12px; align-items:flex-start;+    background:var(--elevated); border:1px solid var(--yellow); border-radius:var(--r-btn);+    padding:12px 16px; font-size:.9rem; color:var(--text-body);+  }+  .banner.show { display:flex; }+  .banner b { color:var(--yellow); }+  .banner svg { width:18px; height:18px; flex:none; fill:none; stroke:var(--yellow); stroke-width:1.8; margin-top:1px; }++  .filters { display:flex; gap:8px; align-items:center; flex-wrap:wrap; }+  .chip {+    background:var(--elevated); border:1px solid var(--border); border-radius:var(--r-pill);+    padding:6px 14px; font-size:.85rem; color:var(--text-2); transition:all .15s ease;+  }+  .chip:hover { border-color:var(--text-3); color:var(--text-body); }+  .chip[aria-pressed="true"] {+    background:var(--overlay); border-color:var(--accent); color:var(--accent);+    box-shadow:inset 0 0 0 1px var(--accent);+  }+  .hint { font-family:'JetBrains Mono', monospace; font-size:.75rem; color:var(--text-2); margin-left:auto; }++  .grid { display:grid; grid-template-columns:repeat(auto-fill,minmax(330px,1fr)); gap:12px; }++  .card {+    position:relative; background:var(--surface); border:1px solid var(--border);+    border-radius:var(--r-card); box-shadow:var(--shadow); overflow:hidden;+    display:flex; flex-direction:column;+  }+  .card::before { content:""; position:absolute; inset:0 auto 0 0; width:3px; background:var(--green); }+  .card.s-down::before { background:var(--red); }+  .card.s-pending::before { background:var(--yellow); }+  .card.s-unknown::before, .card.s-retired::before { background:var(--text-3); }+  .card.s-retired { opacity:.75; }++  .card-head { padding:16px 16px 12px; display:flex; flex-direction:column; gap:8px; }+  .card-title { display:flex; align-items:flex-start; justify-content:space-between; gap:12px; }+  .card-title h3 { margin:0; font-family:'Familjen Grotesk', sans-serif;+                   font-size:1.05rem; font-weight:600; color:var(--text); }+  .card-sub { display:block; font-family:'JetBrains Mono', monospace; font-size:.72rem;+              color:var(--text-2); margin-top:3px; }+  .card-note { font-size:.85rem; color:var(--text-body); margin:0; }++  .pill {+    flex:none; font-family:'JetBrains Mono', monospace; font-size:.68rem; font-weight:500;+    letter-spacing:.04em; padding:4px 10px; border-radius:var(--r-pill);+    white-space:nowrap; border:1px solid transparent;+  }+  .pill.ok { background:rgba(63,185,80,.12); color:var(--green); border-color:rgba(63,185,80,.35); }+  .pill.down { background:rgba(248,81,73,.12); color:var(--red); border-color:rgba(248,81,73,.35); }+  .pill.pending { background:rgba(210,153,34,.12); color:var(--yellow); border-color:rgba(210,153,34,.35); }+  .pill.unknown { background:var(--overlay); color:var(--text-2); border-color:var(--border); }++  .checks { border-top:1px solid var(--border); display:flex; flex-direction:column; }+  .check {+    display:flex; align-items:center; gap:10px; padding:8px 16px;+    border-bottom:1px solid var(--border); font-size:.85rem;+  }+  .check:last-child { border-bottom:none; }+  .led { width:8px; height:8px; border-radius:50%; flex:none; background:var(--green); }+  .led.down { background:var(--red); box-shadow:0 0 0 3px rgba(248,81,73,.18); }+  .led.pending { background:var(--yellow); box-shadow:0 0 0 3px rgba(210,153,34,.18); }+  .check-name { color:var(--text); }+  .check-blurb { color:var(--text-2); font-size:.78rem; margin-left:auto; text-align:right; }+  .check-reason { color:var(--red); font-size:.78rem; margin-left:auto; text-align:right; max-width:62%; }++  .card-foot {+    margin-top:auto;  /* cards stretch to equal height; pin the footer to the bottom */+    display:flex; align-items:center; gap:12px; flex-wrap:wrap;+    padding:12px 16px; border-top:1px solid var(--border); background:var(--elevated);+  }+  .card-foot a {+    font-family:'JetBrains Mono', monospace; font-size:.74rem; color:var(--blue);+    text-decoration:none; border-bottom:1px solid transparent; word-break:break-all;+  }+  .card-foot a:hover { border-bottom-color:var(--blue); }+  .card-foot .nohost { font-family:'JetBrains Mono', monospace; font-size:.74rem; color:var(--text-2); }+  .sbtn {+    margin-left:auto; background:var(--overlay); border:1px solid var(--border);+    border-radius:var(--r-btn); padding:6px 12px; font-size:.8rem; color:var(--text-body);+    transition:all .15s ease;+  }+  .sbtn:hover:not([disabled]) { border-color:var(--accent); color:var(--accent); }+  .sbtn:active:not([disabled]) { background:var(--surface); }+  .sbtn[disabled] { opacity:.45; cursor:not-allowed; }++  /* ── sections and log ──────────────────────────────────────── */+  section h2 {+    margin:0 0 12px; font-family:'Familjen Grotesk', sans-serif;+    font-size:.88rem; font-weight:600; letter-spacing:.02em; color:var(--text-2);+    border-bottom:1px solid var(--border); padding-bottom:8px;+    display:flex; justify-content:space-between; align-items:baseline; gap:12px;+  }+  h2 .sub { font-family:'JetBrains Mono', monospace; font-size:.72rem;+            color:var(--text-2); letter-spacing:0; font-weight:400; }+  .log {+    background:var(--surface); border:1px solid var(--border); border-radius:var(--r-card);+    max-height:290px; overflow:auto;+    /* Keep the layout from jumping sideways when the bar appears. */+    scrollbar-gutter:stable;+  }+  /* Inset the track by at least the radius, or the bar slices the corner curves. */+  .log::-webkit-scrollbar-track { margin:12px 0; background:transparent; }+  .log-row {+    display:flex; gap:12px; padding:6px 16px; border-bottom:1px solid var(--border);+    font-family:'JetBrains Mono', monospace; font-size:.75rem;+  }+  .log-row:last-child { border-bottom:none; }+  .log-ts { color:var(--text-2); flex:none; font-variant-numeric:tabular-nums; }+  .log-text { color:var(--text-body); word-break:break-word; }+  .log-empty { padding:16px; color:var(--text-2); font-size:.85rem; }+  .empty { color:var(--text-2); font-size:.9rem; grid-column:1/-1; }++  /* ── settings modal: dim 26%, never blurred ────────────────── */+  .backdrop {+    position:fixed; inset:0; z-index:60; background:rgba(0,0,0,.26);+    display:none; align-items:flex-start; justify-content:center;+    padding:72px 16px 32px; overflow:auto;+  }+  .backdrop.show { display:flex; }+  .modal {+    width:min(560px,100%); background:var(--surface); border:1px solid var(--border);+    border-radius:var(--r-card); box-shadow:0 24px 60px -20px rgba(0,0,0,.85);+  }+  .modal-head {+    display:flex; align-items:center; justify-content:space-between;+    padding:16px; border-bottom:1px solid var(--border);+  }+  .modal-head h2 { margin:0; border:none; padding:0; font-size:1.05rem;+                   letter-spacing:0; color:var(--text); }+  .setting { position:relative; padding:16px 16px 16px 20px; border-bottom:1px solid var(--border); }+  .setting::before { content:""; position:absolute; inset:16px auto 16px 0; width:3px;+                     background:var(--accent); border-radius:0 2px 2px 0; }+  .setting:last-child { border-bottom:none; }+  .setting h3 { margin:0 0 4px; font-family:'Familjen Grotesk', sans-serif;+                font-size:.95rem; font-weight:600; color:var(--text); }+  .setting p { margin:0 0 12px; font-size:.85rem; color:var(--text-body); }+  .row { display:flex; align-items:center; justify-content:space-between; gap:12px; }+  .row .lbl { font-size:.88rem; color:var(--text-body); }+  .row .def { display:block; font-family:'JetBrains Mono', monospace;+              font-size:.72rem; color:var(--text-2); }+  input[type=number] {+    width:88px; background:var(--elevated); border:1px solid var(--border); color:var(--text);+    border-radius:var(--r-btn); padding:8px; font-family:'JetBrains Mono', monospace; font-size:.85rem;+  }+  input[type=number]:hover { border-color:var(--text-3); }+  .toggle {+    width:44px; height:24px; border-radius:var(--r-pill); background:var(--overlay);+    border:1px solid var(--border); position:relative; flex:none; padding:0;+    transition:background .15s ease, border-color .15s ease;+  }+  .toggle::after { content:""; position:absolute; top:2px; left:2px; width:18px; height:18px;+                   border-radius:50%; background:var(--text-2);+                   transition:transform .15s ease, background .15s ease; }+  .toggle[aria-pressed="true"] {+    background:rgba(0,184,177,.16); border-color:var(--accent);+    box-shadow:inset 0 0 0 1px var(--accent);+  }+  .toggle[aria-pressed="true"]::after { transform:translateX(20px); background:var(--accent); }+  .toggle:hover { border-color:var(--text-3); }++  /* ── toasts: user feedback and the AI's narration channel ──── */+  #toasts {+    position:fixed; left:50%; bottom:24px; transform:translateX(-50%); z-index:80;+    display:flex; flex-direction:column-reverse; gap:8px; align-items:center;+    width:min(520px, calc(100vw - 32px));+  }+  .toast {+    background:var(--overlay); border:1px solid var(--border); border-left:3px solid var(--accent);+    border-radius:var(--r-btn); padding:12px 16px; font-size:.88rem; color:var(--text-body);+    box-shadow:var(--shadow); animation:rise .18s ease-out; width:100%;+    display:flex; align-items:flex-start; gap:10px; cursor:pointer;+  }+  .toast.success { border-left-color:var(--green); }+  .toast.error { border-left-color:var(--red); }+  .toast svg { width:16px; height:16px; flex:none; fill:none; stroke-width:1.8; margin-top:2px; }+  .toast.info svg { stroke:var(--accent); }+  .toast.success svg { stroke:var(--green); }+  .toast.error svg { stroke:var(--red); }+  @keyframes rise { from { opacity:0; transform:translateY(8px); } to { opacity:1; transform:none; } }++  /* ── tooltip: body-appended, fixed, viewport-clamped ───────── */+  #tt {+    position:fixed; z-index:99999; max-width:280px; pointer-events:none;+    background:var(--overlay); color:var(--text-body); border:1px solid var(--border);+    border-radius:var(--r-btn); padding:8px 12px; font-size:.82rem; line-height:1.45;+    box-shadow:var(--shadow);+    opacity:0; transform:translateY(4px); transition:opacity .18s ease, transform .18s ease;+  }+  #tt.show { opacity:1; transform:none; }+  /* Hover does not exist on touch, so the tooltip never fires there. Controls+     that need explaining carry visible helper text instead. */+  @media (hover:none), (pointer:coarse) { #tt { display:none; } }++  @media (prefers-reduced-motion:reduce) {+    * { animation:none !important; transition:none !important; }+  }+  @media (max-width:560px) {+    .check-blurb { display:none; }+    main { padding:12px 12px 56px; }+    .grid { grid-template-columns:1fr; }+  }+</style>+</head>+<body>++<header class="app-header">+  <span class="brand">+    <svg viewBox="0 0 24 24" aria-hidden="true">+      <rect x="3" y="4" width="18" height="6" rx="2"/>+      <rect x="3" y="14" width="18" height="6" rx="2"/>+      <path d="M7 7h.01M7 17h.01"/>+    </svg>+    <span class="app-name">Service Containers</span><span class="sub"> &middot; health</span>+  </span>+  <span class="vsep"></span>+  <span class="subject">+    <span class="led-hdr" id="global-led" aria-hidden="true"></span>+    <span class="name" id="subject-name">Adom fleet</span>+    <span class="meta" id="rollup">loading</span>+  </span>+  <span class="actions">+    <button class="hbtn" id="sweep"+      data-tooltip="Runs service-watchdog.sh right now instead of waiting for the next 5 minute cycle. Takes about 30 seconds and re-probes every container.">Run sweep</button>+    <button class="icon-btn" id="settings" aria-label="Settings"+      data-tooltip="Refresh rate, idle shutdown, and which sections are shown.">+      <svg viewBox="0 0 24 24"><circle cx="12" cy="12" r="3"/><path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 1 1-2.83 2.83l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 1 1-4 0v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 1 1-2.83-2.83l.06-.06A1.65 1.65 0 0 0 4.6 15a1.65 1.65 0 0 0-1.51-1H3a2 2 0 1 1 0-4h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 1 1 2.83-2.83l.06.06A1.65 1.65 0 0 0 9 4.6a1.65 1.65 0 0 0 1-1.51V3a2 2 0 1 1 4 0v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 1 1 2.83 2.83l-.06.06A1.65 1.65 0 0 0 19.4 9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 1 1 0 4h-.09a1.65 1.65 0 0 0-1.51 1z"/></svg>+    </button>+  </span>+</header>+++<main>+  <div class="banner" id="stale-banner">+    <svg viewBox="0 0 24 24" aria-hidden="true"><path d="M12 9v4M12 17h.01"/><path d="M10.3 3.9 1.8 18a2 2 0 0 0 1.7 3h17a2 2 0 0 0 1.7-3L13.7 3.9a2 2 0 0 0-3.4 0z"/></svg>+    <span><b>The watchdog itself has gone quiet.</b> <span id="stale-detail"></span>+    Everything below is the last verdict it managed to write, not live truth. This is the exact+    failure mode that hid a 7.3-hour OAuth outage: check cron on this container.</span>+  </div>++  <div class="filters">+    <button class="chip" id="filter-all" aria-pressed="true">All services</button>+    <button class="chip" id="filter-faults" aria-pressed="false">Needs attention</button>+    <span class="hint" id="updated">checking</span>+  </div>++  <div class="grid" id="grid"></div>++  <section id="retired-section" hidden>+    <h2>Deprecated services <span class="sub">off on purpose, not a fault</span></h2>+    <div class="grid" id="retired"></div>+  </section>++  <section id="log-section">+    <h2>Watchdog log <span class="sub">newest first</span></h2>+    <div class="log" id="log"></div>+  </section>+</main>++<div class="backdrop" id="settings-modal">+  <div class="modal" role="dialog" aria-modal="true" aria-label="Settings">+    <div class="modal-head">+      <h2>Settings</h2>+      <button class="icon-btn" id="close-settings" aria-label="Close settings">+        <svg viewBox="0 0 24 24"><path d="M18 6 6 18M6 6l12 12"/></svg>+      </button>+    </div>++    <div class="setting">+      <h3>Refresh rate</h3>+      <p>How often this page pulls fresh verdicts from disk. It never probes a service directly,+         so a fast refresh costs nothing: the watchdog does the real checking every 5 minutes.</p>+      <div class="row">+        <span class="lbl">Seconds between refreshes <span class="def">Default: 15</span></span>+        <input type="number" id="set-refresh" min="5" max="300" step="5">+      </div>+    </div>++    <div class="setting">+      <h3>Idle shutdown</h3>+      <p>This server exits on its own after a stretch with no requests, so forgotten instances+         don't pile up. Any visit resets the clock.</p>+      <div class="row">+        <span class="lbl">Hours before shutdown <span class="def">Default: 24</span></span>+        <input type="number" id="set-ttl" min="1" max="168" step="1">+      </div>+    </div>++    <div class="setting">+      <h3>Deprecated services</h3>+      <p>Containers that are off on purpose. They still have stale watchdog state on disk;+         showing them keeps you from re-fixing something you already retired.</p>+      <div class="row">+        <span class="lbl">Show the deprecated list <span class="def">Default: on</span></span>+        <button class="toggle" id="set-retired" aria-pressed="true" aria-label="Show deprecated services"></button>+      </div>+    </div>++    <div class="setting">+      <h3>Watchdog log</h3>+      <p>The tail of what the watchdogs actually did: failures, diagnoses, and recoveries.</p>+      <div class="row">+        <span class="lbl">Show the log panel <span class="def">Default: on</span></span>+        <button class="toggle" id="set-log" aria-pressed="true" aria-label="Show watchdog log"></button>+      </div>+    </div>+  </div>+</div>++<div id="toasts"></div>++<script>+const $ = id => document.getElementById(id);+// ── tooltips ───────────────────────────────────────────+// A fixed, body-appended div, never a ::after pseudo-element: cards use+// overflow:hidden, which would clip one. Clamped on BOTH axes so it can+// never run off an edge, and flipped rather than clipped near the bottom+// or right.+const TT = document.createElement('div');+TT.id = 'tt';+document.body.appendChild(TT);+let ttTimer = null;++function placeTip(anchorEl) {+  const M = 8, OFF = 14;+  const r = anchorEl.getBoundingClientRect();+  // Measure only AFTER the content is set and laid out.+  const w = TT.offsetWidth, h = TT.offsetHeight;+  let top = r.bottom + OFF;+  if (top + h + M > innerHeight) top = r.top - h - OFF;   // flip above+  let left = r.left + r.width / 2 - w / 2;+  if (left + w + M > innerWidth) left = r.right - w;      // flip left+  top = Math.max(M, Math.min(top, innerHeight - h - M));+  left = Math.max(M, Math.min(left, innerWidth - w - M));+  TT.style.top = top + 'px';+  TT.style.left = left + 'px';+}++function bindTips(root) {+  for (const el of root.querySelectorAll('[data-tooltip]')) {+    if (el.dataset.tooltipBound) continue;+    el.dataset.tooltipBound = '1';+    el.addEventListener('mouseenter', () => {+      clearTimeout(ttTimer);+      ttTimer = setTimeout(() => {+        TT.textContent = el.dataset.tooltip;+        TT.style.top = '-9999px'; TT.style.left = '-9999px';+        TT.classList.add('show');+        placeTip(el);+      }, 600);+    });+    el.addEventListener('mouseleave', () => {+      clearTimeout(ttTimer);+      TT.classList.remove('show');+    });+    el.addEventListener('click', () => { clearTimeout(ttTimer); TT.classList.remove('show'); });+  }+}++let STATE = null, SETTINGS = null, FILTER = 'all';++function ago(iso) {+  if (!iso) return ',';+  const s = Math.max(0, Math.round((Date.now() - new Date(iso).getTime()) / 1000));+  if (s < 60) return s + 's ago';+  if (s < 3600) return Math.round(s / 60) + 'm ago';+  if (s < 86400) return Math.round(s / 3600) + 'h ago';+  return Math.round(s / 86400) + 'd ago';+}+function esc(s) {+  return String(s ?? '').replace(/[&<>"']/g, c =>+    ({ '&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;' }[c]));+}++const TOAST_ICON = {+  info:    '<path d="M12 16v-4M12 8h.01"/><circle cx="12" cy="12" r="9"/>',+  success: '<path d="M20 6 9 17l-5-5"/>',+  error:   '<circle cx="12" cy="12" r="9"/><path d="M15 9l-6 6M9 9l6 6"/>',+};+function toast(kind, message) {+  const el = document.createElement('div');+  el.className = 'toast ' + kind;+  el.innerHTML = '<svg viewBox="0 0 24 24" aria-hidden="true">' ++                 (TOAST_ICON[kind] || TOAST_ICON.info) + '</svg><span></span>';+  el.querySelector('span').textContent = message;+  el.title = 'Click to dismiss';+  $('toasts').appendChild(el);+  // Errors stay until dismissed; confirmations clear themselves in 4s and+  // pause while the pointer is over them, so a toast can never vanish+  // mid-read.+  let timer = null;+  const arm = () => { if (kind !== 'error') timer = setTimeout(() => el.remove(), 4000); };+  el.onmouseenter = () => { if (timer) clearTimeout(timer); };+  el.onmouseleave = arm;+  el.onclick = () => el.remove();+  arm();+}++function cardHTML(s, retired) {+  const checks = s.checks.map(c => {+    const bad = c.kind === 'down' || c.kind === 'pending';+    const right = bad && c.reason+      ? '<span class="check-reason">' + esc(c.reason) + '</span>'+      : '<span class="check-blurb">' + esc(c.blurb) + '</span>';+    return '<div class="check"><span class="led ' + esc(c.kind === 'ok' ? '' : c.kind) + '"></span>' ++           '<span class="check-name">' + esc(c.name) + '</span>' + right + '</div>';+  }).join('');++  const canRestart = !retired && s.ssh && s.startCmd;+  const restartBtn = retired ? '' :+    '<button class="sbtn" id="restart-' + esc(s.label) + '" data-label="' + esc(s.label) + '"' ++    (canRestart+      ? ' data-tooltip="Runs this service\'s own start command over SSH, the same one the watchdog uses unattended. Anyone using it will see a few seconds of downtime."'+      : ' disabled data-tooltip="The watchdog config has no start command for this service, so there is nothing safe to run. Add one to TARGETS in service-watchdog.sh."') ++    '>Restart</button>';++  const link = s.host+    ? '<a href="https://' + esc(s.host) + '/health" target="_blank" rel="noopener">' + esc(s.host) + '</a>'+    : '<span class="nohost">no public host</span>';++  return '<article class="card s-' + esc(retired ? 'retired' : s.status) + '" id="svc-' + esc(s.label) + '">' ++    '<div class="card-head"><div class="card-title"><div>' ++      '<h3>' + esc(s.name) + '</h3>' ++      '<span class="card-sub">' + esc(s.repo || s.label) + (s.port ? ' · :' + esc(s.port) : '') + '</span>' ++    '</div><span class="pill ' + esc(retired ? 'unknown' : s.status) + '">' ++      esc(retired ? 'Retired' : s.status === 'pending' ? 'Watching'+          : s.status === 'down' ? 'Down' : s.status === 'unknown' ? 'Unknown' : 'Healthy') + '</span></div>' ++    (retired ? '<p class="card-note">' + esc(s.why) + '</p>'+             : (s.note ? '<p class="card-note">' + esc(s.note) + '</p>' : '')) ++    '</div>' ++    (retired ? '' : '<div class="checks">' + checks + '</div>') ++    '<div class="card-foot">' + link + restartBtn + '</div>' ++  '</article>';+}++function render() {+  if (!STATE) return;+  try { renderInner(); }+  catch (e) {+    // A render crash used to blank the page silently. Say so instead.+    console.error('render failed', e);+    toast('error', 'The page failed to draw: ' + e.message);+  }+}++function renderInner() {+  const c = STATE.counts;+  const faults = c.down + c.pending;++  const led = $('global-led');+  led.className = 'led-hdr' + (c.down ? ' bad' : c.pending ? ' warn' : '');+  $('rollup').textContent = faults === 0+    ? c.ok + '/' + c.total + ' healthy'+    : c.down + ' down, ' + c.pending + ' watching, ' + c.ok + ' healthy';+  $('updated').textContent = 'Watchdog last wrote ' + ago(+    STATE.services.map(s => s.lastCheckedAt).filter(Boolean).sort().pop());++  const stale = STATE.sweepStale;+  $('stale-banner').classList.toggle('show', !!stale);+  if (stale) {+    $('stale-detail').textContent =+      'Its last verdict is ' + Math.round(STATE.sweepAgeS / 60) + ' minutes old, and it runs every 5.';+  }++  let list = STATE.services;+  if (FILTER === 'faults') list = list.filter(s => s.status !== 'ok');+  $('grid').innerHTML = list.length+    ? list.map(s => cardHTML(s, false)).join('')+    : '<p class="empty">' ++      (FILTER === 'faults' ? 'Nothing needs attention. Every check is green.' : 'No services found.') + '</p>';++  const showRet = SETTINGS?.showRetired !== false && STATE.retired.length;+  $('retired-section').hidden = !showRet;+  if (showRet) $('retired').innerHTML = STATE.retired.map(s => cardHTML(s, true)).join('');++  const showLog = SETTINGS?.showLog !== false;+  $('log-section').hidden = !showLog;+  if (showLog) {+    $('log').innerHTML = STATE.log.length+      ? STATE.log.map(l =>+          '<div class="log-row"><span class="log-ts">' + esc((l.ts || '').replace('T', ' ').slice(0, 19)) ++          '</span><span class="log-text">' + esc(l.text) + '</span></div>').join('')+      : '<div class="log-empty">Nothing logged. These watchdogs only write when something fails or recovers, so an empty log is good news.</div>';+  }++  for (const b of document.querySelectorAll('.sbtn[data-label]')) {+    b.onclick = () => restart(b.dataset.label);+  }+  bindTips(document);+}++async function restart(label) {+  const svc = STATE.services.find(s => s.label === label);+  if (!svc) return;+  if (!confirm('Restart ' + svc.name + '?\n\nAnyone using it right now will see it drop for a few seconds.')) return;+  const btn = $('restart-' + label);+  if (btn) { btn.disabled = true; btn.textContent = 'Restarting…'; }+  try {+    const r = await fetch('api/restart', {+      method: 'POST', headers: { 'content-type': 'application/json' },+      body: JSON.stringify({ label, reason: 'Operator clicked Restart on the services dashboard' }),+    });+    const j = await r.json();+    if (!r.ok) toast('error', j.hint || j.error || 'Restart failed');+  } catch (e) {+    toast('error', 'Restart failed: ' + e.message);+  } finally {+    if (btn) { btn.disabled = false; btn.textContent = 'Restart'; }+  }+}++async function load() {+  const r = await fetch('api/state');+  STATE = await r.json();+  SETTINGS = STATE.settings;+  syncSettingsUI();+  render();+}++function syncSettingsUI() {+  if (!SETTINGS) return;+  $('set-refresh').value = SETTINGS.refreshSeconds;+  $('set-ttl').value = SETTINGS.ttlHours;+  $('set-retired').setAttribute('aria-pressed', String(SETTINGS.showRetired !== false));+  $('set-log').setAttribute('aria-pressed', String(SETTINGS.showLog !== false));+}++async function patchSettings(patch) {+  const r = await fetch('settings', {+    method: 'POST', headers: { 'content-type': 'application/json' },+    body: JSON.stringify(patch),+  });+  SETTINGS = await r.json();+  syncSettingsUI();+  render();+  schedule();+}++// ── events ─────────────────────────────────────────────+$('sweep').onclick = async () => {+  const b = $('sweep');+  b.disabled = true; b.textContent = 'Sweeping…';+  try {+    const r = await fetch('api/sweep', { method: 'POST' });+    if (!r.ok) toast('error', 'Sweep failed');+  } catch (e) { toast('error', 'Sweep failed: ' + e.message); }+  finally { b.disabled = false; b.textContent = 'Run sweep'; }+};++$('settings').onclick = () => $('settings-modal').classList.add('show');+$('close-settings').onclick = () => $('settings-modal').classList.remove('show');+$('settings-modal').onclick = e => { if (e.target === $('settings-modal')) $('settings-modal').classList.remove('show'); };+document.addEventListener('keydown', e => {+  if (e.key === 'Escape') $('settings-modal').classList.remove('show');+});++$('filter-all').onclick = () => setFilter('all');+$('filter-faults').onclick = () => setFilter('faults');+function setFilter(f) {+  FILTER = f;+  $('filter-all').setAttribute('aria-pressed', String(f === 'all'));+  $('filter-faults').setAttribute('aria-pressed', String(f === 'faults'));+  fetch('api/ui', { method: 'POST', headers: { 'content-type': 'application/json' },+                     body: JSON.stringify({ filter: f }) }).catch(() => {});+  render();+}++$('set-refresh').onchange = e => patchSettings({ refreshSeconds: Number(e.target.value) });+$('set-ttl').onchange = e => patchSettings({ ttlHours: Number(e.target.value) });+$('set-retired').onclick = e => {+  const on = e.currentTarget.getAttribute('aria-pressed') !== 'true';+  patchSettings({ showRetired: on });+};+$('set-log').onclick = e => {+  const on = e.currentTarget.getAttribute('aria-pressed') !== 'true';+  patchSettings({ showLog: on });+};++// ── live channel ───────────────────────────────────────+// Polling, not SSE: the container's ingress proxy buffers text/event-stream,+// so an EventSource through a /proxy/<port>/ URL never opens. Polling reads+// files the watchdog already wrote, so a fast cadence costs nothing.+let msgSince = 0;+let polling = false;++async function poll() {+  if (polling) return;+  polling = true;+  try {+    const [stateRes, msgRes] = await Promise.all([+      fetch('api/state'),+      fetch('api/messages?since=' + msgSince),+    ]);+    STATE = await stateRes.json();+    SETTINGS = STATE.settings;+    const m = await msgRes.json();+    // First poll only syncs the cursor: don't replay toasts from before the+    // page was open.+    if (msgSince === 0) msgSince = m.seq;+    else for (const item of m.messages) {+      msgSince = Math.max(msgSince, item.seq);+      if (item.event === 'toast') toast(item.data.kind, item.data.message);+      if (item.event === 'settings') SETTINGS = item.data;+    }+    syncSettingsUI();+    render();+  } catch (e) {+    // A failed poll is not worth a toast on every cycle; the "last wrote"+    // stamp in the header going stale is the honest signal.+  } finally { polling = false; }+}++let timer = null;+function schedule() {+  if (timer) clearInterval(timer);+  const ms = Math.max(5, SETTINGS?.refreshSeconds || 15) * 1000;+  timer = setInterval(poll, ms);+}++(async () => { await poll(); schedule(); })();+</script>+</body>+</html>
app/package.jsonadded+7
@@ -0,0 +1,7 @@+{+  "name": "svcwatch",+  "version": "1.0.0",+  "private": true,+  "type": "module",+  "scripts": { "start": "node server.js" }+}
app/server.jsadded+399
@@ -0,0 +1,399 @@+// svcwatch: the service container dashboard.+//+// Reads the watchdog's verdicts off disk and renders them. It does not probe+// anything itself; see state.js for why that matters (vendor quota).++import { createServer } from 'http';+import { readFileSync, writeFileSync, mkdirSync, readdirSync, unlinkSync, existsSync } from 'fs';+import { join, dirname } from 'path';+import { fileURLToPath } from 'url';+import { execFile } from 'child_process';++import { readState } from './state.js';++const DIR = dirname(fileURLToPath(import.meta.url));+const SLUG = 'svcwatch';+const VERSION = JSON.parse(readFileSync(join(DIR, 'package.json'), 'utf8')).version;++const INSTANCES = join(process.env.HOME, '.adom', 'instances', SLUG);+const SNAPSHOTS = join(INSTANCES, 'snapshots');+const SETTINGS_FILE = join(process.env.HOME, '.adom', `${SLUG}-settings.json`);++const DEFAULT_SETTINGS = {+  refreshSeconds: 15,+  ttlHours: 24,+  showRetired: true,+  showLog: true,+};++// ── args ───────────────────────────────────────────────────────────────+function argVal(flag, fallback = null) {+  const i = process.argv.indexOf(flag);+  return i > -1 && process.argv[i + 1] ? process.argv[i + 1] : fallback;+}+const CMD = process.argv[2] && !process.argv[2].startsWith('-') ? process.argv[2] : 'serve';+const AI_THREAD = argVal('--ai-thread', process.env.ADOM_AI_THREAD || '');+const KEEP_ALIVE = process.argv.includes('--keep-alive');+const TTL_H = Number(argVal('--ttl', process.env.APP_TTL_HOURS || 0)) || null;++const threadSlug = (AI_THREAD || 'default')+  .toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, '') || 'default';++for (const d of [INSTANCES, SNAPSHOTS, dirname(SETTINGS_FILE)]) mkdirSync(d, { recursive: true });++// ── settings ───────────────────────────────────────────────────────────+function loadSettings() {+  try { return { ...DEFAULT_SETTINGS, ...JSON.parse(readFileSync(SETTINGS_FILE, 'utf8')) }; }+  catch { return { ...DEFAULT_SETTINGS }; }+}+function saveSettings(s) {+  const merged = { ...loadSettings(), ...s };+  writeFileSync(SETTINGS_FILE, JSON.stringify(merged, null, 2));+  return merged;+}++// ── registry (Rule 3) ──────────────────────────────────────────────────+const regPath = join(INSTANCES, `${threadSlug}.json`);++function alive(pid) { try { process.kill(pid, 0); return true; } catch { return false; } }++function listInstances() {+  const out = [];+  let files = [];+  try { files = readdirSync(INSTANCES); } catch { return out; }+  for (const f of files) {+    if (!f.endsWith('.json')) continue;+    const p = join(INSTANCES, f);+    let e;+    try { e = JSON.parse(readFileSync(p, 'utf8')); } catch { continue; }+    if (!e.pid || !alive(e.pid)) { try { unlinkSync(p); } catch {} continue; }+    out.push({ ...e, file: p });+  }+  return out;+}++function register(port) {+  writeFileSync(regPath, JSON.stringify({+    pid: process.pid, port, thread: AI_THREAD || '(unnamed)', threadSlug,+    task: SLUG, version: VERSION,+    startedAt: new Date().toISOString(), lastActivity: new Date().toISOString(),+  }, null, 2));+}+function touchRegistry(port) {+  try {+    const e = JSON.parse(readFileSync(regPath, 'utf8'));+    e.lastActivity = new Date().toISOString();+    writeFileSync(regPath, JSON.stringify(e, null, 2));+  } catch { register(port); }+}+function deregister() { try { unlinkSync(regPath); } catch {} }++// ── snapshot (Rule 2): per thread, never shared ────────────────────────+const snapPath = join(SNAPSHOTS, `${threadSlug}.json`);+let uiState = { selected: null, filter: 'all' };+function saveSnapshot() {+  try { writeFileSync(snapPath, JSON.stringify({ uiState, savedAt: new Date().toISOString() }, null, 2)); }+  catch {}+}+function restoreSnapshot() {+  try {+    const s = JSON.parse(readFileSync(snapPath, 'utf8'));+    if (s.uiState) { uiState = s.uiState; return true; }+  } catch {}+  return false;+}++// ── console ring buffer, so the AI can read back what the page did ─────+const consoleLog = [];+function note(level, msg) {+  consoleLog.push({ ts: new Date().toISOString(), level, msg });+  if (consoleLog.length > 300) consoleLog.shift();+}++// ── live channel ───────────────────────────────────────────────────────+// The container's ingress proxy buffers text/event-stream, so an EventSource+// opened through a /proxy/<port>/ URL never leaves CONNECTING. Verified in+// pup, 2026-08-28. So the page POLLS, and anything the server wants to push+// (a CLI-driven toast, a state swap) lands in this queue for the poller to+// drain. /events still works for a direct 127.0.0.1 client.+const clients = new Set();+let msgSeq = 0;+const messages = [];+function broadcast(event, data) {+  messages.push({ seq: ++msgSeq, event, data, ts: Date.now() });+  // Bound the queue and drop anything older than 2 minutes: a page that has+  // been closed all day must not flood the next viewer with stale toasts.+  const cutoff = Date.now() - 120000;+  while (messages.length > 200 || (messages.length && messages[0].ts < cutoff)) messages.shift();+  const payload = `event: ${event}\ndata: ${JSON.stringify(data)}\n\n`;+  for (const res of clients) { try { res.write(payload); } catch {} }+}++// ── restart a service, via the watchdog's own start command ────────────+// Reuses the exact start_cmd from service-watchdog.sh's TARGETS so the+// button can never drift from what the watchdog would do unattended.+function restartService(label, cb) {+  const st = readState();+  const svc = st.services.find(s => s.label === label);+  if (!svc) return cb(new Error(`unknown service: ${label}`));+  if (!svc.ssh || !svc.startCmd) {+    return cb(new Error(`${svc.name} has no ssh target or start command in the watchdog config`));+  }+  const args = [+    '-k', '10', '90', 'ssh', '-o', 'BatchMode=yes', '-o', 'ConnectTimeout=10',+    '-o', 'ServerAliveInterval=5', '-o', 'StrictHostKeyChecking=accept-new',+    svc.ssh,+    `setsid -f nohup bash -c ${JSON.stringify(svc.startCmd)} >> /tmp/service-${label}.log 2>&1 < /dev/null`,+  ];+  note('info', `restart ${svc.name}: ssh ${svc.ssh}`);+  execFile('timeout', args, { timeout: 100000 }, (err, stdout, stderr) => {+    if (err) { note('error', `restart ${svc.name} failed: ${stderr || err.message}`); return cb(err); }+    note('info', `restart ${svc.name}: start command dispatched`);+    cb(null, { ok: true, service: svc.name, dispatched: svc.startCmd });+  });+}++// ── run one watchdog sweep on demand ───────────────────────────────────+function runSweep(cb) {+  note('info', 'running service-watchdog sweep');+  execFile('timeout', ['-k', '30', '270', '/home/adom/bin/service-watchdog.sh'],+    { timeout: 280000 }, (err) => {+      if (err && err.killed) { note('error', 'sweep timed out'); return cb(new Error('sweep timed out')); }+      note('info', 'sweep complete');+      cb(null, { ok: true });+    });+}++// ── HTTP ───────────────────────────────────────────────────────────────+function send(res, code, body, type = 'application/json') {+  res.writeHead(code, {+    'Content-Type': type,+    'Cache-Control': 'no-store',+    // The dock bar probes /health cross-origin. Without this it shows a red+    // LED and "not answering" while curl against the same URL succeeds.+    'Access-Control-Allow-Origin': '*',+  });+  res.end(typeof body === 'string' ? body : JSON.stringify(body, null, 2));+}++let lastActivity = Date.now();+let PORT = 0;++function handler(req, res) {+  lastActivity = Date.now();+  const url = new URL(req.url, `http://127.0.0.1:${PORT}`);+  const p = url.pathname;++  if (p === '/health') {+    const st = readState();+    return send(res, 200, {+      ok: true, service: SLUG, version: VERSION, port: PORT,+      thread: AI_THREAD || null, counts: st.counts, sweepAgeS: st.sweepAgeS,+    });+  }++  if (p === '/state' || p === '/api/state') {+    const st = readState();+    return send(res, 200, { ...st, settings: loadSettings(), uiState, version: VERSION, port: PORT });+  }++  if (p === '/console') return send(res, 200, { entries: consoleLog.slice(-200) });++  if (p === '/api/messages') {+    const since = Number(url.searchParams.get('since') || 0);+    return send(res, 200, { seq: msgSeq, messages: messages.filter(m => m.seq > since) });+  }++  if (p === '/settings' && req.method === 'GET') return send(res, 200, loadSettings());++  if (p === '/settings' && req.method === 'POST') {+    let body = '';+    req.on('data', c => body += c);+    req.on('end', () => {+      let patch = {};+      try { patch = JSON.parse(body || '{}'); } catch { return send(res, 400, { error: 'invalid JSON' }); }+      const merged = saveSettings(patch);+      note('info', `settings updated: ${Object.keys(patch).join(', ')}`);+      broadcast('settings', merged);+      send(res, 200, merged);+    });+    return;+  }++  if (p === '/api/ui' && req.method === 'POST') {+    let body = '';+    req.on('data', c => body += c);+    req.on('end', () => {+      try { uiState = { ...uiState, ...JSON.parse(body || '{}') }; } catch {}+      saveSnapshot();+      send(res, 200, uiState);+    });+    return;+  }++  // AI-drivable: raise a toast on the page from the CLI.+  if (p === '/api/toast' && req.method === 'POST') {+    let body = '';+    req.on('data', c => body += c);+    req.on('end', () => {+      let m = {};+      try { m = JSON.parse(body || '{}'); } catch {}+      const t = { kind: m.kind || 'info', message: m.message || '' };+      note('info', `toast(${t.kind}): ${t.message}`);+      broadcast('toast', t);+      send(res, 200, { ok: true, ...t });+    });+    return;+  }++  if (p === '/api/restart' && req.method === 'POST') {+    let body = '';+    req.on('data', c => body += c);+    req.on('end', () => {+      let m = {};+      try { m = JSON.parse(body || '{}'); } catch {}+      // Restarting someone else's service is out of the norm: demand a reason.+      if (!m.reason || !String(m.reason).trim()) {+        return send(res, 400, {+          error: 'reason_required',+          hint: 'Restarting a shared service interrupts anyone using it. Re-send with a written "reason" saying why.',+        });+      }+      if (!m.label) return send(res, 400, { error: 'label required' });+      broadcast('toast', { kind: 'info', message: `Restarting ${m.label}...` });+      restartService(m.label, (err, out) => {+        if (err) {+          broadcast('toast', { kind: 'error', message: `Restart failed: ${err.message}` });+          return send(res, 500, { error: err.message });+        }+        broadcast('toast', { kind: 'success', message: `${out.service} restart dispatched` });+        broadcast('state', readState());+        send(res, 200, out);+      });+    });+    return;+  }++  if (p === '/api/sweep' && req.method === 'POST') {+    broadcast('toast', { kind: 'info', message: 'Running watchdog sweep...' });+    runSweep((err) => {+      if (err) {+        broadcast('toast', { kind: 'error', message: err.message });+        return send(res, 500, { error: err.message });+      }+      const st = readState();+      broadcast('state', st);+      broadcast('toast', { kind: 'success', message: `Sweep complete: ${st.counts.ok}/${st.counts.total} healthy` });+      send(res, 200, { ok: true, counts: st.counts });+    });+    return;+  }++  if (p === '/events') {+    res.writeHead(200, {+      'Content-Type': 'text/event-stream',+      'Cache-Control': 'no-cache',+      'Connection': 'keep-alive',+      // The container's ingress proxy buffers responses by default, which+      // leaves EventSource stuck in CONNECTING forever. This header opts out,+      // and the padding below forces a flush through any proxy that ignores it.+      'X-Accel-Buffering': 'no',+    });+    res.write(':' + ' '.repeat(2048) + '\n\n');+    res.write(': connected\n\n');+    if (res.flush) res.flush();+    clients.add(res);+    // Keep-alive comment: an idle SSE stream can be dropped by the proxy.+    const ping = setInterval(() => { try { res.write(': ping\n\n'); } catch {} }, 20000);+    req.on('close', () => { clearInterval(ping); clients.delete(res); });+    return;+  }++  if (p === '/shutdown' && req.method === 'POST') {+    send(res, 200, { ok: true, message: 'shutting down' });+    setTimeout(() => { saveSnapshot(); deregister(); process.exit(0); }, 100);+    return;+  }++  if (p.startsWith('/fonts/') && p.endsWith('.woff2')) {+    // Basename only: never let a path segment escape the fonts directory.+    const name = p.split('/').pop();+    const file = join(DIR, 'fonts', name);+    if (!existsSync(file)) return send(res, 404, { error: 'no such font' });+    res.writeHead(200, { 'Content-Type': 'font/woff2', 'Cache-Control': 'max-age=604800' });+    return res.end(readFileSync(file));+  }++  if (p === '/icon.svg') {+    return send(res, 200, readFileSync(join(DIR, 'icon.svg'), 'utf8'), 'image/svg+xml');+  }++  if (p === '/' || p === '/index.html') {+    return send(res, 200, readFileSync(join(DIR, 'index.html'), 'utf8'), 'text/html; charset=utf-8');+  }++  send(res, 404, { error: 'not found', path: p });+}++// ── boot ───────────────────────────────────────────────────────────────+if (CMD === 'ls') {+  const inst = listInstances();+  if (!inst.length) { console.log('OK: no svcwatch instances running'); process.exit(0); }+  for (const e of inst) {+    const idleM = Math.round((Date.now() - new Date(e.lastActivity).getTime()) / 60000);+    const drift = e.version !== VERSION ? `  WARN: runs ${e.version}, disk has ${VERSION}` : '';+    console.log(`port ${e.port}  thread="${e.thread}"  idle ${idleM}m  v${e.version}${drift}`);+  }+  process.exit(0);+}++// Reuse instead of respawn (Rule 3).+const mine = listInstances().find(e => e.threadSlug === threadSlug);+if (mine && CMD === 'serve') {+  const ageM = Math.round((Date.now() - new Date(mine.startedAt).getTime()) / 60000);+  console.log(`OK: already running on port ${mine.port} (started ${ageM}m ago). Use that instance.`);+  console.log(`URL: http://127.0.0.1:${mine.port}/`);+  process.exit(0);+}++const wantPort = argVal('--port', 'auto');+const server = createServer(handler);+server.listen(wantPort === 'auto' ? 0 : Number(wantPort), '0.0.0.0', () => {+  PORT = server.address().port;+  const restored = restoreSnapshot();+  register(PORT);+  const settings = loadSettings();+  const ttl = TTL_H || settings.ttlHours || 24;++  console.log(`OK: svcwatch v${VERSION} listening on port ${PORT}`);+  console.log(`URL: http://127.0.0.1:${PORT}/`);+  if (restored) console.log(`Restored UI snapshot for thread "${AI_THREAD || 'default'}"`);+  if (KEEP_ALIVE) console.log('Idle reaper disabled (--keep-alive)');+  else console.log(`Idle reaper armed: exits after ${ttl}h with no requests`);++  note('info', `svcwatch v${VERSION} started on ${PORT}`);++  setInterval(() => { touchRegistry(PORT); }, 60e3).unref();++  // Push state on a timer so the page is live without polling.+  setInterval(() => {+    if (clients.size) broadcast('state', readState());+  }, Math.max(5, loadSettings().refreshSeconds) * 1000).unref();++  if (!KEEP_ALIVE) {+    setInterval(() => {+      if (Date.now() - lastActivity > ttl * 3600e3) {+        note('info', 'idle TTL reached, reaping self');+        saveSnapshot();+        deregister();+        execFile('adom-cli', ['hydrogen', 'workspace', 'remove-tab', '--name', 'Services'],+          () => process.exit(0));+        setTimeout(() => process.exit(0), 5000);+      }+    }, 60e3).unref();+  }+});++for (const sig of ['SIGINT', 'SIGTERM']) {+  process.on(sig, () => { saveSnapshot(); deregister(); process.exit(0); });+}
app/state.jsadded+218
@@ -0,0 +1,218 @@+// Reads the watchdog's own on-disk state. This app NEVER probes a service+// itself: service-watchdog.sh already does that every 5 minutes and writes+// its verdicts to $WD_STATE_DIR. Probing again here would double the load+// and, for Mouser and DigiKey, spend billable vendor quota just to draw a+// dot green. Read the verdict, don't re-derive it.++import { readFileSync, readdirSync, statSync, existsSync } from 'fs';+import { join } from 'path';++export const STATE_DIR = process.env.WD_STATE_DIR || '/home/adom/.watchdog-state';+const BIN = '/home/adom/bin';++const WATCHDOG_LOGS = [+  '/tmp/service-watchdog.log',+  '/tmp/kicad-watchdog.log',+  '/tmp/parts-vendor-watchdog.log',+];++// Human copy for each check key. The dashboard says what a failure MEANS,+// not just which probe tripped , "auto-deploy is dead" beats "git-auth down".+const CHECK_META = {+  'health':            { name: 'Health',      blurb: 'Public /health answers 200' },+  'deep':              { name: 'Deep probe',  blurb: 'A real API call, not just the port' },+  'cron':              { name: 'Cron',        blurb: 'Scheduled jobs can still run on the container' },+  'git-auth':          { name: 'Git auth',    blurb: 'Auto-deploy from main still works' },+  '5xx-rate':          { name: '5xx rate',    blurb: 'Recent server errors in the log' },+  '5xx':               { name: '5xx rate',    blurb: 'Recent server errors in the log' },+  'quota':             { name: 'Vendor quota',blurb: 'Daily API allowance not exhausted' },+  'freshness':         { name: 'Data age',    blurb: 'Backing dataset is being refreshed' },+  'credential-health': { name: 'Credentials', blurb: 'Stored credentials still valid' },+  'deploy':            { name: 'Deploy',      blurb: 'Latest main is actually running' },+  'git':               { name: 'Git auth',    blurb: 'Auto-deploy from main still works' },+};++// Service identity. Parsed from the watchdog TARGETS rows where possible so+// this can't drift; the map below only supplies display names and the notes+// that aren't encoded in the watchdog config.+const DISPLAY = {+  'mouser':            { name: 'Mouser',        repo: 'john/service-mouser',   note: 'Parts search vendor' },+  'digikey':           { name: 'DigiKey',       repo: 'john/service-digikey',  note: 'Parts search vendor' },+  'jlcpcb':            { name: 'JLCPCB',        repo: 'john/service-jlcpcb',   note: 'Parts search vendor, local SQLite' },+  'tts':               { name: 'TTS',           repo: 'john/service-tts',      note: 'Speech for hands-free' },+  'step2glb':          { name: 'STEP to GLB',   repo: 'john/service-step2glb', note: 'Chip thumbnails and 3D' },+  'oauth':             { name: 'OAuth Gateway', repo: 'john/service-oauth',    note: 'Static callback relay' },+  'kicad-v10-default': { name: 'KiCad CLI',     repo: 'john/service-kicad',    note: 'KiCad 10 headless' },+};++// Some watchdogs write under their own label but describe a facet of an+// existing service. Fold them in rather than inventing a second card.+const MERGE_INTO = { 'kicad-cred': 'kicad-v10-default' };++// Labels whose containers are deprecated. Their state files linger, but+// showing them as failing services would be a lie , they are off on purpose.+const RETIRED = {+  'wiki':          'Old wiki container, deprecated. wiki.adom.inc is separate and owned elsewhere.',+  'wiki-adom-inc': 'Monitored elsewhere, not ours to run.',+};++// Display names for retired labels, which have no DISPLAY entry.+const RETIRED_NAMES = {+  'wiki':          'Old wiki',+  'wiki-adom-inc': 'wiki.adom.inc',+};++function readTargets() {+  const out = {};+  try {+    const src = readFileSync(join(BIN, 'service-watchdog.sh'), 'utf8');+    const block = src.split('TARGETS=(')[1]?.split('\n)')[0] || '';+    for (const line of block.split('\n')) {+      const m = line.match(/^\s*"([^"]+)"/);+      if (!m) continue;+      const f = m[1].split('|');+      out[f[0]] = { ssh: f[1] || '', host: f[2] || '', port: f[3] || '', start: f[5] || '' };+    }+  } catch {}+  try {+    const src = readFileSync(join(BIN, 'kicad-watchdog-of-watchdogs.sh'), 'utf8');+    const block = src.split('TARGETS=(')[1]?.split('\n)')[0] || '';+    for (const line of block.split('\n')) {+      const m = line.match(/^\s*"([^"]+)"/);+      if (!m) continue;+      const f = m[1].split(':');+      out['kicad-' + f[0]] = { ssh: f[1] || '', host: f[2] || '', port: '8780', start: '' };+    }+  } catch {}+  return out;+}++// A state file holds either "ok" or "<kind>|<reason>|<since>".+function parseStateFile(path) {+  let raw = '';+  try { raw = readFileSync(path, 'utf8').trim(); } catch { return null; }+  if (!raw) return null;+  const [kind, reason = '', since = ''] = raw.split('|');+  let mtime = null;+  try { mtime = statSync(path).mtime.toISOString(); } catch {}+  return { kind, reason, since, mtime };+}++// Roll the per-check verdicts up to one service verdict.+//   down    - a confirmed fault, already announced+//   pending - failing but inside the debounce window, not yet announced+//   ok      - everything green+function rollup(checks) {+  if (checks.some(c => c.kind === 'down')) return 'down';+  if (checks.some(c => c.kind === 'pending')) return 'pending';+  return checks.length ? 'ok' : 'unknown';+}++export function readLogTail(limit = 60) {+  const lines = [];+  for (const p of WATCHDOG_LOGS) {+    if (!existsSync(p)) continue;+    let txt = '';+    try { txt = readFileSync(p, 'utf8'); } catch { continue; }+    const src = p.split('/').pop().replace('.log', '');+    for (const l of txt.trim().split('\n').slice(-limit)) {+      if (!l.trim()) continue;+      const m = l.match(/^\[([^\]]+)\]\s*(.*)$/);+      lines.push({ ts: m ? m[1] : '', text: m ? m[2] : l, src });+    }+  }+  // Newest first, and only entries that actually carry a timestamp sort well.+  lines.sort((a, b) => (b.ts || '').localeCompare(a.ts || ''));+  return lines.slice(0, limit);+}++export function readState() {+  const targets = readTargets();+  const byLabel = {};++  let files = [];+  try { files = readdirSync(STATE_DIR); } catch {}++  for (const f of files) {+    if (!f.endsWith('.state')) continue;+    const stem = f.slice(0, -'.state'.length);+    // Split on the LAST hyphen group that names a known check, so labels+    // containing hyphens (kicad-v10-default) survive intact.+    let label = stem, check = '';+    const known = Object.keys(CHECK_META).sort((a, b) => b.length - a.length);+    for (const k of known) {+      if (stem.endsWith('-' + k)) { check = k; label = stem.slice(0, -(k.length + 1)); break; }+    }+    if (!check) continue;++    const st = parseStateFile(join(STATE_DIR, f));+    if (!st) continue;++    if (MERGE_INTO[label]) { check = 'cred-' + check; label = MERGE_INTO[label]; }++    byLabel[label] ||= { label, checks: [] };+    const base = check.startsWith('cred-') ? check.slice(5) : check;+    const meta = check.startsWith('cred-')+      ? { name: 'Credential ' + (CHECK_META[base]?.name || base).toLowerCase(),+          blurb: 'Stored credentials still valid' }+      : (CHECK_META[check] || { name: check, blurb: '' });+    byLabel[label].checks.push({+      key: check, name: meta.name, blurb: meta.blurb,+      kind: st.kind, reason: st.reason, since: st.since, checkedAt: st.mtime,+    });+  }++  const services = [];+  const retired = [];++  for (const label of Object.keys(byLabel)) {+    const entry = byLabel[label];+    const t = targets[label] || {};+    const d = DISPLAY[label] || {};+    entry.checks.sort((a, b) => a.name.localeCompare(b.name));++    const row = {+      label,+      name: d.name || label,+      repo: d.repo || '',+      note: d.note || '',+      host: t.host || '',+      port: t.port || '',+      ssh: t.ssh || '',+      startCmd: t.start || '',+      checks: entry.checks,+      status: rollup(entry.checks),+      lastCheckedAt: entry.checks.map(c => c.checkedAt).filter(Boolean).sort().pop() || null,+    };++    if (RETIRED[label]) {+      retired.push({ ...row, name: RETIRED_NAMES[label] || row.name, why: RETIRED[label] });+      continue;+    }+    services.push(row);+  }++  // Faults first, then alphabetical, so what needs attention is at the top.+  const rank = { down: 0, pending: 1, unknown: 2, ok: 3 };+  services.sort((a, b) => (rank[a.status] - rank[b.status]) || a.name.localeCompare(b.name));++  const counts = { total: services.length, ok: 0, pending: 0, down: 0, unknown: 0 };+  for (const s of services) counts[s.status]++;++  // How long since the watchdog last wrote ANY verdict. If this grows past+  // ~10 minutes the watchdog itself has stopped, which is the failure mode+  // that hid the 7.3h OAuth outage , so the dashboard has to show it.+  let sweepAgeS = null;+  const newest = services.map(s => s.lastCheckedAt).filter(Boolean).sort().pop();+  if (newest) sweepAgeS = Math.max(0, Math.round((Date.now() - new Date(newest).getTime()) / 1000));++  return {+    generatedAt: new Date().toISOString(),+    counts,+    sweepAgeS,+    sweepStale: sweepAgeS != null && sweepAgeS > 900,+    services,+    retired,+    log: readLogTail(40),+  };+}
dockbar.jsonadded+32
@@ -0,0 +1,32 @@+{+  "dock_manifest": "1.2.0",+  "webview": {+    "title": "Service Containers",+    "favicon": "icon.svg"+  },+  "launch": {+    "mode": "container_command",+    "console": "show",+    "steps": [+      {+        "label": "Installing svcwatch",+        "run": "sh -c 'PATH=\"$HOME/.local/bin:$PATH\"; adom-wiki pkg install john/svcwatch'",+        "skip_if": "sh -c 'PATH=\"$HOME/.local/bin:$PATH\"; command -v svcwatch'",+        "timeout_seconds": 180+      },+      {+        "label": "Starting the services dashboard",+        "run": "sh -c 'PATH=\"$HOME/.local/bin:$PATH\"; exec svcwatch serve --print-url'",+        "await": "url",+        "timeout_seconds": 60+      }+    ],+    "on_ready": "navigate_placeholder",+    "ai": {+      "prompt": "I just opened Service Containers from the dock bar. Give me a one-line summary of my service containers, and if any are down, say which and what you would do about it. Read the state with `svcwatch status`; do not restart anything without asking me first.",+      "thread": "existing",+      "thread_fallback": "new",+      "submit": "staged"+    }+  }+}\ No newline at end of file
docs/HERO.mdadded+8
@@ -0,0 +1,8 @@+Generate `hero.png` for this folder in Hero Studio. It is the page-header image, reused on the homepage and the screensaver; the page overlays live stats on its lower third, so the image carries the title and artwork only.++New app/skill heroes MUST come from Hero Studio (the server rejects anything else with HERO_STUDIO_REQUIRED; the studio stamps provenance into the PNG and exports at 2000x1250):++1. `adom-wiki pkg install adom/hero-studio`+2. start it (`hero-studio`, port 8846) and open the webview so the human sees it+3. pose the hero (the UI, or POST /state), then Generate+4. copy `generated/latest.png` here as `docs/hero.png`
docs/hero.pngadded
⋯ 1 unchanged line ⋯
icon.svgadded+5
@@ -0,0 +1,5 @@+<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="#e6edf3" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round">+  <rect x="3" y="4" width="18" height="6" rx="2"/>+  <rect x="3" y="14" width="18" height="6" rx="2"/>+  <path d="M7 7h.01M7 17h.01"/>+</svg>
install.shadded+10
@@ -0,0 +1,10 @@+#!/bin/sh+set -e+BIN="$HOME/.local/bin"+mkdir -p "$BIN"+# Symlink back into the module dir so edits propagate and a reinstall does not+# clobber a running instance.+chmod +x "$PWD/app/cli.js"+ln -sfn "$PWD/app/cli.js" "$BIN/svcwatch"+echo "OK: svcwatch installed to $BIN/svcwatch"+echo "Hint: start it with \`svcwatch serve\`, or open the tab with \`svcwatch show\`"
package.jsonadded+56
@@ -0,0 +1,56 @@+{+  "slug": "svcwatch",+  "title": "Service Containers",+  "type": "app",+  "version": "1.0.0",+  "description": "One dashboard for every Adom service container: what is healthy, what is down, why, and a restart button that runs the watchdog's own start command. Reads the watchdog's verdicts off disk, so it costs no extra probes and no vendor API quota.",+  "tags": [+    "cli",+    "dashboard",+    "monitoring",+    "services",+    "watchdog"+  ],+  "dependencies": {},+  "discovery_triggers": [+    "are my services up",+    "check my service containers",+    "is mouser down",+    "is digikey down",+    "is jlcpcb down",+    "service dashboard",+    "restart a service container",+    "why is my service down",+    "service health"+  ],+  "discovery_pitch": "Reach for svcwatch when someone asks whether their service containers are healthy, which one is down, or wants to restart one.",+  "sample_prompts": [+    {+      "label": "Health check",+      "prompt": "are all my service containers up?"+    },+    {+      "label": "Diagnose",+      "prompt": "why is the oauth gateway down?"+    },+    {+      "label": "Restart",+      "prompt": "restart the mouser service"+    }+  ],+  "metadata": {+    "activate": {+      "kind": "bridge",+      "skill": "svcwatch",+      "requires": [],+      "verbPrefixes": []+    }+  },+  "scripts": {+    "install": "./install.sh",+    "uninstall": "./uninstall.sh"+  },+  "hero": {+    "path": "docs/hero.png"+  }+}\ No newline at end of file
uninstall.shadded+5
@@ -0,0 +1,5 @@+#!/bin/sh+set -e+"$HOME/.local/bin/svcwatch" stop 2>/dev/null || true+rm -f "$HOME/.local/bin/svcwatch"+echo "OK: svcwatch removed"