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.53.0",
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 = 1;
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
- export const DEFAULT_CACHE_FILE = () => path.join(home(), '.great_cto', 'session-usage-index.json');
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: { plan: 0, server: 0 } };
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
- const PLAN_LIMIT_TEXT = /not your usage limit/i;
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
- if (PLAN_LIMIT_TEXT.test(text)) b.lim.server += 1; else b.lim.plan += 1;
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
- addTokens(b, u.speed === 'fast' ? `${model}@fast` : model, t);
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
- const b = dayBucket(entry, ts);
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
- const b = dayBucket(entry, ts);
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: { plan: 0, server: 0 } });
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
- h.lim.plan += b.lim?.plan || 0;
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, limitHits: h.lim, ...cache(h),
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: { codex: codexLimits ? { ...codexLimits, plan: codexLimits.plan || codexPlan?.plan || null } : null },
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "great-cto",
3
- "version": "3.53.0",
3
+ "version": "3.54.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",