← 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, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;').replace(/'/g, '&#39;');
}

// 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
});