burnrate-cli 0.0.0-stage → 0.1.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.
- package/LICENSE +21 -0
- package/README.md +129 -2
- package/dist/aggregate.js +213 -0
- package/dist/cli.js +387 -0
- package/dist/config.js +23 -0
- package/dist/dashboard/app.js +440 -0
- package/dist/dashboard/favicon.svg +1 -0
- package/dist/dashboard/index.html +131 -0
- package/dist/dashboard/styles.css +200 -0
- package/dist/demo.js +123 -0
- package/dist/format.js +47 -0
- package/dist/notify.js +32 -0
- package/dist/parsers/claude.js +80 -0
- package/dist/parsers/codex.js +93 -0
- package/dist/paths.js +26 -0
- package/dist/pricing.js +84 -0
- package/dist/server.js +99 -0
- package/dist/store.js +216 -0
- package/dist/types.js +1 -0
- package/package.json +47 -4
|
@@ -0,0 +1,200 @@
|
|
|
1
|
+
/* Burnrate dashboard — chart roles follow the validated dataviz reference palette. */
|
|
2
|
+
:root {
|
|
3
|
+
color-scheme: light;
|
|
4
|
+
--page: #f9f9f7;
|
|
5
|
+
--surface: #fcfcfb;
|
|
6
|
+
--surface-2: #f3f2ee;
|
|
7
|
+
--ink: #0b0b0b;
|
|
8
|
+
--ink-2: #52514e;
|
|
9
|
+
--muted: #6e6c66;
|
|
10
|
+
--axis-text: #898781;
|
|
11
|
+
--grid: #e1e0d9;
|
|
12
|
+
--axis: #c3c2b7;
|
|
13
|
+
--border: rgba(11, 11, 11, 0.1);
|
|
14
|
+
--series-1: #2a78d6; /* Claude Code */
|
|
15
|
+
--series-2: #eb6834; /* Codex */
|
|
16
|
+
--series-3: #1baf7a;
|
|
17
|
+
--good: #0ca30c;
|
|
18
|
+
--good-text: #006300;
|
|
19
|
+
--warning: #fab219;
|
|
20
|
+
--critical: #d03b3b;
|
|
21
|
+
--critical-text: #b42318;
|
|
22
|
+
--critical-wash: #fdecea;
|
|
23
|
+
--warning-wash: #fff5dc;
|
|
24
|
+
--warning-text: #7a5300;
|
|
25
|
+
--accent: #0b0b0b;
|
|
26
|
+
--accent-ink: #ffffff;
|
|
27
|
+
--track: #e8e7e1;
|
|
28
|
+
--shadow: 0 1px 2px rgba(11, 11, 11, 0.04);
|
|
29
|
+
--font: system-ui, -apple-system, "Segoe UI", Roboto, sans-serif;
|
|
30
|
+
--mono: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
|
|
31
|
+
}
|
|
32
|
+
@media (prefers-color-scheme: dark) {
|
|
33
|
+
:root:where(:not([data-theme="light"])) {
|
|
34
|
+
color-scheme: dark;
|
|
35
|
+
--page: #0d0d0d;
|
|
36
|
+
--surface: #1a1a19;
|
|
37
|
+
--surface-2: #222220;
|
|
38
|
+
--ink: #f0efec;
|
|
39
|
+
--ink-2: #c3c2b7;
|
|
40
|
+
--muted: #a3a29b;
|
|
41
|
+
--axis-text: #898781;
|
|
42
|
+
--grid: #2c2c2a;
|
|
43
|
+
--axis: #383835;
|
|
44
|
+
--border: rgba(255, 255, 255, 0.1);
|
|
45
|
+
--series-1: #3987e5;
|
|
46
|
+
--series-2: #d95926;
|
|
47
|
+
--series-3: #199e70;
|
|
48
|
+
--good-text: #0ca30c;
|
|
49
|
+
--critical-text: #f2877d;
|
|
50
|
+
--critical-wash: #3a1714;
|
|
51
|
+
--warning-wash: #33280f;
|
|
52
|
+
--warning-text: #fab219;
|
|
53
|
+
--accent: #f0efec;
|
|
54
|
+
--accent-ink: #0b0b0b;
|
|
55
|
+
--track: #2c2c2a;
|
|
56
|
+
--shadow: none;
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
:root[data-theme="dark"] {
|
|
60
|
+
color-scheme: dark;
|
|
61
|
+
--page: #0d0d0d; --surface: #1a1a19; --surface-2: #222220; --ink: #f0efec; --ink-2: #c3c2b7; --muted: #a3a29b;
|
|
62
|
+
--grid: #2c2c2a; --axis: #383835; --border: rgba(255, 255, 255, 0.1); --series-1: #3987e5; --series-2: #d95926;
|
|
63
|
+
--series-3: #199e70; --good-text: #0ca30c; --critical-text: #f2877d; --critical-wash: #3a1714; --warning-wash: #33280f;
|
|
64
|
+
--warning-text: #fab219; --accent: #f0efec; --accent-ink: #0b0b0b; --track: #2c2c2a; --shadow: none;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
* { box-sizing: border-box; }
|
|
68
|
+
html { -webkit-text-size-adjust: 100%; }
|
|
69
|
+
body { margin: 0; background: var(--page); color: var(--ink); font: 15px/1.5 var(--font); -webkit-font-smoothing: antialiased; }
|
|
70
|
+
h1, h2, h3, p { margin: 0; }
|
|
71
|
+
h2 { font-size: 15px; font-weight: 600; }
|
|
72
|
+
code { font-family: var(--mono); font-size: 0.9em; background: var(--surface-2); padding: 1px 5px; border-radius: 4px; }
|
|
73
|
+
button { font: inherit; color: inherit; }
|
|
74
|
+
:focus-visible { outline: 2px solid var(--series-1); outline-offset: 2px; }
|
|
75
|
+
|
|
76
|
+
.page { max-width: 1200px; margin: 0 auto; padding: 0 16px 40px; }
|
|
77
|
+
.topbar { display: flex; align-items: center; gap: 16px; flex-wrap: wrap; padding: 18px 0; }
|
|
78
|
+
.brand { display: inline-flex; align-items: center; gap: 9px; color: var(--ink); text-decoration: none; font-weight: 700; font-size: 17px; letter-spacing: -0.01em; }
|
|
79
|
+
.filters { display: inline-flex; background: var(--surface-2); border: 1px solid var(--border); border-radius: 9px; padding: 3px; }
|
|
80
|
+
.seg { border: 0; background: transparent; padding: 5px 12px; border-radius: 6px; font-size: 13px; font-weight: 600; color: var(--muted); cursor: pointer; }
|
|
81
|
+
.seg:hover { color: var(--ink); }
|
|
82
|
+
.seg[aria-pressed="true"] { background: var(--surface); color: var(--ink); box-shadow: 0 0 0 1px var(--border); }
|
|
83
|
+
.topbar-right { margin-left: auto; display: flex; align-items: center; gap: 12px; }
|
|
84
|
+
.updated { font-size: 12px; color: var(--muted); }
|
|
85
|
+
.btn { border: 1px solid var(--border); background: var(--surface); border-radius: 8px; height: 34px; padding: 0 12px; font-size: 13px; font-weight: 600; cursor: pointer; }
|
|
86
|
+
.btn:hover { background: var(--surface-2); }
|
|
87
|
+
.btn-ghost { border-color: transparent; background: transparent; color: var(--muted); }
|
|
88
|
+
.btn-ghost[aria-pressed="true"] { color: var(--ink); background: var(--surface-2); }
|
|
89
|
+
.btn-primary { background: var(--accent); color: var(--accent-ink); border-color: var(--accent); }
|
|
90
|
+
.btn-primary:hover { background: var(--accent); opacity: 0.9; }
|
|
91
|
+
|
|
92
|
+
main { display: grid; gap: 16px; transition: opacity 0.2s; }
|
|
93
|
+
main.loading { opacity: 0.55; }
|
|
94
|
+
.card { background: var(--surface); border: 1px solid var(--border); border-radius: 14px; padding: 18px 20px; box-shadow: var(--shadow); min-width: 0; }
|
|
95
|
+
.card-head { display: flex; align-items: flex-start; gap: 16px; flex-wrap: wrap; margin-bottom: 14px; }
|
|
96
|
+
.card-head > div:first-child { margin-right: auto; }
|
|
97
|
+
.label { font-size: 13px; color: var(--muted); font-weight: 500; }
|
|
98
|
+
.sub { font-size: 13px; color: var(--muted); }
|
|
99
|
+
|
|
100
|
+
.hero-row { display: grid; grid-template-columns: minmax(0, 1fr) minmax(0, 2fr); gap: 16px; }
|
|
101
|
+
.hero { display: flex; flex-direction: column; justify-content: center; }
|
|
102
|
+
.hero-figure { font-size: 56px; line-height: 1.05; font-weight: 650; letter-spacing: -0.03em; margin: 6px 0 4px; }
|
|
103
|
+
.tiles { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 16px; }
|
|
104
|
+
.tile-value { font-size: 26px; font-weight: 650; letter-spacing: -0.02em; margin: 4px 0 2px; }
|
|
105
|
+
.delta-up { color: var(--critical-text); }
|
|
106
|
+
.delta-down { color: var(--good-text); }
|
|
107
|
+
|
|
108
|
+
.block-stats { display: flex; gap: 22px; flex-wrap: wrap; }
|
|
109
|
+
.stat .v { font-size: 20px; font-weight: 650; letter-spacing: -0.01em; }
|
|
110
|
+
.stat .k { font-size: 12px; color: var(--muted); }
|
|
111
|
+
.alert { display: flex; gap: 10px; align-items: flex-start; border-radius: 10px; padding: 10px 12px; margin-bottom: 14px; font-size: 14px; }
|
|
112
|
+
.alert svg { flex-shrink: 0; margin-top: 2px; }
|
|
113
|
+
.alert.critical { background: var(--critical-wash); color: var(--critical-text); }
|
|
114
|
+
.alert.warning { background: var(--warning-wash); color: var(--warning-text); }
|
|
115
|
+
.alert.good { background: var(--surface-2); color: var(--ink-2); }
|
|
116
|
+
.alert strong { color: inherit; }
|
|
117
|
+
.meter-wrap { margin-bottom: 8px; }
|
|
118
|
+
.meter { height: 10px; background: var(--track); border-radius: 999px; overflow: hidden; }
|
|
119
|
+
.meter-fill { height: 100%; border-radius: 999px; background: var(--good); transition: width 0.4s; }
|
|
120
|
+
.meter-fill.warning { background: var(--warning); }
|
|
121
|
+
.meter-fill.critical { background: var(--critical); }
|
|
122
|
+
.meter-label { font-size: 13px; color: var(--ink-2); margin-top: 6px; }
|
|
123
|
+
|
|
124
|
+
.chart { position: relative; width: 100%; }
|
|
125
|
+
.chart svg { display: block; width: 100%; height: auto; overflow: visible; }
|
|
126
|
+
.chart .grid line { stroke: var(--grid); stroke-width: 1; shape-rendering: crispEdges; }
|
|
127
|
+
.chart .axis-text { fill: var(--axis-text); font-size: 11px; font-family: var(--font); font-variant-numeric: tabular-nums; }
|
|
128
|
+
.chart .baseline { stroke: var(--axis); stroke-width: 1; shape-rendering: crispEdges; }
|
|
129
|
+
.chart .ref-line { stroke: var(--critical); stroke-width: 1; shape-rendering: crispEdges; }
|
|
130
|
+
.chart .ref-text { fill: var(--critical-text); font-size: 11px; font-weight: 600; }
|
|
131
|
+
.chart .hit { fill: transparent; cursor: crosshair; }
|
|
132
|
+
.chart .bar:hover, .chart g.col:hover rect.bar, .chart g.col:focus rect.bar { filter: brightness(1.12); }
|
|
133
|
+
.chart g.col:focus { outline: none; }
|
|
134
|
+
.chart g.col:focus-visible rect.hit { stroke: var(--series-1); stroke-width: 1; }
|
|
135
|
+
.chart .crosshair { stroke: var(--axis); stroke-width: 1; }
|
|
136
|
+
|
|
137
|
+
.legend { display: flex; gap: 14px; align-items: center; font-size: 13px; color: var(--ink-2); flex-wrap: wrap; }
|
|
138
|
+
.legend .key { display: inline-block; width: 10px; height: 10px; border-radius: 3px; margin-right: 6px; vertical-align: -1px; }
|
|
139
|
+
|
|
140
|
+
.tooltip { position: fixed; z-index: 10; pointer-events: none; background: var(--surface); border: 1px solid var(--border); border-radius: 10px; padding: 8px 10px; font-size: 12.5px; box-shadow: 0 8px 24px rgba(0, 0, 0, 0.14); min-width: 150px; }
|
|
141
|
+
.tooltip .tt-title { color: var(--muted); margin-bottom: 4px; }
|
|
142
|
+
.tooltip .tt-row { display: flex; align-items: center; gap: 8px; }
|
|
143
|
+
.tooltip .tt-key { width: 12px; height: 2px; border-radius: 1px; }
|
|
144
|
+
.tooltip .tt-val { font-weight: 650; font-variant-numeric: tabular-nums; }
|
|
145
|
+
.tooltip .tt-name { color: var(--muted); margin-left: auto; padding-left: 10px; }
|
|
146
|
+
|
|
147
|
+
.table-wrap { overflow-x: auto; }
|
|
148
|
+
table { width: 100%; border-collapse: collapse; font-size: 13.5px; }
|
|
149
|
+
th { text-align: left; font-size: 12px; font-weight: 600; color: var(--muted); padding: 6px 8px; border-bottom: 1px solid var(--grid); white-space: nowrap; cursor: pointer; user-select: none; }
|
|
150
|
+
th.num, td.num { text-align: right; font-variant-numeric: tabular-nums; }
|
|
151
|
+
th[aria-sort="descending"]::after { content: " ↓"; }
|
|
152
|
+
th[aria-sort="ascending"]::after { content: " ↑"; }
|
|
153
|
+
td { padding: 8px; border-bottom: 1px solid var(--grid); vertical-align: top; }
|
|
154
|
+
tr:last-child td { border-bottom: 0; }
|
|
155
|
+
td .title { font-weight: 550; display: block; max-width: 340px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
|
156
|
+
td .meta { color: var(--muted); font-size: 12px; }
|
|
157
|
+
td .dot { display: inline-block; width: 8px; height: 8px; border-radius: 50%; margin-right: 6px; vertical-align: 0; }
|
|
158
|
+
.cost-cell { display: flex; align-items: center; justify-content: flex-end; gap: 8px; }
|
|
159
|
+
.inline-bar { height: 4px; border-radius: 2px; background: var(--series-1); opacity: 0.35; }
|
|
160
|
+
.pill { display: inline-block; font-size: 11px; font-weight: 600; padding: 1px 7px; border-radius: 999px; background: var(--surface-2); color: var(--ink-2); }
|
|
161
|
+
.pill.warn { background: var(--warning-wash); color: var(--warning-text); }
|
|
162
|
+
|
|
163
|
+
.grid-2 { display: grid; grid-template-columns: minmax(0, 1.4fr) minmax(0, 1fr); gap: 16px; }
|
|
164
|
+
.empty-note { color: var(--muted); font-size: 13.5px; padding: 6px 0; }
|
|
165
|
+
.limit-row { display: flex; gap: 12px; padding: 8px 0; border-bottom: 1px solid var(--grid); font-size: 13.5px; }
|
|
166
|
+
.limit-row:last-child { border-bottom: 0; }
|
|
167
|
+
.limit-row .when { color: var(--muted); min-width: 92px; }
|
|
168
|
+
.rl { display: flex; gap: 20px; flex-wrap: wrap; margin-top: 14px; padding-top: 14px; border-top: 1px solid var(--grid); font-size: 13px; }
|
|
169
|
+
.rl .rl-item { min-width: 200px; flex: 1; }
|
|
170
|
+
.rl .meter { height: 6px; margin: 6px 0 4px; }
|
|
171
|
+
|
|
172
|
+
.empty { text-align: center; padding: 40px 20px; }
|
|
173
|
+
.empty p { color: var(--ink-2); max-width: 560px; margin: 8px auto 0; }
|
|
174
|
+
|
|
175
|
+
.footer { display: flex; justify-content: space-between; gap: 16px; flex-wrap: wrap; font-size: 12px; color: var(--muted); padding: 24px 0 0; }
|
|
176
|
+
|
|
177
|
+
dialog { border: 1px solid var(--border); border-radius: 14px; background: var(--surface); color: var(--ink); padding: 22px; width: min(440px, calc(100vw - 32px)); }
|
|
178
|
+
dialog::backdrop { background: rgba(0, 0, 0, 0.35); }
|
|
179
|
+
dialog h2 { font-size: 18px; margin-bottom: 4px; }
|
|
180
|
+
dialog form { display: grid; gap: 12px; }
|
|
181
|
+
dialog label { display: grid; gap: 4px; font-size: 13px; font-weight: 600; color: var(--ink-2); }
|
|
182
|
+
dialog input, dialog select { height: 36px; border-radius: 8px; border: 1px solid var(--border); background: var(--page); color: var(--ink); padding: 0 10px; font: inherit; font-size: 14px; }
|
|
183
|
+
dialog fieldset { border: 1px solid var(--border); border-radius: 10px; padding: 10px 12px 12px; display: grid; gap: 10px; }
|
|
184
|
+
dialog legend { font-size: 13px; font-weight: 600; color: var(--ink-2); padding: 0 4px; }
|
|
185
|
+
.hint { font-size: 12px; color: var(--muted); margin-top: -6px; }
|
|
186
|
+
.dialog-actions { display: flex; justify-content: flex-end; gap: 8px; margin-top: 4px; }
|
|
187
|
+
|
|
188
|
+
@media (max-width: 900px) {
|
|
189
|
+
.hero-row, .grid-2 { grid-template-columns: minmax(0, 1fr); }
|
|
190
|
+
}
|
|
191
|
+
@media (max-width: 600px) {
|
|
192
|
+
.tiles { grid-template-columns: minmax(0, 1fr); }
|
|
193
|
+
.hero-figure { font-size: 48px; }
|
|
194
|
+
.topbar-right { margin-left: 0; width: 100%; justify-content: space-between; }
|
|
195
|
+
.card { padding: 16px; }
|
|
196
|
+
td .title { max-width: 200px; }
|
|
197
|
+
}
|
|
198
|
+
@media (prefers-reduced-motion: reduce) {
|
|
199
|
+
main, .meter-fill { transition: none; }
|
|
200
|
+
}
|
package/dist/demo.js
ADDED
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
import fs from "node:fs";
|
|
2
|
+
import os from "node:os";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
/** Deterministic PRNG so demo screenshots are stable. */
|
|
5
|
+
function rng(seed) {
|
|
6
|
+
return () => {
|
|
7
|
+
seed = (seed * 1664525 + 1013904223) % 4294967296;
|
|
8
|
+
return seed / 4294967296;
|
|
9
|
+
};
|
|
10
|
+
}
|
|
11
|
+
const PROJECTS = ["~/code/checkout-api", "~/code/marketing-site", "~/code/mobile-app", "~/code/infra", "~/code/data-pipeline"];
|
|
12
|
+
const PROMPTS = [
|
|
13
|
+
"Refactor the payment retry logic into a state machine",
|
|
14
|
+
"Add dark mode to the settings screen",
|
|
15
|
+
"Why is the nightly ETL job timing out?",
|
|
16
|
+
"Write integration tests for the webhook handler",
|
|
17
|
+
"Migrate auth from sessions to JWT",
|
|
18
|
+
"Fix flaky e2e test on the checkout page",
|
|
19
|
+
"Upgrade Next.js and fix the build errors",
|
|
20
|
+
"Draft a Terraform module for the queue workers",
|
|
21
|
+
"Explain this stack trace and propose a fix",
|
|
22
|
+
"Add pagination to the orders endpoint",
|
|
23
|
+
"Port the CSV importer to streaming",
|
|
24
|
+
"Review this PR for security issues",
|
|
25
|
+
];
|
|
26
|
+
/**
|
|
27
|
+
* Writes realistic Claude Code + Codex logs for the last `days` days into a temp dir.
|
|
28
|
+
* Returns the directory; pass it with --path / codex:--path.
|
|
29
|
+
*/
|
|
30
|
+
export function generateDemo(days = 30, now = Date.now()) {
|
|
31
|
+
const root = fs.mkdtempSync(path.join(os.tmpdir(), "burnrate-demo-"));
|
|
32
|
+
const claude = path.join(root, "claude", "projects");
|
|
33
|
+
const codex = path.join(root, "codex", "sessions");
|
|
34
|
+
const r = rng(42);
|
|
35
|
+
let msgN = 0;
|
|
36
|
+
let limitTs = now - 3 * 86400_000;
|
|
37
|
+
for (let d = days - 1; d >= 0; d--) {
|
|
38
|
+
const day = new Date(now - d * 86400_000);
|
|
39
|
+
const weekend = day.getDay() === 0 || day.getDay() === 6;
|
|
40
|
+
const nSessions = d === 0 ? 3 : weekend ? Math.floor(r() * 2) : 2 + Math.floor(r() * 4);
|
|
41
|
+
for (let sIdx = 0; sIdx < nSessions; sIdx++) {
|
|
42
|
+
const isCodex = d === 0 && sIdx === nSessions - 1 ? false : r() < 0.28;
|
|
43
|
+
const project = PROJECTS[Math.floor(r() * PROJECTS.length)].replace("~", os.homedir());
|
|
44
|
+
const prompt = PROMPTS[Math.floor(r() * PROMPTS.length)];
|
|
45
|
+
const startHour = 9 + Math.floor(r() * 9);
|
|
46
|
+
let ts = new Date(day.getFullYear(), day.getMonth(), day.getDate(), startHour, Math.floor(r() * 60)).getTime();
|
|
47
|
+
if (d === 0)
|
|
48
|
+
ts = now - 2.6 * 3600_000 + sIdx * 25 * 60_000; // today: sessions inside the active block
|
|
49
|
+
const turns = d === 0 && sIdx === nSessions - 1 ? 1000 : 20 + Math.floor(r() * (isCodex ? 50 : 110)); // today's last session runs until now
|
|
50
|
+
const sessionId = `${(0x10000000 + Math.floor(r() * 0xefffffff)).toString(16)}-demo-${d}-${sIdx}`;
|
|
51
|
+
const lines = [];
|
|
52
|
+
const model = isCodex ? "gpt-5-codex" : r() < 0.75 ? "claude-opus-5-5" : "claude-sonnet-5-5";
|
|
53
|
+
if (isCodex) {
|
|
54
|
+
lines.push(JSON.stringify({ timestamp: new Date(ts).toISOString(), type: "session_meta", payload: { id: sessionId, cwd: project } }));
|
|
55
|
+
lines.push(JSON.stringify({ timestamp: new Date(ts).toISOString(), type: "turn_context", payload: { model, cwd: project } }));
|
|
56
|
+
lines.push(JSON.stringify({ timestamp: new Date(ts).toISOString(), type: "response_item", payload: { type: "message", role: "user", content: [{ type: "input_text", text: prompt }] } }));
|
|
57
|
+
}
|
|
58
|
+
else {
|
|
59
|
+
lines.push(JSON.stringify({ type: "user", sessionId, cwd: project, timestamp: new Date(ts).toISOString(), message: { role: "user", content: prompt }, uuid: `u-${sessionId}` }));
|
|
60
|
+
}
|
|
61
|
+
let ctx = 12_000;
|
|
62
|
+
for (let t = 0; t < turns; t++) {
|
|
63
|
+
ts += (20 + r() * 90) * 1000;
|
|
64
|
+
if (ts > now - 60_000)
|
|
65
|
+
break;
|
|
66
|
+
ctx += 1500 + r() * 6000;
|
|
67
|
+
const out = 200 + Math.floor(r() * 2500);
|
|
68
|
+
if (isCodex) {
|
|
69
|
+
const input = Math.floor(ctx);
|
|
70
|
+
lines.push(JSON.stringify({
|
|
71
|
+
timestamp: new Date(ts).toISOString(), type: "event_msg",
|
|
72
|
+
payload: {
|
|
73
|
+
type: "token_count",
|
|
74
|
+
info: { last_token_usage: { input_tokens: input, cached_input_tokens: Math.floor(input * 0.85), output_tokens: out, reasoning_output_tokens: Math.floor(out * 0.4) } },
|
|
75
|
+
rate_limits: {
|
|
76
|
+
primary: { used_percent: Math.min(100, 18 + (turns ? (t / turns) * 40 : 0)), window_minutes: 300, resets_in_seconds: 3 * 3600 },
|
|
77
|
+
secondary: { used_percent: 46, window_minutes: 10080, resets_in_seconds: 3 * 86400 },
|
|
78
|
+
},
|
|
79
|
+
},
|
|
80
|
+
}));
|
|
81
|
+
}
|
|
82
|
+
else {
|
|
83
|
+
const id = `msg_demo_${++msgN}`;
|
|
84
|
+
const write = t === 0 ? Math.floor(ctx) : Math.floor(1500 + r() * 6000);
|
|
85
|
+
const usage = {
|
|
86
|
+
input_tokens: 3 + Math.floor(r() * 20),
|
|
87
|
+
cache_creation_input_tokens: write,
|
|
88
|
+
cache_read_input_tokens: Math.floor(ctx - write),
|
|
89
|
+
output_tokens: out,
|
|
90
|
+
cache_creation: { ephemeral_5m_input_tokens: 0, ephemeral_1h_input_tokens: write },
|
|
91
|
+
server_tool_use: { web_search_requests: r() < 0.03 ? 1 : 0 },
|
|
92
|
+
};
|
|
93
|
+
// Claude Code writes one line per content block with identical usage — duplicates on purpose.
|
|
94
|
+
for (const block of ["thinking", "text", "tool_use"].slice(0, 1 + Math.floor(r() * 3))) {
|
|
95
|
+
lines.push(JSON.stringify({
|
|
96
|
+
type: "assistant", sessionId, cwd: project, requestId: `req_demo_${msgN}`, timestamp: new Date(ts).toISOString(),
|
|
97
|
+
message: { id, model, role: "assistant", content: [{ type: block, text: "" }], usage },
|
|
98
|
+
}));
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
if (d >= 2 && !isCodex)
|
|
103
|
+
limitTs = Math.max(limitTs, ts + 30_000);
|
|
104
|
+
if (isCodex) {
|
|
105
|
+
const dt = new Date(ts);
|
|
106
|
+
const dir = path.join(codex, String(dt.getFullYear()), String(dt.getMonth() + 1).padStart(2, "0"), String(dt.getDate()).padStart(2, "0"));
|
|
107
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
108
|
+
fs.writeFileSync(path.join(dir, `rollout-${sessionId}.jsonl`), lines.join("\n") + "\n");
|
|
109
|
+
}
|
|
110
|
+
else {
|
|
111
|
+
const dir = path.join(claude, project.replace(/[\\/:]/g, "-"));
|
|
112
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
113
|
+
fs.writeFileSync(path.join(dir, `${sessionId}.jsonl`), lines.join("\n") + "\n");
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
// One limit hit right after the last Claude Code message two or more days ago.
|
|
118
|
+
const hitTs = limitTs;
|
|
119
|
+
const dir = path.join(claude, "demo-limit");
|
|
120
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
121
|
+
fs.writeFileSync(path.join(dir, "limit.jsonl"), JSON.stringify({ type: "assistant", isApiErrorMessage: true, sessionId: "demo-limit", cwd: "", timestamp: new Date(hitTs).toISOString(), message: { id: "msg_limit", model: "<synthetic>", role: "assistant", content: [{ type: "text", text: "5-hour limit reached ∙ resets 7pm" }], usage: { input_tokens: 0, output_tokens: 0 } } }) + "\n");
|
|
122
|
+
return { root, claude, codex };
|
|
123
|
+
}
|
package/dist/format.js
ADDED
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
export function usd(n, opts = {}) {
|
|
2
|
+
if (n === null || n === undefined || !Number.isFinite(n))
|
|
3
|
+
return "—";
|
|
4
|
+
const abs = Math.abs(n);
|
|
5
|
+
if (!opts.precise && abs >= 10_000)
|
|
6
|
+
return `$${(n / 1000).toFixed(1)}K`;
|
|
7
|
+
if (abs > 0 && abs < 0.01)
|
|
8
|
+
return "<$0.01";
|
|
9
|
+
return `$${n.toLocaleString("en-US", { minimumFractionDigits: 2, maximumFractionDigits: 2 })}`;
|
|
10
|
+
}
|
|
11
|
+
export function tokens(n) {
|
|
12
|
+
if (n >= 1e9)
|
|
13
|
+
return `${(n / 1e9).toFixed(1)}B`;
|
|
14
|
+
if (n >= 1e6)
|
|
15
|
+
return `${(n / 1e6).toFixed(1)}M`;
|
|
16
|
+
if (n >= 1e3)
|
|
17
|
+
return `${(n / 1e3).toFixed(1)}K`;
|
|
18
|
+
return String(Math.round(n));
|
|
19
|
+
}
|
|
20
|
+
export function duration(ms) {
|
|
21
|
+
if (ms <= 0)
|
|
22
|
+
return "0m";
|
|
23
|
+
const m = Math.round(ms / 60000);
|
|
24
|
+
if (m < 60)
|
|
25
|
+
return `${m}m`;
|
|
26
|
+
const h = Math.floor(m / 60);
|
|
27
|
+
return m % 60 ? `${h}h ${m % 60}m` : `${h}h`;
|
|
28
|
+
}
|
|
29
|
+
export function clock(ts) {
|
|
30
|
+
return new Date(ts).toLocaleTimeString("en-GB", { hour: "2-digit", minute: "2-digit" });
|
|
31
|
+
}
|
|
32
|
+
/** "15:40" today, "Thu 15:40" within a week, else a date. */
|
|
33
|
+
export function when(ts, now = Date.now()) {
|
|
34
|
+
const d = new Date(ts);
|
|
35
|
+
const sameDay = new Date(now).toDateString() === d.toDateString();
|
|
36
|
+
if (sameDay)
|
|
37
|
+
return clock(ts);
|
|
38
|
+
if (Math.abs(ts - now) < 6 * 86400_000)
|
|
39
|
+
return `${d.toLocaleDateString("en-GB", { weekday: "short" })} ${clock(ts)}`;
|
|
40
|
+
return d.toLocaleDateString("en-GB", { day: "numeric", month: "short" });
|
|
41
|
+
}
|
|
42
|
+
export function shortProject(p) {
|
|
43
|
+
if (!p)
|
|
44
|
+
return "(unknown)";
|
|
45
|
+
const parts = p.split(/[\\/]/).filter(Boolean);
|
|
46
|
+
return parts.slice(-2).join("/") || p;
|
|
47
|
+
}
|
package/dist/notify.js
ADDED
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import { execFile } from "node:child_process";
|
|
2
|
+
/** Best-effort desktop notification (macOS, Linux, Windows). Never throws. */
|
|
3
|
+
export function notify(title, body) {
|
|
4
|
+
const cb = () => { };
|
|
5
|
+
try {
|
|
6
|
+
if (process.platform === "darwin") {
|
|
7
|
+
const esc = (s) => s.replace(/\\/g, "\\\\").replace(/"/g, '\\"');
|
|
8
|
+
execFile("osascript", ["-e", `display notification "${esc(body)}" with title "${esc(title)}"`], cb);
|
|
9
|
+
}
|
|
10
|
+
else if (process.platform === "win32") {
|
|
11
|
+
const ps = `[reflection.assembly]::loadwithpartialname('System.Windows.Forms') | Out-Null; $n = New-Object System.Windows.Forms.NotifyIcon; $n.Icon = [System.Drawing.SystemIcons]::Information; $n.Visible = $true; $n.ShowBalloonTip(8000, $env:BR_T, $env:BR_B, 'Info'); Start-Sleep 9`;
|
|
12
|
+
execFile("powershell", ["-NoProfile", "-Command", ps], { env: { ...process.env, BR_T: title, BR_B: body } }, cb);
|
|
13
|
+
}
|
|
14
|
+
else {
|
|
15
|
+
execFile("notify-send", ["-a", "Burnrate", title, body], cb);
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
catch {
|
|
19
|
+
/* ignore */
|
|
20
|
+
}
|
|
21
|
+
process.stdout.write(`\x07[${new Date().toLocaleTimeString()}] ${title}: ${body}\n`);
|
|
22
|
+
}
|
|
23
|
+
export function openBrowser(url) {
|
|
24
|
+
const cmd = process.platform === "darwin" ? "open" : process.platform === "win32" ? "cmd" : "xdg-open";
|
|
25
|
+
const args = process.platform === "win32" ? ["/c", "start", "", url] : [url];
|
|
26
|
+
try {
|
|
27
|
+
execFile(cmd, args, () => { });
|
|
28
|
+
}
|
|
29
|
+
catch {
|
|
30
|
+
/* headless */
|
|
31
|
+
}
|
|
32
|
+
}
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
const LIMIT_RE = /(usage limit|limit reached|rate limit|limit will reset|out of extra usage)/i;
|
|
2
|
+
function textOf(content) {
|
|
3
|
+
if (typeof content === "string")
|
|
4
|
+
return content;
|
|
5
|
+
if (Array.isArray(content)) {
|
|
6
|
+
return content
|
|
7
|
+
.map((b) => (b && typeof b === "object" && b.type === "text" ? String(b.text ?? "") : ""))
|
|
8
|
+
.join(" ");
|
|
9
|
+
}
|
|
10
|
+
return "";
|
|
11
|
+
}
|
|
12
|
+
const n = (v) => (typeof v === "number" && Number.isFinite(v) ? v : 0);
|
|
13
|
+
/**
|
|
14
|
+
* Parses one line of a Claude Code transcript (~/.claude/projects/**\/*.jsonl).
|
|
15
|
+
* Claude Code writes one line per content block, all carrying the same `usage`,
|
|
16
|
+
* so callers must de-duplicate by `key` (message id + request id).
|
|
17
|
+
*/
|
|
18
|
+
export function parseClaudeLine(line, file) {
|
|
19
|
+
let o;
|
|
20
|
+
try {
|
|
21
|
+
o = JSON.parse(line);
|
|
22
|
+
}
|
|
23
|
+
catch {
|
|
24
|
+
return [];
|
|
25
|
+
}
|
|
26
|
+
if (!o || typeof o !== "object")
|
|
27
|
+
return [];
|
|
28
|
+
const out = [];
|
|
29
|
+
const session = o.sessionId ?? file;
|
|
30
|
+
const project = o.cwd ?? "";
|
|
31
|
+
const ts = Date.parse(o.timestamp ?? "");
|
|
32
|
+
if (o.type === "user" && !o.isSidechain && o.message?.role === "user" && !o.isMeta) {
|
|
33
|
+
const text = textOf(o.message.content).trim();
|
|
34
|
+
// Skip tool results, slash-command wrappers and system reminders.
|
|
35
|
+
if (text && !text.startsWith("<") && !Array.isArray(o.toolUseResult) && o.toolUseResult === undefined) {
|
|
36
|
+
out.push({
|
|
37
|
+
kind: "session",
|
|
38
|
+
session,
|
|
39
|
+
meta: { agent: "claude-code", title: text.replace(/\s+/g, " ").slice(0, 80), project, firstTs: ts },
|
|
40
|
+
});
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
if (o.type === "assistant" && o.message) {
|
|
44
|
+
const m = o.message;
|
|
45
|
+
const text = textOf(m.content);
|
|
46
|
+
if ((m.model === "<synthetic>" || o.isApiErrorMessage) && LIMIT_RE.test(text) && Number.isFinite(ts)) {
|
|
47
|
+
out.push({ kind: "limit", hit: { agent: "claude-code", ts, message: text.slice(0, 200) } });
|
|
48
|
+
}
|
|
49
|
+
const u = m.usage;
|
|
50
|
+
if (u && m.model && m.model !== "<synthetic>" && Number.isFinite(ts)) {
|
|
51
|
+
const cc = u.cache_creation ?? {};
|
|
52
|
+
const split = typeof cc.ephemeral_5m_input_tokens === "number" || typeof cc.ephemeral_1h_input_tokens === "number";
|
|
53
|
+
const write5m = split ? n(cc.ephemeral_5m_input_tokens) : n(u.cache_creation_input_tokens);
|
|
54
|
+
const write1h = split ? n(cc.ephemeral_1h_input_tokens) : 0;
|
|
55
|
+
out.push({
|
|
56
|
+
kind: "usage",
|
|
57
|
+
event: {
|
|
58
|
+
key: `cc:${m.id ?? o.uuid}:${o.requestId ?? ""}`,
|
|
59
|
+
agent: "claude-code",
|
|
60
|
+
ts,
|
|
61
|
+
session,
|
|
62
|
+
project,
|
|
63
|
+
model: String(m.model),
|
|
64
|
+
input: n(u.input_tokens),
|
|
65
|
+
output: n(u.output_tokens),
|
|
66
|
+
cacheRead: n(u.cache_read_input_tokens),
|
|
67
|
+
cacheWrite5m: write5m,
|
|
68
|
+
cacheWrite1h: write1h,
|
|
69
|
+
reasoning: n(u.output_tokens_details?.thinking_tokens),
|
|
70
|
+
webSearches: n(u.server_tool_use?.web_search_requests),
|
|
71
|
+
},
|
|
72
|
+
});
|
|
73
|
+
out.push({ kind: "session", session, meta: { agent: "claude-code", project, firstTs: ts } });
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
if (o.type === "system" && typeof o.content === "string" && LIMIT_RE.test(o.content) && Number.isFinite(ts)) {
|
|
77
|
+
out.push({ kind: "limit", hit: { agent: "claude-code", ts, message: o.content.slice(0, 200) } });
|
|
78
|
+
}
|
|
79
|
+
return out;
|
|
80
|
+
}
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
const n = (v) => (typeof v === "number" && Number.isFinite(v) ? v : 0);
|
|
2
|
+
function windowOf(w, ts) {
|
|
3
|
+
if (!w || typeof w !== "object")
|
|
4
|
+
return null;
|
|
5
|
+
const used = typeof w.used_percent === "number" ? w.used_percent : null;
|
|
6
|
+
if (used === null)
|
|
7
|
+
return null;
|
|
8
|
+
const resetsIn = typeof w.resets_in_seconds === "number" ? w.resets_in_seconds : null;
|
|
9
|
+
const resetsAt = typeof w.resets_at === "number" ? w.resets_at * (w.resets_at < 1e12 ? 1000 : 1) : null;
|
|
10
|
+
return {
|
|
11
|
+
usedPercent: used,
|
|
12
|
+
windowMinutes: typeof w.window_minutes === "number" ? w.window_minutes : null,
|
|
13
|
+
resetsAt: resetsAt ?? (resetsIn !== null ? ts + resetsIn * 1000 : null),
|
|
14
|
+
};
|
|
15
|
+
}
|
|
16
|
+
/**
|
|
17
|
+
* Parses one line of a Codex CLI rollout (~/.codex/sessions/YYYY/MM/DD/rollout-*.jsonl).
|
|
18
|
+
* Usage comes from `event_msg` → `token_count` events; `last_token_usage` is the per-turn delta.
|
|
19
|
+
* OpenAI counts cached tokens inside `input_tokens`, so we subtract them.
|
|
20
|
+
*/
|
|
21
|
+
export function parseCodexLine(line, file, offset, state) {
|
|
22
|
+
let o;
|
|
23
|
+
try {
|
|
24
|
+
o = JSON.parse(line);
|
|
25
|
+
}
|
|
26
|
+
catch {
|
|
27
|
+
return [];
|
|
28
|
+
}
|
|
29
|
+
const ts = Date.parse(o.timestamp ?? "") || Date.now();
|
|
30
|
+
const p = o.payload ?? {};
|
|
31
|
+
const out = [];
|
|
32
|
+
if (o.type === "session_meta") {
|
|
33
|
+
state.session = p.id ?? state.session;
|
|
34
|
+
state.project = p.cwd ?? state.project;
|
|
35
|
+
if (p.model)
|
|
36
|
+
state.model = p.model;
|
|
37
|
+
out.push({ kind: "session", session: state.session, meta: { agent: "codex", project: state.project, firstTs: ts } });
|
|
38
|
+
}
|
|
39
|
+
else if (o.type === "turn_context") {
|
|
40
|
+
if (p.model)
|
|
41
|
+
state.model = p.model;
|
|
42
|
+
if (p.cwd)
|
|
43
|
+
state.project = p.cwd;
|
|
44
|
+
}
|
|
45
|
+
else if (o.type === "response_item" && p.type === "message" && p.role === "user") {
|
|
46
|
+
const text = Array.isArray(p.content)
|
|
47
|
+
? p.content.map((c) => (typeof c?.text === "string" ? c.text : "")).join(" ").trim()
|
|
48
|
+
: "";
|
|
49
|
+
if (text && !text.startsWith("<")) {
|
|
50
|
+
out.push({ kind: "session", session: state.session, meta: { agent: "codex", title: text.replace(/\s+/g, " ").slice(0, 80), project: state.project, firstTs: ts } });
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
else if (o.type === "event_msg" && p.type === "token_count") {
|
|
54
|
+
const last = p.info?.last_token_usage;
|
|
55
|
+
if (last) {
|
|
56
|
+
const input = n(last.input_tokens);
|
|
57
|
+
const cached = Math.min(n(last.cached_input_tokens), input);
|
|
58
|
+
if (input + n(last.output_tokens) > 0) {
|
|
59
|
+
out.push({
|
|
60
|
+
kind: "usage",
|
|
61
|
+
event: {
|
|
62
|
+
key: `cx:${file}:${offset}`,
|
|
63
|
+
agent: "codex",
|
|
64
|
+
ts,
|
|
65
|
+
session: state.session,
|
|
66
|
+
project: state.project,
|
|
67
|
+
model: state.model || "gpt-5-codex",
|
|
68
|
+
input: input - cached,
|
|
69
|
+
output: n(last.output_tokens),
|
|
70
|
+
cacheRead: cached,
|
|
71
|
+
cacheWrite5m: 0,
|
|
72
|
+
cacheWrite1h: 0,
|
|
73
|
+
reasoning: n(last.reasoning_output_tokens),
|
|
74
|
+
webSearches: 0,
|
|
75
|
+
},
|
|
76
|
+
});
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
const rl = p.rate_limits ?? p.info?.rate_limits;
|
|
80
|
+
if (rl) {
|
|
81
|
+
out.push({
|
|
82
|
+
kind: "ratelimit",
|
|
83
|
+
snapshot: { agent: "codex", ts, primary: windowOf(rl.primary, ts), secondary: windowOf(rl.secondary, ts) },
|
|
84
|
+
});
|
|
85
|
+
for (const w of [rl.primary, rl.secondary]) {
|
|
86
|
+
if (w && typeof w.used_percent === "number" && w.used_percent >= 100) {
|
|
87
|
+
out.push({ kind: "limit", hit: { agent: "codex", ts, message: `Codex ${w.window_minutes ?? "?"}-minute limit reached` } });
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
return out;
|
|
93
|
+
}
|
package/dist/paths.js
ADDED
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import os from "node:os";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
export function dataDir() {
|
|
4
|
+
return process.env.BURNRATE_HOME || path.join(os.homedir(), ".burnrate");
|
|
5
|
+
}
|
|
6
|
+
export function logRoots(extra = [], includeDefaults = true) {
|
|
7
|
+
const home = os.homedir();
|
|
8
|
+
const roots = [];
|
|
9
|
+
const envPaths = [];
|
|
10
|
+
if (includeDefaults) {
|
|
11
|
+
const claudeDirs = new Set([path.join(home, ".claude", "projects"), path.join(home, ".config", "claude", "projects")]);
|
|
12
|
+
for (const d of (process.env.CLAUDE_CONFIG_DIR ?? "").split(",").filter(Boolean))
|
|
13
|
+
claudeDirs.add(path.join(d, "projects"));
|
|
14
|
+
for (const dir of claudeDirs)
|
|
15
|
+
roots.push({ agent: "claude-code", dir });
|
|
16
|
+
const codexHome = process.env.CODEX_HOME || path.join(home, ".codex");
|
|
17
|
+
roots.push({ agent: "codex", dir: path.join(codexHome, "sessions") });
|
|
18
|
+
envPaths.push(...(process.env.BURNRATE_PATHS ?? "").split(path.delimiter).filter(Boolean));
|
|
19
|
+
}
|
|
20
|
+
for (const spec of [...extra, ...envPaths]) {
|
|
21
|
+
// "codex:/path" or "/path" (Claude Code format by default)
|
|
22
|
+
const m = spec.match(/^(claude-code|claude|codex):(.*)$/);
|
|
23
|
+
roots.push(m ? { agent: m[1] === "codex" ? "codex" : "claude-code", dir: path.resolve(m[2]) } : { agent: "claude-code", dir: path.resolve(spec) });
|
|
24
|
+
}
|
|
25
|
+
return roots;
|
|
26
|
+
}
|