[object Object]

← back to AbramsEgo

feat: model-usage + sub-agent-runs panel

2edd5e6b8b3ffdedf5d1bb3e7394c5bd4e08dad1 · 2026-07-01 11:10:30 -0700 · Steve

GET /api/usage collector reads ~/.claude/cost-ledger.jsonl (per-model
calls/tokens/$ for 24h + all-time) and ~/.claude/projects/**/*.jsonl mtimes
(active/recent Claude sessions + last 20 sub-agent runs, newest first).
Folded a compact usageSummary into the main snapshot. Front-end renders a
per-model mini-bar-chart + a Recent sub-agent runs list where each run row is
an admin card showing created date+time (🕓 toLocaleString, full ISO in title=)
per Steve's hard rule.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

Files touched

Diff

commit 2edd5e6b8b3ffdedf5d1bb3e7394c5bd4e08dad1
Author: Steve <steve@designerwallcoverings.com>
Date:   Wed Jul 1 11:10:30 2026 -0700

    feat: model-usage + sub-agent-runs panel
    
    GET /api/usage collector reads ~/.claude/cost-ledger.jsonl (per-model
    calls/tokens/$ for 24h + all-time) and ~/.claude/projects/**/*.jsonl mtimes
    (active/recent Claude sessions + last 20 sub-agent runs, newest first).
    Folded a compact usageSummary into the main snapshot. Front-end renders a
    per-model mini-bar-chart + a Recent sub-agent runs list where each run row is
    an admin card showing created date+time (🕓 toLocaleString, full ISO in title=)
    per Steve's hard rule.
    
    Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
---
 build-queue/STOP  |   0
 public/index.html |  61 +++++++++++++++++++++++++--
 server.js         | 124 +++++++++++++++++++++++++++++++++++++++++++++++++++++-
 3 files changed, 180 insertions(+), 5 deletions(-)

diff --git a/build-queue/STOP b/build-queue/STOP
deleted file mode 100644
index e69de29..0000000
diff --git a/public/index.html b/public/index.html
index 5a80e6e..0540bfb 100644
--- a/public/index.html
+++ b/public/index.html
@@ -28,9 +28,9 @@
     border-radius:12px;padding:14px 16px;min-height:60px}
   .card h2{margin:0 0 10px;font-size:11px;letter-spacing:1px;text-transform:uppercase;color:var(--dim);
     display:flex;align-items:center;gap:8px}
-  .span12{grid-column:span 12}.span8{grid-column:span 8}.span6{grid-column:span 6}
-  .span4{grid-column:span 4}.span3{grid-column:span 3}
-  @media(max-width:1000px){.span8,.span6,.span4,.span3{grid-column:span 12}}
+  .span12{grid-column:span 12}.span8{grid-column:span 8}.span7{grid-column:span 7}.span6{grid-column:span 6}
+  .span5{grid-column:span 5}.span4{grid-column:span 4}.span3{grid-column:span 3}
+  @media(max-width:1000px){.span8,.span7,.span6,.span5,.span4,.span3{grid-column:span 12}}
   .big{font-size:30px;font-weight:700}
   .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}
@@ -54,6 +54,12 @@
   .chat .ans{margin-top:10px;color:var(--ink);white-space:pre-wrap;background:#0c1420;border:1px solid var(--line);
     border-radius:8px;padding:10px;min-height:20px}
   .muted{color:var(--dim);font-size:11px}
