← back to Loops Dashboard

server.js

427 lines

// loops-dashboard — single-pane aggregator for every AI loop running on Steve's stack.
// Dark theme, auto-refresh every 60s. KeepAlive supervised by launchd at :9930.
//
// Sources (all read-only):
//   - idea-loop:      ~/.claude/skills/idea-loop/data/{ideas,rejects,decisions}.jsonl
//   - codex-3way:     ~/Projects/*/.git/codex-3way-hook/latest.summary.md
//   - morning-review: http://127.0.0.1:9762/api/findings
//   - launchd:        `launchctl list | grep com.steve.`
//   - pm2:            `pm2 jlist` (best-effort)

const http = require('http');
const fs = require('fs');
const path = require('path');
const os = require('os');
const { execFileSync } = require('child_process');

const PORT = process.env.PORT || 9930;
const HOME = os.homedir();
const IDEA_DATA = path.join(HOME, '.claude/skills/idea-loop/data');
const REPOS = ['ventura-claw-leads', 'Designer-Wallcoverings'];

function esc(s) {
  return String(s == null ? '' : s)
    .replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;')
    .replace(/"/g, '&quot;').replace(/'/g, '&#39;');
}

function readJsonl(p) {
  if (!fs.existsSync(p)) return [];
  return fs.readFileSync(p, 'utf8').split('\n').filter(Boolean)
    .map(l => { try { return JSON.parse(l); } catch { return null; } })
    .filter(Boolean);
}

function safeRun(bin, args, opts = {}) {
  try {
    return execFileSync(bin, args, { encoding: 'utf8', timeout: 4000, stdio: ['ignore', 'pipe', 'ignore'], ...opts });
  } catch { return ''; }
}

function ageHuman(ts) {
  if (!ts) return '—';
  const ms = Date.now() - new Date(ts).getTime();
  if (isNaN(ms) || ms < 0) return '—';
  const sec = Math.floor(ms / 1000);
  if (sec < 60) return sec + 's ago';
  const min = Math.floor(sec / 60);
  if (min < 60) return min + 'm ago';
  const hr = Math.floor(min / 60);
  if (hr < 24) return hr + 'h ' + (min % 60) + 'm ago';
  return Math.floor(hr / 24) + 'd ago';
}

// --------------------------------- Sources ---------------------------------

function getIdeaLoopState() {
  const ideas = readJsonl(path.join(IDEA_DATA, 'ideas.jsonl'));
  const rejects = readJsonl(path.join(IDEA_DATA, 'rejects.jsonl'));
  const decisions = readJsonl(path.join(IDEA_DATA, 'decisions.jsonl'));
  const lastTickPath = path.join(IDEA_DATA, 'tick.log');
  let lastTick = null;
  try {
    const log = fs.readFileSync(lastTickPath, 'utf8').trim().split('\n');
    for (let i = log.length - 1; i >= 0; i--) {
      if (/tick \d+-\d+ complete/.test(log[i])) { lastTick = log[i].match(/^\[([^\]]+)\]/)?.[1]; break; }
    }
  } catch {}
  const sorted = [...ideas].sort((a, b) => (b.score_total || 0) - (a.score_total || 0));
  return {
    accepted: ideas.length,
    rejected: rejects.length,
    decisions: decisions.length,
    last_tick: lastTick,
    last_tick_age: ageHuman(lastTick),
    top: sorted.slice(0, 5).map(i => ({
      score: i.score_total, type: i.type, title: i.title,
      first_step: i.first_step, why: i.why
    }))
  };
}

function getCodex3WayState() {
  const out = [];
  for (const repo of REPOS) {
    const dir = path.join(HOME, 'Projects', repo);
    const hookDir = path.join(dir, '.git/codex-3way-hook');
    if (!fs.existsSync(hookDir)) continue;
    let entries = [];
    try { entries = fs.readdirSync(hookDir); } catch {}
    const logs = entries
      .filter(f => /^auto-[a-f0-9]{6,12}\.log$/.test(f))
      .map(f => {
        const full = path.join(hookDir, f);
        let mtime = 0; try { mtime = fs.statSync(full).mtimeMs; } catch {}
        return { sha: f.replace(/^auto-/, '').replace(/\.log$/, ''), mtime, full };
      })
      .sort((a, b) => b.mtime - a.mtime);
    const latest = logs[0];
    let subject = '';
    if (latest) {
      try { subject = execFileSync('git', ['-C', dir, 'show', '-s', '--format=%s', latest.sha], { encoding: 'utf8', timeout: 3000 }).trim(); } catch {}
    }
    out.push({
      repo,
      total_debates: logs.length,
      last_sha: latest?.sha || null,
      last_mtime: latest ? new Date(latest.mtime).toISOString() : null,
      last_age: latest ? ageHuman(new Date(latest.mtime).toISOString()) : '—',
      last_subject: subject
    });
  }
  return out;
}

