[object Object]

← back to Loops Dashboard

initial scaffold: loops-dashboard at :9930 aggregating idea-loop + codex-3way + morning-review + launchd + pm2

5ba48a62738ccd88afc1697aeb8a125a4eaceac3 · 2026-05-08 07:26:08 -0700 · Steve Abrams

Files touched

Diff

commit 5ba48a62738ccd88afc1697aeb8a125a4eaceac3
Author: Steve Abrams <steve@designerwallcoverings.com>
Date:   Fri May 8 07:26:08 2026 -0700

    initial scaffold: loops-dashboard at :9930 aggregating idea-loop + codex-3way + morning-review + launchd + pm2
---
 .gitignore |   3 +
 README.md  |  17 +++
 server.js  | 377 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
 3 files changed, 397 insertions(+)

diff --git a/.gitignore b/.gitignore
new file mode 100644
index 0000000..3c45938
--- /dev/null
+++ b/.gitignore
@@ -0,0 +1,3 @@
+node_modules/
+*.log
+.DS_Store
diff --git a/README.md b/README.md
new file mode 100644
index 0000000..1b2510b
--- /dev/null
+++ b/README.md
@@ -0,0 +1,17 @@
+# loops-dashboard
+
+Single-pane aggregator for every AI loop running on Steve's Mac2 stack.
+Dark-theme HTML page, auto-refresh every 60s, KeepAlive supervised by launchd at http://127.0.0.1:9930.
+
+Sources (all read-only):
+- idea-loop accepted/rejected/decisions ledger
+- codex-3way per-commit auto-debate output
+- morning-review findings count by source
+- launchd `com.steve.*` job statuses
+- pm2 jlist filtered to AI-loop processes
+
+## Run
+
+    node server.js
+
+Or via launchd: `~/Library/LaunchAgents/com.steve.loops-dashboard.plist`
diff --git a/server.js b/server.js
new file mode 100644
index 0000000..120653d
--- /dev/null
+++ b/server.js
@@ -0,0 +1,377 @@
+// 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', () => {
+        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>`;
+}
+
+// --------------------------------- 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() }));
+  }
+
+  try {
+    const idea = getIdeaLoopState();
+    const codex3 = getCodex3WayState();
+    const morning = await getMorningReviewState();
+    const launchd = getLaunchdState();
+    const pm2 = getPm2State();
+
+    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}`);
+});

(oldest)  ·  back to Loops Dashboard  ·  fix: cache + background refresh — 21s → <100ms page load 36c10b1 →