[object Object]

← back to Agent Cabinet

Activity viewer: add Upcoming/scheduled lane — /api/schedule parses 64 launchd plists (plutil→json), computes next fire for calendar jobs (daily/weekly/monthly/one-time) + lists continuous interval jobs; page shows countdown + absolute time + cadence, auto-refresh

5b4da1d1dd0b824aef607f04498f0763978b9d95 · 2026-06-16 17:54:49 -0700 · SteveStudio2

Files touched

Diff

commit 5b4da1d1dd0b824aef607f04498f0763978b9d95
Author: SteveStudio2 <stevestudio2@SteveStacStudio.lan>
Date:   Tue Jun 16 17:54:49 2026 -0700

    Activity viewer: add Upcoming/scheduled lane — /api/schedule parses 64 launchd plists (plutil→json), computes next fire for calendar jobs (daily/weekly/monthly/one-time) + lists continuous interval jobs; page shows countdown + absolute time + cadence, auto-refresh
---
 activity.html | 29 +++++++++++++++++++++++++++--
 server.js     | 55 +++++++++++++++++++++++++++++++++++++++++++++++++++++++
 2 files changed, 82 insertions(+), 2 deletions(-)

diff --git a/activity.html b/activity.html
index 0becaf1..1b8f7a7 100644
--- a/activity.html
+++ b/activity.html
@@ -27,6 +27,18 @@
   .empty{color:var(--dim);padding:40px;text-align:center}
   .pulse{display:inline-block;width:8px;height:8px;border-radius:50%;background:#34f4ce;margin-right:6px;animation:p 1.6s infinite}
   @keyframes p{0%,100%{opacity:.3}50%{opacity:1}}
+  #upcoming{margin:16px 0 4px;border:1px solid #173a36;background:linear-gradient(180deg,rgba(52,244,206,.06),transparent);border-radius:12px;padding:13px 16px}
+  .up-head{font-size:13px;font-weight:700;color:#34f4ce;margin-bottom:9px}
+  .up-head .s{color:var(--dim);font-weight:400;font-size:11.5px}
+  .up-row{display:grid;grid-template-columns:92px 118px 1fr auto;gap:12px;align-items:baseline;padding:5px 4px;border-bottom:1px solid #122b28;font-size:12.5px}
+  .up-row .rel{color:#34f4ce;font:12px ui-monospace,monospace}
+  .up-row .abs{color:#9fb0c9;font:11.5px ui-monospace,monospace}
+  .up-row .lab{color:#e7ecf3;font-weight:600}
+  .up-row .cad{color:var(--dim);font-size:11px;white-space:nowrap}
+  .up-cont{margin-top:10px;font-size:11px;display:flex;gap:7px;flex-wrap:wrap;align-items:center}
+  .up-cont .cc{background:#0e1320;border:1px solid var(--line);border-radius:14px;padding:2px 9px;color:#aeb8c8}
+  .up-more{color:#34f4ce;font-size:11.5px;margin-top:8px;cursor:pointer}
+  .up-more:hover{text-decoration:underline}
 </style></head>
 <body>
 <header>
@@ -34,7 +46,7 @@
   <div class="meta"><span class="pulse"></span><span id="meta">loading…</span> · auto-refreshes every 30s · <a href="/pyramid">← cabinet pyramid</a></div>
   <div class="chips" id="chips"></div>
 </header>
-<main id="feed"><div class="empty">Loading activity…</div></main>
+<main><section id="upcoming"></section><div id="feed"><div class="empty">Loading activity…</div></div></main>
 <script>
 const TYPES={
   commit:{label:'Commits', col:'#a78bfa'},
@@ -83,6 +95,19 @@ async function load(){
     renderChips(); renderFeed();
   }catch(e){ document.getElementById('feed').innerHTML='<div class="empty">Couldn’t reach the server (:9766).</div>'; }
 }
-load(); setInterval(load, 30000);
+function rel(ms){ const d=ms-Date.now(); if(d<=0) return 'now'; const m=Math.round(d/60000); if(m<60) return 'in '+m+'m'; const h=Math.floor(m/60); return 'in '+h+'h '+String(m%60).padStart(2,'0')+'m'; }
+function fmtAbs(iso){ try{ return new Date(iso).toLocaleString(undefined,{weekday:'short',hour:'2-digit',minute:'2-digit'}); }catch(e){ return iso; } }
+let SHOW_ALL_UP=false;
+async function loadSchedule(){
+  let r; try{ r=await(await fetch('/api/schedule')).json(); }catch(e){ return; }
+  const up=r.upcoming||[], cont=r.continuous||[];
+  const n=SHOW_ALL_UP?up.length:14;
+  let h='<div class="up-head">⏭ Upcoming <span class="s">— '+up.length+' scheduled jobs · next fire</span></div>';
+  h+=up.slice(0,n).map(e=>`<div class="up-row"><span class="rel">${rel(Date.parse(e.next_ts))}</span><span class="abs">${fmtAbs(e.next_ts)}</span><span class="lab">${esc(e.label)}</span><span class="cad">${esc(e.cadence)}</span></div>`).join('');
+  if(up.length>14) h+=`<div class="up-more" onclick="SHOW_ALL_UP=!SHOW_ALL_UP;loadSchedule()">${SHOW_ALL_UP?'▲ show less':'▼ show all '+up.length}</div>`;
+  if(cont.length) h+='<div class="up-cont"><b style="color:#8a94a6">Continuous</b>'+cont.map(c=>`<span class="cc">${esc(c.label)} · ${esc(c.cadence)}</span>`).join('')+'</div>';
+  document.getElementById('upcoming').innerHTML=h;
+}
+load(); loadSchedule(); setInterval(()=>{ load(); loadSchedule(); }, 30000);
 </script>
 </body></html>
diff --git a/server.js b/server.js
index 040b873..8a2a5f3 100644
--- a/server.js
+++ b/server.js
@@ -135,6 +135,39 @@ function readCanary(skill) {
 }
 const officerStatus = (sev) => sev === 'FAIL' ? 'down' : sev === 'WARN' ? 'warn' : sev === 'PASS' ? 'up' : 'na';
 
+// ── Scheduled/upcoming lane — compute next launchd fire time from a plist spec ──
+const DOW = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat'];
+function nextCal(s, nowMs) {
+  const d = new Date(nowMs); d.setSeconds(0, 0);
+  const H = s.Hour != null ? s.Hour : 0, M = s.Minute != null ? s.Minute : 0;
+  d.setHours(H, M, 0, 0);
+  if (s.Month != null) {
+    d.setMonth(s.Month - 1); if (s.Day != null) d.setDate(s.Day); d.setHours(H, M, 0, 0);
+    if (d.getTime() <= nowMs) { d.setFullYear(d.getFullYear() + 1); }
+    return d.getTime();
+  }
+  if (s.Weekday != null) {
+    const wd = s.Weekday === 7 ? 0 : s.Weekday;
+    let add = (wd - d.getDay() + 7) % 7; if (add === 0 && d.getTime() <= nowMs) add = 7;
+    d.setDate(d.getDate() + add); return d.getTime();
+  }
+  if (s.Day != null) {
+    d.setDate(s.Day); d.setHours(H, M, 0, 0);
+    if (d.getTime() <= nowMs) { d.setMonth(d.getMonth() + 1); d.setDate(s.Day); d.setHours(H, M, 0, 0); }
+    return d.getTime();
+  }
+  if (d.getTime() <= nowMs) d.setDate(d.getDate() + 1);
+  return d.getTime();
+}
+function calCadence(s) {
+  const hm = ('0' + (s.Hour || 0)).slice(-2) + ':' + ('0' + (s.Minute || 0)).slice(-2);
+  if (s.Month != null) return 'one-time ' + s.Month + '/' + (s.Day || 1) + ' ' + hm;
+  if (s.Weekday != null) return 'weekly ' + DOW[s.Weekday === 7 ? 0 : s.Weekday] + ' ' + hm;
+  if (s.Day != null) return 'monthly day ' + s.Day + ' ' + hm;
+  return 'daily ' + hm;
+}
+function humanInt(sec) { return sec % 3600 === 0 ? 'every ' + sec / 3600 + 'h' : sec % 60 === 0 ? 'every ' + sec / 60 + 'm' : 'every ' + sec + 's'; }
+
 function parseYaml(text) {
   // Tiny YAML subset parser — good enough for cabinet.yaml's shape.
   // Produces {president, cabinet:[{vp, domain, triggers:[], directors:[{skill?,subagent?,owns?}]}]}
@@ -733,6 +766,28 @@ const server = http.createServer((req, res) => {
     wr.on('error', () => finish([])); wr.on('timeout', () => { wr.destroy(); finish([]); }); wr.end();
     return;
   }
+  if (u.pathname === '/api/schedule') {
+    const now = Date.now(), upcoming = [], continuous = [];
+    let files = [];
+    try { files = fs.readdirSync(path.join(HOME, 'Library', 'LaunchAgents')).filter(f => /^com\.steve\..*\.plist$/.test(f)); } catch (e) {}
+    files.forEach(f => {
+      let d; try { d = JSON.parse(execSync('plutil -convert json -o - "' + path.join(HOME, 'Library', 'LaunchAgents', f) + '"', { encoding: 'utf8' })); } catch (e) { return; }
+      const label = (d.Label || f).replace(/^com\.steve\./, '');
+      if (d.StartInterval) {
+        if (d.StartInterval >= 600) continuous.push({ label, cadence: humanInt(d.StartInterval), every_s: d.StartInterval });
+        return;
+      }
+      const sc = d.StartCalendarInterval;
+      if (!sc) return; // KeepAlive / RunAtLoad — not a scheduled fire
+      const specs = Array.isArray(sc) ? sc : [sc];
+      let best = Infinity, spec = null;
+      specs.forEach(s => { const t = nextCal(s, now); if (t < best) { best = t; spec = s; } });
+      if (spec) upcoming.push({ label, next_ts: new Date(best).toISOString(), cadence: Array.isArray(sc) ? (specs.length + '×/day') : calCadence(spec) });
+    });
+    upcoming.sort((a, b) => Date.parse(a.next_ts) - Date.parse(b.next_ts));
+    continuous.sort((a, b) => a.every_s - b.every_s);
+    return J({ ok: true, generated_at: new Date().toISOString(), upcoming, continuous });
+  }
 
   if (req.url === '/cabinet.yaml') {
     try {

← 101c9bc Add /activity viewer — live date/time timeline aggregating g  ·  back to Agent Cabinet  ·  Activity viewer: side-by-side two columns — sticky Upcoming/ 0dfcebe →