← back to Yolo Agent
server.js
1106 lines
#!/usr/bin/env node
// ============================================================================
// YOLO Agent — Autonomous Claude Code Task Runner
// Port: 9670 | PM2: yolo-agent | Codename: Yuri
//
// Picks tasks from the queue, runs them via `claude` CLI, logs results,
// and cycles back for more. Full autonomous operation with dashboard.
// ============================================================================
const express = require('express');
const helmet = require('helmet');
const { execSync, spawn } = require('child_process');
const fs = require('fs');
const path = require('path');
function loadLocalEnv(file) {
if (!fs.existsSync(file)) return;
for (const line of fs.readFileSync(file, 'utf8').split(/\r?\n/)) {
const match = line.match(/^\s*([A-Za-z_][A-Za-z0-9_]*)=(.*)\s*$/);
if (!match || process.env[match[1]] !== undefined) continue;
let value = match[2].trim();
if ((value.startsWith('"') && value.endsWith('"')) || (value.startsWith("'") && value.endsWith("'"))) {
value = value.slice(1, -1);
}
process.env[match[1]] = value;
}
}
loadLocalEnv(path.join(__dirname, '.env'));
const PORT = 9670;
const BIND_HOST = '127.0.0.1';
const AGENT_NAME = 'Yuri';
const AUTH_USER = process.env.YOLO_AUTH_USER;
const AUTH_PASS = process.env.YOLO_AUTH_PASS;
if (!AUTH_USER || !AUTH_PASS) {
console.error('Missing YOLO_AUTH_USER or YOLO_AUTH_PASS; refusing to start without explicit credentials.');
process.exit(1);
}
const TASKS_DIR = path.join(__dirname, 'tasks');
const LOGS_DIR = path.join(__dirname, 'logs');
const HISTORY_DIR = path.join(__dirname, 'history');
const CLAUDE_BIN = process.env.YOLO_CLAUDE_BIN || '/Users/macstudio3/.local/bin/claude';
const MAX_HISTORY = 200;
const DEFAULT_COOLDOWN_SEC = 30;
const MAX_CONCURRENT_CLAUDE = 3; // CIRCUIT BREAKER: max parallel claude --print processes
const MAX_TURNS = 50; // Was 25 — bumped for scraper tasks that need more room
const PROCESS_TIMEOUT_MS = 20 * 60 * 1000; // 20 min max per task (was unlimited)
// Ensure directories exist
[TASKS_DIR, LOGS_DIR, HISTORY_DIR].forEach(d => {
if (!fs.existsSync(d)) fs.mkdirSync(d, { recursive: true });
});
// ── State ──────────────────────────────────────────────────────────────────
const MAX_RETRIES = 3; // After 3 failures, move to failed/ for review
const MAX_LIFETIME_RETRIES = 9; // After 9 total failures (3 cycles), stop auto-retrying
const failCounts = {}; // task name → failure count (resets each cycle)
const lifetimeFailCounts = {}; // task name → total lifetime failures (persists across auto-retry cycles)
// Rolling log buffer for live feed (last 50 lines of current task output)
let liveLogBuffer = [];
const LIVE_LOG_MAX = 50;
function appendLiveLog(line) {
var trimmed = line.trim();
if (!trimmed) return;
liveLogBuffer.push({ ts: new Date().toISOString(), text: trimmed });
if (liveLogBuffer.length > LIVE_LOG_MAX) liveLogBuffer.shift();
}
let state = {
running: false,
paused: false,
currentTask: null,
currentProcess: null,
currentPID: null,
startedAt: null,
cooldownSec: DEFAULT_COOLDOWN_SEC,
maxConcurrent: 1,
totalRuns: 0,
totalSuccess: 0,
totalFailed: 0,
totalSkipped: 0,
currentTaskId: 0,
loopStartedAt: null,
lastRunAt: null,
lastResult: null,
observations: [],
mode: 'queue', // 'queue' = pick from tasks/, 'repeat' = run same task, 'cron' = scheduled
repeatTask: null,
allowedPaths: ['/Users/macstudio3/Projects'],
};
function observe(msg) {
const entry = { ts: new Date().toISOString(), msg };
state.observations.unshift(entry);
if (state.observations.length > MAX_HISTORY) state.observations.length = MAX_HISTORY;
console.log(`[YOLO] ${msg}`);
}
// ── Task Queue Management ──────────────────────────────────────────────────
function getQueuedTasks() {
try {
const files = fs.readdirSync(TASKS_DIR)
.filter(f => f.endsWith('.md') || f.endsWith('.txt'));
// Sort by filename ascending: 01_ runs before 02_, etc.
const withStats = files.map(f => {
const fp = path.join(TASKS_DIR, f);
const st = fs.statSync(fp);
return { name: f, path: fp, size: st.size, created: st.birthtime, mtime: st.mtimeMs };
});
withStats.sort((a, b) => a.name.localeCompare(b.name));
return withStats;
} catch (e) { return []; }
}
function getHistory() {
try {
const files = fs.readdirSync(HISTORY_DIR)
.filter(f => f.endsWith('.json'))
.sort()
.reverse()
.slice(0, 50);
return files.map(f => {
try { return JSON.parse(fs.readFileSync(path.join(HISTORY_DIR, f), 'utf8')); }
catch (e) { return { name: f, error: 'parse error' }; }
});
} catch (e) { return []; }
}
function pickNextTask() {
if (state.mode === 'repeat' && state.repeatTask) {
return { name: path.basename(state.repeatTask), path: state.repeatTask };
}
const tasks = getQueuedTasks();
if (tasks.length === 0) return null;
// Pick first task (alphabetical/priority order)
return tasks[0];
}
function archiveTask(taskFile, result) {
const ts = new Date().toISOString().replace(/[:.]/g, '-');
const baseName = path.basename(taskFile, path.extname(taskFile));
const historyFile = path.join(HISTORY_DIR, `${ts}_${baseName}.json`);
try {
fs.writeFileSync(historyFile, JSON.stringify(result, null, 2));
} catch (e) {
observe(`Failed to save history: ${e.message}`);
}
// In queue mode, move completed task out of queue
if (state.mode === 'queue') {
const taskName = path.basename(taskFile);
if (result.status === 'success') {
// Success — move to done, clear fail count
delete failCounts[taskName];
try {
const doneDir = path.join(TASKS_DIR, 'done');
if (!fs.existsSync(doneDir)) fs.mkdirSync(doneDir, { recursive: true });
fs.renameSync(taskFile, path.join(doneDir, taskName));
} catch (e) {
observe(`Failed to move task to done: ${e.message}`);
}
} else {
// Failed — increment retry count, skip after MAX_RETRIES
failCounts[taskName] = (failCounts[taskName] || 0) + 1;
lifetimeFailCounts[taskName] = (lifetimeFailCounts[taskName] || 0) + 1;
if (failCounts[taskName] >= MAX_RETRIES) {
const lifetime = lifetimeFailCounts[taskName];
const permanent = lifetime >= MAX_LIFETIME_RETRIES;
observe(`Task ${taskName} failed ${failCounts[taskName]} times (${lifetime} lifetime) — moving to ${permanent ? 'dead-letter/' : 'failed/'}`);
delete failCounts[taskName];
state.totalSkipped++;
try {
// FIX 2b: Check file exists before trying to move (prevents ENOENT infinite loop)
if (fs.existsSync(taskFile)) {
// Permanently broken tasks go to dead-letter/ (never auto-retried)
const targetDir = permanent
? path.join(TASKS_DIR, 'dead-letter')
: path.join(TASKS_DIR, 'failed');
if (!fs.existsSync(targetDir)) fs.mkdirSync(targetDir, { recursive: true });
fs.renameSync(taskFile, path.join(targetDir, taskName));
if (permanent) delete lifetimeFailCounts[taskName];
} else {
observe(`Task file already gone: ${taskName} — cleared from queue`);
}
} catch (e) {
observe(`Failed to move task: ${e.message} — removing from retry queue`);
}
} else {
observe(`Task ${taskName} failed (attempt ${failCounts[taskName]}/${MAX_RETRIES}) — will retry`);
}
}
}
}
// ── Claude Runner ──────────────────────────────────────────────────────────
function getRunningClaudeCount() {
try {
const { execFileSync } = require('child_process');
// macOS BSD pgrep has no -c (count) flag; counting lines in JS works on both BSD and GNU pgrep.
const out = execFileSync('/usr/bin/pgrep', ['-f', 'claude --print'], { encoding: 'utf8', timeout: 5000, stdio: ['ignore', 'pipe', 'ignore'] });
return out.trim().split('\n').filter(Boolean).length;
} catch (e) { return 0; }
}
function runClaudeTask(task) {
return new Promise((resolve) => {
// FIX 1: Check if task file still exists (prevents infinite retry on deleted files)
if (!fs.existsSync(task.path)) {
observe(`Task file missing: ${task.name} — removing from queue`);
delete failCounts[task.name];
resolve({ status: 'skipped', reason: 'task file no longer exists', duration: 0 });
return;
}
// FIX 2: Concurrency circuit breaker — refuse to spawn if too many already running
const runningCount = getRunningClaudeCount();
if (runningCount >= MAX_CONCURRENT_CLAUDE) {
observe(`CIRCUIT BREAKER: ${runningCount} claude processes already running (max ${MAX_CONCURRENT_CLAUDE}). Skipping ${task.name}`);
resolve({ status: 'skipped', reason: `${runningCount} claude processes already running`, duration: 0 });
return;
}
const taskContent = fs.readFileSync(task.path, 'utf8').trim();
if (!taskContent) {
resolve({ status: 'skipped', reason: 'empty task file', duration: 0 });
return;
}
const logFile = path.join(LOGS_DIR, `${Date.now()}_${task.name}.log`);
const startTime = Date.now();
state.currentTaskId = state.totalRuns + 1;
observe(`Starting task #${state.currentTaskId}: ${task.name} (${taskContent.length} chars) [${runningCount + 1}/${MAX_CONCURRENT_CLAUDE} slots]`);
state.currentTask = task.name;
state.startedAt = new Date().toISOString();
state.currentLogFile = logFile; // Track for live-log API
liveLogBuffer = []; // Clear live log for new task
// Run claude CLI with the task content piped in
// Using --print mode + --allowedTools for autonomous execution
// FIX 3: max-turns reduced from 150 to 25, timeout added
const args = [
'--print',
'--allowedTools', 'Bash', 'Read', 'Write', 'Edit', 'Glob', 'Grep', 'WebFetch', 'WebSearch',
'--max-turns', String(MAX_TURNS),
];
// Clean env: remove CLAUDECODE to avoid "nested session" error
const cleanEnv = { ...process.env };
delete cleanEnv.CLAUDECODE;
delete cleanEnv.CLAUDE_CODE_ENTRYPOINT;
const proc = spawn(CLAUDE_BIN, args, {
cwd: '/Users/macstudio3/Projects',
env: cleanEnv,
stdio: ['pipe', 'pipe', 'pipe'],
});
// FIX 3b: Kill task if it exceeds timeout (was unlimited)
const taskTimeout = setTimeout(() => {
observe(`TIMEOUT: Task ${task.name} exceeded ${PROCESS_TIMEOUT_MS / 60000}min — killing PID ${proc.pid}`);
try { proc.kill('SIGTERM'); } catch (e) {}
// SIGKILL fallback if SIGTERM doesn't work after 10s
setTimeout(() => {
try { if (!proc.killed) { proc.kill('SIGKILL'); observe(`SIGKILL sent to PID ${proc.pid}`); } } catch (e) {}
}, 10000);
}, PROCESS_TIMEOUT_MS);
state.currentProcess = proc;
state.currentPID = proc.pid;
let stdout = '';
let stderr = '';
let settled = false; // Guard: prevent double-count if both 'error' and 'close' fire
proc.stdout.on('data', (data) => {
stdout += data.toString();
// Write incremental log
try { fs.appendFileSync(logFile, data.toString()); } catch (e) {}
// Feed live log buffer
data.toString().split('\n').forEach(line => appendLiveLog(line));
});
proc.stderr.on('data', (data) => {
stderr += data.toString();
try { fs.appendFileSync(logFile, `[STDERR] ${data.toString()}`); } catch (e) {}
});
// Send the task content as stdin
proc.stdin.write(taskContent);
proc.stdin.end();
proc.on('close', (code) => {
if (settled) return;
settled = true;
clearTimeout(taskTimeout);
const duration = (Date.now() - startTime) / 1000;
state.currentProcess = null;
state.currentPID = null;
state.currentTask = null;
const result = {
taskId: state.currentTaskId,
task: task.name,
status: code === 0 ? 'success' : 'failed',
exitCode: code,
duration: `${duration.toFixed(1)}s`,
durationSec: duration,
stdout: stdout.slice(-2000), // Last 2KB
stderr: stderr.slice(-500),
logFile,
completedAt: new Date().toISOString(),
};
if (code === 0) {
state.totalSuccess++;
observe(`Task ${task.name} completed in ${duration.toFixed(1)}s`);
} else {
state.totalFailed++;
observe(`Task ${task.name} FAILED (exit ${code}) after ${duration.toFixed(1)}s`);
}
state.totalRuns++;
state.lastRunAt = new Date().toISOString();
state.lastResult = result;
archiveTask(task.path, result);
notifySteve(result);
resolve(result);
});
proc.on('error', (err) => {
if (settled) return;
settled = true;
clearTimeout(taskTimeout);
const duration = (Date.now() - startTime) / 1000;
state.currentProcess = null;
state.currentPID = null;
state.currentTask = null;
state.totalFailed++;
state.totalRuns++;
const result = {
task: task.name,
status: 'error',
error: err.message,
duration: `${duration.toFixed(1)}s`,
completedAt: new Date().toISOString(),
};
observe(`Task ${task.name} ERROR: ${err.message}`);
state.lastResult = result;
archiveTask(task.path, result);
notifySteve(result);
resolve(result);
});
});
}
// ── Email Notification via George (port 9850) ────────────────────────────
async function notifySteve(result) {
try {
const statusIcon = result.status === 'success' ? '✅' : '❌';
const subject = `${statusIcon} YOLO: ${result.task} — ${result.status}`;
const body = [
`Task: ${result.task}`,
`Status: ${result.status.toUpperCase()}`,
`Duration: ${result.duration}`,
`Completed: ${new Date(result.completedAt).toLocaleString('en-US', { timeZone: 'America/Los_Angeles' })} PT`,
'',
result.exitCode !== undefined ? `Exit code: ${result.exitCode}` : '',
result.error ? `Error: ${result.error}` : '',
'',
'--- Output (last 500 chars) ---',
(result.stdout || '').slice(-500) || '(no output)',
result.stderr ? `\n--- Errors ---\n${result.stderr.slice(-300)}` : '',
'',
`Log: ${result.logFile || 'N/A'}`,
`Queue remaining: ${getQueuedTasks().length} tasks`,
'',
'— Yuri (YOLO Agent)',
].filter(Boolean).join('\n');
const res = await fetch('http://127.0.0.1:9850/api/send', {
method: 'POST',
headers: { 'Content-Type': 'application/json', 'Authorization': 'Basic ' + Buffer.from(AUTH_USER + ':' + AUTH_PASS).toString('base64') },
body: JSON.stringify({ to: 'steve@designerwallcoverings.com', subject, body }),
});
if (!res.ok) observe(`Email notify failed: ${res.status}`);
} catch (e) {
observe(`Email notify error: ${e.message}`);
}
}
// ── Main Loop ──────────────────────────────────────────────────────────────
async function yoloLoop() {
if (state.running) {
observe('Loop already running');
return;
}
state.running = true;
state.paused = false;
state.loopStartedAt = new Date().toISOString();
observe('YOLO loop STARTED — autonomous mode engaged');
while (state.running && !state.paused) {
const task = pickNextTask();
if (!task) {
// Suppress repeated idle messages — log once every 5 minutes max
const now = Date.now();
if (!state._lastIdleLog || now - state._lastIdleLog > 300000) {
observe('No tasks in queue — waiting...');
state._lastIdleLog = now;
}
await sleep(state.cooldownSec * 1000);
continue;
}
try {
await runClaudeTask(task);
} catch (err) {
observe(`Unexpected error: ${err.message}`);
state.totalFailed++;
}
if (state.running && !state.paused) {
observe(`Cooling down ${state.cooldownSec}s before next task...`);
await sleep(state.cooldownSec * 1000);
}
}
observe('YOLO loop STOPPED');
state.running = false;
}
function sleep(ms) {
return new Promise(resolve => setTimeout(resolve, ms));
}
// ── Express Server ─────────────────────────────────────────────────────────
const app = express();
// Security headers via helmet (added 2026-05-04 overnight YOLO loop)
app.use(helmet({ contentSecurityPolicy: false }));
app.use(express.json());
app.use(express.urlencoded({ extended: true }));
// Global basic auth (skip /health only)
app.use((req, res, next) => {
if (req.path === '/health') return next();
const auth = req.headers.authorization;
if (!auth || !auth.startsWith('Basic ')) {
res.setHeader('WWW-Authenticate', 'Basic realm="YOLO Agent"');
return res.status(401).json({ error: 'Authentication required' });
}
const decoded = Buffer.from(auth.slice(6), 'base64').toString();
const [user, pass] = decoded.split(':');
if (user === AUTH_USER && pass === AUTH_PASS) return next();
res.setHeader('WWW-Authenticate', 'Basic realm="YOLO Agent"');
res.status(401).json({ error: 'Invalid credentials' });
});
// ── Health (no auth) ───────────────────────────────────────────────────────
app.get('/health', (req, res) => {
res.json({
agent: AGENT_NAME,
service: 'yolo-agent',
port: PORT,
status: state.running ? (state.paused ? 'paused' : 'running') : 'idle',
currentTask: state.currentTask,
queueLength: getQueuedTasks().length,
totalRuns: state.totalRuns,
uptime: process.uptime(),
});
});
// ── API Endpoints ──────────────────────────────────────────────────────────
app.get('/api/status', (req, res) => {
res.json({
...state,
currentProcess: state.currentProcess ? { pid: state.currentPID } : null,
observations: state.observations.slice(0, 30),
queueLength: getQueuedTasks().length,
uptime: process.uptime(),
});
});
app.get('/api/queue', (req, res) => {
res.json(getQueuedTasks());
});
app.get('/api/history', (req, res) => {
res.json(getHistory());
});
app.get('/api/observations', (req, res) => {
res.json(state.observations);
});
// Live log feed — reads current task's log file from disk (most reliable)
app.get('/api/live-log', (req, res) => {
// Try reading from the actual log file first (works even when stdout is buffered)
if (state.currentLogFile && fs.existsSync(state.currentLogFile)) {
try {
const content = fs.readFileSync(state.currentLogFile, 'utf8');
const lines = content.split('\n').filter(l => l.trim()).slice(-50);
const result = lines.map(l => ({ ts: new Date().toISOString(), text: l.trim() }));
return res.json(result);
} catch (e) {}
}
// Try /tmp subprocess logs (Claude tasks often write progress to /tmp/*.log)
if (state.currentTask) {
try {
const { readdirSync, readFileSync, statSync } = require('fs');
const tmpFiles = readdirSync('/tmp').filter(f => f.endsWith('.log')).map(f => ({
name: f, path: '/tmp/' + f, mtime: statSync('/tmp/' + f).mtimeMs
})).sort((a,b) => b.mtime - a.mtime).slice(0, 3);
for (const tf of tmpFiles) {
const content = readFileSync(tf.path, 'utf8');
const lines = content.split('\n').filter(l => l.trim()).slice(-40);
if (lines.length > 2) {
return res.json(lines.map(l => ({ ts: new Date().toISOString(), text: l.trim(), source: tf.name })));
}
}
} catch(e) {}
}
// Fallback to in-memory buffer
res.json(liveLogBuffer);
});
// Background processes — detached Claude/Node workers still running
app.get('/api/background', (req, res) => {
const { readdirSync, readFileSync, statSync } = require('fs');
const { execFileSync } = require('child_process');
const procs = [];
try {
const psOut = execFileSync('/bin/ps', ['aux'], { encoding: 'utf8', timeout: 5000 });
const lines = psOut.split('\n');
for (const line of lines) {
// Match enrich-ai-tags, backfill, scrape, push processes
if (line.match(/enrich-ai-tags|backfill.*shopify|scrape-.*\.js|push-.*\.js|full-monte-batch/) && !line.includes('grep')) {
const parts = line.trim().split(/\s+/);
const pid = parts[1];
const cpu = parts[2];
const mem = parts[3];
const cmd = parts.slice(10).join(' ').substring(0, 120);
// Try to find a matching /tmp log
let lastLine = '';
try {
const tmpFiles = readdirSync('/tmp').filter(f => f.endsWith('.log')).map(f => ({
name: f, path: '/tmp/' + f, mtime: statSync('/tmp/' + f).mtimeMs
})).sort((a, b) => b.mtime - a.mtime);
for (const tf of tmpFiles) {
if (cmd.includes(tf.name.replace('.log', '').replace('phase3-', '').split('-')[0])) {
const content = readFileSync(tf.path, 'utf8');
const lns = content.split('\n').filter(l => l.trim());
lastLine = lns.length ? lns[lns.length - 1].trim().substring(0, 150) : '';
break;
}
}
} catch (e) {}
procs.push({ pid, cpu, mem, cmd, lastLine });
}
}
// Also check enrichment PM2 processes
const pm2Out = execFileSync('/bin/bash', ['-c', 'pm2 jlist 2>/dev/null'], { encoding: 'utf8', timeout: 5000 });
const pm2Data = JSON.parse(pm2Out);
for (const p of pm2Data) {
if (p.name.startsWith('enrich-') && p.pm2_env.status === 'online') {
procs.push({ pid: p.pid, cpu: '~', mem: Math.round((p.monit?.memory || 0) / 1048576) + 'MB', cmd: 'pm2: ' + p.name, lastLine: '', type: 'pm2-enrich' });
}
}
} catch (e) { }
res.json({ ok: true, count: procs.length, processes: procs });
});
// Start the YOLO loop
app.post('/api/start', (req, res) => {
if (state.running && !state.paused) {
return res.json({ ok: false, msg: 'Already running' });
}
if (state.paused) {
state.paused = false;
observe('Loop RESUMED');
return res.json({ ok: true, msg: 'Resumed' });
}
yoloLoop();
res.json({ ok: true, msg: 'YOLO loop started' });
});
// Pause the loop
app.post('/api/pause', (req, res) => {
if (!state.running) return res.json({ ok: false, msg: 'Not running' });
state.paused = true;
observe('Loop PAUSED');
res.json({ ok: true, msg: 'Paused — current task will finish' });
});
// Stop the loop (and kill current task)
app.post('/api/stop', (req, res) => {
state.running = false;
state.paused = false;
if (state.currentProcess) {
try {
state.currentProcess.kill('SIGTERM');
observe('Killed current task process');
} catch (e) {}
}
observe('Loop STOPPED');
res.json({ ok: true, msg: 'Stopped' });
});
// Add a task to the queue
app.post('/api/task', (req, res) => {
if (!req.body || typeof req.body !== 'object') {
return res.status(400).json({ error: 'JSON body required' });
}
const { name, content, priority } = req.body;
if (!content) return res.status(400).json({ error: 'content required' });
const fileName = (priority ? `${String(priority).padStart(2, '0')}_` : '') +
(name || `task-${Date.now()}`).replace(/[^a-zA-Z0-9_-]/g, '_') + '.md';
const filePath = path.join(TASKS_DIR, fileName);
fs.writeFileSync(filePath, content);
observe(`Task added: ${fileName}`);
res.json({ ok: true, file: fileName, queued: getQueuedTasks().length });
});
// Add inline task (quick one-liner)
app.post('/api/run', (req, res) => {
if (!req.body || typeof req.body !== 'object') {
return res.status(400).json({ error: 'JSON body required' });
}
const { prompt } = req.body;
if (!prompt) return res.status(400).json({ error: 'prompt required' });
const fileName = `now_${Date.now()}.md`;
const filePath = path.join(TASKS_DIR, fileName);
fs.writeFileSync(filePath, prompt);
observe(`Quick task added: ${fileName}`);
// Auto-start if not running
if (!state.running) {
yoloLoop();
}
res.json({ ok: true, file: fileName, msg: 'Task queued and loop started' });
});
// Set mode
app.post('/api/mode', (req, res) => {
if (!req.body || typeof req.body !== 'object') {
return res.status(400).json({ error: 'JSON body required' });
}
const { mode, task } = req.body;
if (!['queue', 'repeat'].includes(mode)) {
return res.status(400).json({ error: 'mode must be queue or repeat' });
}
state.mode = mode;
if (mode === 'repeat' && task) state.repeatTask = task;
observe(`Mode set to: ${mode}${task ? ` (${task})` : ''}`);
res.json({ ok: true, mode, repeatTask: state.repeatTask });
});
// Set cooldown
app.post('/api/cooldown', (req, res) => {
if (!req.body || typeof req.body !== 'object') {
return res.status(400).json({ error: 'JSON body required' });
}
const { seconds } = req.body;
if (typeof seconds !== 'number' || seconds < 5) {
return res.status(400).json({ error: 'seconds must be >= 5' });
}
state.cooldownSec = seconds;
observe(`Cooldown set to ${seconds}s`);
res.json({ ok: true, cooldownSec: seconds });
});
// Kill current task
app.post('/api/kill', (req, res) => {
if (!state.currentProcess) return res.json({ ok: false, msg: 'No task running' });
try {
state.currentProcess.kill('SIGTERM');
observe('Killed current task');
res.json({ ok: true, msg: 'Task killed' });
} catch (e) {
res.json({ ok: false, msg: e.message });
}
});
// Clear queue
app.post('/api/clear-queue', (req, res) => {
const tasks = getQueuedTasks();
tasks.forEach(t => {
try { fs.unlinkSync(t.path); } catch (e) {}
});
observe(`Queue cleared (${tasks.length} tasks removed)`);
res.json({ ok: true, removed: tasks.length });
});
// ── Staged Tasks (manual approval gate) ────────────────────────────────────
// Files placed in tasks/pending/ are staged — Yuri does NOT read them.
// Promote moves pending/XXX.md → tasks/XXX.md (activates it).
// Reject deletes pending/XXX.md.
const PENDING_DIR = path.join(TASKS_DIR, 'pending');
function safeFilename(name) {
// Reject path traversal, absolute paths, and anything that isn't a .md/.txt file
if (!name || typeof name !== 'string') return null;
if (name.includes('/') || name.includes('\\') || name.includes('..')) return null;
if (!name.endsWith('.md') && !name.endsWith('.txt')) return null;
return name;
}
app.get('/api/staged', (req, res) => {
try {
if (!fs.existsSync(PENDING_DIR)) return res.json([]);
const files = fs.readdirSync(PENDING_DIR)
.filter(f => (f.endsWith('.md') || f.endsWith('.txt')) && !f.startsWith('.'));
const withMeta = files.map(f => {
const fp = path.join(PENDING_DIR, f);
const st = fs.statSync(fp);
let preview = '';
try {
preview = fs.readFileSync(fp, 'utf8').split('\n').slice(0, 3).join(' ').slice(0, 200);
} catch (e) {}
return { name: f, size: st.size, mtime: st.mtimeMs, preview };
});
withMeta.sort((a, b) => a.name.localeCompare(b.name));
res.json(withMeta);
} catch (e) {
res.status(500).json({ error: e.message });
}
});
app.post('/api/promote/:filename', (req, res) => {
const name = safeFilename(req.params.filename);
if (!name) return res.status(400).json({ error: 'invalid filename' });
const src = path.join(PENDING_DIR, name);
const dst = path.join(TASKS_DIR, name);
if (!fs.existsSync(src)) return res.status(404).json({ error: 'not staged' });
if (fs.existsSync(dst)) return res.status(409).json({ error: 'already in active queue' });
try {
fs.renameSync(src, dst);
observe(`Task promoted from pending/: ${name}`);
res.json({ ok: true, promoted: name, queued: getQueuedTasks().length });
} catch (e) {
res.status(500).json({ error: e.message });
}
});
app.post('/api/reject/:filename', (req, res) => {
const name = safeFilename(req.params.filename);
if (!name) return res.status(400).json({ error: 'invalid filename' });
const src = path.join(PENDING_DIR, name);
if (!fs.existsSync(src)) return res.status(404).json({ error: 'not staged' });
try {
fs.unlinkSync(src);
observe(`Task rejected from pending/: ${name}`);
res.json({ ok: true, rejected: name });
} catch (e) {
res.status(500).json({ error: e.message });
}
});
// ── Dashboard ──────────────────────────────────────────────────────────────
function getStagedTasks() {
try {
if (!fs.existsSync(PENDING_DIR)) return [];
const files = fs.readdirSync(PENDING_DIR)
.filter(f => (f.endsWith('.md') || f.endsWith('.txt')) && !f.startsWith('.'));
return files.map(f => {
const fp = path.join(PENDING_DIR, f);
const st = fs.statSync(fp);
let preview = '';
try {
preview = fs.readFileSync(fp, 'utf8').split('\n').slice(0, 3).join(' ').slice(0, 160);
} catch (e) {}
return { name: f, size: st.size, preview };
}).sort((a, b) => a.name.localeCompare(b.name));
} catch (e) { return []; }
}
app.get('/', (req, res) => {
const q = getQueuedTasks();
const staged = getStagedTasks();
const h = getHistory().slice(0, 20);
const statusColor = state.running ? (state.paused ? '#f59e0b' : '#22c55e') : '#6b7280';
const statusText = state.running ? (state.paused ? 'PAUSED' : 'RUNNING') : 'IDLE';
res.send(`<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>YOLO Agent — Yuri</title>
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
body { font-family: 'SF Mono', 'Fira Code', monospace; background: #0a0a0a; color: #e0e0e0; padding: 20px; }
.header { display: flex; align-items: center; gap: 16px; margin-bottom: 24px; border-bottom: 1px solid #333; padding-bottom: 16px; }
.header h1 { font-size: 24px; color: #f97316; }
.header .badge { padding: 4px 12px; border-radius: 12px; font-size: 12px; font-weight: 700; color: #000; background: ${statusColor}; }
.stats { display: grid; grid-template-columns: repeat(auto-fit, minmax(140px, 1fr)); gap: 12px; margin-bottom: 24px; }
.stat { background: #1a1a1a; border: 1px solid #333; border-radius: 8px; padding: 12px; text-align: center; }
.stat .val { font-size: 28px; font-weight: 700; color: #f97316; }
.stat .label { font-size: 11px; color: #888; margin-top: 4px; }
.controls { display: flex; gap: 8px; margin-bottom: 24px; flex-wrap: wrap; }
.btn { padding: 8px 16px; border: none; border-radius: 6px; cursor: pointer; font-family: inherit; font-weight: 600; font-size: 13px; }
.btn-green { background: #22c55e; color: #000; }
.btn-yellow { background: #f59e0b; color: #000; }
.btn-red { background: #ef4444; color: #fff; }
.btn-blue { background: #3b82f6; color: #fff; }
.btn-gray { background: #555; color: #fff; }
.btn:hover { opacity: 0.85; }
.section { margin-bottom: 24px; }
.section h2 { font-size: 16px; color: #f97316; margin-bottom: 8px; border-bottom: 1px solid #333; padding-bottom: 4px; }
.task-input { width: 100%; background: #1a1a1a; border: 1px solid #444; color: #e0e0e0; padding: 10px; border-radius: 6px; font-family: inherit; font-size: 13px; resize: vertical; min-height: 60px; margin-bottom: 8px; }
table { width: 100%; border-collapse: collapse; }
th, td { text-align: left; padding: 6px 10px; border-bottom: 1px solid #222; font-size: 12px; }
th { color: #888; font-weight: 600; }
.success { color: #22c55e; }
.failed { color: #ef4444; }
.running { color: #f59e0b; }
.queue-item { background: #1a1a1a; border: 1px solid #333; border-radius: 6px; padding: 8px 12px; margin-bottom: 4px; font-size: 12px; }
.obs { font-size: 11px; color: #888; padding: 3px 0; border-bottom: 1px solid #1a1a1a; }
.obs .ts { color: #555; margin-right: 8px; }
.current { background: #1a1a0a; border: 2px solid #f97316; border-radius: 8px; padding: 12px; margin-bottom: 16px; }
.current .label { color: #f97316; font-weight: 700; font-size: 14px; }
pre { background: #111; padding: 8px; border-radius: 4px; overflow-x: auto; font-size: 11px; max-height: 200px; overflow-y: auto; }
</style>
</head>
<body>
<div class="header">
<h1>YOLO Agent</h1>
<span class="badge">${statusText}</span>
<span style="color:#666;font-size:12px;">Yuri | Port ${PORT} | Mode: ${state.mode}</span>
</div>
${state.currentTask ? `
<div class="current">
<span class="label">Currently Running:</span> ${state.currentTask}
<span style="color:#888;font-size:11px;margin-left:12px;">PID: ${state.currentPID || '?'} | Started: ${state.startedAt || '?'}</span>
</div>` : ''}
<div class="stats">
<div class="stat"><div class="val">${state.totalRuns}</div><div class="label">Total Runs</div></div>
<div class="stat"><div class="val success">${state.totalSuccess}</div><div class="label">Success</div></div>
<div class="stat"><div class="val failed">${state.totalFailed}</div><div class="label">Failed</div></div>
<div class="stat"><div class="val">${q.length}</div><div class="label">Queue</div></div>
<div class="stat"><div class="val">${state.cooldownSec}s</div><div class="label">Cooldown</div></div>
<div class="stat"><div class="val">${state.mode}</div><div class="label">Mode</div></div>
</div>
<div class="controls">
<button class="btn btn-green" onclick="api('start')">Start</button>
<button class="btn btn-yellow" onclick="api('pause')">Pause</button>
<button class="btn btn-red" onclick="api('stop')">Stop</button>
<button class="btn btn-red" onclick="api('kill')">Kill Task</button>
<button class="btn btn-gray" onclick="api('clear-queue')">Clear Queue</button>
<button class="btn btn-blue" onclick="location.reload()">Refresh</button>
</div>
<div class="section">
<h2>Quick Task</h2>
<textarea id="taskInput" class="task-input" placeholder="Enter a prompt for Claude to execute autonomously..."></textarea>
<button class="btn btn-blue" onclick="submitTask()">Submit Task</button>
</div>
<div class="section">
<h2>Staged — Awaiting Approval (${staged.length})</h2>
${staged.length === 0 ? '<p style="color:#666;font-size:12px;">No staged tasks. Drop files in tasks/pending/ to stage.</p>' :
staged.map(t => `
<div class="queue-item" style="border-color:#f59e0b;display:flex;justify-content:space-between;align-items:center;gap:8px;">
<div style="flex:1;min-width:0;">
<div style="color:#f59e0b;font-weight:600;">${escapeHtml(t.name)} <span style="color:#666;font-weight:400;">(${t.size}B)</span></div>
<div style="color:#888;font-size:11px;margin-top:2px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;">${escapeHtml(t.preview)}</div>
</div>
<div style="display:flex;gap:4px;flex-shrink:0;">
<button class="btn btn-green" style="padding:4px 10px;font-size:11px;" onclick="promoteTask('${escapeHtml(t.name)}')">Approve</button>
<button class="btn btn-red" style="padding:4px 10px;font-size:11px;" onclick="rejectTask('${escapeHtml(t.name)}')">Reject</button>
</div>
</div>
`).join('')}
</div>
<div class="section">
<h2>Queue (${q.length} tasks)</h2>
${q.length === 0 ? '<p style="color:#666;font-size:12px;">No tasks queued</p>' :
q.map(t => `<div class="queue-item">${t.name} <span style="color:#666">(${t.size}B)</span></div>`).join('')}
</div>
<div class="section">
<h2>Recent History</h2>
<table>
<tr><th>Task</th><th>Status</th><th>Duration</th><th>Completed</th></tr>
${h.map(r => `<tr>
<td>${r.task || '?'}</td>
<td class="${r.status}">${r.status || '?'}</td>
<td>${r.duration || '?'}</td>
<td style="color:#666">${r.completedAt ? new Date(r.completedAt).toLocaleString() : '?'}</td>
</tr>`).join('')}
</table>
</div>
${state.lastResult ? `
<div class="section">
<h2>Last Result</h2>
<pre>${escapeHtml(state.lastResult.stdout || '(no output)')}</pre>
${state.lastResult.stderr ? `<pre style="color:#ef4444">${escapeHtml(state.lastResult.stderr)}</pre>` : ''}
</div>` : ''}
<div class="section">
<h2>Observations</h2>
${state.observations.slice(0, 30).map(o =>
`<div class="obs"><span class="ts">${new Date(o.ts).toLocaleTimeString()}</span>${o.msg}</div>`
).join('')}
</div>
<script>
async function api(action) {
const res = await fetch('/api/' + action, {
method: 'POST'
});
const data = await res.json();
alert(data.msg || JSON.stringify(data));
location.reload();
}
async function promoteTask(name) {
if (!confirm('Approve and activate task: ' + name + '?')) return;
const res = await fetch('/api/promote/' + encodeURIComponent(name), {
method: 'POST'
});
const data = await res.json();
if (data.ok) {
alert('Promoted: ' + name + ' (queue now: ' + data.queued + ')');
} else {
alert('Error: ' + (data.error || JSON.stringify(data)));
}
location.reload();
}
async function rejectTask(name) {
if (!confirm('REJECT and DELETE staged task: ' + name + '?\\n\\nThis cannot be undone.')) return;
const res = await fetch('/api/reject/' + encodeURIComponent(name), {
method: 'POST'
});
const data = await res.json();
if (data.ok) {
alert('Rejected: ' + name);
} else {
alert('Error: ' + (data.error || JSON.stringify(data)));
}
location.reload();
}
async function submitTask() {
const prompt = document.getElementById('taskInput').value.trim();
if (!prompt) return alert('Enter a task prompt');
const res = await fetch('/api/run', {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({ prompt })
});
const data = await res.json();
alert(data.msg || JSON.stringify(data));
document.getElementById('taskInput').value = '';
location.reload();
}
// Auto-refresh every 10s
setTimeout(() => location.reload(), 10000);
</script>
</body>
</html>`);
});
function escapeHtml(str) {
return String(str).replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"').replace(/'/g, ''');
}
// React Task Ledger — built with Vite + React + Tailwind + shadcn/ui
app.use('/ledger', express.static(path.join(__dirname, 'ledger-dist')));
// SPA fallback: serve index.html for any /ledger/* route not matched by static files
app.get('/ledger/*', (req, res) => {
res.sendFile(path.join(__dirname, 'ledger-dist', 'index.html'));
});
// Old HTML ledger — kept as fallback
app.get('/ledger-old', (req, res) => {
res.sendFile(path.join(__dirname, 'dashboard', 'task-ledger.html'));
});
// Live dashboard — auto-refreshing collapsed task table (no auth — IP-locked by firewall)
app.get('/live', (req, res) => {
res.sendFile(path.join(__dirname, 'live-dashboard.html'));
});
// Read-only API for live dashboard (no auth — IP-locked by firewall)
app.get('/live-api/status', (req, res) => {
res.json({
running: state.running,
paused: state.paused,
currentTask: state.currentTask,
currentTaskId: state.currentTaskId,
startedAt: state.startedAt,
totalRuns: state.totalRuns,
totalSuccess: state.totalSuccess,
totalFailed: state.totalFailed,
});
});
app.get('/live-api/queue', (req, res) => {
res.json(getQueuedTasks());
});
app.get('/live-api/history', (req, res) => {
res.json(getHistory().slice(-50));
});
// Failed tasks API
app.get('/live-api/failed', (req, res) => {
try {
const failedDir = path.join(TASKS_DIR, 'failed');
if (!fs.existsSync(failedDir)) return res.json([]);
const files = fs.readdirSync(failedDir).filter(f => f.endsWith('.md') || f.endsWith('.txt')).sort();
res.json(files.map(f => ({ name: f, path: path.join(failedDir, f) })));
} catch (e) { res.json([]); }
});
// Retry all failed tasks — move from failed/ back to queue
app.post('/api/retry-failed', (req, res) => {
try {
const failedDir = path.join(TASKS_DIR, 'failed');
if (!fs.existsSync(failedDir)) return res.json({ retried: 0 });
const files = fs.readdirSync(failedDir).filter(f => f.endsWith('.md') || f.endsWith('.txt'));
let count = 0;
for (const f of files) {
try {
fs.renameSync(path.join(failedDir, f), path.join(TASKS_DIR, f));
count++;
observe(`Retrying failed task: ${f}`);
} catch (e) {}
}
res.json({ retried: count });
} catch (e) { res.status(500).json({ error: e.message }); }
});
// ── Graceful Shutdown ─────────────────────────────────────────────────────
process.on('SIGTERM', () => {
observe('SIGTERM received — stopping loop, current task will finish');
state.running = false;
// pm2 sends SIGKILL after kill_timeout (default 1600ms) — give current task time to wrap up
});
process.on('SIGINT', () => {
observe('SIGINT received — shutting down');
state.running = false;
process.exit(0);
});
// ── Start Server ───────────────────────────────────────────────────────────
app.listen(PORT, BIND_HOST, () => {
console.log(`======================================`);
console.log(` YOLO Agent (Yuri) — Port ${PORT}`);
console.log(` Mode: ${state.mode}`);
console.log(` Cooldown: ${state.cooldownSec}s`);
console.log(` Dashboard: http://${BIND_HOST}:${PORT}/`);
console.log(`======================================`);
observe('YOLO Agent started');
// NO auto-start on boot — loop only starts when manually triggered via /api/start
observe('YOLO loop idle — use POST /api/start to begin processing');
// Watchdog: every 60s, log queue status (but do NOT auto-start)
setInterval(() => {
const queue = getQueuedTasks();
if (queue.length > 0 && !state.currentTask && !state.running) {
observe(`Watchdog: ${queue.length} tasks waiting — POST /api/start to begin`);
}
}, 60000);
// Auto-retry: every 2 hours, move failed/ tasks back to queue
setInterval(() => {
const failedDir = path.join(TASKS_DIR, 'failed');
if (!fs.existsSync(failedDir)) return;
const files = fs.readdirSync(failedDir).filter(f => f.endsWith('.md') || f.endsWith('.txt'));
if (files.length === 0) return;
let count = 0;
for (const f of files) {
try {
fs.renameSync(path.join(failedDir, f), path.join(TASKS_DIR, f));
count++;
} catch (e) {}
}
if (count > 0) observe(`Auto-retry: moved ${count} failed tasks back to queue`);
}, 2 * 60 * 60 * 1000); // 2 hours
});