+  .subcard{background:#0c1420;border:1px solid var(--line);border-radius:9px;padding:9px 11px;margin-bottom:8px}
+  .subcard-top{display:flex;align-items:center;justify-content:space-between;gap:8px}
+  .subcard-proj{font-weight:600;color:var(--accent);font-size:12px}
+  .subcard-task{color:var(--ink);font-size:11px;margin:5px 0;opacity:.85;
+    display:-webkit-box;-webkit-line-clamp:2;-webkit-box-orient:vertical;overflow:hidden}
+  .subcard-when{color:var(--dim);font-size:10px}
   a{color:var(--accent);text-decoration:none}
   .alert{grid-column:span 12;background:#ff5d6c14;border:1px solid #ff5d6c44;color:#ffd7db;
     border-radius:10px;padding:8px 14px;font-size:12px;display:none}
@@ -145,6 +151,16 @@
     <div class="list" id="sessions"></div>
   </section>
 
+  <!-- MODEL USAGE + SUB-AGENT RUNS (panel 5) -->
+  <section class="card span5">
+    <h2>🧠 Model usage <span class="muted" id="usage-sub"></span></h2>
+    <div id="modelbars"></div>
+  </section>
+  <section class="card span7">
+    <h2>🧵 Recent sub-agent runs <span class="muted" id="subruns-sub"></span></h2>
+    <div class="list" id="subruns" style="max-height:340px"></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>
@@ -202,6 +218,9 @@ async function load(){
   // ---- cost trend · 7d (pure-SVG area chart) + per-model 30d ----
   renderCostTrend(d);
 
+  // ---- model usage + sub-agent runs (panel 5) ----
+  renderUsage(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>`;
@@ -278,6 +297,42 @@ function renderCostTrend(d){
       (bm.slice(0,8).map(m=>`<div class="row"><span class="dim">${esc(m.api)}</span><span>${usd(m.usd)}</span></div><div class="bar"><span style="width:${Math.round(m.usd/mmax*100)}%"></span></div>`).join('')||'<span class="muted">no model data</span>');
   }
 }
+function renderUsage(d){
+  const u=d.usage||{};
+  const sess=u.sessions||{}, models=(u.models&&u.models.allTime)||[], m24=(u.models&&u.models.last24h)||[];
+  const runs=(u.subagentRuns&&u.subagentRuns.recent)||[];
+  const fmtTok=(n)=> n==null?'—':(n>=1e6?(n/1e6).toFixed(1)+'M':n>=1e3?(n/1e3).toFixed(0)+'k':String(n));
+  // header sub-lines
+  $('usage-sub').textContent=`· ${sess.active||0} active · ${sess.recent||0} recent sessions (24h)`;
+  $('subruns-sub').textContent=`· ${u.subagentRuns&&u.subagentRuns.count||0} total · showing ${runs.length}`;
+  // model mini-bar-chart — per-model share of all-time spend
+  const mel=$('modelbars');
+  if(mel){
+    const mmax=Math.max(...models.map(m=>m.cost),0.0001);
+    const c24=new Map(m24.map(m=>[m.model,m]));
+    mel.innerHTML=(models.slice(0,10).map(m=>{
+      const d24=c24.get(m.model);
+      const t=d24?` <span class="dim">· ${d24.calls} calls/24h</span>`:'';
+      return `<div class="row"><span class="dim">${esc(m.model)}${t}</span>`+
+        `<span>${usd(m.cost)} <span class="dim">· ${m.calls} calls · ${fmtTok(m.tokens)} tok</span></span></div>`+
+        `<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>';
+  }
+}
 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=>({'<':'&lt;','>':'&gt;','&':'&amp;'}[m]));}
diff --git a/server.js b/server.js
index 4b831db..f4f751b 100644
--- a/server.js
+++ b/server.js
@@ -191,6 +191,118 @@ async function collectCrons() {
   return { count: jobs.length, loadedCount: jobs.filter((j) => j.loaded).length, jobs };
 }
 