function getMorningReviewState() {
  return new Promise((resolve) => {
    const auth = 'Basic ' + Buffer.from('admin:DWSecure2024!').toString('base64');
    const req = http.request({
      hostname: '127.0.0.1', port: 9762, path: '/api/findings',
      method: 'GET', headers: { Authorization: auth }, timeout: 4000
    }, (res) => {
      let body = '';
      res.on('data', c => body += c);
      res.on('end', () => {
        if (res.statusCode !== 200) {
          return resolve({ total: 0, by_source: {}, error: 'http-' + res.statusCode });
        }
        try {
          const d = JSON.parse(body);
          const findings = d.findings || (Array.isArray(d) ? d : []);
          const bySource = {};
          for (const f of findings) {
            const s = f.source || 'unknown';
            bySource[s] = (bySource[s] || 0) + 1;
          }
          resolve({ total: findings.length, by_source: bySource });
        } catch { resolve({ total: 0, by_source: {}, error: 'parse-fail' }); }
      });
    });
    req.on('error', () => resolve({ total: 0, by_source: {}, error: 'unreachable' }));
    req.on('timeout', () => { req.destroy(); resolve({ total: 0, by_source: {}, error: 'timeout' }); });
    req.end();
  });
}

function getLaunchdState() {
  const raw = safeRun('/bin/launchctl', ['list']);
  const lines = raw.split('\n').filter(l => l.includes('com.steve.'));
  const jobs = lines.map(l => {
    const parts = l.split(/\s+/);
    return { pid: parts[0], status: parts[1], label: parts[2] };
  });
  const problems = jobs.filter(j => j.status !== '-' && j.status !== '0');
  const ai_loop_jobs = jobs.filter(j =>
    /idea-loop|morning-review|codex-meeting|claude-codex|yolo|debate/.test(j.label || '')
  );
  return { total: jobs.length, problems: problems.length, problem_jobs: problems.slice(0, 8), ai_loop_jobs };
}

function getPm2State() {
  const out = safeRun('/usr/local/bin/pm2', ['jlist']) || safeRun(`${HOME}/.npm-global/bin/pm2`, ['jlist']) || safeRun('pm2', ['jlist']);
  if (!out) return { total: 0, online: 0, errored: 0, ai_loop: [] };
  let procs = [];
  try { procs = JSON.parse(out); } catch { return { total: 0, online: 0, errored: 0, ai_loop: [] }; }
  const online = procs.filter(p => p.pm2_env?.status === 'online').length;
  const errored = procs.filter(p => p.pm2_env?.status === 'errored' || p.pm2_env?.status === 'stopped').length;
  const aiLoop = procs.filter(p =>
    /viewer|morning|review|idea|codex|loop|hawk/.test(p.name || '')
  ).map(p => ({ name: p.name, status: p.pm2_env?.status, mem_mb: Math.round((p.monit?.memory || 0) / 1024 / 1024), cpu: p.monit?.cpu }));
  return { total: procs.length, online, errored, ai_loop: aiLoop };
}

// --------------------------------- Render ---------------------------------

