[object Object]

← back to AbramsEgo

auto-save: 2026-07-01T11:42:20 (8 files) — build-queue/tasks/05-git-activity-feed.md build-queue/tasks/06-hardrule-compliance-pass.md public/index.html server.js build-queue/STOP

628b7374195844c25ebee11ef65a0dd8c3754fdb · 2026-07-01 11:42:22 -0700 · Steve Abrams

Files touched

Diff

commit 628b7374195844c25ebee11ef65a0dd8c3754fdb
Author: Steve Abrams <steve@designerwallcoverings.com>
Date:   Wed Jul 1 11:42:22 2026 -0700

    auto-save: 2026-07-01T11:42:20 (8 files) — build-queue/tasks/05-git-activity-feed.md build-queue/tasks/06-hardrule-compliance-pass.md public/index.html server.js build-queue/STOP
---
 build-queue/STOP                                   |   0
 .../{tasks => done}/05-git-activity-feed.md        |   0
 .../06-hardrule-compliance-pass.md                 |   0
 maxit-tasks/07-cron-launchd-panel.md               |  14 ++
 maxit-tasks/08-canary-verdicts-panel.md            |  15 +++
 maxit-tasks/09-ollama-chat.md                      |  17 +++
 public/index.html                                  | 146 ++++++++++++++++-----
 server.js                                          |   7 +-
 8 files changed, 166 insertions(+), 33 deletions(-)

