← back to Debate Ring Viewer
src/server.js
196 lines
// Debate Ring Viewer — renders the claude-codex 8-way debate as a boxing match.
// Reads runs from ~/.claude/skills/claude-codex/runs/, exposes JSON API,
// serves the boxing-ring SPA on :9730.
import express from 'express';
import helmet from 'helmet';
import fs from 'fs/promises';
import fsync from 'fs';
import path from 'path';
import os from 'os';
import { fileURLToPath } from 'url';
const PORT = Number(process.env.PORT || 9730);
const HOST = process.env.HOST || '127.0.0.1';
const RUNS = process.env.CC_RUNS_DIR ||
path.join(os.homedir(), '.claude/skills/claude-codex/runs');
const FIGHTERS = ['claude','codex','deepseek','gptoss','kimi','mistral','phi4','qwen'];
const FINAL_REPORT_MAX_BYTES = 8000;
// Reject `.`, `..`, separators, and anything containing `..` segments. The
// previous regex `^[A-Za-z0-9_.\-]+$` matched `..`, which let `GET /api/runs/..`
// resolve to the parent of RUNS and exfiltrate any `final_report.md` /
// `cross_exam.md` sitting there.
function isSafeRunName(name) {
if (typeof name !== 'string' || name.length === 0) return false;
if (name === '.' || name === '..') return false;
if (name.includes('..')) return false;
if (name.includes('/') || name.includes('\\') || name.includes('\0')) return false;
return /^[A-Za-z0-9_\-][A-Za-z0-9_.\-]*$/.test(name);
}
// Read at most `maxBytes` bytes from `fp` without slurping the whole file.
// When we hit the cap, trim back to the last whitespace/newline so we don't
// cut a multibyte UTF-8 grapheme (emoji/CJK) and emit a replacement char.
async function readHead(fp, maxBytes) {
let fh;
try {
fh = await fs.open(fp, 'r');
const buf = Buffer.allocUnsafe(maxBytes);
const { bytesRead } = await fh.read(buf, 0, maxBytes, 0);
if (bytesRead === 0) return '';
let end = bytesRead;
if (bytesRead === maxBytes) {
const half = Math.floor(maxBytes / 2);
while (end > half) {
const c = buf[end - 1];
if (c === 0x20 || c === 0x09 || c === 0x0a || c === 0x0d) break;
end--;
}
if (end <= half) end = bytesRead;
}
return buf.slice(0, end).toString('utf8');
} catch {
return '';
} finally {
if (fh) await fh.close().catch(() => {});
}
}
const app = express();
// Security headers via helmet (added 2026-05-04 overnight YOLO loop)
app.use(helmet({ contentSecurityPolicy: false }));
app.use(express.json());
const __dirname = path.dirname(fileURLToPath(import.meta.url));
app.use(express.static(path.join(__dirname, '..', 'public')));
// ---- helpers ---------------------------------------------------------------
async function listRuns() {
let dirs;
try {
dirs = await fs.readdir(RUNS, { withFileTypes: true });
} catch {
return [];
}
const runs = [];
for (const d of dirs) {
if (!d.isDirectory()) continue;
const full = path.join(RUNS, d.name);
let st;
try {
st = await fs.stat(full);
} catch {
continue;
}
let rounds = 0;
try {
const items = await fs.readdir(full);
rounds = items.filter(n => /^round_\d+$/.test(n)).length;
} catch {}
runs.push({ name: d.name, mtime: st.mtimeMs, rounds });
}
runs.sort((a, b) => b.mtime - a.mtime);
return runs;
}
async function readRound(runName, n) {
const dir = path.join(RUNS, runName, `round_${n}`);
try {
await fs.access(dir);
} catch {
return null;
}
const out = {};
for (const f of FIGHTERS) {
const fp = path.join(dir, `${f}.txt`);
try {
const buf = await fs.readFile(fp, 'utf8');
out[f] = (buf || '').trim();
} catch {
out[f] = '';
}
}
return out;
}
async function readManifest(runName) {
const dir = path.join(RUNS, runName);
const items = await fs.readdir(dir).catch(() => []);
const rounds = items
.filter(n => /^round_\d+$/.test(n))
.map(n => Number(n.replace('round_', '')))
.sort((a, b) => a - b);
let final = '';
for (const cand of ['final_report.md','cross_exam.md']) {
const fp = path.join(dir, cand);
const head = await readHead(fp, FINAL_REPORT_MAX_BYTES);
if (head) { final = head; break; }
}
return { name: runName, rounds, final };
}
// ---- API -------------------------------------------------------------------
app.get('/api/runs', async (_req, res) => {
res.json(await listRuns());
});
app.get('/api/runs/:name', async (req, res) => {
const { name } = req.params;
if (!isSafeRunName(name)) return res.status(400).json({ error: 'bad name' });
const m = await readManifest(name);
if (!m.rounds.length) return res.status(404).json({ error: 'no rounds' });
res.json(m);
});
app.get('/api/runs/:name/round/:n', async (req, res) => {
const { name, n } = req.params;
if (!isSafeRunName(name)) return res.status(400).json({ error: 'bad name' });
if (!/^\d+$/.test(n)) return res.status(400).json({ error: 'bad n' });
const r = await readRound(name, n);
if (!r) return res.status(404).json({ error: 'round not found' });
res.json({ round: Number(n), texts: r });
});
// /demo.mp4 — stream the latest demo MP4 (with Steve avatar baked in).
// /demo — small watch page so non-tech viewers (Steve's MacBook Pro) can
// land on a URL and just hit play. Range-byte support is built
// into express.sendFile, so QuickTime/Safari can scrub freely.
const DEMO_MP4 = path.join(os.homedir(),
'.claude/skills/app-demo-video/output/debate-ring/debate-ring-demo.mp4');
const WEEKLY_DIR = path.join(os.homedir(),
'.claude/skills/app-demo-video/output/debate-ring-weekly');
// Express's send() already sets ETag/Last-Modified, but the prior config
// didn't pass maxAge/cacheControl, so QuickTime + Safari kept re-downloading
// the (large) MP4 on every page refresh. Short maxAge + ETag lets conditional
// GETs short-circuit to 304.
app.get('/demo.mp4', (_req, res) => {
res.sendFile(DEMO_MP4, {
headers: { 'content-type': 'video/mp4' },
cacheControl: true,
maxAge: '5m',
etag: true,
lastModified: true,
}, (err) => {
if (err && !res.headersSent) res.status(404).send('no demo yet — run weekly-highlight-reel.sh');
});
});
app.get('/demo', (_req, res) => {
res.type('html').send(`<!doctype html><meta charset=utf-8><title>Debate Ring demo</title>
<style>html,body{margin:0;background:#0a0809;color:#f4ecd9;font-family:-apple-system,system-ui,sans-serif;height:100%}
.wrap{max-width:1100px;margin:0 auto;padding:32px 24px}h1{font-size:22px;margin:0 0 4px}
.sub{color:#9a958a;font-size:13px;margin-bottom:18px}video{width:100%;border-radius:8px;background:#000}
.actions{margin-top:14px;display:flex;gap:10px;flex-wrap:wrap}a{color:#cc785c;text-decoration:none;border:1px solid #2f3441;padding:6px 14px;border-radius:6px;font-size:13px}
a:hover{background:#1d2028}</style>
<div class=wrap><h1>Debate Ring — narrated walkthrough</h1>
<div class=sub>Steve avatar in the bottom-right corner cycles project details. Bake-into-frame, no overlay tricks.</div>
<video src="/demo.mp4" controls autoplay muted playsinline></video>
<div class=actions><a href="/demo.mp4" download>⬇ Download MP4</a><a href="/">▶ Open live ring</a></div></div>`);
});
app.listen(PORT, HOST, () => {
console.log(`[debate-ring] listening on ${HOST}:${PORT} runs=${RUNS}`);
});