function renderPage(data) {
  const { idea, codex3, morning, launchd, pm2 } = data;
  const css = `
    * { box-sizing: border-box; }
    body { font-family: -apple-system, "SF Pro Text", Helvetica, Arial, sans-serif; background: #0a0a0a; color: #f5f1e8; margin: 0; padding: 24px 28px; line-height: 1.5; }
    h1 { font-family: -apple-system, "SF Pro Display", Georgia, serif; font-weight: 200; font-size: 24px; letter-spacing: 0.32em; text-transform: uppercase; margin: 0 0 6px; }
    .sub { color: #888; font-size: 11px; letter-spacing: 0.18em; text-transform: uppercase; margin: 0 0 32px; }
    .grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(380px, 1fr)); gap: 18px; max-width: 1500px; margin: 0 auto; }
    .panel { background: #141414; border: 1px solid #222; border-left: 3px solid #c9a14b; padding: 16px 20px; border-radius: 4px; }
    .panel h2 { font-family: Georgia, serif; font-weight: 400; font-size: 16px; margin: 0 0 6px; color: #f5f1e8; }
    .panel .label { color: #888; font-size: 10px; letter-spacing: 0.18em; text-transform: uppercase; margin-bottom: 12px; }
    .stats { display: flex; gap: 18px; flex-wrap: wrap; margin: 0 0 12px; }
    .stat { display: flex; flex-direction: column; gap: 2px; }
    .stat .v { font-family: ui-monospace, "SF Mono", monospace; font-size: 22px; color: #c9a14b; line-height: 1; }
    .stat .k { font-size: 10px; color: #888; letter-spacing: 0.1em; text-transform: uppercase; }
    .row { display: flex; justify-content: space-between; gap: 12px; padding: 6px 0; border-top: 1px dotted #222; font-size: 13px; }
    .row:first-of-type { border-top: 0; }
    .row .left { color: #ddd; flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; }
    .row .right { color: #888; font-family: ui-monospace, monospace; font-size: 12px; flex-shrink: 0; }
    .badge { display: inline-block; padding: 2px 8px; border-radius: 3px; font-size: 10px; letter-spacing: 0.06em; text-transform: uppercase; margin-left: 6px; }
    .b-online { background: #064e3b; color: #6ee7b7; }
    .b-error { background: #7f1d1d; color: #fecaca; }
    .b-stale { background: #422006; color: #fed7aa; }
    .top-idea { padding: 8px 0; border-top: 1px dotted #222; font-size: 12px; }
    .top-idea:first-child { border-top: 0; }
    .top-idea .head { display: flex; justify-content: space-between; gap: 8px; }
    .top-idea .score { font-family: ui-monospace, monospace; color: #c9a14b; font-weight: 600; font-size: 13px; }
    .top-idea .title { color: #f5f1e8; font-weight: 500; flex: 1; }
    .top-idea .why { color: #aaa; font-size: 11px; margin: 4px 0 0; line-height: 1.45; }
    .links { font-size: 11px; color: #666; margin-top: 24px; text-align: center; }
    .links a { color: #c9a14b; text-decoration: none; margin: 0 8px; }
    .meta-row { font-size: 11px; color: #666; margin: 0 0 12px; font-family: ui-monospace, monospace; }
    .severity-HIGH { color: #fca5a5; }
    .severity-MEDIUM { color: #fcd34d; }
    .severity-LOW { color: #888; }
    .auto-refresh { font-size: 10px; color: #555; text-align: right; margin: 0 0 8px; letter-spacing: 0.06em; }
  `;

  // idea-loop top picks
  const topIdeasHtml = idea.top.length
    ? idea.top.map(i => `
        <div class="top-idea">
          <div class="head">
            <span class="score">${i.score}/40</span>
            <span class="title">${esc(i.title || '')}</span>
            <span class="severity-${i.score >= 32 ? 'HIGH' : i.score >= 28 ? 'MEDIUM' : 'LOW'}">${esc((i.type || '').toUpperCase())}</span>
          </div>
          ${i.why ? `<div class="why">${esc(i.why.slice(0, 140))}${i.why.length > 140 ? '…' : ''}</div>` : ''}
        </div>`).join('')
    : '<div class="top-idea"><div class="why">No accepted ideas yet — wait for the next 10-min tick.</div></div>';

  // codex-3way per-repo
  const codex3Html = codex3.length
    ? codex3.map(c => `
        <div class="row">
          <span class="left">${esc(c.repo)} · <span style="color:#666">${c.total_debates} debates</span></span>
          <span class="right">${esc(c.last_age)}</span>
        </div>
        ${c.last_subject ? `<div style="font-size:11px;color:#aaa;margin:0 0 8px;padding-left:10px">latest: <code style="color:#c9a14b">${esc(c.last_sha?.slice(0,7))}</code> ${esc(c.last_subject.slice(0, 80))}${c.last_subject.length > 80 ? '…' : ''}</div>` : ''}`).join('')
    : '<div class="row"><span class="left">No debates yet</span></div>';

  // launchd issues
  const launchdProblemsHtml = launchd.problem_jobs.length
    ? launchd.problem_jobs.map(j => `
        <div class="row">
          <span class="left">${esc(j.label)}</span>
          <span class="right">exit ${esc(j.status)}<span class="badge b-error">err</span></span>
        </div>`).join('')
    : '<div class="row"><span class="left">All launchd jobs healthy</span><span class="right"><span class="badge b-online">ok</span></span></div>';

  // launchd AI-loop subset
  const aiJobsHtml = launchd.ai_loop_jobs.length
    ? launchd.ai_loop_jobs.map(j => {
        const statusClass = j.status === '-' || j.status === '0' ? 'b-online' : 'b-error';
        const statusText = j.status === '-' || j.status === '0' ? 'ok' : `exit ${j.status}`;
        return `<div class="row"><span class="left">${esc(j.label)}</span><span class="right"><span class="badge ${statusClass}">${esc(statusText)}</span></span></div>`;
      }).join('')
    : '<div class="row"><span class="left">No AI-loop launchd jobs</span></div>';

  // pm2 ai-loop
  const pm2Html = pm2.ai_loop.length
    ? pm2.ai_loop.map(p => {
        const sc = p.status === 'online' ? 'b-online' : 'b-error';
        return `<div class="row"><span class="left">${esc(p.name)}</span><span class="right">${p.mem_mb}MB · ${p.cpu || 0}%<span class="badge ${sc}">${esc(p.status)}</span></span></div>`;
      }).join('')
    : '<div class="row"><span class="left">No AI-loop pm2 procs</span></div>';

  // morning-review by source
  const sortedSources = Object.entries(morning.by_source || {}).sort((a, b) => b[1] - a[1]);
  const morningSourcesHtml = sortedSources.length
    ? sortedSources.map(([s, n]) => `
        <div class="row"><span class="left">${esc(s)}</span><span class="right">${n}</span></div>`).join('')
    : '<div class="row"><span class="left">no findings</span></div>';

  return `<!DOCTYPE html>
<html><head>
  <meta charset="utf-8">
  <meta name="viewport" content="width=device-width,initial-scale=1">
  <title>loops-dashboard · ${idea.accepted}/${idea.decisions} ideas · ${morning.total} findings</title>
  <meta http-equiv="refresh" content="60">
  <style>${css}</style>
</head><body>
  <h1>Loops Dashboard</h1>
  <div class="sub">all AI loops &middot; aggregator &middot; auto-refresh 60s</div>
  <div class="auto-refresh">refreshed ${new Date().toISOString()}</div>

  <div class="grid">
    <!-- IDEA-LOOP -->
    <div class="panel">
      <div class="label">Idea-loop · autonomous R&amp;D</div>
      <h2>${idea.accepted} accepted · ${idea.decisions} decided</h2>
      <div class="meta-row">last tick ${esc(idea.last_tick_age)} · <a href="http://127.0.0.1:9920" style="color:#c9a14b">viewer →</a></div>
      <div class="stats">
        <div class="stat"><div class="v">${idea.accepted}</div><div class="k">accepted</div></div>
        <div class="stat"><div class="v">${idea.rejected}</div><div class="k">rejected</div></div>
        <div class="stat"><div class="v">${idea.decisions}</div><div class="k">decided</div></div>
        <div class="stat"><div class="v">${idea.accepted - idea.decisions}</div><div class="k">awaiting</div></div>
      </div>
      <div style="margin-top:8px">
        <div class="label" style="margin-bottom:6px">Top accepted</div>
        ${topIdeasHtml}
      </div>
    </div>

    <!-- CODEX-3WAY -->
    <div class="panel">
      <div class="label">Codex-3way · auto-debate</div>
      <h2>${codex3.reduce((s, c) => s + c.total_debates, 0)} debates total</h2>
      <div class="meta-row">post-commit hook on ${codex3.length} repos</div>
      ${codex3Html}
    </div>

    <!-- MORNING-REVIEW -->
    <div class="panel">
      <div class="label">Morning-review · 8:30 AM digest</div>
      <h2>${morning.total} findings ${morning.error ? `<span class="badge b-error">${esc(morning.error)}</span>` : ''}</h2>
      <div class="meta-row">6 scanners aggregated · <a href="http://127.0.0.1:9762" style="color:#c9a14b">viewer →</a></div>
      ${morningSourcesHtml}
    </div>

    <!-- LAUNCHD AI-LOOP JOBS -->
    <div class="panel">
      <div class="label">Launchd · AI-loop subset</div>
      <h2>${launchd.ai_loop_jobs.length} jobs · ${launchd.total} total</h2>
      <div class="meta-row">${launchd.problems} problems across full fleet</div>
      ${aiJobsHtml}
    </div>

    <!-- PM2 -->
    <div class="panel">
      <div class="label">pm2 · running services</div>
      <h2>${pm2.online}/${pm2.total} online${pm2.errored ? ` · ${pm2.errored} errored` : ''}</h2>
      <div class="meta-row">filtered to AI-loop processes</div>
      ${pm2Html}
    </div>

    <!-- LAUNCHD PROBLEMS -->
    <div class="panel">
      <div class="label">Launchd · problems</div>
      <h2>${launchd.problems} jobs with non-zero exit</h2>
      <div class="meta-row">across all com.steve.* labels</div>
      ${launchdProblemsHtml}
    </div>
  </div>

  <div class="links">
    <a href="http://127.0.0.1:9920">idea-loop viewer</a> ·
    <a href="http://127.0.0.1:9762">morning-review</a> ·
    <a href="https://leads.venturaclaw.com">VCL</a> ·
    <a href="https://novasuede.com">novasuede</a> ·
    <a href="/api/state">JSON state</a>
  </div>
</body></html>`;
}