+// --- MODEL USAGE + SESSIONS + SUB-AGENT RUNS (panel 5) ---------------------
+// Reads ~/.claude/cost-ledger.jsonl for per-model calls/tokens/$ (24h + all-time)
+// and ~/.claude/projects/**/*.jsonl mtimes for active/recent Claude sessions +
+// the most recent sub-agent runs. Read-only; wrapped by the snapshot builder.
+const PROJECTS_DIR = path.join(HOME, '.claude', 'projects');
+
+// pull a compact meta (agentId, task label, model, start ts) from a sub-agent
+// run file — reads only the head so it stays cheap even on long transcripts.
+function readSubagentMeta(file) {
+  const meta = { agentId: null, task: null, model: null, startedAt: null };
+  try {
+    const head = fs.readFileSync(file, 'utf8').split('\n', 60);
+    for (const ln of head) {
+      if (!ln.trim()) continue;
+      let j; try { j = JSON.parse(ln); } catch (e) { continue; }
+      if (!meta.agentId && j.agentId) meta.agentId = j.agentId;
+      if (!meta.startedAt && j.timestamp) meta.startedAt = j.timestamp;
+      const m = j.message;
+      if (m && typeof m === 'object') {
+        if (!meta.model && m.model) meta.model = m.model;
+        if (!meta.task && j.type === 'user') {
+          const c = m.content;
+          const txt = Array.isArray(c) ? c.map((x) => (x && x.text) || '').join(' ') : (typeof c === 'string' ? c : '');
+          if (txt.trim()) meta.task = txt.trim().replace(/\s+/g, ' ').slice(0, 140);
+        }
+      }
+      if (meta.agentId && meta.model && meta.task) break;
+    }
+  } catch (e) {}
+  return meta;
+}
+
+function collectUsage() {
+  const now = Date.now();
+  const DAY = 24 * 3.6e6;
+  // ---- per-model usage from the cost ledger ----
+  const models = {};
+  try {
+    const lines = fs.readFileSync(COST_LEDGER, 'utf8').split('\n');
+    for (const ln of lines) {
+      if (!ln.trim()) continue;
+      let j; try { j = JSON.parse(ln); } catch (e) { continue; }
+      const t = Date.parse(j.ts); if (isNaN(t)) continue;
+      const key = j.api || j.model || 'unknown';
+      const r = models[key] || (models[key] = { model: key, calls: 0, cost: 0, inTok: 0, outTok: 0, calls24: 0, cost24: 0, tok24: 0 });
+      const c = j.cost_usd || 0;
+      let inTok = 0, outTok = 0;
+      if (Array.isArray(j.units)) for (const u of j.units) {
+        if (/input/i.test(u.unit)) inTok += u.qty || 0;
+        else if (/output/i.test(u.unit)) outTok += u.qty || 0;
+      }
+      r.calls++; r.cost += c; r.inTok += inTok; r.outTok += outTok;
+      if (t >= now - DAY) { r.calls24++; r.cost24 += c; r.tok24 += inTok + outTok; }
+    }
+  } catch (e) {}
+  const allTime = Object.values(models)
+    .map((r) => ({ model: r.model, calls: r.calls, tokens: r.inTok + r.outTok, inTok: r.inTok, outTok: r.outTok, cost: round(r.cost, 4) }))
+    .sort((a, b) => b.cost - a.cost);
+  const last24h = Object.values(models)
+    .filter((r) => r.calls24 > 0)
+    .map((r) => ({ model: r.model, calls: r.calls24, tokens: r.tok24, cost: round(r.cost24, 4) }))
+    .sort((a, b) => b.cost - a.cost);
+
+  // ---- sessions + sub-agent runs from projects/**/*.jsonl mtimes ----
+  let totalSessions = 0, activeSessions = 0, recentSessions = 0;
+  const subRuns = [];
+  let projDirs = [];
+  try { projDirs = fs.readdirSync(PROJECTS_DIR, { withFileTypes: true }).filter((d) => d.isDirectory()); } catch (e) {}
+  for (const pd of projDirs) {
+    const pdir = path.join(PROJECTS_DIR, pd.name);
+    let entries = [];
+    try { entries = fs.readdirSync(pdir, { withFileTypes: true }); } catch (e) { continue; }
+    for (const e of entries) {
+      if (e.isFile() && e.name.endsWith('.jsonl')) {           // a top-level session
+        try {
+          const st = fs.statSync(path.join(pdir, e.name));
+          totalSessions++;
+          const age = now - st.mtimeMs;
+          if (age <= 15 * 60 * 1000) activeSessions++;
+          if (age <= DAY) recentSessions++;
+        } catch (e2) {}
+      } else if (e.isDirectory()) {                            // <session>/subagents/agent-*.jsonl
+        const subDir = path.join(pdir, e.name, 'subagents');
+        let subFiles = [];
+        try { subFiles = fs.readdirSync(subDir); } catch (e2) { continue; }
+        for (const sf of subFiles) {
+          if (!sf.endsWith('.jsonl')) continue;
+          const fp = path.join(subDir, sf);
+          try { subRuns.push({ file: fp, projectDir: pd.name, mtimeMs: fs.statSync(fp).mtimeMs }); } catch (e3) {}
+        }
+      }
+    }
+  }
+  subRuns.sort((a, b) => b.mtimeMs - a.mtimeMs);
+  const recent = subRuns.slice(0, 20).map((r) => {
+    const m = readSubagentMeta(r.file);
+    // project name from the dashed dir (…-Projects-Foo → Foo) as a readable label
+    const proj = (r.projectDir.match(/-Projects-(.+)$/) || [])[1] || r.projectDir.replace(/^-/, '');
+    return {
+      agentId: m.agentId, project: proj.replace(/-/g, ' '),
+      task: m.task || '(no prompt captured)', model: m.model || 'unknown',
+      startedAt: m.startedAt, updatedAt: new Date(r.mtimeMs).toISOString(),
+    };
+  });
+
+  return {
+    models: { allTime, last24h },
+    sessions: { total: totalSessions, active: activeSessions, recent: recentSessions },
+    subagentRuns: { count: subRuns.length, recent },
+  };
+}
+
 // --- COST side of the P&L --------------------------------------------------
 function readCostLedger(sinceMs) {
   // cost-ledger.jsonl: {ts, api, vendor, app, cost_usd, units:[...]}
@@ -371,6 +483,7 @@ async function doBuild() {
   const cost = collectCost();
   const revenue = collectRevenue();
   const pnl = buildPnL(cost, revenue);
+  const usage = (() => { try { return collectUsage(); } catch (e) { return { error: e.message }; } })();
   SNAP = {
     builtAt: new Date().toISOString(),
     version: VERSION,
@@ -378,7 +491,14 @@ async function doBuild() {
     sessions: summarizeSessions(sessions),
     wins: summarizeWins(wins),
     officers: summarizeOfficers(officers),
-    cost, revenue, pnl,
+    cost, revenue, pnl, usage,
+    // compact form folded into the snapshot for at-a-glance headers
+    usageSummary: {
+      activeSessions: usage.sessions && usage.sessions.active,
+      recentSessions: usage.sessions && usage.sessions.recent,
+      subagentRuns: usage.subagentRuns && usage.subagentRuns.count,
+      topModel: usage.models && usage.models.allTime && usage.models.allTime[0] && usage.models.allTime[0].model,
+    },
   };
   try { fs.writeFileSync(SNAPSHOT, JSON.stringify(SNAP)); } catch (e) {}
   return SNAP;
@@ -435,7 +555,7 @@ app.get('/api/healthz', (req, res) => res.json({ ok: true, service: 'abramsego',
 app.get('/api/data', (req, res) => { res.json(SNAP); });
 app.get('/api/refresh', async (req, res) => { const s = await buildSnapshot(); res.json(s); });
 // per-panel slices (served from cache)
-for (const key of ['system', 'localFleet', 'kamatera', 'canaries', 'crons', 'sessions', 'wins', 'officers', 'cost', 'revenue', 'pnl']) {
+for (const key of ['system', 'localFleet', 'kamatera', 'canaries', 'crons', 'sessions', 'wins', 'officers', 'cost', 'revenue', 'pnl', 'usage']) {
   app.get(`/api/${key}`, (req, res) => res.json(SNAP[key] || {}));
 }
 

← 40832b5 queue: mark task 03 done (completed by interactive session d  ·  back to AbramsEgo  ·  auto-save: 2026-07-01T11:11:52 (2 files) — build-queue/tasks ef6ad60 →