great-cto 3.53.0 → 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.
|
|
4
|
+
"version": "3.54.0",
|
|
5
5
|
"author": {
|
|
6
6
|
"name": "Alexander Velikiy",
|
|
7
7
|
"url": "https://hashnode.com/@Greatcto"
|
|
@@ -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
|
|
@@ -2404,7 +2409,9 @@ h1, h2, h3, h4 { font-weight: 600; font-family: var(--sans); }
|
|
|
2404
2409
|
.usage-sub { font-size: var(--fs-caption); color: var(--text3); line-height: 1.5; }
|
|
2405
2410
|
.usage-note { font-size: var(--fs-small); color: var(--text2); line-height: 1.55; }
|
|
2406
2411
|
.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; }
|
|
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; }
|
|
2408
2415
|
.usage-legend { display: flex; flex-wrap: wrap; gap: 4px 14px; font-size: var(--fs-caption); color: var(--text2); margin-top: 8px; }
|
|
2409
2416
|
.usage-legend i { display: inline-block; width: 10px; height: 10px; margin-right: 5px; vertical-align: -1px; }
|
|
2410
2417
|
.usage-table-wrap { overflow-x: auto; }
|
|
@@ -2425,6 +2432,15 @@ h1, h2, h3, h4 { font-weight: 600; font-family: var(--sans); }
|
|
|
2425
2432
|
.usage-list li .n { font-family: var(--mono); font-variant-numeric: tabular-nums; color: var(--text2); }
|
|
2426
2433
|
.usage-list li .bar { grid-column: 1 / -1; height: 3px; background: var(--bg-muted); }
|
|
2427
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); }
|
|
2428
2444
|
@media (max-width: 767px) { .usage-title { max-width: 160px; } .usage-chip { min-height: 44px; } }
|
|
2429
2445
|
.mp-section-label {
|
|
2430
2446
|
font-family: var(--mono); font-size: var(--fs-caption);
|
|
@@ -6017,6 +6033,7 @@ function setUsageHost(host) {
|
|
|
6017
6033
|
function usageLimitWindow(minutes) {
|
|
6018
6034
|
if (minutes === 10080) return 'Weekly limit';
|
|
6019
6035
|
if (minutes === 300) return '5-hour limit';
|
|
6036
|
+
if (minutes === 43200) return 'Monthly limit';
|
|
6020
6037
|
return minutes ? `${minutes}-minute limit` : 'Limit';
|
|
6021
6038
|
}
|
|
6022
6039
|
|
|
@@ -6141,6 +6158,90 @@ function usageList(title, rows, empty) {
|
|
|
6141
6158
|
return `<section class="usage-card"><h3><span>${title}</span></h3><ul class="usage-list">${items}</ul></section>`;
|
|
6142
6159
|
}
|
|
6143
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
|
+
|
|
6144
6245
|
function renderUsage(d) {
|
|
6145
6246
|
const body = document.getElementById('usage-body');
|
|
6146
6247
|
if (!body) return;
|
|
@@ -6149,6 +6250,8 @@ function renderUsage(d) {
|
|
|
6149
6250
|
const hostBtn = (h) => `<button type="button" class="usage-chip${usageHost === h ? ' active' : ''}" aria-pressed="${usageHost === h}" onclick="setUsageHost('${h}')">${USAGE_HOSTS[h]}</button>`;
|
|
6150
6251
|
body.innerHTML = `
|
|
6151
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>
|
|
6152
6255
|
<h2 class="mp-section-label">By day, by kind of work</h2>
|
|
6153
6256
|
<div class="usage-grid">${usageDayChart(d, 'claude')}${usageDayChart(d, 'codex')}</div>
|
|
6154
6257
|
<h2 class="mp-section-label">Heaviest conversations</h2>
|
|
@@ -40,19 +40,29 @@ import path from 'node:path';
|
|
|
40
40
|
import { createHash } from 'node:crypto';
|
|
41
41
|
import { priceUsage, effectivePrices } from './cost-meter.mjs';
|
|
42
42
|
|
|
43
|
-
const INDEX_VERSION =
|
|
43
|
+
const INDEX_VERSION = 2; // 2: hourly buckets, limit kinds, Codex limit series
|
|
44
44
|
const RING = 256; // recent response ids remembered across reads
|
|
45
45
|
const KEEP_GONE_DAYS = 400; // how long a vanished file's days are kept
|
|
46
|
+
const SERIES_MAX = 4000; // Codex limit readings kept per file
|
|
46
47
|
const CHUNK = 1 << 20;
|
|
47
48
|
|
|
48
49
|
const home = () => os.homedir();
|
|
49
50
|
export const DEFAULT_CLAUDE_DIR = () => path.join(home(), '.claude', 'projects');
|
|
50
51
|
export const DEFAULT_CODEX_DIRS = () => [path.join(home(), '.codex', 'sessions'), path.join(home(), '.codex', 'archived_sessions')];
|
|
51
52
|
export const DEFAULT_CODEX_TITLES = () => path.join(home(), '.codex', 'session_index.jsonl');
|
|
52
|
-
|
|
53
|
+
// The version is in the name: a board started on an older release keeps running
|
|
54
|
+
// until restarted, and two versions sharing one file would rebuild it from
|
|
55
|
+
// scratch on every pass, each throwing the other's away.
|
|
56
|
+
export const DEFAULT_CACHE_FILE = () => path.join(home(), '.great_cto', `session-usage-index.v${INDEX_VERSION}.json`);
|
|
53
57
|
|
|
54
58
|
const short = (s) => createHash('sha1').update(String(s)).digest('hex').slice(0, 16);
|
|
55
59
|
|
|
60
|
+
/** Local hour (00–23) of an ISO timestamp. */
|
|
61
|
+
export function hourOf(ts) {
|
|
62
|
+
const d = new Date(ts);
|
|
63
|
+
return Number.isNaN(d.getTime()) ? null : String(d.getHours()).padStart(2, '0');
|
|
64
|
+
}
|
|
65
|
+
|
|
56
66
|
/** Local calendar day of an ISO timestamp — the operator's day, not UTC's. */
|
|
57
67
|
export function dayOf(ts) {
|
|
58
68
|
const d = new Date(ts);
|
|
@@ -107,7 +117,26 @@ function isDir(p) { try { return fs.statSync(p).isDirectory(); } catch { return
|
|
|
107
117
|
// ── shared accumulation ───────────────────────────────────────────────────────
|
|
108
118
|
|
|
109
119
|
function emptyDay() {
|
|
110
|
-
return { in: 0, out: 0, cr: 0, cw: 0, cwh: 0, rs: 0, msgs: 0, models: {}, tools: {}, skills: {}, agents: {}, mcp: {}, lim: {
|
|
120
|
+
return { in: 0, out: 0, cr: 0, cw: 0, cwh: 0, rs: 0, msgs: 0, models: {}, tools: {}, skills: {}, agents: {}, mcp: {}, lim: {}, h: {} };
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/** The hour inside a day: tokens per model as [in, out, cacheRead, cacheWrite5m, cacheWrite1h], and limit refusals. */
|
|
124
|
+
function hourBucket(b, ts) {
|
|
125
|
+
const hh = hourOf(ts);
|
|
126
|
+
if (!hh) return null;
|
|
127
|
+
return b.h[hh] || (b.h[hh] = { m: {}, lim: {} });
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
function addUsage(entry, ts, model, t) {
|
|
131
|
+
const b = dayBucket(entry, ts);
|
|
132
|
+
if (!b) return null;
|
|
133
|
+
addTokens(b, model, t);
|
|
134
|
+
const hb = hourBucket(b, ts);
|
|
135
|
+
if (hb) {
|
|
136
|
+
const v = hb.m[model] || (hb.m[model] = [0, 0, 0, 0, 0]);
|
|
137
|
+
v[0] += t.in; v[1] += t.out; v[2] += t.cr; v[3] += t.cw; v[4] += t.cwh;
|
|
138
|
+
}
|
|
139
|
+
return b;
|
|
111
140
|
}
|
|
112
141
|
|
|
113
142
|
function dayBucket(entry, ts) {
|
|
@@ -159,7 +188,22 @@ function claudeFileMeta(file) {
|
|
|
159
188
|
return { session: base, parent: null, fn: null, agentType: null };
|
|
160
189
|
}
|
|
161
190
|
|
|
162
|
-
|
|
191
|
+
/**
|
|
192
|
+
* Which limit refused a request, from the words Claude Code shows. Seen on this
|
|
193
|
+
* machine: "session limit" (the 5-hour window), "weekly limit", "monthly spend
|
|
194
|
+
* limit", "reached your <model> limit", "out of usage credits", and the server's
|
|
195
|
+
* own throttle, which says outright that it is "not your usage limit".
|
|
196
|
+
*/
|
|
197
|
+
export function limitKind(text) {
|
|
198
|
+
const t = String(text || '');
|
|
199
|
+
if (/not your usage limit/i.test(t)) return 'server';
|
|
200
|
+
if (/monthly spend limit/i.test(t)) return 'spend';
|
|
201
|
+
if (/out of usage credits/i.test(t)) return 'credits';
|
|
202
|
+
if (/session limit/i.test(t)) return 'session';
|
|
203
|
+
if (/weekly limit/i.test(t)) return 'weekly';
|
|
204
|
+
if (/reached your .{1,40} limit/i.test(t)) return 'model';
|
|
205
|
+
return 'other';
|
|
206
|
+
}
|
|
163
207
|
|
|
164
208
|
/** `great-cto:senior-dev` and `senior-dev` are one agent; `feature-dev:code-reviewer` is not ours. */
|
|
165
209
|
export function agentName(raw) {
|
|
@@ -201,7 +245,12 @@ export function claudeLine(entry, line) {
|
|
|
201
245
|
if (r.isApiErrorMessage && r.error === 'rate_limit') {
|
|
202
246
|
const c = r.message?.content;
|
|
203
247
|
const text = Array.isArray(c) ? c.map((x) => x?.text || '').join(' ') : String(c || '');
|
|
204
|
-
|
|
248
|
+
const kind = limitKind(text);
|
|
249
|
+
bump(b.lim, kind);
|
|
250
|
+
const hb = hourBucket(b, ts);
|
|
251
|
+
if (hb) bump(hb.lim, kind);
|
|
252
|
+
// The words carry the reset time ("resets Sep 12 at 3pm"); the latest is kept to show.
|
|
253
|
+
if (!meta.lastHit || ts > meta.lastHit.at) meta.lastHit = { at: ts, kind, text: text.replace(/\s+/g, ' ').trim().slice(0, 200) };
|
|
205
254
|
return;
|
|
206
255
|
}
|
|
207
256
|
|
|
@@ -228,7 +277,7 @@ export function claudeLine(entry, line) {
|
|
|
228
277
|
cwh,
|
|
229
278
|
rs: u.output_tokens_details?.thinking_tokens || 0,
|
|
230
279
|
};
|
|
231
|
-
|
|
280
|
+
addUsage(entry, ts, u.speed === 'fast' ? `${model}@fast` : model, t);
|
|
232
281
|
// Older Claude Code wrote subagent turns into the parent's own file.
|
|
233
282
|
if (r.isSidechain && meta.fn !== 'subagent') b.side = (b.side || 0) + t.in + t.out + t.cr + t.cw + t.cwh;
|
|
234
283
|
}
|
|
@@ -292,10 +341,8 @@ export function codexLine(entry, line) {
|
|
|
292
341
|
if (id && st.ids.includes(id)) return;
|
|
293
342
|
if (id) remember(st.ids, id);
|
|
294
343
|
st.records = true;
|
|
295
|
-
|
|
296
|
-
if (!b) return;
|
|
344
|
+
if (!addUsage(entry, ts, st.model || 'unknown', codexUsage(p.usage))) return;
|
|
297
345
|
touchSpan(meta, ts);
|
|
298
|
-
addTokens(b, st.model || 'unknown', codexUsage(p.usage));
|
|
299
346
|
return;
|
|
300
347
|
}
|
|
301
348
|
|
|
@@ -308,6 +355,17 @@ export function codexLine(entry, line) {
|
|
|
308
355
|
secondary: rl.secondary ? { used: rl.secondary.used_percent, minutes: rl.secondary.window_minutes, resets: rl.secondary.resets_at } : null,
|
|
309
356
|
credits: rl.credits ? { has: !!rl.credits.has_credits, unlimited: !!rl.credits.unlimited, balance: rl.credits.balance ?? null } : null,
|
|
310
357
|
};
|
|
358
|
+
// The window's history: one point per change, not per event — Codex
|
|
359
|
+
// repeats the same reading after every response.
|
|
360
|
+
const L = meta.limits;
|
|
361
|
+
const pt = [Date.parse(ts), L.primary?.used ?? null, L.primary?.minutes ?? null, L.primary?.resets ?? null,
|
|
362
|
+
L.secondary?.used ?? null, L.secondary?.minutes ?? null, L.secondary?.resets ?? null, L.reached ? 1 : 0];
|
|
363
|
+
const series = meta.series || (meta.series = []);
|
|
364
|
+
const last = series[series.length - 1];
|
|
365
|
+
if (Number.isFinite(pt[0]) && (!last || [1, 3, 4, 6, 7].some((i) => last[i] !== pt[i]))) {
|
|
366
|
+
series.push(pt);
|
|
367
|
+
if (series.length > SERIES_MAX) series.splice(0, series.length - SERIES_MAX);
|
|
368
|
+
}
|
|
311
369
|
}
|
|
312
370
|
// Older sessions have no token_usage_record. Their usage is the growth of
|
|
313
371
|
// the cumulative total — token_count is emitted more than once per response,
|
|
@@ -324,10 +382,8 @@ export function codexLine(entry, line) {
|
|
|
324
382
|
// A thread forked from another opens with a total and no breakdown — the
|
|
325
383
|
// context it inherited, not a response. It is the base, never usage.
|
|
326
384
|
if (!delta.input_tokens && !delta.output_tokens && !delta.cached_input_tokens) return;
|
|
327
|
-
|
|
328
|
-
if (!b) return;
|
|
385
|
+
if (!addUsage(entry, ts, st.model || 'unknown', codexUsage(delta))) return;
|
|
329
386
|
touchSpan(meta, ts);
|
|
330
|
-
addTokens(b, st.model || 'unknown', codexUsage(delta));
|
|
331
387
|
}
|
|
332
388
|
}
|
|
333
389
|
}
|
|
@@ -471,6 +527,131 @@ export function mcpLabel(server) {
|
|
|
471
527
|
return UUID.test(server) ? `claude.ai connector ${server.slice(0, 8)}` : server;
|
|
472
528
|
}
|
|
473
529
|
|
|
530
|
+
const HOUR = 3600000;
|
|
531
|
+
|
|
532
|
+
/** Start of the local hour for a local day + hour key. */
|
|
533
|
+
function hourStart(day, hh) {
|
|
534
|
+
const [y, m, d] = day.split('-').map(Number);
|
|
535
|
+
return new Date(y, m - 1, d, Number(hh)).getTime();
|
|
536
|
+
}
|
|
537
|
+
|
|
538
|
+
function median(xs) {
|
|
539
|
+
if (!xs.length) return null;
|
|
540
|
+
const s = [...xs].sort((a, b) => a - b);
|
|
541
|
+
const mid = Math.floor(s.length / 2);
|
|
542
|
+
return s.length % 2 ? s[mid] : (s[mid - 1] + s[mid]) / 2;
|
|
543
|
+
}
|
|
544
|
+
|
|
545
|
+
/**
|
|
546
|
+
* Claude Code's limits as far as its logs can say. The logs carry no plan
|
|
547
|
+
* percentage — only what was spent and when a request was refused, and by which
|
|
548
|
+
* limit. So this reports spend in the last 5 hours and 7 days, and, for every
|
|
549
|
+
* refusal, what had been spent in that window when it came: the ceiling as
|
|
550
|
+
* observed, an estimate with its count, never a stated limit.
|
|
551
|
+
*/
|
|
552
|
+
function claudeLimits(hourly, { fromMs, now, lastHit }) {
|
|
553
|
+
const nowH = Math.floor(now / HOUR) * HOUR;
|
|
554
|
+
const startH = Math.floor(fromMs / HOUR) * HOUR;
|
|
555
|
+
const at = (t) => hourly.get(t) || { usd: 0, tokens: 0, lim: {} };
|
|
556
|
+
const roll = (t, n) => {
|
|
557
|
+
let usd = 0; let tokens = 0;
|
|
558
|
+
for (let i = 0; i < n; i++) { const x = at(t - i * HOUR); usd += x.usd; tokens += x.tokens; }
|
|
559
|
+
return { usd, tokens };
|
|
560
|
+
};
|
|
561
|
+
const series = [];
|
|
562
|
+
const hits = [];
|
|
563
|
+
const ceilings = { session: [], weekly: [] };
|
|
564
|
+
for (let t = startH; t <= nowH; t += HOUR) {
|
|
565
|
+
series.push(Number(roll(t, 5).usd.toFixed(4)));
|
|
566
|
+
const lim = at(t).lim;
|
|
567
|
+
for (const [kind, n] of Object.entries(lim)) {
|
|
568
|
+
hits.push({ t, kind, n });
|
|
569
|
+
// One reading per refused hour: ten retries against the same wall are one wall.
|
|
570
|
+
if (kind === 'session') ceilings.session.push(roll(t, 5).usd);
|
|
571
|
+
if (kind === 'weekly') ceilings.weekly.push(roll(t, 168).usd);
|
|
572
|
+
}
|
|
573
|
+
}
|
|
574
|
+
const est = (xs) => (xs.length ? { median: median(xs), min: Math.min(...xs), max: Math.max(...xs), n: xs.length } : null);
|
|
575
|
+
return {
|
|
576
|
+
fiveHour: roll(nowH, 5),
|
|
577
|
+
sevenDay: roll(nowH, 168),
|
|
578
|
+
series: { from: startH, stepMs: HOUR, usd5h: series },
|
|
579
|
+
hits,
|
|
580
|
+
ceilings: { session: est(ceilings.session), weekly: est(ceilings.weekly) },
|
|
581
|
+
lastHit: lastHit || null,
|
|
582
|
+
};
|
|
583
|
+
}
|
|
584
|
+
|
|
585
|
+
/** A Codex window by its length — the field that carries it moved from `secondary` to `primary` between plans. */
|
|
586
|
+
export function laneName(minutes) {
|
|
587
|
+
if (minutes === 10080) return 'weekly';
|
|
588
|
+
if (minutes === 300) return 'fiveHour';
|
|
589
|
+
return `${minutes}m`;
|
|
590
|
+
}
|
|
591
|
+
|
|
592
|
+
/**
|
|
593
|
+
* Codex's plan windows from its own readings: every window the period touched
|
|
594
|
+
* with its peak and when it filled, the readings to draw, and — for the window
|
|
595
|
+
* still open — where the current pace ends up by the reset.
|
|
596
|
+
*
|
|
597
|
+
* Windows are keyed by their length, not by `primary`/`secondary`: on one plan
|
|
598
|
+
* the 5-hour window was primary and the week secondary, on the next the week was
|
|
599
|
+
* primary. And `resets_at` drifts by seconds between readings of one window, so
|
|
600
|
+
* it is grouped to ten minutes — unrounded, one week read as forty windows.
|
|
601
|
+
*/
|
|
602
|
+
function codexLimitDetail(points, { fromMs, now }) {
|
|
603
|
+
if (!points.length) return null;
|
|
604
|
+
const pts = [...points].sort((a, b) => a[0] - b[0]);
|
|
605
|
+
const lanes = {};
|
|
606
|
+
for (const p of pts) {
|
|
607
|
+
for (const [iu, im, ir] of [[1, 2, 3], [4, 5, 6]]) {
|
|
608
|
+
const used = p[iu]; const minutes = p[im]; const resets = p[ir];
|
|
609
|
+
if (used == null || !minutes || !resets) continue;
|
|
610
|
+
const resetMs = resets * 1000;
|
|
611
|
+
if (resetMs < fromMs) continue;
|
|
612
|
+
const lane = lanes[laneName(minutes)] || (lanes[laneName(minutes)] = { minutes, windows: new Map(), series: [] });
|
|
613
|
+
if (p[0] >= fromMs) {
|
|
614
|
+
const prev = lane.series[lane.series.length - 1];
|
|
615
|
+
if (!prev || prev[1] !== used) lane.series.push([p[0], used]);
|
|
616
|
+
}
|
|
617
|
+
const key = Math.round(resets / 600);
|
|
618
|
+
const w = lane.windows.get(key) || { start: resetMs - minutes * 60000, resets: resetMs, peak: 0, fullAt: null, last: null };
|
|
619
|
+
w.resets = Math.max(w.resets, resetMs);
|
|
620
|
+
w.peak = Math.max(w.peak, used);
|
|
621
|
+
if (used >= 100 && !w.fullAt) w.fullAt = p[0];
|
|
622
|
+
if (!w.last || p[0] >= w.last[0]) w.last = [p[0], used];
|
|
623
|
+
lane.windows.set(key, w);
|
|
624
|
+
}
|
|
625
|
+
}
|
|
626
|
+
const out = {};
|
|
627
|
+
for (const [name, lane] of Object.entries(lanes)) {
|
|
628
|
+
const list = [...lane.windows.values()].sort((a, b) => a.start - b.start);
|
|
629
|
+
// The open window is the one the LATEST reading belongs to. Codex has reset a
|
|
630
|
+
// week early more than once, so an older window can still look "open" on paper.
|
|
631
|
+
const latest = list.reduce((a, w) => (!a || (w.last && w.last[0] > a.last[0]) ? w : a), null);
|
|
632
|
+
const cur = latest && latest.resets > now ? latest : null;
|
|
633
|
+
let projection = null;
|
|
634
|
+
if (cur && cur.last) {
|
|
635
|
+
const [t, used] = cur.last;
|
|
636
|
+
if (used >= 100) projection = { state: 'full', resets: cur.resets, readAt: t };
|
|
637
|
+
else if (used > 0 && t > cur.start) {
|
|
638
|
+
const fullBy = cur.start + ((t - cur.start) * 100) / used;
|
|
639
|
+
projection = fullBy < cur.resets
|
|
640
|
+
? { state: 'will-fill', fullBy, resets: cur.resets, readAt: t, used }
|
|
641
|
+
: { state: 'fits', atReset: (used * (cur.resets - cur.start)) / (t - cur.start), resets: cur.resets, readAt: t, used };
|
|
642
|
+
}
|
|
643
|
+
}
|
|
644
|
+
out[name] = {
|
|
645
|
+
minutes: lane.minutes,
|
|
646
|
+
windows: list.map((w) => ({ start: w.start, resets: w.resets, peak: w.peak, fullAt: w.fullAt })),
|
|
647
|
+
filled: list.filter((w) => w.fullAt).length,
|
|
648
|
+
series: lane.series,
|
|
649
|
+
projection,
|
|
650
|
+
};
|
|
651
|
+
}
|
|
652
|
+
return Object.keys(out).length ? out : null;
|
|
653
|
+
}
|
|
654
|
+
|
|
474
655
|
/**
|
|
475
656
|
* The window the board shows.
|
|
476
657
|
* @param {object} index from scanUsage
|
|
@@ -483,7 +664,7 @@ export function summarizeUsage(index, { days = 30, now = Date.now(), codexTitles
|
|
|
483
664
|
const inWindow = new Set(dates);
|
|
484
665
|
const from = dates[0];
|
|
485
666
|
|
|
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: {
|
|
667
|
+
const blank = () => ({ in: 0, out: 0, cr: 0, cw: 0, cwh: 0, rs: 0, msgs: 0, usd: 0, unpricedMsgs: 0, sessions: 0, byFn: {}, lim: {} });
|
|
487
668
|
const hosts = { claude: blank(), codex: blank() };
|
|
488
669
|
const daily = Object.fromEntries(dates.map((d) => [d, { claude: { tokens: 0, usd: 0, byFn: {} }, codex: { tokens: 0, usd: 0, byFn: {} } }]));
|
|
489
670
|
const models = {};
|
|
@@ -492,6 +673,12 @@ export function summarizeUsage(index, { days = 30, now = Date.now(), codexTitles
|
|
|
492
673
|
const unpriced = new Set();
|
|
493
674
|
let codexLimits = null;
|
|
494
675
|
let codexPlan = null;
|
|
676
|
+
const fromMs = hourStart(from, '00');
|
|
677
|
+
// Hours from a week before the period: a 7-day window ending on its first day needs them.
|
|
678
|
+
const hourlyFrom = dayOf(fromMs - 7 * 86400000);
|
|
679
|
+
const hourly = { claude: new Map(), codex: new Map() };
|
|
680
|
+
const codexPoints = [];
|
|
681
|
+
let lastHit = null;
|
|
495
682
|
|
|
496
683
|
for (const e of Object.values(index.files || {})) {
|
|
497
684
|
const host = e.host;
|
|
@@ -499,14 +686,29 @@ export function summarizeUsage(index, { days = 30, now = Date.now(), codexTitles
|
|
|
499
686
|
const lim = host === 'codex' ? e.meta?.limits : null;
|
|
500
687
|
if (lim && (!codexLimits || lim.at > codexLimits.at)) codexLimits = lim;
|
|
501
688
|
if (lim?.plan && (!codexPlan || lim.at > codexPlan.at)) codexPlan = { at: lim.at, plan: lim.plan };
|
|
689
|
+
if (host === 'codex' && Array.isArray(e.meta?.series)) for (const p of e.meta.series) codexPoints.push(p);
|
|
690
|
+
if (host === 'claude' && e.meta?.lastHit && (!lastHit || e.meta.lastHit.at > lastHit.at)) lastHit = e.meta.lastHit;
|
|
691
|
+
for (const [day, b] of Object.entries(e.days || {})) {
|
|
692
|
+
if (day < hourlyFrom || !b.h) continue;
|
|
693
|
+
for (const [hh, hb] of Object.entries(b.h)) {
|
|
694
|
+
const t = hourStart(day, hh);
|
|
695
|
+
const slot = hourly[host].get(t) || { usd: 0, tokens: 0, lim: {} };
|
|
696
|
+
for (const [model, v] of Object.entries(hb.m || {})) {
|
|
697
|
+
const { usd } = priceBucket(model, { in: v[0], out: v[1], cr: v[2], cw: v[3], cwh: v[4] }, prices);
|
|
698
|
+
if (usd != null) slot.usd += usd;
|
|
699
|
+
slot.tokens += v[0] + v[1] + v[2] + v[3] + v[4];
|
|
700
|
+
}
|
|
701
|
+
for (const [k, n] of Object.entries(hb.lim || {})) bump(slot.lim, k, n);
|
|
702
|
+
hourly[host].set(t, slot);
|
|
703
|
+
}
|
|
704
|
+
}
|
|
502
705
|
const fn = e.meta?.fn || 'chat';
|
|
503
706
|
let touched = false;
|
|
504
707
|
for (const [day, b] of Object.entries(e.days || {})) {
|
|
505
708
|
if (!inWindow.has(day)) continue;
|
|
506
709
|
touched = true;
|
|
507
710
|
const h = hosts[host];
|
|
508
|
-
|
|
509
|
-
h.lim.server += b.lim?.server || 0;
|
|
711
|
+
for (const [k, n] of Object.entries(b.lim || {})) bump(h.lim, k, n);
|
|
510
712
|
for (const kind of ['tools', 'skills', 'agents', 'mcp']) for (const [k, n] of Object.entries(b[kind] || {})) bump(lists[host][kind], k, n);
|
|
511
713
|
let dayUsd = 0;
|
|
512
714
|
let dayUnpriced = false;
|
|
@@ -568,7 +770,9 @@ export function summarizeUsage(index, { days = 30, now = Date.now(), codexTitles
|
|
|
568
770
|
hosts: Object.fromEntries(Object.entries(hosts).map(([k, h]) => [k, {
|
|
569
771
|
tokens: tokensOf(h), input: h.in, output: h.out, cacheRead: h.cr, cacheWrite: h.cw + h.cwh, reasoning: h.rs,
|
|
570
772
|
responses: h.msgs, sessions: h.sessions, usd: h.unpricedMsgs === h.msgs && h.msgs > 0 ? null : h.usd,
|
|
571
|
-
unpricedResponses: h.unpricedMsgs, byFn: h.byFn,
|
|
773
|
+
unpricedResponses: h.unpricedMsgs, byFn: h.byFn, ...cache(h),
|
|
774
|
+
// `plan` is every refusal by a limit of the account; `server` is the API's own throttle.
|
|
775
|
+
limitHits: { ...h.lim, plan: Object.entries(h.lim).filter(([k]) => k !== 'server').reduce((a, [, n]) => a + n, 0), server: h.lim.server || 0 },
|
|
572
776
|
}])),
|
|
573
777
|
daily: dates.map((d) => ({ date: d, ...daily[d] })),
|
|
574
778
|
models: Object.values(models).sort((a, b) => tokensOf(b) - tokensOf(a)).map((m) => ({
|
|
@@ -578,7 +782,10 @@ export function summarizeUsage(index, { days = 30, now = Date.now(), codexTitles
|
|
|
578
782
|
lists: Object.fromEntries(Object.entries(lists).map(([host, l]) => [host, {
|
|
579
783
|
tools: rank(l.tools, 15), skills: rank(l.skills, 15), agents: rank(l.agents, 15), mcp: rank(l.mcp, 15, mcpLabel),
|
|
580
784
|
}])),
|
|
581
|
-
limits: {
|
|
785
|
+
limits: {
|
|
786
|
+
codex: codexLimits ? { ...codexLimits, plan: codexLimits.plan || codexPlan?.plan || null, detail: codexLimitDetail(codexPoints, { fromMs, now }) } : null,
|
|
787
|
+
claude: claudeLimits(hourly.claude, { fromMs, now, lastHit }),
|
|
788
|
+
},
|
|
582
789
|
unpricedModels: [...unpriced].sort(),
|
|
583
790
|
};
|
|
584
791
|
}
|
package/package.json
CHANGED