← 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
M activity.htmlM server.js
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 →