← back to Resume Queue Viewer

server.js

451 lines

// Resume Queue Viewer — single-file, zero-dependency.
// Surfaces everything in Steve's "resume queue": CNCP parking-lot TODOs,
// session recaps (resumable sessions), and recent wins (done).
// Reads the live CNCP JSON stores on every request so the board never goes stale.

const http = require('http');
const fs = require('fs');
const os = require('os');
const path = require('path');
const { execFile } = require('child_process');

const PORT = process.env.PORT || 9768;
const CNCP = path.join(process.env.HOME, 'cncp-starter');

function readJSON(name) {
  try { return JSON.parse(fs.readFileSync(path.join(CNCP, name), 'utf8')); }
  catch (e) { return []; }
}

// Write a JSON response in one call.
function sendJSON(res, code, obj) {
  res.writeHead(code, { 'Content-Type': 'application/json' });
  res.end(JSON.stringify(obj));
}

// Collect and JSON-parse a request body (capped at 100 KB).
function readBody(req) {
  return new Promise((resolve, reject) => {
    let body = '';
    req.on('data', c => {
      body += c;
      if (body.length > 1e5) { req.destroy(); reject(new Error('body too large')); }
    });
    req.on('end', () => {
      try { resolve(JSON.parse(body || '{}')); }
      catch (e) { reject(new Error('bad JSON')); }
    });
    req.on('error', reject);
  });
}

// Build the unified queue from the three CNCP stores.
function buildQueue() {
  const parking = (readJSON('cncp-parking-lot.json') || []).map(p => ({
    type: 'parking',
    id: p.id || '',
    title: p.id || 'untitled',
    body: p.note || '',
    date: p.saved ? new Date(p.saved).toISOString().slice(0, 10) : '',
    ts: p.saved || 0,
    badge: p.discussed ? 'discussed' : 'open',
    discussed: !!p.discussed,
    link: p.url || '',
  }));

  const recaps = (readJSON('cncp-session-recaps.json') || []).map(r => ({
    type: 'recap',
    title: r.name || r.file || 'recap',
    body: (r.summary || '').replace(/^name:.*?description:\s*/s, '').slice(0, 320),
    date: r.added || '',
    ts: r.added ? Date.parse(r.added) || 0 : 0,
    badge: 'resumable',
    link: '',
    path: r.path || '',
  }));

  const wins = (readJSON('cncp-wins.json') || []).map(w => ({
    type: 'win',
    title: w.title || 'win',
    body: w.summary || '',
    date: w.date || '',
    ts: w.date ? Date.parse(w.date) || 0 : 0,
    badge: 'done',
    rank: w.rank || 3,
    project: w.project || '',
    link: w.live || '',
  }));

  return { parking, recaps, wins,
    counts: { parking: parking.length, recaps: recaps.length, wins: wins.length } };
}

