great-cto 3.52.0 → 3.53.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "great-cto",
3
3
  "description": "You already have the agent. This is everything around it. great_cto runs Claude Code as a pipeline of 72 specialist agents \u2014 an independent model checks each stage before the next builds on it, spending caps refuse rather than warn, and three decisions stay yours: what gets built, how, and whether it ships.",
4
- "version": "3.52.0",
4
+ "version": "3.53.0",
5
5
  "author": {
6
6
  "name": "Alexander Velikiy",
7
7
  "url": "https://hashnode.com/@Greatcto"
@@ -13,6 +13,7 @@ import { sseClients, notifHistory } from './state.mjs';
13
13
  import { autoRegisterProject, listProjects, resolveProjectCwd, resolveProjectInfo, getChangeTier, readProjectsRegistry, getRegistryDegradation } from './projects.mjs';
14
14
  import { readVerdictsWithHealth } from './verdicts.mjs';
15
15
  import { agentUsage, usageSnapshot } from '../../../scripts/lib/agent-usage.mjs';
16
+ import { usageIndexSnapshot, summarizeUsage, readCodexTitles } from '../../../scripts/lib/session-usage.mjs';
16
17
  import { reviewerStatus } from '../../../scripts/lib/required-reviewers.mjs';
17
18
  import { readSessionStatus } from '../../../scripts/lib/session-status.mjs';
18
19
  import { readScores, summarizeScores } from '../../../scripts/lib/scores.mjs';
@@ -1492,6 +1493,31 @@ async function dispatch(req, res, url, cwd) {
1492
1493
  return true;
1493
1494
  }
1494
1495
 
1496
+ // What Claude Code and Codex consumed on this machine — tokens, models, the
1497
+ // heaviest conversations, tools, skills, agents, cache, Codex's plan window —
1498
+ // read from the hosts' own session logs (scripts/lib/session-usage.mjs).
1499
+ // Machine-wide like /api/agent-usage: the logs are one history. Served on the
1500
+ // board's own host only; nothing here is sent anywhere.
1501
+ if (pathname === '/api/usage') {
1502
+ const rawDays = parseInt(url.searchParams.get('days') || '30', 10);
1503
+ const days = Number.isFinite(rawDays) && rawDays > 0 ? Math.min(rawDays, 365) : 30;
1504
+ const snap = boardSessionUsage().get();
1505
+ let body;
1506
+ if (snap.state !== 'ready') {
1507
+ body = { state: snap.state, why: snap.why };
1508
+ } else {
1509
+ const sum = summarizeUsage(snap.index, { days, codexTitles: readCodexTitles() });
1510
+ const ours = new Set(boardAgentNames());
1511
+ for (const host of Object.keys(sum.lists)) {
1512
+ sum.lists[host].agents = sum.lists[host].agents.map((a) => ({ ...a, ours: ours.has(a.name) }));
1513
+ }
1514
+ body = { ...sum, seen: snap.seen, files: snap.files };
1515
+ }
1516
+ res.writeHead(200, { 'Content-Type': 'application/json', 'Cache-Control': 'no-store' });
1517
+ res.end(JSON.stringify(body));
1518
+ return true;
1519
+ }
1520
+
1495
1521
  // Domain reviewers this project's PROJECT.md requires (archetype, packs,
1496
1522
  // compliance), each with whether a verdict exists — the list gate:ship refuses on.
1497
1523
  // Sessions in this project that wait for a person — written by the
@@ -2141,14 +2167,24 @@ async function dispatch(req, res, url, cwd) {
2141
2167
 
2142
2168
 
2143
2169
  let _usageSnap = null;
2170
+ function boardAgentNames() {
2171
+ const agentsDir = path.join(path.dirname(new URL(import.meta.url).pathname), '..', '..', '..', 'agents');
2172
+ try { return fs.readdirSync(agentsDir).filter((f) => f.endsWith('.md')).map((f) => f.slice(0, -3)); } catch { return []; }
2173
+ }
2174
+
2144
2175
  function boardUsage() {
2145
2176
  if (!_usageSnap) {
2146
- const agentsDir = path.join(path.dirname(new URL(import.meta.url).pathname), '..', '..', '..', 'agents');
2147
- let agents = [];
2148
- try { agents = fs.readdirSync(agentsDir).filter((f) => f.endsWith('.md')).map((f) => f.slice(0, -3)); } catch { /* none: every count is unavailable-by-omission */ }
2177
+ // No agents dir: every count is unavailable-by-omission.
2178
+ const agents = boardAgentNames();
2149
2179
  _usageSnap = usageSnapshot({ compute: () => agentUsage({ agents }) });
2150
2180
  }
2151
2181
  return _usageSnap;
2152
2182
  }
2153
2183
 
2184
+ let _sessionUsageSnap = null;
2185
+ function boardSessionUsage() {
2186
+ if (!_sessionUsageSnap) _sessionUsageSnap = usageIndexSnapshot();
2187
+ return _sessionUsageSnap;
2188
+ }
2189
+
2154
2190
  export { dispatch, secondOpinionForTree };
@@ -21,12 +21,14 @@
21
21
  import fs from 'fs';
22
22
  import path from 'path';
23
23
 
24
- // The only view names the redesigned IA renders (BRD-R8): Decisions, Ledger,
25
- // Fleet, Harness, Settings, plus the demoted-to-deep-link `kanban`. Anything
26
- // else is rejected outright, before any filesystem write — an unknown view
27
- // name written to this file would silently corrupt the K2/K3 counts it
28
- // exists to protect.
29
- const VALID_VIEWS = ['decisions', 'ledger', 'fleet', 'harness', 'settings', 'kanban'];
24
+ // The view names the board routes to (index.html `ROUTES`): Work, History,
25
+ // Decisions, Ledger, Fleet, Harness, Usage, Settings, plus the demoted-to-deep-link
26
+ // `kanban`. Anything else is rejected outright, before any filesystem write — an
27
+ // unknown view name written to this file would silently corrupt the K2/K3 counts
28
+ // it exists to protect. Work and History were routed and missing from this list,
29
+ // so opening either answered 400 into the console; the test now pins the two
30
+ // lists to each other.
31
+ const VALID_VIEWS = ['work', 'history', 'decisions', 'ledger', 'fleet', 'harness', 'usage', 'settings', 'kanban'];
30
32
 
31
33
  function logFilePath(root) {
32
34
  return path.join(root, '.great_cto', 'view-counter.log');
@@ -2369,6 +2369,63 @@ h1, h2, h3, h4 { font-weight: 600; font-family: var(--sans); }
2369
2369
 
2370
2370
  /* ── Metrics page ─────────────────────────────────────────────────────────── */
2371
2371
  .metrics-page { padding: 24px 32px; }
2372
+
2373
+ /* Usage (great_cto-egkw) — two hosts side by side, every block in two columns
2374
+ that fold into one at phone width. */
2375
+ .usage-head { display: flex; align-items: flex-start; justify-content: space-between; gap: 12px; flex-wrap: wrap; }
2376
+ .usage-chips {
2377
+ display: inline-flex; gap: 4px; padding: 3px;
2378
+ background: var(--bg-muted); border: 1px solid var(--border);
2379
+ }
2380
+ .usage-chip {
2381
+ background: transparent; border: none; color: var(--text2);
2382
+ padding: 5px 12px; min-height: 32px; cursor: pointer;
2383
+ font-family: var(--mono); font-size: var(--fs-caption); font-weight: 500; letter-spacing: 0.05em;
2384
+ }
2385
+ .usage-chip:hover { color: var(--text); }
2386
+ .usage-chip.active { background: var(--accent); color: var(--on-accent); }
2387
+ .usage-chip:focus-visible { outline: 2px solid var(--focus-ring); outline-offset: 1px; }
2388
+ .usage-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(min(100%, 380px), 1fr)); gap: 12px; margin-bottom: 20px; }
2389
+ .usage-card { background: var(--bg-card); border: 1px solid var(--border); padding: 16px 18px; min-width: 0; }
2390
+ .usage-card h3 { font-size: var(--fs-title-s); font-weight: 600; margin: 0 0 10px; color: var(--text); display: flex; justify-content: space-between; gap: 8px; }
2391
+ .usage-card h3 .muted { font-weight: 400; font-size: var(--fs-caption); }
2392
+ .usage-big { font-family: var(--display); font-size: var(--fs-num-m); font-variant-numeric: tabular-nums; color: var(--text); line-height: 1.1; }
2393
+ .usage-stats { display: grid; grid-template-columns: repeat(auto-fit, minmax(110px, 1fr)); gap: 10px; margin-top: 12px; }
2394
+ .usage-stat { min-width: 0; }
2395
+ .usage-stat b { display: block; font-family: var(--mono); font-size: var(--fs-title-s); font-weight: 500; font-variant-numeric: tabular-nums; color: var(--text); }
2396
+ .usage-stat span { font-size: var(--fs-caption); color: var(--text3); }
2397
+ .usage-limit { margin-top: 14px; padding-top: 12px; border-top: 1px solid var(--border); }
2398
+ .usage-limit-head { display: flex; justify-content: space-between; gap: 8px; font-size: var(--fs-small); color: var(--text2); }
2399
+ .usage-limit-head b { font-family: var(--mono); font-variant-numeric: tabular-nums; color: var(--text); font-weight: 500; }
2400
+ .usage-meter { height: 8px; background: var(--bg-muted); border: 1px solid var(--border); margin: 6px 0; }
2401
+ .usage-meter > span { display: block; height: 100%; background: var(--accent); }
2402
+ .usage-meter.warn > span { background: var(--status-progress); }
2403
+ .usage-meter.full > span { background: var(--status-blocked); }
2404
+ .usage-sub { font-size: var(--fs-caption); color: var(--text3); line-height: 1.5; }
2405
+ .usage-note { font-size: var(--fs-small); color: var(--text2); line-height: 1.55; }
2406
+ .usage-chart svg { display: block; width: 100%; height: 120px; }
2407
+ .usage-axis { display: flex; justify-content: space-between; font-family: var(--mono); font-size: var(--fs-caption); color: var(--text3); margin-top: 4px; }
2408
+ .usage-legend { display: flex; flex-wrap: wrap; gap: 4px 14px; font-size: var(--fs-caption); color: var(--text2); margin-top: 8px; }
2409
+ .usage-legend i { display: inline-block; width: 10px; height: 10px; margin-right: 5px; vertical-align: -1px; }
2410
+ .usage-table-wrap { overflow-x: auto; }
2411
+ .usage-table { width: 100%; border-collapse: collapse; font-size: var(--fs-small); }
2412
+ .usage-table th { text-align: left; font-weight: 500; color: var(--text3); font-size: var(--fs-caption); padding: 4px 6px; border-bottom: 1px solid var(--border); white-space: nowrap; }
2413
+ .usage-table td { padding: 6px; border-bottom: 1px solid var(--border); color: var(--text); vertical-align: top; }
2414
+ .usage-table td.num, .usage-table th.num { text-align: right; font-family: var(--mono); font-variant-numeric: tabular-nums; white-space: nowrap; }
2415
+ .usage-title { max-width: 260px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
2416
+ .usage-tag { display: inline-block; font-family: var(--mono); font-size: var(--fs-eyebrow); letter-spacing: 0.04em; padding: 1px 5px; margin-left: 6px; border: 1px solid var(--border-strong); color: var(--text2); }
2417
+ .usage-share { display: inline-block; width: 48px; height: 6px; background: var(--bg-muted); margin-right: 6px; vertical-align: 1px; }
2418
+ .usage-share > span { display: block; height: 100%; background: var(--accent); }
2419
+ .usage-sec-head { display: flex; justify-content: space-between; align-items: center; gap: 12px; flex-wrap: wrap; margin-bottom: 12px; }
2420
+ .usage-sec-head .mp-section-label { margin: 0; }
2421
+ .usage-lists { display: grid; grid-template-columns: repeat(auto-fit, minmax(min(100%, 220px), 1fr)); gap: 12px; margin-bottom: 20px; }
2422
+ .usage-list { list-style: none; margin: 0; padding: 0; }
2423
+ .usage-list li { display: grid; grid-template-columns: minmax(0, 1fr) auto; gap: 8px; font-size: var(--fs-small); padding: 4px 0; position: relative; }
2424
+ .usage-list li .nm { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; color: var(--text); }
2425
+ .usage-list li .n { font-family: var(--mono); font-variant-numeric: tabular-nums; color: var(--text2); }
2426
+ .usage-list li .bar { grid-column: 1 / -1; height: 3px; background: var(--bg-muted); }
2427
+ .usage-list li .bar > span { display: block; height: 100%; background: var(--accent); opacity: 0.7; }
2428
+ @media (max-width: 767px) { .usage-title { max-width: 160px; } .usage-chip { min-height: 44px; } }
2372
2429
  .mp-section-label {
2373
2430
  font-family: var(--mono); font-size: var(--fs-caption);
2374
2431
  letter-spacing: 0.1em; text-transform: uppercase;
@@ -3355,6 +3412,10 @@ h1, h2, h3, h4 { font-weight: 600; font-family: var(--sans); }
3355
3412
  <span>Harness</span>
3356
3413
  <span class="count" id="nav-harness-count"></span>
3357
3414
  </div>
3415
+ <div class="nav-item" data-tab="usage" role="tab" tabindex="0" aria-label="Usage" title="Usage" onkeydown="navTabKeydown(event, this)" onclick="switchTab('usage', this)">
3416
+ <svg class="icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5"><path d="M4 20V10 M10 20V4 M16 20v-7 M22 20H2"/></svg>
3417
+ <span>Usage</span>
3418
+ </div>
3358
3419
  </nav>
3359
3420
  </details>
3360
3421
  <div class="nav-divider"></div>
@@ -3603,6 +3664,23 @@ h1, h2, h3, h4 { font-weight: 600; font-family: var(--sans); }
3603
3664
  </div>
3604
3665
  </div>
3605
3666
 
3667
+ <!-- Usage (great_cto-egkw). What Claude Code and Codex consumed on this
3668
+ machine, read from the hosts' own session logs by
3669
+ scripts/lib/session-usage.mjs. Machine-wide, not per project. -->
3670
+ <div class="panel" id="panel-usage" role="tabpanel">
3671
+ <div class="metrics-page">
3672
+ <div class="usage-head">
3673
+ <h1 class="page-title">Usage <span class="muted" style="font-weight:400;font-size:var(--fs-small)">what Claude Code and Codex consumed on this machine</span></h1>
3674
+ <div class="usage-chips" role="group" aria-label="Period">
3675
+ <button type="button" class="usage-chip" data-days="7" onclick="loadUsage(7)">7D</button>
3676
+ <button type="button" class="usage-chip active" data-days="30" onclick="loadUsage(30)">30D</button>
3677
+ <button type="button" class="usage-chip" data-days="90" onclick="loadUsage(90)">90D</button>
3678
+ </div>
3679
+ </div>
3680
+ <div id="usage-body" class="usage-note" role="status">Reading the session logs of Claude Code and Codex on this machine.</div>
3681
+ </div>
3682
+ </div>
3683
+
3606
3684
  <div class="panel" id="panel-kanban">
3607
3685
  <h1 class="page-title">Tasks</h1>
3608
3686
  <div id="filter-bar" class="filter-bar"></div>
@@ -5877,6 +5955,216 @@ function renderAgentActivity(snap) {
5877
5955
  // The Harnesses card. Four states for the second opinion, and the fourth is
5878
5956
  // the reason the card exists: `unavailable` — declared codex, no codex here —
5879
5957
  // must never look like `none` or like a switch someone turned off.
5958
+ // ── Usage (great_cto-egkw) ─────────────────────────────────────────────────
5959
+ // Both hosts, side by side, from /api/usage. Three honesty rules carried from
5960
+ // the reader: an unpriced model is n/a, never $0; Claude Code's plan use is not
5961
+ // written anywhere, so it is not drawn — only the refusals are; and a host with
5962
+ // no logs on this machine says so instead of showing zeros.
5963
+ let usageDays = 30;
5964
+ let usageHost = 'claude';
5965
+ let usageData = null;
5966
+ let _usageTimer = null;
5967
+ const USAGE_HOSTS = { claude: 'Claude Code', codex: 'Codex' };
5968
+ const USAGE_LOGS = { claude: '~/.claude/projects', codex: '~/.codex/sessions' };
5969
+ const USAGE_FN = { chat: 'Conversations', subagent: 'Subagents', automation: 'Automations', headless: 'Headless runs' };
5970
+ const USAGE_FN_COLOR = { chat: 'var(--accent)', subagent: 'var(--status-progress)', automation: 'var(--status-gate)', headless: 'var(--text3)' };
5971
+
5972
+ function fmtTok(n) {
5973
+ n = Number(n) || 0;
5974
+ if (n >= 1e9) return `${(n / 1e9).toFixed(2)}B`;
5975
+ if (n >= 1e6) return `${(n / 1e6).toFixed(1)}M`;
5976
+ if (n >= 1e3) return `${(n / 1e3).toFixed(1)}k`;
5977
+ return String(Math.round(n));
5978
+ }
5979
+ function fmtUsdEq(n) {
5980
+ if (n == null) return null;
5981
+ return n >= 100 ? `$${Math.round(n).toLocaleString('en-US')}` : `$${n.toFixed(2)}`;
5982
+ }
5983
+ const fmtPct = (x) => (x == null ? null : `${Math.round(x * 100)}%`);
5984
+
5985
+ async function loadUsage(days) {
5986
+ if (days) usageDays = days;
5987
+ document.querySelectorAll('.usage-chip[data-days]').forEach((c) => c.classList.toggle('active', Number(c.dataset.days) === usageDays));
5988
+ clearTimeout(_usageTimer);
5989
+ const d = await api(`/api/usage?days=${usageDays}`);
5990
+ const body = document.getElementById('usage-body');
5991
+ if (!body) return;
5992
+ if (!d || d.error) {
5993
+ body.className = 'usage-note';
5994
+ body.textContent = `Usage could not be read: ${(d && d.error) || 'the board is not reachable — is the server still running?'}`;
5995
+ return;
5996
+ }
5997
+ if (d.state === 'computing') {
5998
+ body.className = 'usage-note';
5999
+ body.textContent = `Counting — ${d.why}`;
6000
+ _usageTimer = setTimeout(() => { if (currentTab === 'usage') loadUsage(); }, 2000);
6001
+ return;
6002
+ }
6003
+ if (d.state !== 'counted') {
6004
+ body.className = 'usage-note';
6005
+ body.textContent = `Usage is not available: ${d.why || d.state}`;
6006
+ return;
6007
+ }
6008
+ usageData = d;
6009
+ renderUsage(d);
6010
+ }
6011
+
6012
+ function setUsageHost(host) {
6013
+ usageHost = host;
6014
+ if (usageData) renderUsage(usageData);
6015
+ }
6016
+
6017
+ function usageLimitWindow(minutes) {
6018
+ if (minutes === 10080) return 'Weekly limit';
6019
+ if (minutes === 300) return '5-hour limit';
6020
+ return minutes ? `${minutes}-minute limit` : 'Limit';
6021
+ }
6022
+
6023
+ function usageHostCard(d, host) {
6024
+ const name = USAGE_HOSTS[host];
6025
+ if (!d.seen || !d.seen[host]) {
6026
+ return `<section class="usage-card" data-host="${host}"><h3>${name}</h3><p class="usage-note">No ${name} session logs on this machine (${USAGE_LOGS[host]}) — nothing to count, which is not the same as nothing used.</p></section>`;
6027
+ }
6028
+ const h = d.hosts[host];
6029
+ const unpricedHere = (d.models || []).filter((m) => m.host === host && m.usd == null).map((m) => m.model);
6030
+ const usd = h.usd == null
6031
+ ? absent('uncomputable', `no price is set for ${unpricedHere.join(', ') || 'these models'} — add one to ~/.great_cto/model-prices.json`)
6032
+ : esc(fmtUsdEq(h.usd)) + (h.unpricedResponses ? ` <span class="usage-tag" title="${esc(`${h.unpricedResponses} responses from unpriced models are not in this figure`)}">partial</span>` : '');
6033
+ const stat = (v, label) => `<div class="usage-stat"><b>${v}</b><span>${label}</span></div>`;
6034
+ let limit;
6035
+ if (host === 'codex') {
6036
+ const L = d.limits && d.limits.codex;
6037
+ const win = (w) => {
6038
+ if (!w) return '';
6039
+ const used = Math.max(0, Math.min(100, Number(w.used) || 0));
6040
+ const cls = used >= 90 ? 'full' : used >= 70 ? 'warn' : '';
6041
+ const resets = w.resets ? fmtDT(new Date(w.resets * 1000).toISOString()) : '—';
6042
+ return `<div class="usage-limit-head"><span>${usageLimitWindow(w.minutes)}</span><b>${used}% used</b></div>
6043
+ <div class="usage-meter ${cls}" role="meter" aria-valuemin="0" aria-valuemax="100" aria-valuenow="${used}" aria-label="${esc(usageLimitWindow(w.minutes))}"><span style="width:${used}%"></span></div>
6044
+ <div class="usage-sub">resets ${esc(resets)}</div>`;
6045
+ };
6046
+ limit = L && (L.primary || L.secondary)
6047
+ ? `<div class="usage-limit">${win(L.primary)}${win(L.secondary)}<div class="usage-sub">plan ${esc(L.plan || '—')} · as Codex last reported it, ${esc(relTime(L.at))} ago</div></div>`
6048
+ : `<div class="usage-limit"><div class="usage-limit-head"><span>Plan limit</span>${absent('unloaded', 'no Codex session has reported its rate-limit window yet')}</div></div>`;
6049
+ } else {
6050
+ const hits = h.limitHits || { plan: 0, server: 0 };
6051
+ limit = `<div class="usage-limit"><div class="usage-limit-head"><span>Plan limit</span>${absent('uncomputable', 'Claude Code does not write how much of the plan is used — only when a request is refused')}</div>
6052
+ <div class="usage-sub">${hits.plan} refused at the plan limit · ${hits.server} server-side throttles (not your limit) in ${d.window.days} days</div></div>`;
6053
+ }
6054
+ return `<section class="usage-card" data-host="${host}">
6055
+ <h3><span>${name}</span><span class="muted">${esc(d.window.from)} → ${esc(d.window.to)}</span></h3>
6056
+ <div class="usage-big" title="${esc(`${(h.tokens || 0).toLocaleString('en-US')} tokens, cache reads included`)}">${fmtTok(h.tokens)} <span class="muted" style="font-size:var(--fs-small)">tokens</span></div>
6057
+ <div class="usage-stats">
6058
+ ${stat(usd, 'at API list price')}
6059
+ ${stat(esc((h.sessions || 0).toLocaleString('en-US')), 'conversations')}
6060
+ ${stat(esc((h.responses || 0).toLocaleString('en-US')), 'model responses')}
6061
+ ${stat(h.hitRate == null ? absent('none', 'no input in this window') : esc(fmtPct(h.hitRate)), 'input from cache')}
6062
+ ${stat(esc(fmtTok(h.output)), 'output tokens')}
6063
+ </div>
6064
+ ${limit}
6065
+ </section>`;
6066
+ }
6067
+
6068
+ function usageDayChart(d, host) {
6069
+ const name = USAGE_HOSTS[host];
6070
+ const head = `<h3><span>${name} by day</span><span class="muted">tokens</span></h3>`;
6071
+ if (!d.seen || !d.seen[host]) return `<section class="usage-card usage-chart">${head}<p class="usage-note">No logs to draw.</p></section>`;
6072
+ const days = d.daily || [];
6073
+ const max = Math.max(0, ...days.map((x) => x[host].tokens));
6074
+ if (!max) return `<section class="usage-card usage-chart">${head}<p class="usage-note">Nothing in this window.</p></section>`;
6075
+ const fns = Object.keys(USAGE_FN).filter((f) => days.some((x) => x[host].byFn[f]));
6076
+ const W = 10;
6077
+ let bars = '';
6078
+ days.forEach((x, i) => {
6079
+ let y = 100;
6080
+ const parts = fns.map((f) => `${USAGE_FN[f]} ${fmtTok(x[host].byFn[f] || 0)}`).join(' · ');
6081
+ const tip = `${x.date}: ${fmtTok(x[host].tokens)} tokens${x[host].usd ? ` · ${fmtUsdEq(x[host].usd)}` : ''}${parts ? ` (${parts})` : ''}`;
6082
+ bars += `<g><title>${esc(tip)}</title>`;
6083
+ for (const f of fns) {
6084
+ const v = x[host].byFn[f] || 0;
6085
+ if (!v) continue;
6086
+ const hgt = (v / max) * 96;
6087
+ y -= hgt;
6088
+ bars += `<rect x="${i * W + 1}" y="${y.toFixed(2)}" width="${W - 2}" height="${hgt.toFixed(2)}" fill="${USAGE_FN_COLOR[f]}"></rect>`;
6089
+ }
6090
+ bars += `<rect x="${i * W}" y="0" width="${W}" height="100" fill="transparent"></rect></g>`;
6091
+ });
6092
+ const legend = fns.map((f) => `<span><i style="background:${USAGE_FN_COLOR[f]}"></i>${USAGE_FN[f]} ${fmtTok(d.hosts[host].byFn[f] || 0)}</span>`).join('');
6093
+ return `<section class="usage-card usage-chart">${head}
6094
+ <svg viewBox="0 0 ${days.length * W} 100" preserveAspectRatio="none" role="img" aria-label="${esc(`${name} tokens per day, peak ${fmtTok(max)}`)}">${bars}</svg>
6095
+ <div class="usage-axis"><span>${esc(days[0].date)}</span><span>peak ${fmtTok(max)}</span><span>${esc(days[days.length - 1].date)}</span></div>
6096
+ <div class="usage-legend">${legend}</div>
6097
+ </section>`;
6098
+ }
6099
+
6100
+ function usageTopTable(d, host) {
6101
+ const name = USAGE_HOSTS[host];
6102
+ const rows = (d.top && d.top[host]) || [];
6103
+ const head = `<h3><span>${name}: heaviest conversations</span><span class="muted">share of ${name} tokens</span></h3>`;
6104
+ if (!rows.length) return `<section class="usage-card">${head}<p class="usage-note">${d.seen && d.seen[host] ? 'Nothing in this window.' : 'No logs on this machine.'}</p></section>`;
6105
+ const money = host === 'claude';
6106
+ const body = rows.map((r) => {
6107
+ const tag = r.fn && r.fn !== 'chat' ? `<span class="usage-tag">${esc(USAGE_FN[r.fn] || r.fn)}</span>` : '';
6108
+ const sub = r.subagentTokens ? `<div class="usage-sub">${fmtPct(r.subagentTokens / r.tokens)} in subagents</div>` : '';
6109
+ const share = Math.max(1, Math.round(r.share * 100));
6110
+ return `<tr>
6111
+ <td><div class="usage-title" title="${esc(r.title)}">${esc(r.title)}</div>${sub}</td>
6112
+ <td>${esc(r.project || '—')}${tag}</td>
6113
+ <td class="num"><span class="usage-share" aria-hidden="true"><span style="width:${share}%"></span></span>${fmtPct(r.share)}</td>
6114
+ <td class="num">${fmtTok(r.tokens)}</td>
6115
+ ${money ? `<td class="num">${r.usd == null ? absent('uncomputable', 'a model in this conversation has no price') : esc(fmtUsdEq(r.usd))}</td>` : ''}
6116
+ </tr>`;
6117
+ }).join('');
6118
+ return `<section class="usage-card">${head}<div class="usage-table-wrap"><table class="usage-table">
6119
+ <thead><tr><th>Conversation</th><th>Project</th><th class="num">Share</th><th class="num">Tokens</th>${money ? '<th class="num">$ list</th>' : ''}</tr></thead>
6120
+ <tbody>${body}</tbody></table></div></section>`;
6121
+ }
6122
+
6123
+ function usageModels(d) {
6124
+ const rows = d.models || [];
6125
+ if (!rows.length) return '<p class="usage-note">No model responses in this window.</p>';
6126
+ const body = rows.map((m) => {
6127
+ const fast = m.model.endsWith('@fast');
6128
+ const label = fast ? `${m.model.slice(0, -5)} (fast)` : m.model;
6129
+ return `<tr><td>${esc(USAGE_HOSTS[m.host])}</td><td>${esc(label)}</td><td class="num">${m.responses.toLocaleString('en-US')}</td><td class="num">${fmtTok(m.tokens)}</td><td class="num">${fmtTok(m.output)}</td>
6130
+ <td class="num">${m.usd == null ? absent('uncomputable', `no price is set for ${m.model} — add one to ~/.great_cto/model-prices.json`) : esc(fmtUsdEq(m.usd))}</td></tr>`;
6131
+ }).join('');
6132
+ return `<section class="usage-card" style="margin-bottom:20px"><div class="usage-table-wrap"><table class="usage-table">
6133
+ <thead><tr><th>Host</th><th>Model</th><th class="num">Responses</th><th class="num">Tokens</th><th class="num">Output</th><th class="num">$ list</th></tr></thead>
6134
+ <tbody>${body}</tbody></table></div></section>`;
6135
+ }
6136
+
6137
+ function usageList(title, rows, empty) {
6138
+ if (!rows || !rows.length) return `<section class="usage-card"><h3><span>${title}</span></h3><p class="usage-note">${empty}</p></section>`;
6139
+ const max = Math.max(...rows.map((r) => r.n));
6140
+ const items = rows.map((r) => `<li><span class="nm" title="${esc(r.name)}">${esc(r.name)}${r.ours ? '<span class="usage-tag">great_cto</span>' : ''}</span><span class="n">${r.n.toLocaleString('en-US')}</span><span class="bar" aria-hidden="true"><span style="width:${Math.max(2, Math.round((r.n / max) * 100))}%"></span></span></li>`).join('');
6141
+ return `<section class="usage-card"><h3><span>${title}</span></h3><ul class="usage-list">${items}</ul></section>`;
6142
+ }
6143
+
6144
+ function renderUsage(d) {
6145
+ const body = document.getElementById('usage-body');
6146
+ if (!body) return;
6147
+ body.className = '';
6148
+ const l = (d.lists && d.lists[usageHost]) || {};
6149
+ const hostBtn = (h) => `<button type="button" class="usage-chip${usageHost === h ? ' active' : ''}" aria-pressed="${usageHost === h}" onclick="setUsageHost('${h}')">${USAGE_HOSTS[h]}</button>`;
6150
+ body.innerHTML = `
6151
+ <div class="usage-grid">${usageHostCard(d, 'claude')}${usageHostCard(d, 'codex')}</div>
6152
+ <h2 class="mp-section-label">By day, by kind of work</h2>
6153
+ <div class="usage-grid">${usageDayChart(d, 'claude')}${usageDayChart(d, 'codex')}</div>
6154
+ <h2 class="mp-section-label">Heaviest conversations</h2>
6155
+ <div class="usage-grid">${usageTopTable(d, 'claude')}${usageTopTable(d, 'codex')}</div>
6156
+ <h2 class="mp-section-label">Models</h2>
6157
+ ${usageModels(d)}
6158
+ <div class="usage-sec-head"><h2 class="mp-section-label">What was called</h2><div class="usage-chips" role="group" aria-label="Host">${hostBtn('claude')}${hostBtn('codex')}</div></div>
6159
+ <div class="usage-lists">
6160
+ ${usageList('Tools', l.tools, 'No tool calls in this window.')}
6161
+ ${usageList('Skills', l.skills, 'No skills were loaded in this window.')}
6162
+ ${usageList('Agents', l.agents, 'No subagents were dispatched in this window.')}
6163
+ ${usageList('MCP servers', l.mcp, 'No MCP tools were called in this window.')}
6164
+ </div>
6165
+ <p class="usage-note">Read on this machine from ${USAGE_LOGS.claude} and ${USAGE_LOGS.codex}; nothing is sent anywhere. Tokens include cache reads. <b>$ list</b> is the API list-price equivalent from great_cto's price table, not a subscription bill — Claude Code's own estimate can differ for some models. A model without a price shows n/a, never $0.</p>`;
6166
+ }
6167
+
5880
6168
  async function renderHarnesses() {
5881
6169
  const body = document.getElementById('harnesses-body');
5882
6170
  const status = document.getElementById('harnesses-status');
@@ -8340,6 +8628,7 @@ function cmdkActions() {
8340
8628
  { group: 'Go', label: 'Ledger — what it cost, what is running', run: () => switchTab('ledger') },
8341
8629
  { group: 'Go', label: 'Fleet — the agents and what they may do', run: () => switchTab('fleet') },
8342
8630
  { group: 'Go', label: 'Harness — host and second opinion', run: () => switchTab('harness') },
8631
+ { group: 'Go', label: 'Usage — what Claude Code and Codex consumed', run: () => switchTab('usage') },
8343
8632
  { group: 'Go', label: 'Settings', run: () => switchTab('settings') },
8344
8633
  { group: 'Go', label: 'Tasks board (#/kanban, deep link)', run: () => switchTab('kanban') },
8345
8634
  { group: 'Do', label: 'New issue', run: () => openNewIssueModal() },
@@ -8532,7 +8821,7 @@ try {
8532
8821
  // panels, not keys.
8533
8822
  const DEST = { decisions: 'inbox', ledger: 'budgets', fleet: 'agents', dashboard: 'budgets', share: 'settings', notifications: 'settings' };
8534
8823
  const NAV = { inbox: 'decisions', budgets: 'ledger', agents: 'fleet', dashboard: 'ledger', share: 'settings', notifications: 'settings' };
8535
- const ROUTES = { work: 1, history: 1, decisions: 1, ledger: 1, fleet: 1, harness: 1, settings: 1, kanban: 1 };
8824
+ const ROUTES = { work: 1, history: 1, decisions: 1, ledger: 1, fleet: 1, harness: 1, usage: 1, settings: 1, kanban: 1 };
8536
8825
  function switchTab(id, el) {
8537
8826
  const lid = DEST[id] || id; // the panel's own name
8538
8827
  const navKey = NAV[id] || id; // the destination to highlight
@@ -8541,7 +8830,7 @@ function switchTab(id, el) {
8541
8830
  currentTab = lid;
8542
8831
  document.getElementById('nav-views-label').hidden = lid !== 'agents';
8543
8832
  document.querySelector('.nav-views').hidden = lid !== 'agents';
8544
- if (['agents', 'budgets', 'harness'].includes(lid)) document.getElementById('tools-nav').open = true;
8833
+ if (['agents', 'budgets', 'harness', 'usage'].includes(lid)) document.getElementById('tools-nav').open = true;
8545
8834
  if (lid === 'work' || lid === 'history') window.GctoWork.refresh();
8546
8835
  if (ROUTES[id]) {
8547
8836
  if (location.hash !== `#/${id}`) history.replaceState(null, '', `#/${id}`);
@@ -8562,7 +8851,7 @@ function switchTab(id, el) {
8562
8851
  // crumb — `labels` used to be referenced here but is only ever a function-local in
8563
8852
  // this file, so this threw ReferenceError on every tab switch and skipped the per-tab
8564
8853
  // loaders below (e.g. logs stuck on "Loading…"). Use a local label map.
8565
- const TAB_LABELS = { work: 'Work', history: 'History', decisions: 'Decisions', ledger: 'Ledger', fleet: 'Fleet', harness: 'Harness', settings: 'Settings', budgets: 'Budgets', docs: 'Docs', dashboard: 'Board', inbox: 'Inbox', tasks: 'Tasks', agents: 'Agents', logs: 'Activity', notifications: 'Notifications', metrics: 'Metrics', share: 'Share' };
8854
+ const TAB_LABELS = { work: 'Work', history: 'History', decisions: 'Decisions', ledger: 'Ledger', fleet: 'Fleet', harness: 'Harness', usage: 'Usage', settings: 'Settings', budgets: 'Budgets', docs: 'Docs', dashboard: 'Board', inbox: 'Inbox', tasks: 'Tasks', agents: 'Agents', logs: 'Activity', notifications: 'Notifications', metrics: 'Metrics', share: 'Share' };
8566
8855
  const crumb = document.getElementById('crumb-here'); if (crumb) crumb.textContent = TAB_LABELS[id] || TAB_LABELS[lid] || 'Board';
8567
8856
  // Budgets render on Metrics, so the heartbeat that feeds them has to be
8568
8857
  // fetched when Metrics opens — not only from the agents drill-in it used to
@@ -8595,6 +8884,7 @@ function switchTab(id, el) {
8595
8884
  if (lid === 'notifications' || lid === 'settings') loadNotifications();
8596
8885
  if (lid === 'settings') loadSettingsPage();
8597
8886
  if (lid === 'harness') renderHarnesses();
8887
+ if (lid === 'usage') loadUsage();
8598
8888
  }
8599
8889
 
8600
8890
  // Sidebar tabs: Enter/Space activate, arrows move (WAI-ARIA tablist).
@@ -0,0 +1,629 @@
1
+ /**
2
+ * session-usage — what Claude Code and Codex actually consumed on this machine,
3
+ * read from their own session logs.
4
+ *
5
+ * Claude Code ~/.claude/projects/<slug>/<session>.jsonl
6
+ * ~/.claude/projects/<slug>/<session>/subagents/agent-*.jsonl
7
+ * Codex ~/.codex/sessions/YYYY/MM/DD/rollout-*.jsonl
8
+ * ~/.codex/archived_sessions/rollout-*.jsonl
9
+ *
10
+ * Both hosts already write per-response token usage, the model, every tool call
11
+ * and — Codex only — the plan's rate-limit window. Nothing on the board read it:
12
+ * the Ledger priced verdicts, which is what great_cto's agents reported, not
13
+ * what the hosts spent. This module is the missing reader.
14
+ *
15
+ * Rules, each from a way this goes wrong:
16
+ *
17
+ * - One assistant response is written as SEVERAL Claude Code lines, one per
18
+ * content block, and every one of them repeats the same `usage`. Summing lines
19
+ * counts a response two to three times (58 lines, 26 responses in the session
20
+ * this was found in). Usage is taken once per `message.id`; tool calls are
21
+ * taken per line, because each line carries a different block.
22
+ * - Codex reports `input_tokens` INCLUDING the cached part. It is split here
23
+ * into fresh input and cache reads so both hosts share one shape.
24
+ * - Dollars are not stored. Tokens are stored per day per model and priced when
25
+ * read, so a price override applies to history, and a model nobody priced is
26
+ * reported as unpriced, never as free.
27
+ * - A 429 is not a plan limit. Claude Code writes "not your usage limit" on a
28
+ * server-side throttle; the two are counted apart.
29
+ * - Read-only on the logs. The index lives in ~/.great_cto. A file that
30
+ * disappears (Claude Code prunes old transcripts) keeps its counted days: the
31
+ * history is ours once read, and losing it would read as a quiet month.
32
+ * - Append-only files are read from the last complete line, so a refresh costs
33
+ * the new bytes, not the gigabytes.
34
+ * - Nothing here leaves the machine. Titles and project names are private; the
35
+ * board serves them on its own host only.
36
+ */
37
+ import fs from 'node:fs';
38
+ import os from 'node:os';
39
+ import path from 'node:path';
40
+ import { createHash } from 'node:crypto';
41
+ import { priceUsage, effectivePrices } from './cost-meter.mjs';
42
+
43
+ const INDEX_VERSION = 1;
44
+ const RING = 256; // recent response ids remembered across reads
45
+ const KEEP_GONE_DAYS = 400; // how long a vanished file's days are kept
46
+ const CHUNK = 1 << 20;
47
+
48
+ const home = () => os.homedir();
49
+ export const DEFAULT_CLAUDE_DIR = () => path.join(home(), '.claude', 'projects');
50
+ export const DEFAULT_CODEX_DIRS = () => [path.join(home(), '.codex', 'sessions'), path.join(home(), '.codex', 'archived_sessions')];
51
+ export const DEFAULT_CODEX_TITLES = () => path.join(home(), '.codex', 'session_index.jsonl');
52
+ export const DEFAULT_CACHE_FILE = () => path.join(home(), '.great_cto', 'session-usage-index.json');
53
+
54
+ const short = (s) => createHash('sha1').update(String(s)).digest('hex').slice(0, 16);
55
+
56
+ /** Local calendar day of an ISO timestamp — the operator's day, not UTC's. */
57
+ export function dayOf(ts) {
58
+ const d = new Date(ts);
59
+ if (Number.isNaN(d.getTime())) return null;
60
+ const p = (n) => String(n).padStart(2, '0');
61
+ return `${d.getFullYear()}-${p(d.getMonth() + 1)}-${p(d.getDate())}`;
62
+ }
63
+
64
+ /**
65
+ * A working directory → the project a person would name.
66
+ * Agent worktrees are folded into the repository they belong to: forty
67
+ * `wt-1788…` rows would hide which project the work was for.
68
+ */
69
+ export function projectName(cwd, homeDir = home()) {
70
+ if (!cwd || typeof cwd !== 'string') return null;
71
+ const cw = cwd.match(/^(.*)\/\.claude\/worktrees\/[^/]+/);
72
+ if (cw) return path.basename(cw[1]);
73
+ const cx = cwd.match(/\/\.codex\/worktrees\/[^/]+\/([^/]+)/);
74
+ if (cx) return cx[1];
75
+ if (path.resolve(cwd) === path.resolve(homeDir)) return '~';
76
+ return path.basename(cwd) || null;
77
+ }
78
+
79
+ // ── file discovery ────────────────────────────────────────────────────────────
80
+
81
+ function walk(dir, maxDepth, out) {
82
+ const go = (d, depth) => {
83
+ let entries;
84
+ try { entries = fs.readdirSync(d, { withFileTypes: true }); } catch { return; }
85
+ for (const e of entries) {
86
+ const p = path.join(d, e.name);
87
+ if (e.isDirectory()) { if (depth < maxDepth) go(p, depth + 1); }
88
+ else if (e.isFile() && e.name.endsWith('.jsonl')) out.push(p);
89
+ }
90
+ };
91
+ go(dir, 0);
92
+ return out;
93
+ }
94
+
95
+ export function listLogFiles({ claudeDir = DEFAULT_CLAUDE_DIR(), codexDirs = DEFAULT_CODEX_DIRS() } = {}) {
96
+ const files = [];
97
+ const seen = { claude: false, codex: false };
98
+ if (isDir(claudeDir)) { seen.claude = true; for (const f of walk(claudeDir, 4, [])) files.push({ host: 'claude', file: f }); }
99
+ for (const d of codexDirs) {
100
+ if (isDir(d)) { seen.codex = true; for (const f of walk(d, 4, [])) files.push({ host: 'codex', file: f }); }
101
+ }
102
+ return { files, seen };
103
+ }
104
+
105
+ function isDir(p) { try { return fs.statSync(p).isDirectory(); } catch { return false; } }
106
+
107
+ // ── shared accumulation ───────────────────────────────────────────────────────
108
+
109
+ function emptyDay() {
110
+ return { in: 0, out: 0, cr: 0, cw: 0, cwh: 0, rs: 0, msgs: 0, models: {}, tools: {}, skills: {}, agents: {}, mcp: {}, lim: { plan: 0, server: 0 } };
111
+ }
112
+
113
+ function dayBucket(entry, ts) {
114
+ const day = dayOf(ts);
115
+ if (!day) return null;
116
+ return entry.days[day] || (entry.days[day] = emptyDay());
117
+ }
118
+
119
+ const bump = (map, key, n = 1) => { if (key) map[key] = (map[key] || 0) + n; };
120
+
121
+ function addTokens(b, model, t) {
122
+ b.in += t.in; b.out += t.out; b.cr += t.cr; b.cw += t.cw; b.cwh += t.cwh; b.rs += t.rs; b.msgs += 1;
123
+ const m = b.models[model] || (b.models[model] = { in: 0, out: 0, cr: 0, cw: 0, cwh: 0, msgs: 0 });
124
+ m.in += t.in; m.out += t.out; m.cr += t.cr; m.cw += t.cw; m.cwh += t.cwh; m.msgs += 1;
125
+ }
126
+
127
+ function countTool(b, name) {
128
+ if (!name) return;
129
+ if (name.startsWith('mcp__')) {
130
+ const server = name.split('__')[1] || name;
131
+ bump(b.mcp, server);
132
+ }
133
+ bump(b.tools, name);
134
+ }
135
+
136
+ function remember(ring, id) {
137
+ ring.push(id);
138
+ if (ring.length > RING) ring.splice(0, ring.length - RING);
139
+ }
140
+
141
+ function touchSpan(meta, ts) {
142
+ if (!ts) return;
143
+ if (!meta.first || ts < meta.first) meta.first = ts;
144
+ if (!meta.last || ts > meta.last) meta.last = ts;
145
+ }
146
+
147
+ // ── Claude Code ───────────────────────────────────────────────────────────────
148
+
149
+ /** Session id, parent and function of a Claude Code transcript, from its path. */
150
+ function claudeFileMeta(file) {
151
+ const base = path.basename(file, '.jsonl');
152
+ const dir = path.dirname(file);
153
+ if (path.basename(dir) === 'subagents') {
154
+ const parent = path.basename(path.dirname(dir));
155
+ let agentType = null;
156
+ try { agentType = JSON.parse(fs.readFileSync(path.join(dir, `${base}.meta.json`), 'utf8')).agentType || null; } catch { /* older layout: no meta */ }
157
+ return { session: base, parent, fn: 'subagent', agentType };
158
+ }
159
+ return { session: base, parent: null, fn: null, agentType: null };
160
+ }
161
+
162
+ const PLAN_LIMIT_TEXT = /not your usage limit/i;
163
+
164
+ /** `great-cto:senior-dev` and `senior-dev` are one agent; `feature-dev:code-reviewer` is not ours. */
165
+ export function agentName(raw) {
166
+ if (typeof raw !== 'string' || !raw) return 'general-purpose';
167
+ return raw.replace(/^great[-_]cto:/, '');
168
+ }
169
+
170
+ export function claudeLine(entry, line) {
171
+ // Most bytes in a transcript are tool RESULTS in user lines. They carry no
172
+ // usage, so they are not parsed — except to name a session that has no title.
173
+ const isAssistant = line.includes('"type":"assistant"');
174
+ const isTitle = line.includes('"custom-title"');
175
+ const wantsPrompt = !entry.meta.prompt && !entry.meta.title && line.includes('"type":"user"');
176
+ if (!isAssistant && !isTitle && !wantsPrompt) return;
177
+ let r;
178
+ try { r = JSON.parse(line); } catch { return; }
179
+ const meta = entry.meta;
180
+
181
+ if (r.type === 'custom-title' && r.customTitle) { meta.title = String(r.customTitle).slice(0, 160); return; }
182
+ if (r.type === 'user') {
183
+ if (r.isCompactSummary || r.isMeta) return;
184
+ const c = r.message?.content;
185
+ const text = typeof c === 'string' ? c : Array.isArray(c) ? c.filter((b) => b?.type === 'text').map((b) => b.text || '').join(' ') : '';
186
+ const clean = text.replace(/<[^>]+>/g, ' ').replace(/\s+/g, ' ').trim();
187
+ if (clean && !clean.startsWith('Caveat:')) meta.prompt = clean.slice(0, 120);
188
+ return;
189
+ }
190
+ if (r.type !== 'assistant') return;
191
+
192
+ const ts = typeof r.timestamp === 'string' ? r.timestamp : null;
193
+ if (!ts) return;
194
+ touchSpan(meta, ts);
195
+ if (!meta.cwd && r.cwd) meta.cwd = r.cwd;
196
+ if (!meta.entry && r.entrypoint) meta.entry = r.entrypoint;
197
+ if (!meta.fn && !r.isSidechain) meta.fn = /^sdk/.test(r.entrypoint || '') ? 'headless' : 'chat';
198
+ const b = dayBucket(entry, ts);
199
+ if (!b) return;
200
+
201
+ if (r.isApiErrorMessage && r.error === 'rate_limit') {
202
+ const c = r.message?.content;
203
+ const text = Array.isArray(c) ? c.map((x) => x?.text || '').join(' ') : String(c || '');
204
+ if (PLAN_LIMIT_TEXT.test(text)) b.lim.server += 1; else b.lim.plan += 1;
205
+ return;
206
+ }
207
+
208
+ const msg = r.message || {};
209
+ for (const block of Array.isArray(msg.content) ? msg.content : []) {
210
+ if (block?.type !== 'tool_use') continue;
211
+ countTool(b, block.name);
212
+ if (block.name === 'Skill') bump(b.skills, typeof block.input?.skill === 'string' ? block.input.skill : null);
213
+ if (block.name === 'Agent' || block.name === 'Task') bump(b.agents, agentName(block.input?.subagent_type));
214
+ }
215
+
216
+ const u = msg.usage;
217
+ const model = msg.model;
218
+ if (!u || !model || model === '<synthetic>') return;
219
+ const id = msg.id || r.requestId || r.uuid;
220
+ if (id && entry.state.ids.includes(id)) return;
221
+ if (id) remember(entry.state.ids, id);
222
+ const cwh = u.cache_creation?.ephemeral_1h_input_tokens || 0;
223
+ const t = {
224
+ in: u.input_tokens || 0,
225
+ out: u.output_tokens || 0,
226
+ cr: u.cache_read_input_tokens || 0,
227
+ cw: Math.max(0, (u.cache_creation_input_tokens || 0) - cwh),
228
+ cwh,
229
+ rs: u.output_tokens_details?.thinking_tokens || 0,
230
+ };
231
+ addTokens(b, u.speed === 'fast' ? `${model}@fast` : model, t);
232
+ // Older Claude Code wrote subagent turns into the parent's own file.
233
+ if (r.isSidechain && meta.fn !== 'subagent') b.side = (b.side || 0) + t.in + t.out + t.cr + t.cw + t.cwh;
234
+ }
235
+
236
+ // ── Codex ─────────────────────────────────────────────────────────────────────
237
+
238
+ const SKILL_PATH = /skills\/(?:\.system\/)?([A-Za-z0-9_.-]+)\/SKILL\.md/g;
239
+ const TOOL_CALL = /\btools\.([A-Za-z_][A-Za-z0-9_]*)\s*\(/g;
240
+
241
+ function codexUsage(u) {
242
+ const input = u.input_tokens || 0;
243
+ const cached = Math.min(u.cached_input_tokens || 0, input);
244
+ return { in: input - cached, out: u.output_tokens || 0, cr: cached, cw: u.cache_write_input_tokens || 0, cwh: 0, rs: u.reasoning_output_tokens || 0 };
245
+ }
246
+
247
+ export function codexLine(entry, line) {
248
+ const hit = line.includes('"token_usage_record"') || line.includes('"token_count"')
249
+ || line.includes('"turn_context"') || line.includes('"session_meta"')
250
+ || line.includes('"custom_tool_call"') || line.includes('"function_call"');
251
+ if (!hit) return;
252
+ let r;
253
+ try { r = JSON.parse(line); } catch { return; }
254
+ const p = r.payload || {};
255
+ const ts = typeof r.timestamp === 'string' ? r.timestamp : null;
256
+ const meta = entry.meta;
257
+ const st = entry.state;
258
+
259
+ if (r.type === 'session_meta') {
260
+ meta.session = p.id || p.session_id || meta.session;
261
+ if (p.cwd) meta.cwd = p.cwd;
262
+ meta.entry = p.originator || null;
263
+ const sub = p.source && typeof p.source === 'object' ? p.source.subagent?.thread_spawn : null;
264
+ if (sub) {
265
+ meta.fn = 'subagent';
266
+ meta.parent = sub.parent_thread_id || null;
267
+ meta.agentType = sub.agent_role || p.agent_role || null;
268
+ const b = ts && dayBucket(entry, ts);
269
+ if (b) bump(b.agents, meta.agentType || 'subagent');
270
+ } else if (p.thread_source === 'automation') meta.fn = 'automation';
271
+ else if (p.originator === 'codex_exec' || p.source === 'exec') meta.fn = 'headless';
272
+ else meta.fn = 'chat';
273
+ return;
274
+ }
275
+ if (r.type === 'turn_context') { if (p.model) st.model = p.model; return; }
276
+ if (!ts) return;
277
+
278
+ if (r.type === 'response_item' && (p.type === 'custom_tool_call' || p.type === 'function_call')) {
279
+ const b = dayBucket(entry, ts);
280
+ if (!b) return;
281
+ touchSpan(meta, ts);
282
+ const input = String(p.input ?? p.arguments ?? '');
283
+ // `exec` is Codex's code-mode wrapper: the tools are called inside it.
284
+ const inner = p.name === 'exec' ? [...input.matchAll(TOOL_CALL)].map((m) => m[1]) : [];
285
+ if (inner.length) for (const n of inner) countTool(b, n); else countTool(b, p.name);
286
+ for (const m of input.matchAll(SKILL_PATH)) bump(b.skills, m[1]);
287
+ return;
288
+ }
289
+
290
+ if (r.type === 'token_usage_record' && p.usage) {
291
+ const id = p.response_id;
292
+ if (id && st.ids.includes(id)) return;
293
+ if (id) remember(st.ids, id);
294
+ st.records = true;
295
+ const b = dayBucket(entry, ts);
296
+ if (!b) return;
297
+ touchSpan(meta, ts);
298
+ addTokens(b, st.model || 'unknown', codexUsage(p.usage));
299
+ return;
300
+ }
301
+
302
+ if (r.type === 'event_msg' && p.type === 'token_count') {
303
+ const rl = p.rate_limits;
304
+ if (rl && (rl.primary || rl.secondary)) {
305
+ meta.limits = {
306
+ at: ts, plan: rl.plan_type || meta.limits?.plan || null, reached: rl.rate_limit_reached_type || null,
307
+ primary: rl.primary ? { used: rl.primary.used_percent, minutes: rl.primary.window_minutes, resets: rl.primary.resets_at } : null,
308
+ secondary: rl.secondary ? { used: rl.secondary.used_percent, minutes: rl.secondary.window_minutes, resets: rl.secondary.resets_at } : null,
309
+ credits: rl.credits ? { has: !!rl.credits.has_credits, unlimited: !!rl.credits.unlimited, balance: rl.credits.balance ?? null } : null,
310
+ };
311
+ }
312
+ // Older sessions have no token_usage_record. Their usage is the growth of
313
+ // the cumulative total — token_count is emitted more than once per response,
314
+ // so `last_token_usage` would count a response twice.
315
+ const tot = p.info?.total_token_usage;
316
+ if (!st.records && tot && typeof tot.total_tokens === 'number') {
317
+ const prev = st.prev;
318
+ st.prev = tot;
319
+ if (prev && tot.total_tokens <= prev.total_tokens) return;
320
+ const delta = {};
321
+ for (const k of ['input_tokens', 'cached_input_tokens', 'cache_write_input_tokens', 'output_tokens', 'reasoning_output_tokens']) {
322
+ delta[k] = Math.max(0, (tot[k] || 0) - ((prev && prev[k]) || 0));
323
+ }
324
+ // A thread forked from another opens with a total and no breakdown — the
325
+ // context it inherited, not a response. It is the base, never usage.
326
+ if (!delta.input_tokens && !delta.output_tokens && !delta.cached_input_tokens) return;
327
+ const b = dayBucket(entry, ts);
328
+ if (!b) return;
329
+ touchSpan(meta, ts);
330
+ addTokens(b, st.model || 'unknown', codexUsage(delta));
331
+ }
332
+ }
333
+ }
334
+
335
+ // ── incremental reading ───────────────────────────────────────────────────────
336
+
337
+ /** Feed every COMPLETE line in [start, size) to onLine; return where the next read starts. */
338
+ async function readFrom(file, start, onLine) {
339
+ const fh = await fs.promises.open(file, 'r');
340
+ try {
341
+ const buf = Buffer.allocUnsafe(CHUNK);
342
+ let pos = start;
343
+ let carry = null;
344
+ let committed = start;
345
+ for (;;) {
346
+ const { bytesRead } = await fh.read(buf, 0, CHUNK, pos);
347
+ if (bytesRead === 0) break;
348
+ pos += bytesRead;
349
+ let data = buf.subarray(0, bytesRead);
350
+ if (carry) { data = Buffer.concat([carry, data]); carry = null; }
351
+ let from = 0;
352
+ for (;;) {
353
+ const nl = data.indexOf(10, from);
354
+ if (nl === -1) break;
355
+ if (nl > from) onLine(data.toString('utf8', from, nl));
356
+ committed += nl + 1 - from;
357
+ from = nl + 1;
358
+ }
359
+ // A line still being written stays unread until it has its newline.
360
+ if (from < data.length) carry = Buffer.from(data.subarray(from));
361
+ }
362
+ return committed;
363
+ } finally {
364
+ await fh.close();
365
+ }
366
+ }
367
+
368
+ function freshEntry(host, file) {
369
+ const meta = host === 'claude' ? claudeFileMeta(file) : { session: null, parent: null, fn: null, agentType: null };
370
+ return { host, size: 0, mtimeMs: 0, offset: 0, meta: { ...meta, title: null, prompt: null, cwd: null, entry: null, first: null, last: null }, state: { ids: [], model: null, prev: null, records: false }, days: {} };
371
+ }
372
+
373
+ function loadIndex(cacheFile) {
374
+ try {
375
+ const x = JSON.parse(fs.readFileSync(cacheFile, 'utf8'));
376
+ if (x && x.version === INDEX_VERSION && x.files && typeof x.files === 'object') return x;
377
+ } catch { /* missing or corrupt: rebuilt, never trusted */ }
378
+ return { version: INDEX_VERSION, files: {} };
379
+ }
380
+
381
+ function saveIndex(cacheFile, index) {
382
+ try {
383
+ fs.mkdirSync(path.dirname(cacheFile), { recursive: true });
384
+ const tmp = `${cacheFile}.${process.pid}.tmp`;
385
+ fs.writeFileSync(tmp, JSON.stringify(index));
386
+ fs.renameSync(tmp, cacheFile);
387
+ } catch { /* unsaved: the next pass re-reads, nothing else is lost */ }
388
+ }
389
+
390
+ /**
391
+ * Bring the index up to date with the logs on disk.
392
+ * @returns {Promise<{index:object, seen:{claude:boolean,codex:boolean}, read:number, files:number}>}
393
+ */
394
+ export async function scanUsage({ claudeDir = DEFAULT_CLAUDE_DIR(), codexDirs = DEFAULT_CODEX_DIRS(), cacheFile = DEFAULT_CACHE_FILE(), now = Date.now() } = {}) {
395
+ const index = loadIndex(cacheFile);
396
+ const { files, seen } = listLogFiles({ claudeDir, codexDirs });
397
+ const live = new Set();
398
+ let read = 0;
399
+ for (const { host, file } of files) {
400
+ const key = short(file);
401
+ live.add(key);
402
+ let st;
403
+ try { st = fs.statSync(file); } catch { continue; }
404
+ let e = index.files[key];
405
+ if (e && e.size === st.size && e.mtimeMs === st.mtimeMs) continue;
406
+ // Smaller than what was read means rewritten, not appended: start over.
407
+ if (!e || e.host !== host || st.size < e.offset) e = freshEntry(host, file);
408
+ try {
409
+ const onLine = host === 'claude' ? (l) => claudeLine(e, l) : (l) => codexLine(e, l);
410
+ e.offset = await readFrom(file, e.offset, onLine);
411
+ e.size = st.size;
412
+ e.mtimeMs = st.mtimeMs;
413
+ index.files[key] = e;
414
+ read++;
415
+ } catch { /* unreadable now: tried again next pass, never counted as empty */ }
416
+ }
417
+ // A file that is gone keeps its days until they are too old to matter.
418
+ const cutoff = dayOf(now - KEEP_GONE_DAYS * 86400000);
419
+ for (const [key, e] of Object.entries(index.files)) {
420
+ if (live.has(key)) continue;
421
+ e.gone = true;
422
+ if (!e.meta?.last || dayOf(e.meta.last) < cutoff) delete index.files[key];
423
+ }
424
+ saveIndex(cacheFile, index);
425
+ return { index, seen, read, files: files.length };
426
+ }
427
+
428
+ // ── the summary the board draws ───────────────────────────────────────────────
429
+
430
+ /** Codex thread names, last write wins. */
431
+ export function readCodexTitles(file = DEFAULT_CODEX_TITLES()) {
432
+ const out = {};
433
+ let text = '';
434
+ try { text = fs.readFileSync(file, 'utf8'); } catch { return out; }
435
+ for (const line of text.split('\n')) {
436
+ if (!line.trim()) continue;
437
+ try { const r = JSON.parse(line); if (r.id && r.thread_name) out[r.id] = String(r.thread_name).slice(0, 160); } catch { /* partial */ }
438
+ }
439
+ return out;
440
+ }
441
+
442
+ const FAST = '@fast';
443
+
444
+ /** Dollars for one model bucket, or null when nobody priced the model. */
445
+ function priceBucket(model, m, prices) {
446
+ const fast = model.endsWith(FAST);
447
+ const base = fast ? model.slice(0, -FAST.length) : model;
448
+ const p = priceUsage({
449
+ model: base,
450
+ prices,
451
+ usage: {
452
+ input_tokens: m.in, output_tokens: m.out, cache_read_input_tokens: m.cr,
453
+ cache_creation_input_tokens: m.cw + m.cwh,
454
+ cache_creation: { ephemeral_1h_input_tokens: m.cwh, ephemeral_5m_input_tokens: m.cw },
455
+ },
456
+ });
457
+ if (!p.priced) return { usd: null, assumed: false };
458
+ // Fast mode bills at twice the standard rate (Opus 5: $10/$50 against $5/$25).
459
+ return { usd: fast ? p.usd * 2 : p.usd, assumed: p.assumed };
460
+ }
461
+
462
+ const tokensOf = (t) => t.in + t.out + t.cr + t.cw + t.cwh;
463
+
464
+ function rank(map, limit, label = (n) => n) {
465
+ return Object.entries(map).sort((a, b) => b[1] - a[1]).slice(0, limit).map(([name, n]) => ({ name: label(name), n }));
466
+ }
467
+
468
+ const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
469
+ /** claude.ai connectors reach the transcript as a bare UUID; say what it is rather than print it. */
470
+ export function mcpLabel(server) {
471
+ return UUID.test(server) ? `claude.ai connector ${server.slice(0, 8)}` : server;
472
+ }
473
+
474
+ /**
475
+ * The window the board shows.
476
+ * @param {object} index from scanUsage
477
+ * @param {{days?:number, now?:number, codexTitles?:Record<string,string>, prices?:object, top?:number}} opts
478
+ */
479
+ export function summarizeUsage(index, { days = 30, now = Date.now(), codexTitles = {}, prices = effectivePrices(), top = 10 } = {}) {
480
+ const span = Math.max(1, Math.min(365, Math.floor(days) || 30));
481
+ const dates = [];
482
+ for (let i = span - 1; i >= 0; i--) dates.push(dayOf(now - i * 86400000));
483
+ const inWindow = new Set(dates);
484
+ const from = dates[0];
485
+
486
+ const blank = () => ({ in: 0, out: 0, cr: 0, cw: 0, cwh: 0, rs: 0, msgs: 0, usd: 0, unpricedMsgs: 0, sessions: 0, byFn: {}, lim: { plan: 0, server: 0 } });
487
+ const hosts = { claude: blank(), codex: blank() };
488
+ const daily = Object.fromEntries(dates.map((d) => [d, { claude: { tokens: 0, usd: 0, byFn: {} }, codex: { tokens: 0, usd: 0, byFn: {} } }]));
489
+ const models = {};
490
+ const lists = { claude: { tools: {}, skills: {}, agents: {}, mcp: {} }, codex: { tools: {}, skills: {}, agents: {}, mcp: {} } };
491
+ const sessions = {};
492
+ const unpriced = new Set();
493
+ let codexLimits = null;
494
+ let codexPlan = null;
495
+
496
+ for (const e of Object.values(index.files || {})) {
497
+ const host = e.host;
498
+ if (!hosts[host]) continue;
499
+ const lim = host === 'codex' ? e.meta?.limits : null;
500
+ if (lim && (!codexLimits || lim.at > codexLimits.at)) codexLimits = lim;
501
+ if (lim?.plan && (!codexPlan || lim.at > codexPlan.at)) codexPlan = { at: lim.at, plan: lim.plan };
502
+ const fn = e.meta?.fn || 'chat';
503
+ let touched = false;
504
+ for (const [day, b] of Object.entries(e.days || {})) {
505
+ if (!inWindow.has(day)) continue;
506
+ touched = true;
507
+ const h = hosts[host];
508
+ h.lim.plan += b.lim?.plan || 0;
509
+ h.lim.server += b.lim?.server || 0;
510
+ for (const kind of ['tools', 'skills', 'agents', 'mcp']) for (const [k, n] of Object.entries(b[kind] || {})) bump(lists[host][kind], k, n);
511
+ let dayUsd = 0;
512
+ let dayUnpriced = false;
513
+ for (const [model, m] of Object.entries(b.models || {})) {
514
+ const { usd } = priceBucket(model, m, prices);
515
+ if (usd == null) dayUnpriced = true;
516
+ const mk = `${host}|${model}`;
517
+ const row = models[mk] || (models[mk] = { host, model, in: 0, out: 0, cr: 0, cw: 0, cwh: 0, msgs: 0, usd: 0, priced: usd != null });
518
+ row.in += m.in; row.out += m.out; row.cr += m.cr; row.cw += m.cw; row.cwh += m.cwh; row.msgs += m.msgs;
519
+ if (usd != null) { row.usd += usd; dayUsd += usd; } else { row.priced = false; h.unpricedMsgs += m.msgs; unpriced.add(model); }
520
+ }
521
+ const tk = tokensOf(b);
522
+ const side = Math.min(b.side || 0, tk);
523
+ h.in += b.in; h.out += b.out; h.cr += b.cr; h.cw += b.cw; h.cwh += b.cwh; h.rs += b.rs; h.msgs += b.msgs; h.usd += dayUsd;
524
+ const dd = daily[day][host];
525
+ dd.tokens += tk; dd.usd += dayUsd;
526
+ for (const [f, n] of [[fn, tk - side], ['subagent', side]]) {
527
+ if (!n) continue;
528
+ h.byFn[f] = (h.byFn[f] || 0) + n;
529
+ dd.byFn[f] = (dd.byFn[f] || 0) + n;
530
+ }
531
+ // A subagent's spend belongs to the conversation that dispatched it.
532
+ const sid = `${host}|${e.meta?.parent || e.meta?.session || 'unknown'}`;
533
+ const s = sessions[sid] || (sessions[sid] = { host, id: e.meta?.parent || e.meta?.session || null, tokens: 0, usd: 0, priced: true, msgs: 0, subagentTokens: 0, last: null, title: null, project: null, fn: null });
534
+ s.tokens += tk; s.usd += dayUsd; s.msgs += b.msgs;
535
+ s.subagentTokens += fn === 'subagent' ? tk : side;
536
+ if (dayUnpriced) s.priced = false;
537
+ }
538
+ if (!touched) continue;
539
+ const sid = `${host}|${e.meta?.parent || e.meta?.session || 'unknown'}`;
540
+ const s = sessions[sid];
541
+ if (e.meta?.last && (!s.last || e.meta.last > s.last)) s.last = e.meta.last;
542
+ // The conversation's own file names it; a subagent's file only fills a gap.
543
+ const own = !e.meta?.parent;
544
+ if (own || !s.title) s.title = (host === 'codex' ? codexTitles[e.meta?.session] : null) || e.meta?.title || e.meta?.prompt || s.title;
545
+ if (own || !s.project) s.project = projectName(e.meta?.cwd) || s.project;
546
+ if (own || !s.fn) s.fn = fn;
547
+ }
548
+
549
+ const sess = Object.values(sessions);
550
+ for (const host of ['claude', 'codex']) hosts[host].sessions = sess.filter((s) => s.host === host).length;
551
+ const topFor = (host) => {
552
+ const all = sess.filter((s) => s.host === host);
553
+ const total = all.reduce((a, s) => a + s.tokens, 0) || 1;
554
+ return all.sort((a, b) => b.tokens - a.tokens).slice(0, top).map((s) => ({
555
+ title: s.title || '(untitled)', project: s.project, fn: s.fn, tokens: s.tokens,
556
+ usd: s.priced ? s.usd : null, share: s.tokens / total, subagentTokens: s.subagentTokens, last: s.last,
557
+ }));
558
+ };
559
+
560
+ const cache = (h) => {
561
+ const read = h.cr; const all = h.in + h.cr + h.cw + h.cwh;
562
+ return { hitRate: all ? read / all : null };
563
+ };
564
+
565
+ return {
566
+ state: 'counted',
567
+ window: { from, to: dates[dates.length - 1], days: span },
568
+ hosts: Object.fromEntries(Object.entries(hosts).map(([k, h]) => [k, {
569
+ tokens: tokensOf(h), input: h.in, output: h.out, cacheRead: h.cr, cacheWrite: h.cw + h.cwh, reasoning: h.rs,
570
+ responses: h.msgs, sessions: h.sessions, usd: h.unpricedMsgs === h.msgs && h.msgs > 0 ? null : h.usd,
571
+ unpricedResponses: h.unpricedMsgs, byFn: h.byFn, limitHits: h.lim, ...cache(h),
572
+ }])),
573
+ daily: dates.map((d) => ({ date: d, ...daily[d] })),
574
+ models: Object.values(models).sort((a, b) => tokensOf(b) - tokensOf(a)).map((m) => ({
575
+ host: m.host, model: m.model, responses: m.msgs, tokens: tokensOf(m), output: m.out, usd: m.priced ? m.usd : null,
576
+ })),
577
+ top: { claude: topFor('claude'), codex: topFor('codex') },
578
+ lists: Object.fromEntries(Object.entries(lists).map(([host, l]) => [host, {
579
+ tools: rank(l.tools, 15), skills: rank(l.skills, 15), agents: rank(l.agents, 15), mcp: rank(l.mcp, 15, mcpLabel),
580
+ }])),
581
+ limits: { codex: codexLimits ? { ...codexLimits, plan: codexLimits.plan || codexPlan?.plan || null } : null },
582
+ unpricedModels: [...unpriced].sort(),
583
+ };
584
+ }
585
+
586
+ /**
587
+ * A request-safe view: `get()` answers at once and refreshes in the background.
588
+ * The first pass over a real history reads gigabytes; a board request cannot
589
+ * wait for it, and a second request must not start a second pass.
590
+ */
591
+ export function usageIndexSnapshot({ scan = () => scanUsage(), ttlMs = 60 * 1000, now = () => Date.now() } = {}) {
592
+ let last = null;
593
+ let at = 0;
594
+ let running = null;
595
+ const start = () => {
596
+ if (running) return;
597
+ let pass;
598
+ try { pass = Promise.resolve(scan()); } catch (e) { pass = Promise.reject(e); }
599
+ running = pass
600
+ .then((r) => { last = { state: 'ready', ...r }; }, (e) => { last = last?.state === 'ready' ? last : { state: 'unavailable', why: `session logs could not be read: ${e?.message || e}` }; })
601
+ .finally(() => { at = now(); running = null; });
602
+ };
603
+ return {
604
+ get() {
605
+ if (!last) { start(); return { state: 'computing', why: 'reading the Claude Code and Codex session logs — the first pass over a large history takes a minute or two' }; }
606
+ if (now() - at > ttlMs) start();
607
+ return last;
608
+ },
609
+ /** For tests and the CLI: wait for the pass in flight. */
610
+ async settle() { start(); while (running) await running; return last; },
611
+ };
612
+ }
613
+
614
+ // CLI: node scripts/lib/session-usage.mjs [--days N] [--json]
615
+ const isMain = process.argv[1] && path.resolve(process.argv[1]) === path.resolve(new URL(import.meta.url).pathname);
616
+ if (isMain) {
617
+ const i = process.argv.indexOf('--days');
618
+ const days = i > -1 ? Number(process.argv[i + 1]) : 30;
619
+ const t0 = Date.now();
620
+ const r = await scanUsage();
621
+ const s = summarizeUsage(r.index, { days, codexTitles: readCodexTitles() });
622
+ if (process.argv.includes('--json')) { console.log(JSON.stringify(s, null, 2)); process.exit(0); }
623
+ const fmt = (n) => (n >= 1e9 ? `${(n / 1e9).toFixed(2)}B` : n >= 1e6 ? `${(n / 1e6).toFixed(1)}M` : n >= 1e3 ? `${(n / 1e3).toFixed(1)}k` : String(n));
624
+ console.log(`session-usage: ${r.files} log files, ${r.read} read in ${((Date.now() - t0) / 1000).toFixed(1)}s · window ${s.window.from} → ${s.window.to}`);
625
+ for (const [h, v] of Object.entries(s.hosts)) {
626
+ console.log(` ${h.padEnd(7)} ${fmt(v.tokens).padStart(8)} tokens · ${v.responses} responses · ${v.sessions} sessions · cache ${v.hitRate == null ? '—' : `${Math.round(v.hitRate * 100)}%`} · ${v.usd == null ? 'unpriced' : `$${v.usd.toFixed(2)} at list price`}`);
627
+ }
628
+ if (s.limits.codex?.primary) console.log(` codex limit: ${s.limits.codex.primary.used}% of ${s.limits.codex.primary.minutes} min window (plan ${s.limits.codex.plan})`);
629
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "great-cto",
3
- "version": "3.52.0",
3
+ "version": "3.53.0",
4
4
  "description": "One command install for the great_cto Claude Code plugin. Auto-detects your stack, picks the right archetype, bootstraps PROJECT.md.",
5
5
  "keywords": [
6
6
  "claude-code",