great-cto 3.52.1 → 3.54.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.1",
4
+ "version": "3.54.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');
@@ -2244,6 +2244,11 @@ h1, h2, h3, h4 { font-weight: 600; font-family: var(--sans); }
2244
2244
  font-size: var(--fs-body);
2245
2245
  }
2246
2246
  .judge-status[hidden] { display: none; }
2247
+ /* The "retry" link appears only when the live key check got no answer — a
2248
+ degraded state, so no screenshot ever carried it, and it rendered in the
2249
+ browser's default blue until the on-system check met it on 2026-10-06. */
2250
+ .judge-status a { color: var(--accent-text); text-decoration: underline; text-underline-offset: 2px; }
2251
+ .judge-status a:focus-visible { outline: 2px solid var(--focus-ring); outline-offset: 1px; }
2247
2252
 
2248
2253
  /* What the judge CONCLUDED, under whether it is connected.
2249
2254
  The scores existed for weeks and no page read them: /api/scores had zero
@@ -2369,6 +2374,74 @@ h1, h2, h3, h4 { font-weight: 600; font-family: var(--sans); }
2369
2374
 
2370
2375
  /* ── Metrics page ─────────────────────────────────────────────────────────── */
2371
2376
  .metrics-page { padding: 24px 32px; }
2377
+
2378
+ /* Usage (great_cto-egkw) — two hosts side by side, every block in two columns
2379
+ that fold into one at phone width. */
2380
+ .usage-head { display: flex; align-items: flex-start; justify-content: space-between; gap: 12px; flex-wrap: wrap; }
2381
+ .usage-chips {
2382
+ display: inline-flex; gap: 4px; padding: 3px;
2383
+ background: var(--bg-muted); border: 1px solid var(--border);
2384
+ }
2385
+ .usage-chip {
2386
+ background: transparent; border: none; color: var(--text2);
2387
+ padding: 5px 12px; min-height: 32px; cursor: pointer;
2388
+ font-family: var(--mono); font-size: var(--fs-caption); font-weight: 500; letter-spacing: 0.05em;
2389
+ }
2390
+ .usage-chip:hover { color: var(--text); }
2391
+ .usage-chip.active { background: var(--accent); color: var(--on-accent); }
2392
+ .usage-chip:focus-visible { outline: 2px solid var(--focus-ring); outline-offset: 1px; }
2393
+ .usage-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(min(100%, 380px), 1fr)); gap: 12px; margin-bottom: 20px; }
2394
+ .usage-card { background: var(--bg-card); border: 1px solid var(--border); padding: 16px 18px; min-width: 0; }
2395
+ .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; }
2396
+ .usage-card h3 .muted { font-weight: 400; font-size: var(--fs-caption); }
2397
+ .usage-big { font-family: var(--display); font-size: var(--fs-num-m); font-variant-numeric: tabular-nums; color: var(--text); line-height: 1.1; }
2398
+ .usage-stats { display: grid; grid-template-columns: repeat(auto-fit, minmax(110px, 1fr)); gap: 10px; margin-top: 12px; }
2399
+ .usage-stat { min-width: 0; }
2400
+ .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); }
2401
+ .usage-stat span { font-size: var(--fs-caption); color: var(--text3); }
2402
+ .usage-limit { margin-top: 14px; padding-top: 12px; border-top: 1px solid var(--border); }
2403
+ .usage-limit-head { display: flex; justify-content: space-between; gap: 8px; font-size: var(--fs-small); color: var(--text2); }
2404
+ .usage-limit-head b { font-family: var(--mono); font-variant-numeric: tabular-nums; color: var(--text); font-weight: 500; }
2405
+ .usage-meter { height: 8px; background: var(--bg-muted); border: 1px solid var(--border); margin: 6px 0; }
2406
+ .usage-meter > span { display: block; height: 100%; background: var(--accent); }
2407
+ .usage-meter.warn > span { background: var(--status-progress); }
2408
+ .usage-meter.full > span { background: var(--status-blocked); }
2409
+ .usage-sub { font-size: var(--fs-caption); color: var(--text3); line-height: 1.5; }
2410
+ .usage-note { font-size: var(--fs-small); color: var(--text2); line-height: 1.55; }
2411
+ .usage-chart svg { display: block; width: 100%; height: 120px; }
2412
+ .usage-axis { display: flex; justify-content: space-between; gap: 8px; font-family: var(--mono); font-size: var(--fs-caption); color: var(--text3); margin-top: 4px; }
2413
+ .usage-axis span { white-space: nowrap; }
2414
+ .usage-axis span:nth-child(2) { overflow: hidden; text-overflow: ellipsis; min-width: 0; }
2415
+ .usage-legend { display: flex; flex-wrap: wrap; gap: 4px 14px; font-size: var(--fs-caption); color: var(--text2); margin-top: 8px; }
2416
+ .usage-legend i { display: inline-block; width: 10px; height: 10px; margin-right: 5px; vertical-align: -1px; }
2417
+ .usage-table-wrap { overflow-x: auto; }
2418
+ .usage-table { width: 100%; border-collapse: collapse; font-size: var(--fs-small); }
2419
+ .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; }
2420
+ .usage-table td { padding: 6px; border-bottom: 1px solid var(--border); color: var(--text); vertical-align: top; }
2421
+ .usage-table td.num, .usage-table th.num { text-align: right; font-family: var(--mono); font-variant-numeric: tabular-nums; white-space: nowrap; }
2422
+ .usage-title { max-width: 260px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
2423
+ .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); }
2424
+ .usage-share { display: inline-block; width: 48px; height: 6px; background: var(--bg-muted); margin-right: 6px; vertical-align: 1px; }
2425
+ .usage-share > span { display: block; height: 100%; background: var(--accent); }
2426
+ .usage-sec-head { display: flex; justify-content: space-between; align-items: center; gap: 12px; flex-wrap: wrap; margin-bottom: 12px; }
2427
+ .usage-sec-head .mp-section-label { margin: 0; }
2428
+ .usage-lists { display: grid; grid-template-columns: repeat(auto-fit, minmax(min(100%, 220px), 1fr)); gap: 12px; margin-bottom: 20px; }
2429
+ .usage-list { list-style: none; margin: 0; padding: 0; }
2430
+ .usage-list li { display: grid; grid-template-columns: minmax(0, 1fr) auto; gap: 8px; font-size: var(--fs-small); padding: 4px 0; position: relative; }
2431
+ .usage-list li .nm { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; color: var(--text); }
2432
+ .usage-list li .n { font-family: var(--mono); font-variant-numeric: tabular-nums; color: var(--text2); }
2433
+ .usage-list li .bar { grid-column: 1 / -1; height: 3px; background: var(--bg-muted); }
2434
+ .usage-list li .bar > span { display: block; height: 100%; background: var(--accent); opacity: 0.7; }
2435
+ .usage-line svg { display: block; width: 100%; height: 110px; overflow: visible; }
2436
+ .usage-line .ln { fill: none; stroke: var(--accent); stroke-width: 1.5; vector-effect: non-scaling-stroke; }
2437
+ .usage-line .ceil { stroke: var(--status-progress); stroke-width: 1; stroke-dasharray: 4 3; vector-effect: non-scaling-stroke; }
2438
+ .usage-line .grid { stroke: var(--border-strong); stroke-width: 1; vector-effect: non-scaling-stroke; }
2439
+ .usage-kinds { display: flex; flex-wrap: wrap; gap: 6px 14px; font-size: var(--fs-small); color: var(--text2); margin: 10px 0 4px; }
2440
+ .usage-kinds b { font-family: var(--mono); font-variant-numeric: tabular-nums; color: var(--text); font-weight: 500; margin-right: 4px; }
2441
+ .usage-quote { font-size: var(--fs-small); color: var(--text2); border-left: 2px solid var(--border-strong); padding: 2px 0 2px 10px; margin: 10px 0 0; overflow-wrap: anywhere; }
2442
+ .usage-pace { font-size: var(--fs-small); color: var(--text); margin: 4px 0 8px; }
2443
+ .usage-pace.bad { color: var(--status-blocked-ink); }
2444
+ @media (max-width: 767px) { .usage-title { max-width: 160px; } .usage-chip { min-height: 44px; } }
2372
2445
  .mp-section-label {
2373
2446
  font-family: var(--mono); font-size: var(--fs-caption);
2374
2447
  letter-spacing: 0.1em; text-transform: uppercase;
@@ -3355,6 +3428,10 @@ h1, h2, h3, h4 { font-weight: 600; font-family: var(--sans); }
3355
3428
  <span>Harness</span>
3356
3429
  <span class="count" id="nav-harness-count"></span>
3357
3430
  </div>
3431
+ <div class="nav-item" data-tab="usage" role="tab" tabindex="0" aria-label="Usage" title="Usage" onkeydown="navTabKeydown(event, this)" onclick="switchTab('usage', this)">
3432
+ <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>
3433
+ <span>Usage</span>
3434
+ </div>
3358
3435
  </nav>
3359
3436
  </details>
3360
3437
  <div class="nav-divider"></div>
@@ -3603,6 +3680,23 @@ h1, h2, h3, h4 { font-weight: 600; font-family: var(--sans); }
3603
3680
  </div>
3604
3681
  </div>
3605
3682
 
3683
+ <!-- Usage (great_cto-egkw). What Claude Code and Codex consumed on this
3684
+ machine, read from the hosts' own session logs by
3685
+ scripts/lib/session-usage.mjs. Machine-wide, not per project. -->
3686
+ <div class="panel" id="panel-usage" role="tabpanel">
3687
+ <div class="metrics-page">
3688
+ <div class="usage-head">
3689
+ <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>
3690
+ <div class="usage-chips" role="group" aria-label="Period">
3691
+ <button type="button" class="usage-chip" data-days="7" onclick="loadUsage(7)">7D</button>
3692
+ <button type="button" class="usage-chip active" data-days="30" onclick="loadUsage(30)">30D</button>
3693
+ <button type="button" class="usage-chip" data-days="90" onclick="loadUsage(90)">90D</button>
3694
+ </div>
3695
+ </div>
3696
+ <div id="usage-body" class="usage-note" role="status">Reading the session logs of Claude Code and Codex on this machine.</div>
3697
+ </div>
3698
+ </div>
3699
+
3606
3700
  <div class="panel" id="panel-kanban">
3607
3701
  <h1 class="page-title">Tasks</h1>
3608
3702
  <div id="filter-bar" class="filter-bar"></div>
@@ -5877,6 +5971,303 @@ function renderAgentActivity(snap) {
5877
5971
  // The Harnesses card. Four states for the second opinion, and the fourth is
5878
5972
  // the reason the card exists: `unavailable` — declared codex, no codex here —
5879
5973
  // must never look like `none` or like a switch someone turned off.
5974
+ // ── Usage (great_cto-egkw) ─────────────────────────────────────────────────
5975
+ // Both hosts, side by side, from /api/usage. Three honesty rules carried from
5976
+ // the reader: an unpriced model is n/a, never $0; Claude Code's plan use is not
5977
+ // written anywhere, so it is not drawn — only the refusals are; and a host with
5978
+ // no logs on this machine says so instead of showing zeros.
5979
+ let usageDays = 30;
5980
+ let usageHost = 'claude';
5981
+ let usageData = null;
5982
+ let _usageTimer = null;
5983
+ const USAGE_HOSTS = { claude: 'Claude Code', codex: 'Codex' };
5984
+ const USAGE_LOGS = { claude: '~/.claude/projects', codex: '~/.codex/sessions' };
5985
+ const USAGE_FN = { chat: 'Conversations', subagent: 'Subagents', automation: 'Automations', headless: 'Headless runs' };
5986
+ const USAGE_FN_COLOR = { chat: 'var(--accent)', subagent: 'var(--status-progress)', automation: 'var(--status-gate)', headless: 'var(--text3)' };
5987
+
5988
+ function fmtTok(n) {
5989
+ n = Number(n) || 0;
5990
+ if (n >= 1e9) return `${(n / 1e9).toFixed(2)}B`;
5991
+ if (n >= 1e6) return `${(n / 1e6).toFixed(1)}M`;
5992
+ if (n >= 1e3) return `${(n / 1e3).toFixed(1)}k`;
5993
+ return String(Math.round(n));
5994
+ }
5995
+ function fmtUsdEq(n) {
5996
+ if (n == null) return null;
5997
+ return n >= 100 ? `$${Math.round(n).toLocaleString('en-US')}` : `$${n.toFixed(2)}`;
5998
+ }
5999
+ const fmtPct = (x) => (x == null ? null : `${Math.round(x * 100)}%`);
6000
+
6001
+ async function loadUsage(days) {
6002
+ if (days) usageDays = days;
6003
+ document.querySelectorAll('.usage-chip[data-days]').forEach((c) => c.classList.toggle('active', Number(c.dataset.days) === usageDays));
6004
+ clearTimeout(_usageTimer);
6005
+ const d = await api(`/api/usage?days=${usageDays}`);
6006
+ const body = document.getElementById('usage-body');
6007
+ if (!body) return;
6008
+ if (!d || d.error) {
6009
+ body.className = 'usage-note';
6010
+ body.textContent = `Usage could not be read: ${(d && d.error) || 'the board is not reachable — is the server still running?'}`;
6011
+ return;
6012
+ }
6013
+ if (d.state === 'computing') {
6014
+ body.className = 'usage-note';
6015
+ body.textContent = `Counting — ${d.why}`;
6016
+ _usageTimer = setTimeout(() => { if (currentTab === 'usage') loadUsage(); }, 2000);
6017
+ return;
6018
+ }
6019
+ if (d.state !== 'counted') {
6020
+ body.className = 'usage-note';
6021
+ body.textContent = `Usage is not available: ${d.why || d.state}`;
6022
+ return;
6023
+ }
6024
+ usageData = d;
6025
+ renderUsage(d);
6026
+ }
6027
+
6028
+ function setUsageHost(host) {
6029
+ usageHost = host;
6030
+ if (usageData) renderUsage(usageData);
6031
+ }
6032
+
6033
+ function usageLimitWindow(minutes) {
6034
+ if (minutes === 10080) return 'Weekly limit';
6035
+ if (minutes === 300) return '5-hour limit';
6036
+ if (minutes === 43200) return 'Monthly limit';
6037
+ return minutes ? `${minutes}-minute limit` : 'Limit';
6038
+ }
6039
+
6040
+ function usageHostCard(d, host) {
6041
+ const name = USAGE_HOSTS[host];
6042
+ if (!d.seen || !d.seen[host]) {
6043
+ 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>`;
6044
+ }
6045
+ const h = d.hosts[host];
6046
+ const unpricedHere = (d.models || []).filter((m) => m.host === host && m.usd == null).map((m) => m.model);
6047
+ const usd = h.usd == null
6048
+ ? absent('uncomputable', `no price is set for ${unpricedHere.join(', ') || 'these models'} — add one to ~/.great_cto/model-prices.json`)
6049
+ : 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>` : '');
6050
+ const stat = (v, label) => `<div class="usage-stat"><b>${v}</b><span>${label}</span></div>`;
6051
+ let limit;
6052
+ if (host === 'codex') {
6053
+ const L = d.limits && d.limits.codex;
6054
+ const win = (w) => {
6055
+ if (!w) return '';
6056
+ const used = Math.max(0, Math.min(100, Number(w.used) || 0));
6057
+ const cls = used >= 90 ? 'full' : used >= 70 ? 'warn' : '';
6058
+ const resets = w.resets ? fmtDT(new Date(w.resets * 1000).toISOString()) : '—';
6059
+ return `<div class="usage-limit-head"><span>${usageLimitWindow(w.minutes)}</span><b>${used}% used</b></div>
6060
+ <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>
6061
+ <div class="usage-sub">resets ${esc(resets)}</div>`;
6062
+ };
6063
+ limit = L && (L.primary || L.secondary)
6064
+ ? `<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>`
6065
+ : `<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>`;
6066
+ } else {
6067
+ const hits = h.limitHits || { plan: 0, server: 0 };
6068
+ 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>
6069
+ <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>`;
6070
+ }
6071
+ return `<section class="usage-card" data-host="${host}">
6072
+ <h3><span>${name}</span><span class="muted">${esc(d.window.from)} → ${esc(d.window.to)}</span></h3>
6073
+ <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>
6074
+ <div class="usage-stats">
6075
+ ${stat(usd, 'at API list price')}
6076
+ ${stat(esc((h.sessions || 0).toLocaleString('en-US')), 'conversations')}
6077
+ ${stat(esc((h.responses || 0).toLocaleString('en-US')), 'model responses')}
6078
+ ${stat(h.hitRate == null ? absent('none', 'no input in this window') : esc(fmtPct(h.hitRate)), 'input from cache')}
6079
+ ${stat(esc(fmtTok(h.output)), 'output tokens')}
6080
+ </div>
6081
+ ${limit}
6082
+ </section>`;
6083
+ }
6084
+
6085
+ function usageDayChart(d, host) {
6086
+ const name = USAGE_HOSTS[host];
6087
+ const head = `<h3><span>${name} by day</span><span class="muted">tokens</span></h3>`;
6088
+ if (!d.seen || !d.seen[host]) return `<section class="usage-card usage-chart">${head}<p class="usage-note">No logs to draw.</p></section>`;
6089
+ const days = d.daily || [];
6090
+ const max = Math.max(0, ...days.map((x) => x[host].tokens));
6091
+ if (!max) return `<section class="usage-card usage-chart">${head}<p class="usage-note">Nothing in this window.</p></section>`;
6092
+ const fns = Object.keys(USAGE_FN).filter((f) => days.some((x) => x[host].byFn[f]));
6093
+ const W = 10;
6094
+ let bars = '';
6095
+ days.forEach((x, i) => {
6096
+ let y = 100;
6097
+ const parts = fns.map((f) => `${USAGE_FN[f]} ${fmtTok(x[host].byFn[f] || 0)}`).join(' · ');
6098
+ const tip = `${x.date}: ${fmtTok(x[host].tokens)} tokens${x[host].usd ? ` · ${fmtUsdEq(x[host].usd)}` : ''}${parts ? ` (${parts})` : ''}`;
6099
+ bars += `<g><title>${esc(tip)}</title>`;
6100
+ for (const f of fns) {
6101
+ const v = x[host].byFn[f] || 0;
6102
+ if (!v) continue;
6103
+ const hgt = (v / max) * 96;
6104
+ y -= hgt;
6105
+ bars += `<rect x="${i * W + 1}" y="${y.toFixed(2)}" width="${W - 2}" height="${hgt.toFixed(2)}" fill="${USAGE_FN_COLOR[f]}"></rect>`;
6106
+ }
6107
+ bars += `<rect x="${i * W}" y="0" width="${W}" height="100" fill="transparent"></rect></g>`;
6108
+ });
6109
+ 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('');
6110
+ return `<section class="usage-card usage-chart">${head}
6111
+ <svg viewBox="0 0 ${days.length * W} 100" preserveAspectRatio="none" role="img" aria-label="${esc(`${name} tokens per day, peak ${fmtTok(max)}`)}">${bars}</svg>
6112
+ <div class="usage-axis"><span>${esc(days[0].date)}</span><span>peak ${fmtTok(max)}</span><span>${esc(days[days.length - 1].date)}</span></div>
6113
+ <div class="usage-legend">${legend}</div>
6114
+ </section>`;
6115
+ }
6116
+
6117
+ function usageTopTable(d, host) {
6118
+ const name = USAGE_HOSTS[host];
6119
+ const rows = (d.top && d.top[host]) || [];
6120
+ const head = `<h3><span>${name}: heaviest conversations</span><span class="muted">share of ${name} tokens</span></h3>`;
6121
+ 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>`;
6122
+ const money = host === 'claude';
6123
+ const body = rows.map((r) => {
6124
+ const tag = r.fn && r.fn !== 'chat' ? `<span class="usage-tag">${esc(USAGE_FN[r.fn] || r.fn)}</span>` : '';
6125
+ const sub = r.subagentTokens ? `<div class="usage-sub">${fmtPct(r.subagentTokens / r.tokens)} in subagents</div>` : '';
6126
+ const share = Math.max(1, Math.round(r.share * 100));
6127
+ return `<tr>
6128
+ <td><div class="usage-title" title="${esc(r.title)}">${esc(r.title)}</div>${sub}</td>
6129
+ <td>${esc(r.project || '—')}${tag}</td>
6130
+ <td class="num"><span class="usage-share" aria-hidden="true"><span style="width:${share}%"></span></span>${fmtPct(r.share)}</td>
6131
+ <td class="num">${fmtTok(r.tokens)}</td>
6132
+ ${money ? `<td class="num">${r.usd == null ? absent('uncomputable', 'a model in this conversation has no price') : esc(fmtUsdEq(r.usd))}</td>` : ''}
6133
+ </tr>`;
6134
+ }).join('');
6135
+ return `<section class="usage-card">${head}<div class="usage-table-wrap"><table class="usage-table">
6136
+ <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>
6137
+ <tbody>${body}</tbody></table></div></section>`;
6138
+ }
6139
+
6140
+ function usageModels(d) {
6141
+ const rows = d.models || [];
6142
+ if (!rows.length) return '<p class="usage-note">No model responses in this window.</p>';
6143
+ const body = rows.map((m) => {
6144
+ const fast = m.model.endsWith('@fast');
6145
+ const label = fast ? `${m.model.slice(0, -5)} (fast)` : m.model;
6146
+ 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>
6147
+ <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>`;
6148
+ }).join('');
6149
+ return `<section class="usage-card" style="margin-bottom:20px"><div class="usage-table-wrap"><table class="usage-table">
6150
+ <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>
6151
+ <tbody>${body}</tbody></table></div></section>`;
6152
+ }
6153
+
6154
+ function usageList(title, rows, empty) {
6155
+ if (!rows || !rows.length) return `<section class="usage-card"><h3><span>${title}</span></h3><p class="usage-note">${empty}</p></section>`;
6156
+ const max = Math.max(...rows.map((r) => r.n));
6157
+ 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('');
6158
+ return `<section class="usage-card"><h3><span>${title}</span></h3><ul class="usage-list">${items}</ul></section>`;
6159
+ }
6160
+
6161
+ // ── Limits (great_cto-egkw stage 2) ────────────────────────────────────────
6162
+ const USAGE_LIMIT_KIND = { session: '5-hour session', weekly: 'weekly', spend: 'monthly spend', model: 'per-model', credits: 'usage credits', other: 'other' };
6163
+ const USAGE_LIMIT_COLOR = { session: 'var(--status-blocked)', weekly: 'var(--status-progress)', spend: 'var(--status-gate)', model: 'var(--text2)', credits: 'var(--text2)', other: 'var(--text3)' };
6164
+ const fmtWhen = (ms) => (ms ? fmtDT(new Date(ms).toISOString()) : '—');
6165
+ function fmtHours(ms) {
6166
+ const h = ms / 3600000;
6167
+ return h >= 48 ? `${Math.round(h / 24)} days` : `${Math.max(1, Math.round(h))} h`;
6168
+ }
6169
+
6170
+ /** A line over the period: points [ms, value]; marks [{t, color, title}] drawn as ticks. */
6171
+ function usageLineSvg({ from, to, points, max, marks = [], ceiling = null, label }) {
6172
+ const W = 1000; const H = 100;
6173
+ const x = (t) => (((t - from) / Math.max(1, to - from)) * W).toFixed(1);
6174
+ const y = (v) => (H - (Math.min(v, max) / max) * (H - 4)).toFixed(1);
6175
+ const path = points.map((p, i) => `${i ? 'L' : 'M'}${x(p[0])},${y(p[1])}`).join('');
6176
+ const ticks = marks.map((m) => `<g><title>${esc(m.title)}</title><line x1="${x(m.t)}" x2="${x(m.t)}" y1="0" y2="${H}" stroke="${m.color}" stroke-width="2" vector-effect="non-scaling-stroke" opacity="0.8"></line></g>`).join('');
6177
+ const ceil = ceiling != null ? `<line class="ceil" x1="0" x2="${W}" y1="${y(ceiling)}" y2="${y(ceiling)}"></line>` : '';
6178
+ return `<svg viewBox="0 0 ${W} ${H}" preserveAspectRatio="none" role="img" aria-label="${esc(label)}">
6179
+ <line class="grid" x1="0" x2="${W}" y1="${H}" y2="${H}"></line>${ceil}${ticks}<path class="ln" d="${path}"></path></svg>`;
6180
+ }
6181
+
6182
+ function usageClaudeLimits(d) {
6183
+ const head = `<h3><span>Claude Code limits</span><span class="muted">from refusals — no plan percentage is recorded</span></h3>`;
6184
+ if (!d.seen || !d.seen.claude) return `<section class="usage-card" data-limits="claude">${head}<p class="usage-note">No Claude Code logs on this machine.</p></section>`;
6185
+ const L = d.limits && d.limits.claude;
6186
+ if (!L) return `<section class="usage-card" data-limits="claude">${head}<p class="usage-note">Not computed.</p></section>`;
6187
+ const hits = (d.hosts.claude && d.hosts.claude.limitHits) || {};
6188
+ const stat = (v, label) => `<div class="usage-stat"><b>${v}</b><span>${label}</span></div>`;
6189
+ const ceilLine = (c, span) => c
6190
+ ? `Refused at about <b>${esc(fmtUsdEq(c.median))}</b> in ${span} <span class="muted">(median of ${c.n} refused hour${c.n === 1 ? '' : 's'}, ${esc(fmtUsdEq(c.min))}–${esc(fmtUsdEq(c.max))})</span>`
6191
+ : null;
6192
+ const ceilings = [ceilLine(L.ceilings.session, '5 hours'), ceilLine(L.ceilings.weekly, '7 days')].filter(Boolean);
6193
+ const kinds = Object.keys(USAGE_LIMIT_KIND).filter((k) => hits[k]).map((k) => `<span><b>${hits[k]}</b>${USAGE_LIMIT_KIND[k]}</span>`).join('');
6194
+ const s = L.series || { usd5h: [] };
6195
+ const to = s.from + (s.usd5h.length - 1) * s.stepMs;
6196
+ const pts = s.usd5h.map((v, i) => [s.from + i * s.stepMs, v]);
6197
+ const max = Math.max(1, ...s.usd5h, L.ceilings.session ? L.ceilings.session.max : 0);
6198
+ const marks = (L.hits || []).filter((h) => h.kind !== 'server').map((h) => ({ t: h.t, color: USAGE_LIMIT_COLOR[h.kind] || 'var(--text3)', title: `${fmtWhen(h.t)} — refused by the ${USAGE_LIMIT_KIND[h.kind] || h.kind} limit ×${h.n}` }));
6199
+ const chart = pts.length > 1 ? `<div class="usage-line">${usageLineSvg({ from: s.from, to, points: pts, max, marks, ceiling: L.ceilings.session ? L.ceilings.session.median : null, label: 'Claude Code spend in the trailing 5 hours, with refusals marked' })}</div>
6200
+ <div class="usage-axis"><span>${esc(d.window.from)}</span><span>$ in the trailing 5 hours · peak ${esc(fmtUsdEq(max))}</span><span>${esc(d.window.to)}</span></div>` : '';
6201
+ const last = L.lastHit ? `<p class="usage-quote" title="${esc(fmtDate(L.lastHit.at))}">${esc(L.lastHit.text)} <span class="muted">— ${esc(relTime(L.lastHit.at))} ago</span></p>` : '';
6202
+ return `<section class="usage-card" data-limits="claude">${head}
6203
+ <div class="usage-stats">${stat(esc(fmtUsdEq(L.fiveHour.usd)), 'last 5 hours, $ list')}${stat(esc(fmtUsdEq(L.sevenDay.usd)), 'last 7 days, $ list')}${stat(esc(String(hits.plan || 0)), `refusals in ${d.window.days} days`)}</div>
6204
+ ${kinds ? `<div class="usage-kinds">${kinds}</div>` : `<p class="usage-note">No request was refused by a limit in this period.</p>`}
6205
+ ${ceilings.length ? `<p class="usage-note">${ceilings.join('<br>')}. <span class="muted">An estimate from what had been spent when Claude Code refused — not a published limit.</span></p>` : ''}
6206
+ ${chart}
6207
+ ${hits.server ? `<p class="usage-sub">${hits.server} server-side throttles (not your limit) are not marked.</p>` : ''}
6208
+ ${last}
6209
+ </section>`;
6210
+ }
6211
+
6212
+ function usageCodexLane(name, lane, d) {
6213
+ const title = usageLimitWindow(lane.minutes);
6214
+ const P = lane.projection;
6215
+ const lastRead = lane.series.length ? lane.series[lane.series.length - 1][0] : null;
6216
+ let pace = `<p class="usage-pace muted">No open window${lastRead ? ` — the last reading of it is from ${esc(fmtWhen(lastRead))}; the plan may no longer have it` : ''}.</p>`;
6217
+ if (P && P.state === 'full') pace = `<p class="usage-pace bad">Full — resets ${esc(fmtWhen(P.resets))}.</p>`;
6218
+ else if (P && P.state === 'will-fill') pace = `<p class="usage-pace bad">${P.used}% used. At this pace it is full by ${esc(fmtWhen(P.fullBy))} — ${esc(fmtHours(P.resets - P.fullBy))} before the reset.</p>`;
6219
+ else if (P && P.state === 'fits') pace = `<p class="usage-pace">${P.used}% used. At this pace about ${Math.round(P.atReset)}% by the reset, ${esc(fmtWhen(P.resets))}.</p>`;
6220
+ const from = Date.parse(`${d.window.from}T00:00:00`);
6221
+ const to = Date.now();
6222
+ const pts = [];
6223
+ for (const [t, v] of lane.series) { if (pts.length) pts.push([t, pts[pts.length - 1][1]]); pts.push([t, v]); }
6224
+ const marks = lane.windows.filter((w) => w.fullAt && w.fullAt >= from).map((w) => ({ t: w.fullAt, color: 'var(--status-blocked)', title: `filled ${fmtWhen(w.fullAt)}, ${fmtHours(w.resets - w.fullAt)} before its reset` }));
6225
+ const chart = pts.length > 1 ? `<div class="usage-line">${usageLineSvg({ from, to, points: pts, max: 100, marks, label: `Codex ${title} percent used over the period` })}</div>
6226
+ <div class="usage-axis"><span>${esc(d.window.from)}</span><span>% used · red = filled</span><span>${esc(d.window.to)}</span></div>` : '';
6227
+ const touched = lane.windows.filter((w) => w.peak > 0);
6228
+ const early = lane.windows.filter((w) => w.fullAt).map((w) => w.resets - w.fullAt);
6229
+ const summary = touched.length
6230
+ ? `${lane.filled} of ${touched.length} used windows filled${early.length ? `, on median ${esc(fmtHours(early.sort((a, b) => a - b)[Math.floor(early.length / 2)]))} before the reset` : ''}.`
6231
+ : 'No window was used in this period.';
6232
+ return `<div class="usage-limit" data-lane="${esc(name)}"><div class="usage-limit-head"><span>${esc(title)}</span></div>${pace}${chart}<p class="usage-sub">${summary}</p></div>`;
6233
+ }
6234
+
6235
+ function usageCodexLimits(d) {
6236
+ const head = `<h3><span>Codex limits</span><span class="muted">as Codex reported them</span></h3>`;
6237
+ if (!d.seen || !d.seen.codex) return `<section class="usage-card" data-limits="codex">${head}<p class="usage-note">No Codex logs on this machine.</p></section>`;
6238
+ const D = d.limits && d.limits.codex && d.limits.codex.detail;
6239
+ if (!D) return `<section class="usage-card" data-limits="codex">${head}<p class="usage-note">No Codex session reported its plan window in this period.</p></section>`;
6240
+ const order = ['fiveHour', 'weekly', ...Object.keys(D).filter((k) => k !== 'fiveHour' && k !== 'weekly')];
6241
+ const lanes = order.filter((k) => D[k]).map((k) => usageCodexLane(k, D[k], d)).join('');
6242
+ return `<section class="usage-card" data-limits="codex">${head}${lanes}<p class="usage-sub">A window Codex reset early shows as its own window.</p></section>`;
6243
+ }
6244
+
6245
+ function renderUsage(d) {
6246
+ const body = document.getElementById('usage-body');
6247
+ if (!body) return;
6248
+ body.className = '';
6249
+ const l = (d.lists && d.lists[usageHost]) || {};
6250
+ const hostBtn = (h) => `<button type="button" class="usage-chip${usageHost === h ? ' active' : ''}" aria-pressed="${usageHost === h}" onclick="setUsageHost('${h}')">${USAGE_HOSTS[h]}</button>`;
6251
+ body.innerHTML = `
6252
+ <div class="usage-grid">${usageHostCard(d, 'claude')}${usageHostCard(d, 'codex')}</div>
6253
+ <h2 class="mp-section-label">Limits</h2>
6254
+ <div class="usage-grid">${usageClaudeLimits(d)}${usageCodexLimits(d)}</div>
6255
+ <h2 class="mp-section-label">By day, by kind of work</h2>
6256
+ <div class="usage-grid">${usageDayChart(d, 'claude')}${usageDayChart(d, 'codex')}</div>
6257
+ <h2 class="mp-section-label">Heaviest conversations</h2>
6258
+ <div class="usage-grid">${usageTopTable(d, 'claude')}${usageTopTable(d, 'codex')}</div>
6259
+ <h2 class="mp-section-label">Models</h2>
6260
+ ${usageModels(d)}
6261
+ <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>
6262
+ <div class="usage-lists">
6263
+ ${usageList('Tools', l.tools, 'No tool calls in this window.')}
6264
+ ${usageList('Skills', l.skills, 'No skills were loaded in this window.')}
6265
+ ${usageList('Agents', l.agents, 'No subagents were dispatched in this window.')}
6266
+ ${usageList('MCP servers', l.mcp, 'No MCP tools were called in this window.')}
6267
+ </div>
6268
+ <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>`;
6269
+ }
6270
+
5880
6271
  async function renderHarnesses() {
5881
6272
  const body = document.getElementById('harnesses-body');
5882
6273
  const status = document.getElementById('harnesses-status');
@@ -8340,6 +8731,7 @@ function cmdkActions() {
8340
8731
  { group: 'Go', label: 'Ledger — what it cost, what is running', run: () => switchTab('ledger') },
8341
8732
  { group: 'Go', label: 'Fleet — the agents and what they may do', run: () => switchTab('fleet') },
8342
8733
  { group: 'Go', label: 'Harness — host and second opinion', run: () => switchTab('harness') },
8734
+ { group: 'Go', label: 'Usage — what Claude Code and Codex consumed', run: () => switchTab('usage') },
8343
8735
  { group: 'Go', label: 'Settings', run: () => switchTab('settings') },
8344
8736
  { group: 'Go', label: 'Tasks board (#/kanban, deep link)', run: () => switchTab('kanban') },
8345
8737
  { group: 'Do', label: 'New issue', run: () => openNewIssueModal() },
@@ -8532,7 +8924,7 @@ try {
8532
8924
  // panels, not keys.
8533
8925
  const DEST = { decisions: 'inbox', ledger: 'budgets', fleet: 'agents', dashboard: 'budgets', share: 'settings', notifications: 'settings' };
8534
8926
  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 };
8927
+ const ROUTES = { work: 1, history: 1, decisions: 1, ledger: 1, fleet: 1, harness: 1, usage: 1, settings: 1, kanban: 1 };
8536
8928
  function switchTab(id, el) {
8537
8929
  const lid = DEST[id] || id; // the panel's own name
8538
8930
  const navKey = NAV[id] || id; // the destination to highlight
@@ -8541,7 +8933,7 @@ function switchTab(id, el) {
8541
8933
  currentTab = lid;
8542
8934
  document.getElementById('nav-views-label').hidden = lid !== 'agents';
8543
8935
  document.querySelector('.nav-views').hidden = lid !== 'agents';
8544
- if (['agents', 'budgets', 'harness'].includes(lid)) document.getElementById('tools-nav').open = true;
8936
+ if (['agents', 'budgets', 'harness', 'usage'].includes(lid)) document.getElementById('tools-nav').open = true;
8545
8937
  if (lid === 'work' || lid === 'history') window.GctoWork.refresh();
8546
8938
  if (ROUTES[id]) {
8547
8939
  if (location.hash !== `#/${id}`) history.replaceState(null, '', `#/${id}`);
@@ -8562,7 +8954,7 @@ function switchTab(id, el) {
8562
8954
  // crumb — `labels` used to be referenced here but is only ever a function-local in
8563
8955
  // this file, so this threw ReferenceError on every tab switch and skipped the per-tab
8564
8956
  // 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' };
8957
+ 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
8958
  const crumb = document.getElementById('crumb-here'); if (crumb) crumb.textContent = TAB_LABELS[id] || TAB_LABELS[lid] || 'Board';
8567
8959
  // Budgets render on Metrics, so the heartbeat that feeds them has to be
8568
8960
  // fetched when Metrics opens — not only from the agents drill-in it used to
@@ -8595,6 +8987,7 @@ function switchTab(id, el) {
8595
8987
  if (lid === 'notifications' || lid === 'settings') loadNotifications();
8596
8988
  if (lid === 'settings') loadSettingsPage();
8597
8989
  if (lid === 'harness') renderHarnesses();
8990
+ if (lid === 'usage') loadUsage();
8598
8991
  }
8599
8992
 
8600
8993
  // Sidebar tabs: Enter/Space activate, arrows move (WAI-ARIA tablist).