// ── Run an item ────────────────────────────────────────────────────────────
// Opens a fresh iTerm2 tab and boots a `claude` session pre-loaded with a
// task prompt built from the queue item. Returns a Promise<{ok,message}>.
function escAS(s) { return String(s).replace(/\\/g, '\\\\').replace(/"/g, '\\"'); }

function buildPrompt(item) {
  if (item.type === 'recap') {
    return [
      'Resume this prior work session from its CNCP recap.',
      item.path ? 'Recap file: ' + item.path : '',
      '',
      item.title,
      item.body || '',
      '',
      'Read the recap, re-establish full context, then continue where it left off.',
    ].filter(Boolean).join('\n');
  }
  // parking-lot TODO (default)
  return [
    'Resume-queue item from the CNCP parking lot — work this now.',
    '',
    'TITLE: ' + item.title,
    'NOTE:  ' + (item.body || ''),
    item.link ? 'REF:   ' + item.link : '',
    '',
    'Investigate, propose a concrete approach, confirm anything risky with Steve, then proceed.',
  ].filter(Boolean).join('\n');
}

function runItem(item) {
  return new Promise((resolve) => {
    const prompt = buildPrompt(item);
    // Stash the prompt in a temp file so no shell/AppleScript escaping of the
    // prompt body is needed — the tab just cats it into `claude`.
    const tmp = path.join(os.tmpdir(), 'rq-run-' + Date.now() + '-' + Math.random().toString(36).slice(2, 8) + '.txt');
    try { fs.writeFileSync(tmp, prompt, 'utf8'); }
    catch (e) { return resolve({ ok: false, message: 'temp write failed: ' + e.message }); }

    const home = process.env.HOME;
    const shellCmd = "cd '" + home + "' && claude \"$(cat '" + tmp + "')\"; rm -f '" + tmp + "'";
    const script =
      'tell application "iTerm2"\n' +
      '  activate\n' +
      '  if (count of windows) = 0 then\n' +
      '    create window with default profile\n' +
      '  else\n' +
      '    tell current window to create tab with default profile\n' +
      '  end if\n' +
      '  tell current session of current window to write text "' + escAS(shellCmd) + '"\n' +
      'end tell';

    execFile('osascript', ['-e', script], { timeout: 15000 }, (err, stdout, stderr) => {
      if (err) return resolve({ ok: false, message: (stderr || err.message || 'osascript failed').trim() });
      resolve({ ok: true, message: 'Launched a claude tab for "' + item.title + '"' });
    });
  });
}

// Toggle the `discussed` flag on a parking-lot entry, written straight back
// to the live CNCP store. Returns {ok,message}.
function setDiscussed(id, discussed) {
  const file = path.join(CNCP, 'cncp-parking-lot.json');
  let list;
  try { list = JSON.parse(fs.readFileSync(file, 'utf8')); }
  catch (e) { return { ok: false, message: 'cannot read parking lot: ' + e.message }; }
  if (!Array.isArray(list)) return { ok: false, message: 'parking lot is not an array' };
  const entry = list.find(p => p.id === id);
  if (!entry) return { ok: false, message: 'no parking-lot entry "' + id + '"' };
  entry.discussed = !!discussed;
  try { fs.writeFileSync(file, JSON.stringify(list, null, 2) + '\n', 'utf8'); }
  catch (e) { return { ok: false, message: 'write failed: ' + e.message }; }
  return { ok: true, message: (discussed ? 'Marked done: ' : 'Reopened: ') + id };
}

const PAGE = `<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Resume Queue</title>
<style>
  :root { --bg:#0e0f13; --card:#181a21; --line:#272a34; --txt:#e7e8ee; --dim:#9aa0b0;
          --accent:#f4348c; --cols:3; }
  * { box-sizing:border-box; margin:0; padding:0; }
  body { background:var(--bg); color:var(--txt); font:15px/1.5 -apple-system,BlinkMacSystemFont,"Segoe UI",sans-serif; }
  header { padding:26px 32px 14px; border-bottom:1px solid var(--line); }
  h1 { font-size:22px; font-weight:650; letter-spacing:-.01em; }
  h1 span { color:var(--accent); }
  .sub { color:var(--dim); font-size:13px; margin-top:3px; }
  .bar { display:flex; gap:8px; align-items:center; flex-wrap:wrap; padding:14px 32px; border-bottom:1px solid var(--line); }
  .bar button { background:var(--card); color:var(--txt); border:1px solid var(--line);
                 padding:6px 13px; border-radius:7px; cursor:pointer; font-size:13px; }
  .bar button.on { background:var(--accent); border-color:var(--accent); color:#fff; }
  .bar .grow { flex:1; }
  .bar label { color:var(--dim); font-size:12px; }
  select, input[type=range] { vertical-align:middle; }
  select, input[type=search] { background:var(--card); color:var(--txt); border:1px solid var(--line); border-radius:7px; padding:5px 8px; font-size:13px; }
  input[type=search] { width:170px; }
  input[type=search]:focus { outline:none; border-color:var(--accent); }
  .bar button .ct { color:var(--dim); font-size:11px; margin-left:4px; }
  .bar button.on .ct { color:#fff; opacity:.8; }
  main { padding:22px 32px 60px; }
  .grid { display:grid; grid-template-columns:repeat(var(--cols),1fr); gap:14px; }
  .card { background:var(--card); border:1px solid var(--line); border-radius:11px; padding:15px 16px;
          display:flex; flex-direction:column; gap:8px; }
  .card .top { display:flex; align-items:center; gap:8px; }
  .tag { font-size:10.5px; font-weight:700; letter-spacing:.06em; text-transform:uppercase;
         padding:3px 8px; border-radius:20px; }
  .tag.parking { background:#3a2f10; color:#f5c451; }
  .tag.recap   { background:#10303a; color:#5fd0e8; }
  .tag.win     { background:#123a1c; color:#5fe07f; }
  .badge { margin-left:auto; font-size:10.5px; color:var(--dim); border:1px solid var(--line);
           padding:2px 7px; border-radius:20px; }
  .badge.open { color:#f5c451; border-color:#5a4a18; }
  .badge.done { color:#5fe07f; border-color:#1f5a30; }
  .card h3 { font-size:14px; font-weight:600; word-break:break-word; }
  .card p { font-size:12.5px; color:var(--dim); }
  .card .meta { font-size:11px; color:var(--dim); display:flex; gap:10px; flex-wrap:wrap;
                border-top:1px solid var(--line); padding-top:7px; margin-top:auto; }
  .card a { color:var(--accent); text-decoration:none; }
  .stars { color:#f5c451; }
  .empty { color:var(--dim); padding:40px; text-align:center; }
  .card .actions { display:flex; gap:8px; }
  button.run { background:var(--accent); color:#fff; border:none; border-radius:7px;
               padding:7px 14px; font-size:12.5px; font-weight:600; cursor:pointer; }
  button.run:hover { filter:brightness(1.12); }
  button.run:disabled { opacity:.55; cursor:progress; }
  button.done { background:transparent; color:var(--dim); border:1px solid var(--line);
                border-radius:7px; padding:7px 12px; font-size:12.5px; cursor:pointer; }
  button.done:hover { color:var(--txt); border-color:var(--dim); }
  button.done:disabled { opacity:.55; cursor:progress; }
  #toast { position:fixed; left:50%; bottom:26px; transform:translateX(-50%) translateY(20px);
           background:#1f2230; border:1px solid var(--line); color:var(--txt);
           padding:11px 18px; border-radius:9px; font-size:13px; opacity:0;
           transition:.25s; pointer-events:none; max-width:80vw; }
  #toast.show { opacity:1; transform:translateX(-50%) translateY(0); }
  #toast.err { border-color:#7a2336; }
</style>
</head>
<body>
<header>
  <h1>Resume <span>Queue</span></h1>
  <div class="sub" id="sub">loading…</div>
</header>
<div class="bar">
  <button data-f="all" class="on">All<span class="ct" data-ct="all"></span></button>
  <button data-f="parking">Parking Lot<span class="ct" data-ct="parking"></span></button>
  <button data-f="recap">Recaps<span class="ct" data-ct="recap"></span></button>
  <button data-f="win">Wins<span class="ct" data-ct="win"></span></button>
  <input type="search" id="q" placeholder="search…" autocomplete="off">
  <button id="refresh" title="Reload CNCP data now">↻ Refresh</button>
  <span class="grow"></span>
  <label>Sort</label>
  <select id="sort">
    <option value="new">Newest</option>
    <option value="old">Oldest</option>
    <option value="status">Status (open first)</option>
    <option value="priority">Priority (★)</option>
    <option value="title">Title A→Z</option>
    <option value="type">Type</option>
  </select>
  <label>Density</label>
  <input type="range" id="dens" min="1" max="5" value="3">
</div>
<main><div class="grid" id="grid"></div></main>
<div id="toast"></div>
<script>
let DATA = { parking:[], recaps:[], wins:[] };
let filter = localStorage.getItem('rq.filter') || 'all';
let sort = localStorage.getItem('rq.sort') || 'new';
let dens = +(localStorage.getItem('rq.dens') || 3);
let query = '';

const TAGCLASS = { parking:'parking', recap:'recap', win:'win' };
const esc = s => String(s||'').replace(/[&<>"]/g, c => ({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;'}[c]));

function render() {
  document.documentElement.style.setProperty('--cols', dens);
  let all = [...DATA.parking, ...DATA.recaps, ...DATA.wins];
  const q = query.trim().toLowerCase();
  if (q) all = all.filter(i =>
    ((i.title||'')+' '+(i.body||'')+' '+(i.project||'')).toLowerCase().includes(q));

  const counts = { all: all.length, parking:0, recap:0, win:0 };
  all.forEach(i => { counts[i.type] = (counts[i.type]||0) + 1; });
  document.querySelectorAll('.ct').forEach(el => {
    el.textContent = ' ' + (counts[el.dataset.ct] || 0); });

  let items = filter === 'all' ? all : all.filter(i => i.type === filter);
  // Status rank: open parking items > resumable recaps > everything done.
  const statusRank = i => (i.type==='parking' && !i.discussed) ? 0
                        : (i.type==='recap') ? 1
                        : (i.type==='parking' && i.discussed) ? 2 : 3;
  if (sort === 'new') items.sort((a,b)=>b.ts-a.ts);
  else if (sort === 'old') items.sort((a,b)=>a.ts-b.ts);
  else if (sort === 'status') items.sort((a,b)=>statusRank(a)-statusRank(b) || b.ts-a.ts);
  else if (sort === 'priority') items.sort((a,b)=>(b.rank||0)-(a.rank||0) || b.ts-a.ts);
  else if (sort === 'title') items.sort((a,b)=>(a.title||'').localeCompare(b.title||''));
  else items.sort((a,b)=>a.type.localeCompare(b.type) || b.ts-a.ts);

  const g = document.getElementById('grid');
  if (!items.length) {
    g.innerHTML = '<div class="empty">' +
      (q ? 'No matches for “' + esc(query.trim()) + '”.' : 'Nothing in the queue.') + '</div>';
    return;
  }
  window.LASTITEMS = items;
  g.innerHTML = items.map((i, idx) => {
    const stars = i.type==='win' ? '<span class="stars">'+'★'.repeat(i.rank||3)+'</span>' : '';
    const link = i.link ? '<a href="'+esc(i.link)+'" target="_blank" rel="noopener noreferrer">open ↗</a>' : '';
    const proj = i.project ? esc(i.project) : '';
    const runnable = i.type==='parking' || i.type==='recap';
    const runLabel = i.type==='recap' ? 'Resume ▸' : 'Run ▸';
    let actionBtns = '';
    if (runnable) actionBtns += '<button class="run" data-idx="'+idx+'">'+runLabel+'</button>';
    if (i.type==='parking')
      actionBtns += '<button class="done" data-idx="'+idx+'">'+(i.discussed?'↩ Reopen':'✓ Done')+'</button>';
    const actions = actionBtns ? '<div class="actions">'+actionBtns+'</div>' : '';
    return '<div class="card">'
      + '<div class="top"><span class="tag '+TAGCLASS[i.type]+'">'+i.type+'</span>'
      + '<span class="badge '+(i.badge==='open'?'open':i.badge==='done'?'done':'')+'">'+esc(i.badge)+'</span></div>'
      + '<h3>'+esc(i.title)+'</h3>'
      + '<p>'+esc(i.body)+'</p>'
      + actions
      + '<div class="meta">'+(i.date?'<span>'+esc(i.date)+'</span>':'')
      + (proj?'<span>'+proj+'</span>':'') + stars + link + '</div>'
      + '</div>';
  }).join('');
}

let toastTimer;
function toast(msg, isErr) {
  const t = document.getElementById('toast');
  t.textContent = msg;
  t.className = 'show' + (isErr ? ' err' : '');
  clearTimeout(toastTimer);
  toastTimer = setTimeout(() => { t.className = ''; }, 4200);
}

async function runItem(idx, btn) {
  const item = (window.LASTITEMS || [])[idx];
  if (!item) return;
  btn.disabled = true;
  const orig = btn.textContent;
  btn.textContent = 'Launching…';
  try {
    const r = await fetch('/api/run', {
      method: 'POST', headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify(item),
    });
    const j = await r.json();
    toast(j.message || (j.ok ? 'launched' : 'failed'), !j.ok);
  } catch (e) {
    toast('request failed: ' + e.message, true);
  } finally {
    btn.disabled = false;
    btn.textContent = orig;
  }
}

async function markDone(idx, btn) {
  const item = (window.LASTITEMS || [])[idx];
  if (!item || item.type !== 'parking') return;
  btn.disabled = true;
  try {
    const r = await fetch('/api/discussed', {
      method: 'POST', headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify({ id: item.id, discussed: !item.discussed }),
    });
    const j = await r.json();
    toast(j.message || (j.ok ? 'updated' : 'failed'), !j.ok);
    if (j.ok) return load();
  } catch (e) { toast('request failed: ' + e.message, true); }
  btn.disabled = false;
}

function setFilter(f){ filter=f; localStorage.setItem('rq.filter',f);
  document.querySelectorAll('.bar button').forEach(b=>b.classList.toggle('on',b.dataset.f===f));
  render(); }

async function load(manual) {
  try {
    const r = await fetch('/api/queue'); DATA = await r.json();
    const c = DATA.counts;
    document.getElementById('sub').textContent =
      c.parking+' parking-lot · '+c.recaps+' recaps · '+c.wins+' wins — auto-refreshes every 30s';
    render();
    if (manual) toast('Refreshed — '+(c.parking+c.recaps+c.wins)+' items');
  } catch(e){
    document.getElementById('sub').textContent = 'failed to load CNCP data';
    if (manual) toast('Refresh failed: '+e.message, true);
  }
}

document.querySelectorAll('.bar button').forEach(b=>b.onclick=()=>setFilter(b.dataset.f));
document.getElementById('sort').onchange = e=>{ sort=e.target.value; localStorage.setItem('rq.sort',sort); render(); };
document.getElementById('dens').oninput = e=>{ dens=+e.target.value; localStorage.setItem('rq.dens',dens); render(); };

document.getElementById('grid').addEventListener('click', e => {
  const run = e.target.closest('button.run');
  if (run) return runItem(+run.dataset.idx, run);
  const done = e.target.closest('button.done');
  if (done) return markDone(+done.dataset.idx, done);
});
document.getElementById('q').addEventListener('input', e => { query = e.target.value; render(); });
document.getElementById('refresh').onclick = () => load(true);

document.getElementById('sort').value = sort;
document.getElementById('dens').value = dens;
setFilter(filter);
load();
setInterval(load, 30000);
</script>
</body>
</html>`;

// Defensive 404-guard: even though this app serves no static files, refuse to
// answer any URL that looks like a snapshot/backup leak (*.bak, *.pre-*,
// *.orig, *.rej, *.swp, *~) before any other routing. Belt-and-braces in case
// a future static-mount is bolted on top.
const SNAPSHOT_RE = /(\.bak(\.|$)|\.pre-|\.orig$|\.rej$|\.swp$|~$)/i;

const server = http.createServer((req, res) => {
  if (SNAPSHOT_RE.test(req.url || '')) {
    res.writeHead(404, { 'Content-Type': 'text/plain; charset=utf-8' });
    res.end('404 — not found');
    return;
  }

  if (req.url === '/api/queue') return sendJSON(res, 200, buildQueue());

  if (req.url === '/api/run' && req.method === 'POST') {
    readBody(req)
      .then(item => {
        if (!item || !item.title) return sendJSON(res, 400, { ok: false, message: 'missing item' });
        return runItem(item).then(r => sendJSON(res, r.ok ? 200 : 500, r));
      })
      .catch(e => sendJSON(res, 400, { ok: false, message: e.message }));
    return;
  }

  if (req.url === '/api/discussed' && req.method === 'POST') {
    readBody(req)
      .then(p => {
        if (!p || !p.id) return sendJSON(res, 400, { ok: false, message: 'missing id' });
        const r = setDiscussed(p.id, !!p.discussed);
        sendJSON(res, r.ok ? 200 : 500, r);
      })
      .catch(e => sendJSON(res, 400, { ok: false, message: e.message }));
    return;
  }

  if (req.url === '/healthz') { res.writeHead(200); res.end('ok'); return; }

  if (req.url === '/' || req.url === '/index.html') {
    res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' });
    res.end(PAGE);
    return;
  }

  // Anything else is genuinely not found — don't masquerade as the page.
  res.writeHead(404, { 'Content-Type': 'text/plain; charset=utf-8' });
  res.end('404 — not found');
});

server.listen(PORT, '127.0.0.1', () => {
  console.log('Resume Queue Viewer → http://127.0.0.1:' + PORT);
});