← 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
D build-queue/STOPM public/index.htmlM server.js
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=>({'<':'<','>':'>','&':'&'}[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 →