diff --git a/build-queue/STOP b/build-queue/STOP
new file mode 100644
index 0000000..e69de29
diff --git a/build-queue/tasks/05-git-activity-feed.md b/build-queue/done/05-git-activity-feed.md
similarity index 100%
rename from build-queue/tasks/05-git-activity-feed.md
rename to build-queue/done/05-git-activity-feed.md
diff --git a/build-queue/tasks/06-hardrule-compliance-pass.md b/build-queue/failed/06-hardrule-compliance-pass.md
similarity index 100%
rename from build-queue/tasks/06-hardrule-compliance-pass.md
rename to build-queue/failed/06-hardrule-compliance-pass.md
diff --git a/maxit-tasks/07-cron-launchd-panel.md b/maxit-tasks/07-cron-launchd-panel.md
new file mode 100644
index 0000000..d2b5f5b
--- /dev/null
+++ b/maxit-tasks/07-cron-launchd-panel.md
@@ -0,0 +1,14 @@
+# AbramsEgo maxit-07 — cron / launchd status panel (SPEC panel 4)
+
+Read ../SPEC.md. Build the scheduled-jobs panel. Keep edits MINIMAL and in clearly
+delimited regions (a merge will follow) — add ONE new route + ONE new panel only.
+
+1. server.js: add authed `GET /api/crons` — run `launchctl list` and parse Steve's
+   `com.steve.*` jobs (label, PID or `-`, last exit code); fold a compact list into the snapshot.
+2. public/index.html: add a "Cron / launchd" panel listing each job with a status pill
+   (running=green PID / idle=grey / nonzero-exit=red). Each row is an admin card → show
+   created/last-run date+time per Steve's hard rule (🕓 toLocaleString, ISO in title=).
+3. Verify with `node --check server.js` (syntax only — do NOT run the server / pm2 restart;
+   node_modules is absent in this worktree). Commit `feat(cron-launchd): scheduled-jobs panel`.
+
+Local only. No deploy/publish.
diff --git a/maxit-tasks/08-canary-verdicts-panel.md b/maxit-tasks/08-canary-verdicts-panel.md
new file mode 100644
index 0000000..bee7721
--- /dev/null
+++ b/maxit-tasks/08-canary-verdicts-panel.md
@@ -0,0 +1,15 @@
+# AbramsEgo maxit-08 — canary verdicts panel (SPEC panel 6, health)
+
+Read ../SPEC.md. Surface Steve's canary fleet health. MINIMAL edits, delimited regions
+(merge follows) — ONE new route + ONE new panel.
+
+1. server.js: add authed `GET /api/canaries` — read each `~/.claude/skills/*/data/latest.json`
+   (disk-space, swap-pressure, dw-uptime-probe, dw-five-field, dw-map-auditor, backup, meta-watchdog,
+   etc.), return {skill, verdict/status, ts, summary} sorted worst-first. Cache in snapshot
+   (stale-while-revalidate — this globs the filesystem, keep off the hot path).
+2. public/index.html: add a "Canaries" panel; each canary is an admin card with a PASS/WARN/FAIL
+   pill and its last-run date+time (🕓 toLocaleString, ISO in title=) per Steve's hard rule.
+3. Verify `node --check server.js` (syntax only; no server run / pm2). Commit
+   `feat(canaries): canary-verdicts health panel`.
+
+Local only. Read-only file globs. No deploy/publish.
diff --git a/maxit-tasks/09-ollama-chat.md b/maxit-tasks/09-ollama-chat.md
new file mode 100644
index 0000000..142b9d0
--- /dev/null
+++ b/maxit-tasks/09-ollama-chat.md
@@ -0,0 +1,17 @@
+# AbramsEgo maxit-09 — AI chat panel wired to LOCAL Ollama (SPEC panel 7, unmetered)
+
+Read ../SPEC.md. Build the natural-language chat over the snapshot. Use LOCAL Ollama so it's
+$0/unmetered and does NOT consume the Anthropic Max session cap. MINIMAL edits, delimited
+regions (merge follows) — ONE new route + ONE new panel.
+
+1. server.js: add authed `POST /api/chat {q}` — build a compact context string from the current
+   snapshot (costs, fleet, crons, sessions) and POST to Ollama:
+   endpoints in priority order `http://192.168.1.133:11434` (Mac1) then `http://localhost:11434`
+   (Mac2 fallback), model `qwen3:14b`, `stream:false`, ~8s timeout. If neither reachable, return a
+   deterministic grounded summary from the snapshot (never call any paid API). Always include a
+   cost line in the response payload: `$0 (local ollama)`.
+2. public/index.html: add an "Ask AbramsEgo" chat panel (input + answer area) calling /api/chat.
+3. Verify `node --check server.js` (syntax only; no server run / pm2). Commit
+   `feat(chat): local-ollama AI chat panel ($0, cap-relieving)`.
+
+Local only. No paid API, no keys, no deploy.
diff --git a/public/index.html b/public/index.html
index 197afc2..d82f128 100644
--- a/public/index.html
+++ b/public/index.html
@@ -35,6 +35,18 @@
   .good{color:var(--good)}.warn{color:var(--warn)}.bad{color:var(--bad)}.gold{color:var(--gold)}.dim{color:var(--dim)}
   .row{display:flex;justify-content:space-between;gap:10px;padding:4px 0;border-bottom:1px dashed #ffffff08}
   .row:last-child{border-bottom:0}
+  /* list row with a visible created/started date+time line (Steve's admin-card rule) */
+  .lrow{padding:5px 0;border-bottom:1px dashed #ffffff08}
+  .lrow:last-child{border-bottom:0}
+  .lrow-top{display:flex;justify-content:space-between;gap:10px;align-items:baseline}
+  .lrow-when{margin-top:2px}
+  .when{font-size:9.5px;color:var(--dim);white-space:nowrap}
+  /* sort + density control bar shared by every grid/list */
+  .gridctl{display:flex;gap:12px;align-items:center;margin:0 0 10px;flex-wrap:wrap}
+  .gridctl select,.gridctl input[type=range]{background:#0c1420;border:1px solid var(--line);color:var(--ink);
+    border-radius:6px;font-family:var(--mono);font-size:11px;padding:3px 6px}
+  .gridctl label{display:flex;align-items:center;gap:5px;color:var(--dim);font-size:11px}
+  .gsentinel{grid-column:1/-1;text-align:center;color:var(--dim);font-size:10px;padding:6px}
   .pill{font-size:10px;padding:2px 7px;border-radius:20px;border:1px solid var(--line);color:var(--dim);white-space:nowrap}
   .pill.gated{color:var(--warn);border-color:#ffb45444;background:#ffb4540f}
   .pill.live{color:var(--good);border-color:#3fd18b44;background:#3fd18b0f}
@@ -158,12 +170,14 @@
   </section>
   <section class="card span7">
     <h2>🧵 Recent sub-agent runs <span class="muted" id="subruns-sub"></span></h2>
+    <div id="subruns-ctl"></div>
     <div class="list" id="subruns" style="max-height:340px"></div>
   </section>
 
   <!-- GIT ACTIVITY FEED (panel 6) -->
   <section class="card span12">
     <h2>📜 Activity <span class="muted" id="activity-sub">· recent commits across ~/Projects</span></h2>
+    <div id="activity-ctl"></div>
     <div id="activity" style="display:grid;grid-template-columns:repeat(auto-fill,minmax(300px,1fr));gap:8px;max-height:440px;overflow:auto"></div>
   </section>
 
@@ -180,6 +194,68 @@ const $ = (id)=>document.getElementById(id);
 const usd = (n)=> n==null? '—' : '$'+Number(n).toFixed(n<1?4:2);
 const cls = (pct,warn,bad)=> pct>=bad?'bad':pct>=warn?'warn':'good';
 
+// ---- created/started date+time chip (Steve's hard admin-card rule) ----------
+// Accepts an ISO string OR an epoch-ms number/string; returns {iso,label} or null.
+function fmtWhen(v){
+  if(v==null||v==='') return null;
+  let iso = (typeof v==='number' || /^\d{10,}$/.test(String(v))) ? new Date(Number(v)).toISOString() : String(v);
+  const dt=new Date(iso); if(isNaN(dt)) return null;
+  return {iso, label: dt.toLocaleString(undefined,{year:'numeric',month:'short',day:'numeric',hour:'numeric',minute:'2-digit'})};
+}
+// visible 🕓 date+time line with full ISO in title=; picks the first present field
+function whenChip(...vals){
+  for(const v of vals){ const w=fmtWhen(v); if(w) return `<span class="when" title="${esc(w.iso)}">🕓 ${esc(w.label)}</span>`; }
+  return '';
+}
+function tsOf(...vals){ for(const v of vals){ const w=fmtWhen(v); if(w) return Date.parse(w.iso); } return 0; }
+// two-line list row: main + right-meta, then a visible created/started date+time
+function lrow(main, meta, whenHtml){
+  return `<div class="lrow"><div class="lrow-top"><span>${main}</span><span class="dim">${meta||''}</span></div>`+
+    (whenHtml?`<div class="lrow-when">${whenHtml}</div>`:'')+`</div>`;
+}
+
+// ---- reusable grid: sort <select> + density slider + infinite scroll --------
+// Persists sort+density to localStorage; renders in batches with an
+// IntersectionObserver sentinel so a list can exceed ~40 rows without lag.
+const GRIDS={};
+function initGrid(name, cfg){
+  if(GRIDS[name]) return GRIDS[name];
+  const g=GRIDS[name]={items:[], cfg, shown:cfg.batch};
+  const kSort='ae.'+name+'.sort', kDens='ae.'+name+'.dens';
+  const savedSort=localStorage.getItem(kSort)||cfg.sortModes[0].k;
+  const savedDens=parseInt(localStorage.getItem(kDens)||cfg.defaultCols,10);
+  const bar=document.createElement('div'); bar.className='gridctl';
+  bar.innerHTML=`<label>sort <select class="gsort">${cfg.sortModes.map(m=>`<option value="${m.k}"${m.k===savedSort?' selected':''}>${m.label}</option>`).join('')}</select></label>`+
+    `<label>▦ <input type="range" class="gdens" min="${cfg.minCols}" max="${cfg.maxCols}" value="${savedDens}"></label>`;
+  cfg.controlsHost.appendChild(bar);
+  g.sortEl=bar.querySelector('.gsort'); g.densEl=bar.querySelector('.gdens');
+  g.sortEl.addEventListener('change',()=>{localStorage.setItem(kSort,g.sortEl.value);g.shown=cfg.batch;draw(g);});
+  g.densEl.addEventListener('input',()=>{localStorage.setItem(kDens,g.densEl.value);applyDensity(g);});
+  applyDensity(g);
+  g.io=new IntersectionObserver((ents)=>{
+    if(ents.some(e=>e.isIntersecting) && g.shown<g.items.length){ g.shown+=cfg.batch; draw(g); }
+  },{root:cfg.host, rootMargin:'140px'});
+  return g;
+}
+function applyDensity(g){
+  const cols=parseInt(g.densEl.value,10);
+  g.cfg.host.style.display='grid';
+  g.cfg.host.style.gap='8px';
+  g.cfg.host.style.gridTemplateColumns=`repeat(${cols},minmax(0,1fr))`;
+}
+function draw(g){
+  const mode=g.cfg.sortModes.find(m=>m.k===g.sortEl.value)||g.cfg.sortModes[0];
+  const items=g.items.slice().sort(mode.cmp);
+  const slice=items.slice(0,g.shown);
+  g.cfg.host.innerHTML = slice.map(g.cfg.render).join('') || `<span class="muted">${g.cfg.empty||'nothing yet'}</span>`;
+  if(g.shown<items.length){
+    const s=document.createElement('div'); s.className='gsentinel';
+    s.textContent=`… ${items.length-g.shown} more · scroll to load`;
+    g.cfg.host.appendChild(s); g.io.observe(s);
+  }
+}
+function feedGrid(name, items){ const g=GRIDS[name]; if(!g) return; g.items=Array.isArray(items)?items:[]; if(g.shown>g.items.length) g.shown=g.cfg.batch; draw(g); }
+
 async function load(){
   let d; try{ d = await (await fetch('/api/data')).json(); }catch(e){ return; }
   if(!d || !d.builtAt) return;
@@ -192,7 +268,7 @@ async function load(){
     ['NODE', s.node||'—',''],
     ['UP', fmtDur(s.dashboardUptimeSec)||'—',''],
   ].map(([k,v,c])=>`<div class="metric"><div class="v ${c}">${v}</div><div class="k">${k}</div></div>`).join('');
-  $('refresh').textContent = 'snapshot '+new Date(d.builtAt).toLocaleTimeString()+' · auto 15s';
+  $('refresh').textContent = 'snapshot '+new Date(d.builtAt).toLocaleTimeString()+' · auto 15s · $0 (local)';
 
   // ---- P&L ----
   const p=(d.pnl&&d.pnl.today)||{}, c=(d.cost&&d.cost.today)||{};
@@ -233,16 +309,16 @@ async function load(){
   // ---- 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>`;
-  $('kam-list').innerHTML=(k.procs||[]).slice(0,40).map(pr=>`<div class="row"><span>${statusDot(pr.status)}${pr.name}</span><span class="dim">${pr.restarts??''}↺</span></div>`).join('')|| (k.note?`<span class="muted">${k.note}</span>`:'');
+  $('kam-list').innerHTML=(k.procs||[]).slice(0,40).map(pr=>lrow(`${statusDot(pr.status)}${esc(pr.name)}`,`${pr.restarts??''}↺`,whenChip(pr.startedAt))).join('')|| (k.note?`<span class="muted">${esc(k.note)}</span>`:'');
   $('loc-up').innerHTML=`<span class="good">${l.up??'—'}</span><span class="dim" style="font-size:16px">/${l.total??'—'} up</span>`;
-  $('loc-list').innerHTML=(l.procs||[]).map(pr=>`<div class="row"><span>${statusDot(pr.status)}${pr.name}</span><span class="dim">${pr.memMB!=null?pr.memMB+'M':''}</span></div>`).join('')||'<span class="muted">no local pm2 procs</span>';
+  $('loc-list').innerHTML=(l.procs||[]).map(pr=>lrow(`${statusDot(pr.status)}${esc(pr.name)}`,`${pr.memMB!=null?pr.memMB+'M':''}`,whenChip(pr.startedAt))).join('')||'<span class="muted">no local pm2 procs</span>';
 
   // ---- canaries ----
   const cans=(d.canaries&&d.canaries.canaries)||[];
   $('canaries').innerHTML=cans.map(cn=>{
     const v=(cn.verdict||'').toUpperCase();
     const col=/FAIL|DOWN|CRIT/.test(v)?'bad':/WARN|DEGRAD/.test(v)?'warn':/PASS|UP|HEALTH|OK/.test(v)?'good':'dim';
-    return `<div class="row"><span>${cn.name}</span><span class="${col}">${cn.verdict||'—'} <span class="dim">${cn.ageMin}m${cn.stale?' ⚠':''}</span></span></div>`;
+    return lrow(esc(cn.name),`<span class="${col}">${esc(cn.verdict||'—')}</span> <span class="dim">${cn.ageMin}m${cn.stale?' ⚠':''}</span>`,whenChip(cn.mtime,cn.scanned));
   }).join('')||'<span class="muted">no canaries</span>';
 
   // ---- crons / officers ----
@@ -255,11 +331,11 @@ async function load(){
   // ---- wins ----
   const w=d.wins||{};
   $('wins-today').textContent=`· ${w.today||0} today · ${w.count||0} total`;
-  $('wins').innerHTML=(w.recent||[]).map(x=>`<div class="row"><span>${esc(x.title)} <span class="dim">${x.project||''}</span></span><span class="dim">${(x.date||'').slice(5,10)}</span></div>`).join('')||'<span class="muted">no wins logged</span>';
+  $('wins').innerHTML=(w.recent||[]).map(x=>lrow(esc(x.title),`<span class="dim">${esc(x.project||'')}</span>`,whenChip(x.createdAt,x.date))).join('')||'<span class="muted">no wins logged</span>';
 
   // ---- sessions ----
   const ss=(d.sessions&&d.sessions.recent)||[];
-  $('sessions').innerHTML=ss.map(x=>`<div class="row"><span>${esc(x.title||x.cwd||'session')}</span><span class="dim">${x.msgCount!=null?x.msgCount+' msg':''}</span></div>`).join('')||'<span class="muted">no sessions</span>';
+  $('sessions').innerHTML=ss.map(x=>lrow(esc(x.title||x.cwd||'session'),`${x.msgCount!=null?x.msgCount+' msg':''}`,whenChip(x.lastTs,x.mtime,x.firstTs))).join('')||'<span class="muted">no sessions</span>';
 
   // ---- alert banner ----
   const alerts=[];
@@ -327,39 +403,47 @@ function renderUsage(d){
         `<div class="bar"><span style="width:${Math.round(m.cost/mmax*100)}%"></span></div>`;
     }).join('')) || '<span class="muted">no model usage</span>';
   }
-  // recent sub-agent runs — each row is an ADMIN CARD → MUST show created date+time
-  const rel=$('subruns');
-  if(rel){
-    rel.innerHTML=runs.map(r=>{
-      const iso=r.startedAt||r.updatedAt||'';
-      const dt=iso?new Date(iso):null;
-      const when=dt?dt.toLocaleString(undefined,{year:'numeric',month:'short',day:'numeric',hour:'numeric',minute:'2-digit'}):'—';
-      return `<div class="subcard" title="${esc(iso)}">
-        <div class="subcard-top"><span class="subcard-proj">${esc(r.project||'?')}</span>`+
-        `<span class="pill">${esc(r.model||'unknown')}</span></div>`+
-        `<div class="subcard-task">${esc(r.task||'')}</div>`+
-        `<div class="subcard-when">🕓 ${esc(when)}</div></div>`;
-    }).join('')||'<span class="muted">no sub-agent runs</span>';
-  }
+  // recent sub-agent runs — each is an ADMIN CARD → shows created date+time;
+  // grid gets sort + density + infinite scroll.
+  initGrid('subruns',{
+    controlsHost:$('subruns-ctl'), host:$('subruns'), batch:10, minCols:1, maxCols:3, defaultCols:1,
+    empty:'no sub-agent runs',
+    sortModes:[
+      {k:'new',  label:'Newest',      cmp:(a,b)=>tsOf(b.startedAt,b.updatedAt)-tsOf(a.startedAt,a.updatedAt)},
+      {k:'old',  label:'Oldest',      cmp:(a,b)=>tsOf(a.startedAt,a.updatedAt)-tsOf(b.startedAt,b.updatedAt)},
+      {k:'proj', label:'Project A→Z', cmp:(a,b)=>(a.project||'').localeCompare(b.project||'')},
+      {k:'model',label:'Model A→Z',   cmp:(a,b)=>(a.model||'').localeCompare(b.model||'')},
+    ],
+    render:(r)=>`<div class="subcard" title="${esc(r.startedAt||r.updatedAt||'')}">
+      <div class="subcard-top"><span class="subcard-proj">${esc(r.project||'?')}</span>`+
+      `<span class="pill">${esc(r.model||'unknown')}</span></div>`+
+      `<div class="subcard-task">${esc(r.task||'')}</div>`+
+      `<div class="subcard-when">${whenChip(r.startedAt,r.updatedAt)||'🕓 —'}</div></div>`,
+  });
+  feedGrid('subruns', runs);
 }
-// git activity — each commit row is an ADMIN CARD → MUST show created date+time
+// git activity — each commit is an ADMIN CARD → shows created date+time;
+// grid gets sort + density + infinite scroll (can exceed ~40 rows).
 function renderActivity(d){
   const a=d.activity||{};
   const commits=a.commits||[];
   const sub=$('activity-sub');
-  if(sub) sub.textContent=`· ${commits.length} commits · ${a.repoCount||0} repos · newest first`;
-  const el=$('activity');
-  if(!el) return;
-  el.innerHTML=commits.map(cm=>{
-    const iso=cm.date||'';
-    const dt=iso?new Date(iso):null;
-    const when=dt&&!isNaN(dt)?dt.toLocaleString(undefined,{year:'numeric',month:'short',day:'numeric',hour:'numeric',minute:'2-digit'}):'—';
-    return `<div class="subcard" title="${esc(iso)}">
+  if(sub) sub.textContent=`· ${commits.length} commits · ${a.repoCount||0} repos`;
+  initGrid('activity',{
+    controlsHost:$('activity-ctl'), host:$('activity'), batch:12, minCols:1, maxCols:5, defaultCols:3,
+    empty:'no recent commits',
+    sortModes:[
+      {k:'new', label:'Newest', cmp:(x,y)=>tsOf(y.date)-tsOf(x.date)},
+      {k:'old', label:'Oldest', cmp:(x,y)=>tsOf(x.date)-tsOf(y.date)},
+      {k:'repo',label:'Repo A→Z',cmp:(x,y)=>(x.repo||'').localeCompare(y.repo||'')||tsOf(y.date)-tsOf(x.date)},
+    ],
+    render:(cm)=>`<div class="subcard" title="${esc(cm.date||'')}">
       <div class="subcard-top"><span class="subcard-proj">${esc(cm.repo||'?')}</span>`+
       `<span class="pill">${esc(cm.hash||'')}</span></div>`+
       `<div class="subcard-task">${esc(cm.subject||'')}</div>`+
-      `<div class="subcard-when">🕓 ${esc(when)}${cm.author?' · '+esc(cm.author):''}</div></div>`;
-  }).join('')||'<span class="muted">no recent commits</span>';
+      `<div class="subcard-when">${whenChip(cm.date)||'🕓 —'}${cm.author?' · '+esc(cm.author):''}</div></div>`,
+  });
+  feedGrid('activity', commits);
 }
 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';}
diff --git a/server.js b/server.js
index e317052..f8cb99a 100644
--- a/server.js
+++ b/server.js
@@ -118,6 +118,8 @@ async function collectLocalFleet() {
     memMB: p.monit ? round(p.monit.memory / 1e6, 0) : null,
     restarts: p.pm2_env && p.pm2_env.restart_time,
     uptimeMs: p.pm2_env && p.pm2_env.pm_uptime ? Date.now() - p.pm2_env.pm_uptime : null,
+    // created/started time — every fleet tile obeys Steve's admin-card date+time rule
+    startedAt: p.pm2_env && p.pm2_env.pm_uptime ? new Date(p.pm2_env.pm_uptime).toISOString() : null,
   }));
   return { procs, up: procs.filter((p) => p.status === 'online').length, total: procs.length };
 }
@@ -135,7 +137,7 @@ async function collectKamateraFleet() {
     if (r.ok) {
       try {
         const list = JSON.parse(r.out);
-        const procs = list.map((p) => ({ name: p.name, status: p.pm2_env && p.pm2_env.status, restarts: p.pm2_env && p.pm2_env.restart_time }));
+        const procs = list.map((p) => ({ name: p.name, status: p.pm2_env && p.pm2_env.status, restarts: p.pm2_env && p.pm2_env.restart_time, startedAt: p.pm2_env && p.pm2_env.pm_uptime ? new Date(p.pm2_env.pm_uptime).toISOString() : null }));
         fs.writeFileSync(KAM_CACHE, JSON.stringify({ fetchedAt: new Date().toISOString(), procs }));
       } catch (e) { /* keep last-good */ }
     }
@@ -162,7 +164,8 @@ async function collectCanaries() {
         scanned = j.scanned_at || j.checked_at || null;
       } catch (e) {}
       const ageMin = Math.round((Date.now() - st.mtimeMs) / 60000);
-      out.push({ name, verdict, scanned, ageMin, stale: ageMin > 24 * 60 });
+      // heartbeat/last-run time so each canary card can show created date+time
+      out.push({ name, verdict, scanned, mtime: new Date(st.mtimeMs).toISOString(), ageMin, stale: ageMin > 24 * 60 });
     } catch (e) { /* no heartbeat */ }
   }
   out.sort((a, b) => a.ageMin - b.ageMin);

← ce2face feat(cron-launchd): scheduled-jobs panel  ·  back to AbramsEgo  ·  queue: re-split timed-out 06 into 06a (card date+time) + 06b 6268895 →