← back to Animals
agents/animal-yolo/server.js
222 lines
// Animal YOLO Loop — autonomous overnight feature builder.
//
// Picks one task at a time from agents/animal-yolo/task-pool.json, hands it
// to Claude CLI (`claude --print -p "<task>"`), captures the diff, runs a
// quick syntax/sanity check, commits if clean, sleeps, repeats.
//
// Failure modes are by design BENIGN: Claude either edits no files (skip),
// edits files that fail node -c (revert), or edits files that pass — commit.
// The agent NEVER pushes to remote; commits live on the local main branch
// for Steve to review in the morning.
//
// Express HTTP control surface on :9726:
// GET /healthz
// GET / — pretty status dashboard
// GET /api/status — JSON
// POST /api/skip — abort current task, advance pointer
// POST /api/pause / /api/resume
import 'dotenv/config';
import express from 'express';
import fs from 'node:fs';
import path from 'node:path';
import { spawn, spawnSync } from 'node:child_process';
import { fileURLToPath } from 'node:url';
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const ROOT = path.resolve(__dirname, '..', '..');
const POOL_PATH = path.join(__dirname, 'task-pool.json');
const STATE_PATH = path.join(__dirname, 'state.json');
const LOG_PATH = path.join(ROOT, 'logs', 'animal-yolo.log');
let paused = false;
let currentTask = null;
let lastResult = null;
let history = [];
function log(...args) {
const line = `[${new Date().toISOString()}] ${args.join(' ')}`;
console.log(line);
fs.appendFileSync(LOG_PATH, line + '\n');
}
function readState() {
try { return JSON.parse(fs.readFileSync(STATE_PATH, 'utf8')); }
catch { return { cursor: 0, runs: 0, started_at: new Date().toISOString() }; }
}
function writeState(s) { fs.writeFileSync(STATE_PATH, JSON.stringify(s, null, 2)); }
function readPool() {
return JSON.parse(fs.readFileSync(POOL_PATH, 'utf8'));
}
const app = express();
app.use(express.json({ limit: '128kb' }));
app.get('/healthz', (_req, res) => res.json({ ok: true, paused, currentTask: currentTask?.id || null }));
app.get('/api/status', (_req, res) => {
const state = readState();
const pool = readPool();
res.json({
paused,
current: currentTask,
last: lastResult,
history: history.slice(-25),
pool_size: pool.length,
cursor: state.cursor,
runs_completed: state.runs,
});
});
app.post('/api/pause', (_req, res) => { paused = true; res.json({ paused }); });
app.post('/api/resume', (_req, res) => { paused = false; res.json({ paused }); });
app.get('/', (_req, res) => {
const state = readState();
const pool = readPool();
res.send(`<!doctype html><html><head><meta charset=utf-8><title>Animal YOLO</title>
<style>body{font:14px -apple-system,sans-serif;background:#0f1117;color:#e4e4e7;padding:20px;max-width:1100px;margin:0 auto}h1{color:#c97a3e}h1 small{font-size:.5em;color:#888;font-weight:400}h2{margin-top:1.5em;color:#0f4d3a}table{width:100%;border-collapse:collapse;background:#1a1d27;border:1px solid #2a2d3a;border-radius:8px;overflow:hidden;margin:10px 0}th,td{padding:8px 12px;text-align:left;border-bottom:1px solid #2a2d3a;font-size:13px}th{background:#22252f;color:#888;text-transform:uppercase;font-size:11px}.dot{display:inline-block;width:8px;height:8px;border-radius:50%;margin-right:6px}.dot.green{background:#4ade80}.ok{color:#4ade80}.fail{color:#f87171}.skip{color:#888}</style>
</head><body>
<h1>🦴 Animal YOLO <small>${paused?'paused':'<span class="dot green"></span>running'} · cursor ${state.cursor}/${pool.length} · ${state.runs} runs</small></h1>
<form onsubmit="event.preventDefault();fetch(this.action,{method:'POST'}).then(_=>location.reload())" action="${paused?'/api/resume':'/api/pause'}"><button>${paused?'Resume':'Pause'}</button></form>
<h2>Current</h2>
<pre>${currentTask ? JSON.stringify(currentTask, null, 2).replace(/</g,'<') : 'idle'}</pre>
<h2>Recent runs</h2>
<table><tr><th>at</th><th>id</th><th>result</th><th>files changed</th><th>commit</th></tr>
${history.slice(-25).reverse().map(h => `<tr><td>${h.at}</td><td>${h.id}</td><td class="${h.outcome}">${h.outcome}</td><td>${h.files_changed||0}</td><td>${(h.commit_sha||'').slice(0,7)}</td></tr>`).join('')}
</table>
<h2>Task pool (${pool.length})</h2>
<table><tr><th>idx</th><th>id</th><th>title</th></tr>
${pool.map((t, i) => `<tr><td>${i === state.cursor ? '→ ' : ''}${i}</td><td>${t.id}</td><td>${t.title}</td></tr>`).join('')}
</table>
</body></html>`);
});
const PORT = parseInt(process.env.PORT || '9726', 10);
app.listen(PORT, () => log(`animal-yolo listening on :${PORT}`));
// ─── main loop ───────────────────────────────────────────────────────────
const SLEEP_MS = parseInt(process.env.YOLO_SLEEP_MS || String(40 * 60 * 1000), 10); // 40 min between cycles
const TASK_TIMEOUT_MS = parseInt(process.env.YOLO_TASK_TIMEOUT_MS || String(20 * 60 * 1000), 10); // 20 min per task
async function loop() {
while (true) {
if (paused) { await sleep(15000); continue; }
try { await runOne(); }
catch (err) { log('loop iteration error:', err.message); }
await sleep(SLEEP_MS);
}
}
async function runOne() {
const pool = readPool();
const state = readState();
if (pool.length === 0) { log('empty pool — sleeping'); return; }
const task = pool[state.cursor % pool.length];
currentTask = task;
log(`▶ ${task.id} — ${task.title}`);
// Snapshot HEAD before; we'll diff against this after Claude finishes
const headBefore = git(['rev-parse', 'HEAD']).trim();
const dirtyBefore = git(['status', '--porcelain']).split('\n').filter(Boolean).length;
const claude = which('claude');
if (!claude) {
log('✗ claude CLI not on PATH; aborting cycle');
history.push({ at: new Date().toISOString(), id: task.id, outcome: 'skip', error: 'claude not found' });
advance(); currentTask = null; return;
}
// Run Claude in print mode with the prompt; cwd = project root.
// Claude is allowed to edit + bash but should NOT push to remote.
const claudeArgs = ['--model', 'opus', '--print', '--permission-mode', 'acceptEdits', '-p', task.prompt];
const out = await runChild(claude, claudeArgs, ROOT, TASK_TIMEOUT_MS);
if (out.timed_out) {
log(`✗ ${task.id} — timed out after ${TASK_TIMEOUT_MS/60000}min`);
git(['checkout', '--', '.']); // discard partial edits
history.push({ at: new Date().toISOString(), id: task.id, outcome: 'fail', error: 'timeout' });
advance(); currentTask = null; return;
}
// What did Claude touch?
const dirtyNow = git(['status', '--porcelain']).split('\n').filter(Boolean);
const newDirty = dirtyNow.length - dirtyBefore;
log(` ${newDirty} file(s) changed`);
if (newDirty <= 0) {
log(` no edits; skip`);
history.push({ at: new Date().toISOString(), id: task.id, outcome: 'skip', files_changed: 0 });
advance(); currentTask = null; return;
}
// Sanity-check: parse all touched .js files
const touched = dirtyNow
.map(line => line.slice(3).trim())
.filter(p => /\.(js|cjs|mjs)$/.test(p));
let parseFail = false;
for (const f of touched) {
const r = spawnSync('node', ['-c', f], { cwd: ROOT });
if (r.status !== 0) { log(` parse fail: ${f}`); parseFail = true; break; }
}
if (parseFail) {
log(` reverting due to parse error`);
git(['checkout', '--', '.']);
git(['clean', '-fd']); // drop untracked too
history.push({ at: new Date().toISOString(), id: task.id, outcome: 'fail', error: 'parse_fail', files_changed: newDirty });
advance(); currentTask = null; return;
}
// Stage + commit. Use a -c override so we don't touch global git config.
git(['add', '-A']);
const msg = `yolo: ${task.title}\n\nTask: ${task.id}\nPrompt: ${task.prompt.slice(0, 200)}`;
const commitR = spawnSync('git',
['-c', 'user.email=steve@designerwallcoverings.com',
'-c', 'user.name=animal-yolo',
'commit', '-m', msg],
{ cwd: ROOT });
if (commitR.status !== 0) {
log(` commit failed: ${commitR.stderr?.toString().slice(0,200)}`);
history.push({ at: new Date().toISOString(), id: task.id, outcome: 'fail', error: 'commit_fail', files_changed: newDirty });
advance(); currentTask = null; return;
}
const sha = git(['rev-parse', 'HEAD']).trim();
log(` ✓ committed ${sha.slice(0,7)}`);
history.push({ at: new Date().toISOString(), id: task.id, outcome: 'ok', files_changed: newDirty, commit_sha: sha, head_before: headBefore });
lastResult = history[history.length - 1];
advance();
currentTask = null;
}
function advance() {
const s = readState();
s.cursor = (s.cursor + 1) % readPool().length;
s.runs += 1;
writeState(s);
}
// ─── helpers ──────────────────────────────────────────────────────────────
function git(args) {
const r = spawnSync('git', args, { cwd: ROOT, encoding: 'utf8' });
return r.stdout || '';
}
function which(bin) {
const r = spawnSync('which', [bin], { encoding: 'utf8' });
return r.status === 0 ? r.stdout.trim() : null;
}
function runChild(cmd, args, cwd, timeoutMs) {
return new Promise((resolve) => {
const child = spawn(cmd, args, { cwd, env: process.env });
let out = '', err = '', timed_out = false;
const t = setTimeout(() => { timed_out = true; try { child.kill('SIGKILL'); } catch {} }, timeoutMs);
child.stdout.on('data', d => { out += d.toString(); if (out.length > 60000) out = out.slice(-60000); });
child.stderr.on('data', d => { err += d.toString(); if (err.length > 30000) err = err.slice(-30000); });
child.on('close', (code) => { clearTimeout(t); resolve({ code, out, err, timed_out }); });
child.on('error', () => { clearTimeout(t); resolve({ code: -1, out: '', err: 'spawn-fail', timed_out: false }); });
});
}
const sleep = (ms) => new Promise(r => setTimeout(r, ms));
loop();