[object Object]

← back to Resume Queue Viewer

Add Run/Resume action — launches a claude iTerm2 tab per queue item

bf816e72cf503adb96abfec51813c67def862efa · 2026-05-18 17:23:25 -0700 · SteveStudio2

Files touched

Diff

commit bf816e72cf503adb96abfec51813c67def862efa
Author: SteveStudio2 <stevestudio2@SteveStacStudio.lan>
Date:   Mon May 18 17:23:25 2026 -0700

    Add Run/Resume action — launches a claude iTerm2 tab per queue item
---
 server.js | 132 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-
 1 file changed, 131 insertions(+), 1 deletion(-)

diff --git a/server.js b/server.js
index 4ba9b92..c3dd24a 100644
--- a/server.js
+++ b/server.js
@@ -5,7 +5,9 @@
 
 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');
@@ -54,6 +56,64 @@ function buildQueue() {
     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 + '"' });
+    });
+  });
+}
+
 const PAGE = `<!doctype html>
 <html lang="en">
 <head>
@@ -98,6 +158,17 @@ const PAGE = `<!doctype html>
   .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; }
+  #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>
@@ -121,6 +192,7 @@ const PAGE = `<!doctype html>
   <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';
@@ -140,21 +212,58 @@ function render() {
 
   const g = document.getElementById('grid');
   if (!items.length) { g.innerHTML = '<div class="empty">Nothing in the queue.</div>'; return; }
-  g.innerHTML = items.map(i => {
+  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">open ↗</a>' : '';
     const proj = i.project ? esc(i.project) : '';
+    const runnable = i.type==='parking' || i.type==='recap';
+    const runLabel = i.type==='recap' ? 'Resume ▸' : 'Run ▸';
+    const actions = runnable
+      ? '<div class="actions"><button class="run" data-idx="'+idx+'">'+runLabel+'</button></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;
+  }
+}
+
 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(); }
@@ -173,6 +282,11 @@ document.querySelectorAll('.bar button').forEach(b=>b.onclick=()=>setFilter(b.da
 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 btn = e.target.closest('button.run');
+  if (btn) runItem(+btn.dataset.idx, btn);
+});
+
 document.getElementById('sort').value = sort;
 document.getElementById('dens').value = dens;
 setFilter(filter);
@@ -188,6 +302,22 @@ const server = http.createServer((req, res) => {
     res.end(JSON.stringify(buildQueue()));
     return;
   }
+  if (req.url === '/api/run' && req.method === 'POST') {
+    let body = '';
+    req.on('data', c => { body += c; if (body.length > 1e5) req.destroy(); });
+    req.on('end', async () => {
+      let item;
+      try { item = JSON.parse(body || '{}'); }
+      catch (e) { res.writeHead(400, { 'Content-Type': 'application/json' });
+        return res.end(JSON.stringify({ ok: false, message: 'bad JSON' })); }
+      if (!item || !item.title) { res.writeHead(400, { 'Content-Type': 'application/json' });
+        return res.end(JSON.stringify({ ok: false, message: 'missing item' })); }
+      const result = await runItem(item);
+      res.writeHead(result.ok ? 200 : 500, { 'Content-Type': 'application/json' });
+      res.end(JSON.stringify(result));
+    });
+    return;
+  }
   if (req.url === '/healthz') { res.writeHead(200); res.end('ok'); return; }
   res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' });
   res.end(PAGE);

← 598f4ad initial scaffold — resume queue viewer (CNCP parking-lot + r  ·  back to Resume Queue Viewer  ·  Add launchd plist reference copy for auto-start eaf6280 →