// --------------------------------- Cache + background refresh ---------------------------------
// Each source has a TTL. Page renders read cache instantly (snappy). A background
// loop refreshes stale entries every REFRESH_MS so the page never blocks on slow ops.
const REFRESH_MS = 30_000;
const cache = {
  idea:    { value: null, ts: 0, ttl: 5_000 },   // cheap (file reads), short TTL
  codex3:  { value: null, ts: 0, ttl: 30_000 },  // git show calls, 30s
  morning: { value: null, ts: 0, ttl: 60_000 },  // 6 scanners on the other end, 1 min
  launchd: { value: null, ts: 0, ttl: 15_000 },  // launchctl list, fast
  pm2:     { value: null, ts: 0, ttl: 30_000 }   // pm2 jlist, slow, 30s
};

async function refresh(key, fn) {
  try {
    const v = await fn();
    cache[key].value = v;
    cache[key].ts = Date.now();
  } catch (e) {
    console.error(`[cache:${key}] refresh error:`, e.message);
  }
}

async function refreshAll() {
  await Promise.all([
    refresh('idea',    async () => getIdeaLoopState()),
    refresh('codex3',  async () => getCodex3WayState()),
    refresh('morning', async () => await getMorningReviewState()),
    refresh('launchd', async () => getLaunchdState()),
    refresh('pm2',     async () => getPm2State())
  ]);
}

