← back to AbramsEgo
feat(cron-launchd): scheduled-jobs panel
ce2faceb53c4a50443c414e80038863a41383606 · 2026-07-01 11:21:53 -0700 · Steve
Files touched
M public/index.htmlM server.js
Diff
commit ce2faceb53c4a50443c414e80038863a41383606
Author: Steve <steve@designerwallcoverings.com>
Date: Wed Jul 1 11:21:53 2026 -0700
feat(cron-launchd): scheduled-jobs panel
---
public/index.html | 36 ++++++++++++++++++++++++++++++++++++
server.js | 36 +++++++++++++++++++++++++++++++++++-
2 files changed, 71 insertions(+), 1 deletion(-)
diff --git a/public/index.html b/public/index.html
index 0540bfb..56c276f 100644
--- a/public/index.html
+++ b/public/index.html
@@ -161,6 +161,12 @@
<div class="list" id="subruns" style="max-height:340px"></div>
</section>
+ <!-- CRON / LAUNCHD (panel 4) — every scheduled com.steve.* job as an admin card -->
+ <section class="card span12">
+ <h2>⏱ Cron / launchd <span class="muted" id="crons-sub"></span></h2>
+ <div id="crongrid" style="display:grid;grid-template-columns:repeat(auto-fill,minmax(240px,1fr));gap:10px"></div>
+ </section>
+
<!-- AI CHAT -->
<section class="card span12 chat">
<h2>🤖 Ask AbramsEgo <span class="muted">· grounded in the live snapshot · cost shown per query</span></h2>
@@ -221,6 +227,9 @@ async function load(){
// ---- model usage + sub-agent runs (panel 5) ----
renderUsage(d);
+ // ---- cron / launchd (panel 4) ----
+ renderCrons(d);
+
// ---- fleet ----
const k=d.kamatera||{}, l=d.localFleet||{};
$('kam-up').innerHTML=`<span class="${cls(100-(k.up/(k.total||1)*100),20,50)}">${k.up??'—'}</span><span class="dim" style="font-size:16px">/${k.total??'—'} up</span>`;
@@ -333,6 +342,33 @@ function renderUsage(d){
}).join('')||'<span class="muted">no sub-agent runs</span>';
}
}
+// panel 4 — every com.steve.* launchd job as an ADMIN CARD → MUST show created
+// date+time (🕓 toLocaleString, full ISO in title=). Status pill: running=green
+// PID · idle=grey · nonzero-exit=red.
+function renderCrons(d){
+ const cr=d.crons||{};
+ const jobs=(cr.jobs||[]).slice().sort((a,b)=>String(a.label).localeCompare(String(b.label)));
+ $('crons-sub').textContent=`· ${cr.loadedCount??0} loaded / ${cr.count??0} total · com.steve.*`;
+ const el=$('crongrid'); if(!el) return;
+ const pill=(j)=>{
+ if(j.state==='running') return `<span class="pill live">▶ running · ${esc(j.pid)}</span>`;
+ if(j.state==='error') return `<span class="pill" style="color:var(--bad);border-color:#ff5d6c44;background:#ff5d6c0f">✕ exit ${esc(j.lastExit)}</span>`;
+ return `<span class="pill">idle</span>`;
+ };
+ el.innerHTML=jobs.map(j=>{
+ const iso=j.created||j.modified||'';
+ const dt=iso?new Date(iso):null;
+ const when=dt?dt.toLocaleString(undefined,{year:'numeric',month:'short',day:'numeric',hour:'numeric',minute:'2-digit'}):'—';
+ const name=String(j.label||'').replace(/^com\.steve\./,'');
+ const sched=j.schedule?`⏲ ${esc(j.schedule)}`:'<span class="dim">no schedule</span>';
+ const prog=j.program?` · <span class="dim">${esc(j.program)}</span>`:'';
+ return `<div class="subcard" title="created ${esc(iso)}">
+ <div class="subcard-top"><span class="subcard-proj">${esc(name)}</span>${pill(j)}</div>
+ <div class="subcard-when" style="margin-top:6px">${sched}${prog}</div>
+ <div class="subcard-when">🕓 ${esc(when)}</div>
+ </div>`;
+ }).join('')||'<span class="muted">no com.steve.* jobs</span>';
+}
function statusDot(st){const c=st==='online'?'var(--good)':st?'var(--bad)':'var(--dim)';return `<span class="dot" style="background:${c}"></span>`;}
function fmtDur(s){if(s==null)return null;if(s<60)return s+'s';if(s<3600)return Math.floor(s/60)+'m';return Math.floor(s/3600)+'h';}
function esc(x){return (x==null?'':String(x)).replace(/[<>&]/g,m=>({'<':'<','>':'>','&':'&'}[m]));}
diff --git a/server.js b/server.js
index aad33c4..99c425c 100644
--- a/server.js
+++ b/server.js
@@ -169,6 +169,35 @@ async function collectCanaries() {
return { canaries: out, count: out.length };
}
+// parse a launchd plist for a human-readable schedule + the file's created/
+// modified timestamps (satisfies Steve's admin-card "created date+time" rule).
+// Cheap synchronous read; never throws — degrades to nulls.
+function readCronPlistMeta(filePath) {
+ const meta = { schedule: null, program: null, created: null, modified: null };
+ try {
+ const st = fs.statSync(filePath);
+ meta.created = new Date(st.birthtimeMs || st.ctimeMs).toISOString();
+ meta.modified = new Date(st.mtimeMs).toISOString();
+ } catch (e) {}
+ let xml = '';
+ try { xml = fs.readFileSync(filePath, 'utf8'); } catch (e) { return meta; }
+ const iv = xml.match(/<key>StartInterval<\/key>\s*<integer>(\d+)<\/integer>/i);
+ if (iv) {
+ const s = parseInt(iv[1], 10);
+ meta.schedule = s % 3600 === 0 ? `every ${s / 3600}h` : s % 60 === 0 ? `every ${s / 60}m` : `every ${s}s`;
+ } else if (/<key>StartCalendarInterval<\/key>/i.test(xml)) {
+ const h = xml.match(/<key>Hour<\/key>\s*<integer>(\d+)<\/integer>/i);
+ const m = xml.match(/<key>Minute<\/key>\s*<integer>(\d+)<\/integer>/i);
+ meta.schedule = h ? `daily ${String(h[1]).padStart(2, '0')}:${m ? String(m[1]).padStart(2, '0') : '00'}` : 'calendar';
+ } else if (/<key>RunAtLoad<\/key>\s*<true/i.test(xml)) {
+ meta.schedule = 'at load';
+ }
+ const prog = xml.match(/<key>Program<\/key>\s*<string>([^<]+)<\/string>/i)
+ || xml.match(/<key>ProgramArguments<\/key>\s*<array>\s*<string>([^<]+)<\/string>/i);
+ if (prog) meta.program = prog[1].split('/').pop();
+ return meta;
+}
+
async function collectCrons() {
const laDir = path.join(HOME, 'Library', 'LaunchAgents');
let files = [];
@@ -186,7 +215,12 @@ async function collectCrons() {
const jobs = files.map((f) => {
const label = f.replace(/\.plist$/, '');
const l = loaded[label];
- return { label, loaded: !!l, pid: l && l.pid !== '-' ? l.pid : null, lastExit: l ? l.exit : null };
+ const pid = l && l.pid !== '-' ? l.pid : null;
+ const lastExit = l ? l.exit : null;
+ const meta = readCronPlistMeta(path.join(laDir, f));
+ // running = live PID; error = loaded with a non-zero last exit; idle otherwise.
+ const state = pid ? 'running' : (lastExit && lastExit !== '0' && lastExit !== '-') ? 'error' : 'idle';
+ return { label, loaded: !!l, pid, lastExit, state, schedule: meta.schedule, program: meta.program, created: meta.created, modified: meta.modified };
});
return { count: jobs.length, loadedCount: jobs.filter((j) => j.loaded).length, jobs };
}
← f5a8b07 chore: gitignore ephemeral data/activity.json snapshot cache
·
back to AbramsEgo
·
auto-save: 2026-07-01T11:42:20 (8 files) — build-queue/tasks 628b737 →