← 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
A .gitignoreA README.mdA server.js
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, '&').replace(/</g, '<').replace(/>/g, '>')
+ .replace(/"/g, '"').replace(/'/g, ''');
+}
+
+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 · aggregator · 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&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 →