// Read cache (refresh in background if stale; never block the read).
function readCache(key, fallback) {
  const c = cache[key];
  if (!c.value) return fallback;
  return c.value;
}

// Kick off initial refresh + interval
refreshAll().catch(() => {});
setInterval(() => { refreshAll().catch(() => {}); }, REFRESH_MS);

// --------------------------------- Server ---------------------------------

const server = http.createServer(async (req, res) => {
  if (req.url === '/healthz') {
    res.writeHead(200, { 'Content-Type': 'application/json' });
    return res.end(JSON.stringify({
      ok: true, ts: new Date().toISOString(),
      cache_ages_s: Object.fromEntries(Object.entries(cache).map(([k, v]) => [k, v.ts ? Math.round((Date.now() - v.ts) / 1000) : -1]))
    }));
  }

  try {
    const idea    = readCache('idea',    { accepted: 0, rejected: 0, decisions: 0, top: [], last_tick_age: '—' });
    const codex3  = readCache('codex3',  []);
    const morning = readCache('morning', { total: 0, by_source: {} });
    const launchd = readCache('launchd', { total: 0, problems: 0, problem_jobs: [], ai_loop_jobs: [] });
    const pm2     = readCache('pm2',     { total: 0, online: 0, errored: 0, ai_loop: [] });

    if (req.url === '/api/state') {
      res.writeHead(200, { 'Content-Type': 'application/json' });
      return res.end(JSON.stringify({ idea, codex3, morning, launchd, pm2 }, null, 2));
    }

    res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' });
    res.end(renderPage({ idea, codex3, morning, launchd, pm2 }));
  } catch (e) {
    res.writeHead(500, { 'Content-Type': 'text/plain' });
    res.end('error: ' + e.message);
  }
});

server.listen(PORT, '127.0.0.1', () => {
  console.log(`loops-dashboard → http://127.0.0.1:${PORT}`);
});