metrascope 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/src/index.js ADDED
@@ -0,0 +1,77 @@
1
+ #!/usr/bin/env node
2
+
3
+ const { createServer } = require('./server');
4
+
5
+ function readOption(args, name) {
6
+ const index = args.indexOf(name);
7
+ if (index === -1) return null;
8
+ return args[index + 1] || null;
9
+ }
10
+
11
+ const args = process.argv.slice(2);
12
+
13
+ if (args.includes('--help') || args.includes('-h')) {
14
+ console.log(`
15
+ metrascope - See where your coding-agent tokens go
16
+
17
+ Auto-detects local usage for Codex, Claude Code, Qwen Code and Gemini CLI,
18
+ and lets you switch between them in the dashboard. All data stays local.
19
+
20
+ Usage:
21
+ metrascope [options]
22
+
23
+ Options:
24
+ --port <port> Port to run dashboard on (default: 3457)
25
+ --codex-home <path> Override Codex home (default: CODEX_HOME or ~/.codex)
26
+ --no-open Do not auto-open the browser
27
+ --help, -h Show this help message
28
+
29
+ Per-agent homes can also be set via env: CODEX_HOME, CLAUDE_HOME,
30
+ QWEN_HOME, GEMINI_HOME.
31
+
32
+ Examples:
33
+ metrascope
34
+ metrascope --port 8080
35
+ metrascope --codex-home ~/.codex --no-open
36
+ `);
37
+ process.exit(0);
38
+ }
39
+
40
+ const port = parseInt(readOption(args, '--port') || '3457', 10);
41
+ const codexHome = readOption(args, '--codex-home') || process.env.CODEX_HOME || null;
42
+ const noOpen = args.includes('--no-open');
43
+
44
+ if (!Number.isInteger(port) || port <= 0 || port > 65535) {
45
+ console.error('Error: --port must be a valid port number');
46
+ process.exit(1);
47
+ }
48
+
49
+ const app = createServer({ codexHome });
50
+
51
+ const server = app.listen(port, async () => {
52
+ const url = `http://localhost:${port}`;
53
+ console.log(`\n metrascope dashboard running at ${url}\n`);
54
+
55
+ if (!noOpen) {
56
+ try {
57
+ const open = (await import('open')).default;
58
+ await open(url);
59
+ } catch {
60
+ console.log(' Could not auto-open browser. Open the URL manually.');
61
+ }
62
+ }
63
+ });
64
+
65
+ server.on('error', (err) => {
66
+ if (err.code === 'EADDRINUSE') {
67
+ console.error(`Port ${port} is already in use. Try --port <other-port>`);
68
+ process.exit(1);
69
+ }
70
+ throw err;
71
+ });
72
+
73
+ process.on('SIGINT', () => {
74
+ console.log('\n Shutting down...');
75
+ server.close();
76
+ process.exit(0);
77
+ });
@@ -0,0 +1,398 @@
1
+ <!DOCTYPE html>
2
+ <html lang="en" data-theme="dark">
3
+ <head>
4
+ <meta charset="UTF-8">
5
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
6
+ <title>Agent Spend</title>
7
+ <link rel="preconnect" href="https://fonts.googleapis.com">
8
+ <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
9
+ <link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700;800&family=JetBrains+Mono:wght@400;500;600;700&display=swap" rel="stylesheet">
10
+ <style>
11
+ *{box-sizing:border-box}
12
+ :root{
13
+ color-scheme:dark;
14
+ --canvas:#131418;--bg:#16171c;--surface:#1f2027;--surface-2:#25272f;--surface-3:#2a2c35;--elevated:#2d2f38;--overlay:rgba(0,0,0,.55);
15
+ --ink:#e3e2df;--muted:#b4b6bc;--soft:#888a92;--faint:#5d6068;
16
+ --line:#2f3138;--line-2:#3f4149;--line-3:#25272f;--focus:#d4a44a;
17
+ --brand:#d4a44a;--brand-fg:#1a1306;--brand-hover:#e0b25c;
18
+ --rose:#d68a8c;--rose-bg:rgba(209,135,137,.12);--rose-border:rgba(209,135,137,.30);
19
+ --green:#a3c98e;--green-bg:rgba(158,196,139,.12);--green-border:rgba(158,196,139,.30);
20
+ --blue:#87a9e0;--blue-bg:rgba(130,168,230,.12);--blue-border:rgba(130,168,230,.30);
21
+ --mauve:#c89cdf;--mauve-bg:rgba(195,154,230,.12);--mauve-border:rgba(195,154,230,.30);
22
+ --amber:#e3c281;--amber-bg:rgba(229,192,123,.12);--amber-border:rgba(229,192,123,.30);
23
+ --shadow:0 2px 4px rgba(0,0,0,.35),0 4px 12px rgba(0,0,0,.50);--shadow-strong:0 4px 8px rgba(0,0,0,.40),0 16px 32px rgba(0,0,0,.60);--inset:inset 0 1px 0 rgba(255,255,255,.04);--edge:inset 0 -1px 0 rgba(0,0,0,.40)
24
+ }
25
+ [data-theme="light"]{color-scheme:light;--canvas:#eef0f3;--bg:#f4f5f7;--surface:#fff;--surface-2:#fafbfc;--surface-3:#f0f1f4;--elevated:#fff;--overlay:rgba(20,22,26,.45);--ink:#1a1d23;--muted:#3d434c;--soft:#6b727d;--faint:#9aa0aa;--line:#e3e5e9;--line-2:#cdd0d6;--line-3:#eef0f3;--focus:#5a7896;--brand:#1a1d23;--brand-fg:#fff;--brand-hover:#2f3640;--rose:#ad6f73;--rose-bg:rgba(173,111,115,.10);--rose-border:rgba(173,111,115,.35);--green:#5f8a5f;--green-bg:rgba(95,138,95,.10);--green-border:rgba(95,138,95,.35);--blue:#4f7896;--blue-bg:rgba(79,120,150,.10);--blue-border:rgba(79,120,150,.35);--mauve:#886f9c;--mauve-bg:rgba(136,111,156,.10);--mauve-border:rgba(136,111,156,.35);--amber:#b08544;--amber-bg:rgba(176,133,68,.10);--amber-border:rgba(176,133,68,.35);--shadow:0 1px 2px rgba(20,22,26,.04),0 1px 3px rgba(20,22,26,.06);--shadow-strong:0 4px 8px rgba(20,22,26,.06),0 12px 28px rgba(20,22,26,.14);--inset:inset 0 1px 0 rgba(255,255,255,.6);--edge:inset 0 -1px 0 rgba(20,22,26,.08)}
26
+ :root{--mono:"JetBrains Mono",ui-monospace,"SF Mono",Menlo,monospace;--font:Inter,ui-sans-serif,system-ui,-apple-system,BlinkMacSystemFont,"Segoe UI",sans-serif}
27
+ html{background:var(--canvas)}
28
+ body{margin:0;font-family:var(--font);color:var(--ink);background:linear-gradient(180deg,var(--canvas),var(--bg));min-height:100vh;-webkit-font-smoothing:antialiased}
29
+ button,input{font:inherit}
30
+ .wrap{width:min(1160px,calc(100% - 40px));margin:0 auto;padding:22px 0 72px}
31
+ .loading{min-height:70vh;display:grid;place-items:center;color:var(--muted);font-weight:700}
32
+ @keyframes up{from{opacity:0;transform:translateY(8px)}to{opacity:1;transform:none}}
33
+ @keyframes fade{from{opacity:0}to{opacity:1}}
34
+ .anim{animation:up .4s cubic-bezier(.2,0,.2,1) both}
35
+ .view{animation:fade .28s ease both}
36
+ /* header */
37
+ header{display:flex;align-items:center;justify-content:space-between;gap:18px;margin-bottom:22px}
38
+ .brand{display:flex;align-items:center;gap:12px}
39
+ .mark{width:36px;height:36px;border-radius:9px;color:var(--brand-fg);background:linear-gradient(160deg,var(--brand),var(--brand-hover));display:grid;place-items:center;font-weight:850;font-size:14px;letter-spacing:.02em;box-shadow:var(--edge)}
40
+ h1{margin:0;font-size:18px;line-height:1.1;letter-spacing:-.01em}
41
+ .sub{margin-top:3px;color:var(--soft);font-size:11px;font-family:var(--mono);max-width:44ch;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
42
+ .actions{display:flex;align-items:center;gap:9px;flex-wrap:wrap;justify-content:flex-end}
43
+ .btn{cursor:pointer;border:1px solid var(--line);background:var(--surface);color:var(--ink);padding:8px 13px;border-radius:8px;font-size:12px;font-weight:700;box-shadow:var(--inset);transition:background-color .14s,border-color .14s,transform .14s}
44
+ .btn:hover{background:var(--surface-2);border-color:var(--line-2);transform:translateY(-1px)}
45
+ .btn:active{transform:translateY(0)}
46
+ .btn.primary{background:var(--brand);color:var(--brand-fg);border-color:transparent}
47
+ .btn.primary:hover{background:var(--brand-hover)}
48
+ /* summary band: KPIs + rate */
49
+ .summary{display:grid;grid-template-columns:repeat(4,1fr);gap:1px;background:var(--line);border:1px solid var(--line);border-radius:12px;overflow:hidden;box-shadow:var(--shadow),var(--inset);margin-bottom:14px}
50
+ .kpi{background:var(--surface);padding:16px 18px}
51
+ .kpi .label{color:var(--faint);font-size:10px;font-weight:750;text-transform:uppercase;letter-spacing:.08em;font-family:var(--mono)}
52
+ .kpi .value{margin-top:7px;font-size:25px;line-height:1;font-weight:780;letter-spacing:-.01em}
53
+ .kpi .hint{margin-top:7px;color:var(--soft);font-size:12px;font-weight:600;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
54
+ .ratebar{display:flex;align-items:center;gap:20px;flex-wrap:wrap;background:var(--surface);border:1px solid var(--line);border-radius:12px;box-shadow:var(--shadow),var(--inset);padding:13px 18px;margin-bottom:20px}
55
+ .ratebar .plan{display:flex;align-items:center;gap:9px;padding-right:18px;border-right:1px solid var(--line)}
56
+ .ratebar .plan .name{font-size:14px;font-weight:800;text-transform:capitalize}
57
+ .rl{flex:1;min-width:200px;display:flex;flex-direction:column;gap:6px}
58
+ .rl-top{display:flex;align-items:baseline;justify-content:space-between;gap:10px}
59
+ .rl-top .k{font-size:10px;font-weight:750;text-transform:uppercase;letter-spacing:.07em;font-family:var(--mono);color:var(--soft)}
60
+ .rl-top .v{font-size:13px;font-weight:780;font-family:var(--mono)}
61
+ .rl-track{height:7px;background:var(--surface-3);border-radius:999px;overflow:hidden}
62
+ .rl-fill{height:100%;border-radius:inherit;transition:width .5s cubic-bezier(.2,0,.2,1)}
63
+ .rl-reset{color:var(--soft);font-size:11px;font-weight:600;font-family:var(--mono)}
64
+ /* tabs */
65
+ .tabs{display:flex;gap:4px;padding:4px;background:var(--surface);border:1px solid var(--line);border-radius:11px;box-shadow:var(--inset);margin-bottom:20px;width:fit-content;max-width:100%;overflow:auto}
66
+ .tab{cursor:pointer;border:0;background:transparent;color:var(--soft);padding:9px 16px;border-radius:8px;font-size:13px;font-weight:700;white-space:nowrap;transition:background-color .14s,color .14s}
67
+ .tab:hover{color:var(--ink)}
68
+ .tab.on{background:var(--brand);color:var(--brand-fg)}
69
+ /* agent switcher */
70
+ .agentbar{display:flex;gap:8px;flex-wrap:wrap;margin-bottom:18px}
71
+ .agent{display:inline-flex;align-items:center;gap:9px;cursor:pointer;border:1px solid var(--line);background:var(--surface);color:var(--muted);padding:8px 14px 8px 8px;border-radius:11px;font-size:13px;font-weight:700;box-shadow:var(--inset);transition:border-color .14s,color .14s,transform .14s,background-color .14s}
72
+ .agent:hover:not(:disabled){border-color:var(--line-2);color:var(--ink);transform:translateY(-1px)}
73
+ .agent.on{border-color:var(--brand);color:var(--ink);background:color-mix(in srgb,var(--brand) 12%,var(--surface))}
74
+ .agent:disabled{opacity:.5;cursor:not-allowed}
75
+ .agent .amark{width:26px;height:26px;border-radius:7px;display:grid;place-items:center;font-size:11px;font-weight:850;letter-spacing:.02em;flex:none}
76
+ /* cards & grids */
77
+ .card{background:var(--surface);border:1px solid var(--line);border-radius:12px;box-shadow:var(--shadow),var(--inset)}
78
+ .section{padding:20px}
79
+ .section-head{display:flex;align-items:center;justify-content:space-between;gap:12px;margin-bottom:16px}
80
+ h2{margin:0;font-size:14px;letter-spacing:-.01em;font-weight:760}
81
+ .muted{color:var(--soft);font-size:12px}
82
+ .grid{display:grid;gap:16px}
83
+ .row2{grid-template-columns:1fr 1fr;margin-top:16px}
84
+ .mb{margin-bottom:16px}
85
+ .badge{display:inline-flex;align-items:center;border-radius:999px;padding:3px 8px;font-size:11px;font-weight:750;background:var(--blue-bg);border:1px solid var(--blue-border);color:var(--blue)}
86
+ .warnbox{padding:12px 14px;margin-bottom:16px;color:var(--amber);background:var(--amber-bg);border:1px solid var(--amber-border);border-radius:10px;font-size:13px;font-weight:650}
87
+ canvas{width:100%;max-width:100%;display:block}
88
+ .legend{display:flex;gap:14px;flex-wrap:wrap;color:var(--soft);font-size:12px;font-weight:650;margin-top:10px}
89
+ .dot{width:10px;height:10px;border-radius:3px;display:inline-block;margin-right:6px;vertical-align:-1px}
90
+ /* insights */
91
+ .insights{display:grid;gap:11px}
92
+ .insights.col{max-width:760px}
93
+ .insight{padding:14px 16px;border:1px solid var(--line);border-radius:11px;background:var(--surface-2);box-shadow:var(--inset);border-left-width:3px}
94
+ .insight .ihead{display:flex;align-items:center;gap:9px}
95
+ .insight .ico{width:18px;height:18px;flex:none}
96
+ .insight strong{font-size:13px;display:block;line-height:1.35}
97
+ .insight .detail{display:block;color:var(--muted);font-size:12.5px;line-height:1.55;margin-top:7px}
98
+ .insight .act{display:flex;gap:7px;color:var(--soft);font-size:12.5px;line-height:1.55;margin-top:9px;padding-top:9px;border-top:1px dashed var(--line)}
99
+ .insight .act b{color:var(--ink);font-weight:750;flex:none}
100
+ .warning{border-left-color:var(--amber)}.info{border-left-color:var(--blue)}.neutral{border-left-color:var(--green)}
101
+ /* bar lists */
102
+ .bar-row{display:grid;grid-template-columns:minmax(0,1fr) auto;gap:12px;align-items:center;padding:11px 0;border-bottom:1px solid var(--line-3)}
103
+ .bar-row.click{cursor:pointer}
104
+ .bar-row:last-child{border-bottom:0}
105
+ .bar-row.click:hover .bar-name{color:var(--brand)}
106
+ .bar-name{font-weight:720;font-size:13px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
107
+ .bar-track{height:6px;background:var(--surface-3);border-radius:999px;overflow:hidden;margin-top:7px}
108
+ .bar-fill{height:100%;background:linear-gradient(90deg,var(--brand),var(--blue));border-radius:inherit}
109
+ .num{text-align:right;font-family:var(--mono);font-weight:720;white-space:nowrap}
110
+ /* prompts list */
111
+ .plist{display:grid;gap:10px}
112
+ .prow{display:grid;grid-template-columns:auto minmax(0,1fr) auto;gap:14px;align-items:center;padding:13px 14px;border:1px solid var(--line);border-radius:11px;background:var(--surface-2);cursor:pointer;transition:border-color .14s,transform .14s}
113
+ .prow:hover{border-color:var(--focus);transform:translateY(-1px)}
114
+ .prow .rk{width:26px;height:26px;border-radius:7px;background:var(--surface-3);color:var(--soft);font-weight:800;display:grid;place-items:center;font-size:12px;font-family:var(--mono)}
115
+ .prow .pt{font-weight:700;font-size:13px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
116
+ .token-strip{display:flex;height:6px;border-radius:999px;overflow:hidden;background:var(--surface-3);margin-top:8px}
117
+ .fresh{background:var(--blue)}.cached{background:var(--green)}.reason{background:var(--mauve)}.out{background:var(--amber)}
118
+ /* weekday */
119
+ .wk{display:grid;gap:10px}
120
+ .wk-row{display:grid;grid-template-columns:34px 1fr auto;gap:11px;align-items:center}
121
+ .wk-row .d{font-size:11px;font-weight:750;color:var(--soft);font-family:var(--mono)}
122
+ .wk-track{height:8px;background:var(--surface-3);border-radius:999px;overflow:hidden}
123
+ .wk-fill{height:100%;background:linear-gradient(90deg,var(--mauve),var(--brand));border-radius:inherit}
124
+ /* sessions table */
125
+ .table-card{overflow:hidden}
126
+ .table-top{display:flex;align-items:center;justify-content:space-between;padding:15px 18px;gap:12px;border-bottom:1px solid var(--line);flex-wrap:wrap}
127
+ .table-tools{display:flex;align-items:center;gap:10px;flex-wrap:wrap}
128
+ .chips{display:flex;gap:6px;flex-wrap:wrap}
129
+ .chip{cursor:pointer;border:1px solid var(--line);background:var(--surface);color:var(--soft);border-radius:999px;padding:5px 10px;font-size:11px;font-weight:700;transition:all .14s}
130
+ .chip:hover{border-color:var(--line-2);color:var(--ink)}
131
+ .chip.on{background:var(--brand);color:var(--brand-fg);border-color:transparent}
132
+ .search{width:min(260px,100%);border:1px solid var(--line);background:var(--surface-2);color:var(--ink);border-radius:8px;padding:9px 11px;outline:none}
133
+ .search::placeholder{color:var(--faint)}
134
+ .search:focus{border-color:var(--focus);box-shadow:0 0 0 3px color-mix(in srgb,var(--focus) 20%,transparent)}
135
+ table{width:100%;border-collapse:collapse;font-size:13px}
136
+ th{text-align:left;color:var(--faint);font-size:10px;text-transform:uppercase;letter-spacing:.08em;background:var(--surface-2);cursor:pointer;user-select:none;font-family:var(--mono);font-weight:650;white-space:nowrap}
137
+ th .ar{opacity:.55;margin-left:3px}
138
+ th,td{padding:12px 14px;border-bottom:1px solid var(--line-3);vertical-align:top}
139
+ tr:last-child td{border-bottom:0}
140
+ tbody tr{cursor:pointer;transition:background-color .14s}
141
+ tbody tr:hover td{background:color-mix(in srgb,var(--surface-2) 70%,transparent)}
142
+ .title{font-weight:720;max-width:420px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
143
+ .selected td{background:var(--amber-bg)!important}
144
+ .empty{padding:38px;color:var(--soft);text-align:center}
145
+ /* drawer */
146
+ .scrim{position:fixed;inset:0;background:var(--overlay);backdrop-filter:blur(3px);z-index:50;opacity:0;pointer-events:none;transition:opacity .25s}
147
+ .scrim.open{opacity:1;pointer-events:auto}
148
+ .drawer{position:fixed;top:0;right:0;height:100vh;width:min(760px,100%);background:var(--bg);border-left:1px solid var(--line);box-shadow:var(--shadow-strong);z-index:51;transform:translateX(100%);transition:transform .3s cubic-bezier(.2,0,.2,1);display:flex;flex-direction:column;overflow:hidden}
149
+ .drawer.open{transform:none}
150
+ #drawerContent{display:flex;flex-direction:column;flex:1 1 auto;min-height:0;width:100%}
151
+ .drawer-head{display:flex;align-items:flex-start;justify-content:space-between;gap:14px;padding:18px 20px;border-bottom:1px solid var(--line);background:var(--surface);flex:none}
152
+ .drawer-title{font-size:16px;font-weight:790;line-height:1.35}
153
+ .drawer-meta{margin-top:5px;color:var(--soft);font-size:12px;display:flex;gap:7px;flex-wrap:wrap;align-items:center}
154
+ .iconbtn{cursor:pointer;border:1px solid var(--line);background:var(--surface);color:var(--muted);width:32px;height:32px;border-radius:8px;font-size:16px;line-height:1;display:grid;place-items:center;flex:none}
155
+ .iconbtn:hover{background:var(--surface-2);color:var(--ink)}
156
+ .drawer-body{padding:18px 20px;overflow-y:auto;overflow-x:hidden;display:grid;gap:16px;min-width:0;flex:1 1 auto;min-height:0;-webkit-overflow-scrolling:touch}
157
+ .drawer-body>*{min-width:0;max-width:100%}
158
+ .mini-stats{display:grid;grid-template-columns:repeat(5,1fr);gap:8px}
159
+ .mini{border:1px solid var(--line);border-radius:9px;background:var(--surface-2);padding:10px}
160
+ .mini .label{color:var(--faint);font-size:10px;font-weight:750;text-transform:uppercase;letter-spacing:.06em;font-family:var(--mono)}
161
+ .mini .value{font-size:17px;margin-top:5px;font-weight:780}
162
+ .prompt-rail{display:flex;gap:8px;overflow-x:auto;padding-bottom:4px}
163
+ .prail-item{flex:0 0 220px;border:1px solid var(--line);background:var(--surface-2);border-radius:9px;padding:10px;cursor:pointer;transition:border-color .14s}
164
+ .prail-item:hover,.prail-item.active{border-color:var(--focus)}
165
+ .prail-item .pt{font-size:12px;font-weight:680;line-height:1.4;display:-webkit-box;-webkit-line-clamp:2;-webkit-box-orient:vertical;overflow:hidden}
166
+ .prompt-full{white-space:pre-wrap;background:var(--surface-2);border:1px solid var(--line);border-radius:9px;padding:13px;font-size:12.5px;line-height:1.55;max-height:160px;overflow:auto;font-family:var(--mono)}
167
+ .tool-chip{display:inline-flex;margin:5px 5px 0 0;padding:2px 7px;border:1px solid var(--mauve-border);border-radius:999px;background:var(--mauve-bg);color:var(--mauve);font-size:11px;font-weight:720}
168
+ .turns{display:grid;gap:8px}
169
+ .turn{display:grid;grid-template-columns:30px minmax(0,1fr) auto;gap:10px;align-items:start;padding:10px;border:1px solid var(--line);border-radius:9px;background:var(--surface-2)}
170
+ .turn .rk{width:26px;height:26px;border-radius:7px;background:var(--amber-bg);border:1px solid var(--amber-border);color:var(--amber);font-weight:800;display:grid;place-items:center;font-size:11px}
171
+ /* share overlay */
172
+ .overlay{position:fixed;inset:0;background:var(--overlay);backdrop-filter:blur(4px);z-index:60;display:none;align-items:center;justify-content:center;padding:20px}
173
+ .overlay.open{display:flex}
174
+ .share-box{background:var(--surface);border:1px solid var(--line);border-radius:14px;box-shadow:var(--shadow-strong);padding:20px;width:min(680px,100%);max-height:92vh;overflow:auto}
175
+ .share-box h3{margin:0 0 4px;font-size:16px}
176
+ .share-box p{margin:0 0 14px;color:var(--soft);font-size:13px}
177
+ .share-box canvas{width:100%;height:auto;border-radius:10px;border:1px solid var(--line)}
178
+ .share-actions{display:flex;gap:10px;justify-content:flex-end;margin-top:14px}
179
+ .chart-tip{position:fixed;z-index:70;pointer-events:none;background:var(--elevated);border:1px solid var(--line-2);border-radius:9px;box-shadow:var(--shadow-strong);padding:9px 11px;font-size:12px;color:var(--ink);opacity:0;transform:translateY(4px);transition:opacity .12s,transform .12s;max-width:260px;left:0;top:0}
180
+ .chart-tip.on{opacity:1;transform:none}
181
+ .chart-tip .t{font-weight:760;margin-bottom:5px;font-size:12px}
182
+ .chart-tip .r{display:flex;align-items:center;justify-content:space-between;gap:16px;color:var(--soft);font-family:var(--mono);font-size:11px;line-height:1.7}
183
+ .chart-tip .r b{color:var(--ink);font-weight:700}
184
+ .chart-tip .r i{width:8px;height:8px;border-radius:2px;display:inline-block;margin-right:6px;font-style:normal}
185
+ .hoverable{cursor:crosshair}
186
+ @media(max-width:860px){.summary{grid-template-columns:1fr 1fr}.row2{grid-template-columns:1fr}.ratebar .plan{border-right:0}.mini-stats{grid-template-columns:1fr 1fr}}
187
+ @media(max-width:560px){.wrap{width:min(100% - 24px,1160px)}header{flex-direction:column;align-items:flex-start}.actions{justify-content:flex-start}th:nth-child(3),td:nth-child(3),th:nth-child(7),td:nth-child(7){display:none}}
188
+ </style>
189
+ </head>
190
+ <body>
191
+ <div id="app" class="loading">Loading agent usage&hellip;</div>
192
+ <div class="scrim" id="scrim" onclick="closeDrawer()"></div>
193
+ <div class="chart-tip" id="chartTip"></div>
194
+ <aside class="drawer" id="drawer" aria-hidden="true"><div id="drawerContent"></div></aside>
195
+ <div class="overlay" id="shareOverlay" onclick="if(event.target===this)closeShare()">
196
+ <div class="share-box">
197
+ <h3>Share your Codex stats</h3>
198
+ <p>A 1200&times;630 image rendered from your local data. Nothing is uploaded.</p>
199
+ <canvas id="shareCanvas" width="1200" height="630"></canvas>
200
+ <div class="share-actions">
201
+ <button class="btn" onclick="closeShare()">Close</button>
202
+ <button class="btn primary" onclick="downloadShare()">Download PNG</button>
203
+ </div>
204
+ </div>
205
+ </div>
206
+ <script>
207
+ let DATA=null, SOURCES=[], sourceId=localStorage.getItem('codex-spend-source')||null;
208
+ let sort={key:'updatedTimestamp',dir:'desc'}, query='', modelFilter='';
209
+ let activeTab=localStorage.getItem('codex-spend-tab')||'overview';
210
+ let selectedSessionId=null, selectedPromptRank=null, rlTimer=null;
211
+ let theme=localStorage.getItem('codex-spend-theme')||'dark';
212
+ document.documentElement.dataset.theme=theme;
213
+ const caps=()=>DATA&&DATA.capabilities||{};
214
+ const cssVar=name=>getComputedStyle(document.documentElement).getPropertyValue(name).trim();
215
+ const themeColor=cssVar;
216
+ const fmt=n=>{n=Number(n||0);if(n>=1_000_000)return(n/1_000_000).toFixed(1)+'M';if(n>=10_000)return Math.round(n/1000)+'K';if(n>=1000)return(n/1000).toFixed(1)+'K';return n.toLocaleString()};
217
+ const dateFmt=v=>!v||v==='unknown'?'unknown':new Date(v).toLocaleDateString(undefined,{month:'short',day:'numeric'});
218
+ const timeFmt=v=>!v?'':new Date(v).toLocaleString(undefined,{month:'short',day:'numeric',hour:'2-digit',minute:'2-digit'});
219
+ const esc=v=>String(v??'').replace(/[&<>"']/g,c=>({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#039;'}[c]));
220
+ const q1=s=>String(s).replace(/'/g,'&#039;');
221
+ const freshInput=o=>Math.max(0,(o.inputTokens||0)-(o.cachedInputTokens||0));
222
+ const visibleOut=o=>Math.max(0,(o.outputTokens||0)-(o.reasoningOutputTokens||0));
223
+
224
+ async function loadSources(){
225
+ try{const r=await fetch('/api/sources');const j=await r.json();SOURCES=j.sources||[];
226
+ const avail=SOURCES.filter(s=>s.available);
227
+ if(!sourceId||!SOURCES.find(s=>s.id===sourceId&&s.available))sourceId=(avail.find(s=>s.id===j.default)||avail[0]||SOURCES[0]||{id:'codex'}).id;
228
+ }catch{SOURCES=[]}
229
+ }
230
+ function setSource(id){if(id===sourceId)return;sourceId=id;localStorage.setItem('codex-spend-source',id);selectedSessionId=null;selectedPromptRank=null;query='';modelFilter='';closeDrawer();const app=document.getElementById('app');app.className='loading';app.textContent='Loading '+id+' usage…';fetchData().catch(showErr)}
231
+ function applyAccent(hex){if(!hex)return;const c=hex.replace('#','');const r=parseInt(c.slice(0,2),16),g=parseInt(c.slice(2,4),16),b=parseInt(c.slice(4,6),16);const lum=(0.299*r+0.587*g+0.114*b)/255;const fg=lum>0.6?'#1a1306':'#ffffff';const hover=`rgb(${Math.min(255,r+18)},${Math.min(255,g+18)},${Math.min(255,b+18)})`;const root=document.documentElement.style;root.setProperty('--brand',hex);root.setProperty('--brand-hover',hover);root.setProperty('--brand-fg',fg);root.setProperty('--focus',hex)}
232
+
233
+ async function fetchData(refresh=false){
234
+ const qs='?source='+encodeURIComponent(sourceId);
235
+ const res=await fetch((refresh?'/api/refresh':'/api/data')+qs);
236
+ if(!res.ok)throw new Error((await res.json()).error||'Failed to load data');
237
+ if(refresh){DATA=await(await fetch('/api/data'+qs)).json()}else DATA=await res.json();
238
+ if(DATA.source)applyAccent(DATA.source.accent);
239
+ if(!selectedSessionId&&DATA.sessions[0])selectedSessionId=DATA.sessions[0].sessionId;
240
+ render();
241
+ }
242
+
243
+ function render(){
244
+ const t=DATA.totals, app=document.getElementById('app'), src=DATA.source||{}, c=caps();
245
+ app.className='wrap';
246
+ app.innerHTML=`
247
+ <header class="anim"><div class="brand"><div class="mark">${esc(src.mark||'CX')}</div><div><h1>Agent Spend</h1><div class="sub" title="${esc(src.home||'')}">${esc(src.label||'')} · ${esc(src.home||'')}${t.dateRange?' · '+t.dateRange.from+' → '+t.dateRange.to:''}</div></div></div><div class="actions"><button class="btn" onclick="toggleTheme()">${theme==='dark'?'☀ Light':'☾ Dark'}</button><button class="btn" onclick="openShare()">Share</button><button class="btn primary" onclick="refresh()">↻ Refresh</button></div></header>
248
+ ${agentBar()}
249
+ ${DATA.warnings?.length?`<div class="warnbox anim">${DATA.warnings.map(w=>esc(w.message)).join('<br>')}</div>`:''}
250
+ ${!DATA.sessions.length?`<div class="card empty anim">No ${esc(src.label||'agent')} sessions with token usage found.<div class="muted" style="margin-top:8px">${esc(src.home||'')}</div></div>`:`
251
+ <div class="summary anim">${kpiStrip(t,c)}</div>
252
+ ${c.rateLimit?ratebar(t.latestRateLimit):''}
253
+ <div class="tabs anim">
254
+ ${tabBtn('overview','Overview')}${tabBtn('sessions','Sessions')}${tabBtn('prompts','Prompts')}${tabBtn('insights',`Insights · ${DATA.insights.length}`)}
255
+ </div>
256
+ <div id="view" class="view"></div>`}`;
257
+ if(DATA.sessions.length){renderView();if(c.rateLimit)startRlTimer()}
258
+ }
259
+
260
+ function agentBar(){if(!SOURCES.length)return'';return`<div class="agentbar anim">${SOURCES.map(s=>`<button class="agent ${s.id===sourceId?'on':''}" ${s.available?'':'disabled'} title="${s.available?esc(s.home):'not detected on this machine'}" onclick="setSource('${s.id}')"><span class="amark" style="background:${s.available?s.accent:'var(--surface-3)'};color:${s.available?accentFg(s.accent):'var(--faint)'}">${esc(s.mark)}</span>${esc(s.label)}${s.available?'':' <span class="muted" style="font-size:10px">·n/a</span>'}</button>`).join('')}</div>`}
261
+ function accentFg(hex){if(!hex)return'#fff';const c=hex.replace('#','');const lum=(0.299*parseInt(c.slice(0,2),16)+0.587*parseInt(c.slice(2,4),16)+0.114*parseInt(c.slice(4,6),16))/255;return lum>0.6?'#1a1306':'#fff'}
262
+ function kpiStrip(t,c){const cachePct=t.totalTokens?Math.round(t.cachedInputTokens/t.totalTokens*100):0;const reasonPct=t.outputTokens?Math.round(t.reasoningOutputTokens/t.outputTokens*100):0;
263
+ let third;
264
+ if(c.reasoning)third=kpi('Reasoning',fmt(t.reasoningOutputTokens),`${reasonPct}% of output`);
265
+ else if(c.cost)third=kpi('Est. Cost',(t.totalCost>=1000?'$'+(t.totalCost/1000).toFixed(1)+'K':'$'+t.totalCost.toFixed(2)),'API-rate estimate');
266
+ else third=kpi('Output',fmt(t.outputTokens),`${t.totalTokens?Math.round(t.outputTokens/t.totalTokens*100):0}% of tokens`);
267
+ return kpi('Total Tokens',fmt(t.totalTokens),`${fmt(t.inputTokens)} in · ${fmt(t.outputTokens)} out`)
268
+ +kpi('Cached Input',fmt(t.cachedInputTokens),`${cachePct}% of all tokens`)
269
+ +third
270
+ +kpi('Sessions',fmt(t.totalSessions),`${fmt(t.totalPrompts)} prompts · ${fmt(t.totalToolCalls)} tools`)}
271
+ function kpi(label,value,hint){return`<div class="kpi"><div class="label">${label}</div><div class="value">${value}</div><div class="hint">${esc(hint)}</div></div>`}
272
+ function showErr(err){const app=document.getElementById('app');app.className='wrap';app.innerHTML=`<div class="card empty">${esc(err.message||err)}</div>`}
273
+ function tabBtn(id,label){return`<button class="tab ${activeTab===id?'on':''}" onclick="setTab('${id}')">${label}</button>`}
274
+ function setTab(id){activeTab=id;localStorage.setItem('codex-spend-tab',id);document.querySelectorAll('.tab').forEach(b=>b.classList.toggle('on',b.getAttribute('onclick').includes("'"+id+"'")));renderView()}
275
+ function toggleTheme(){theme=theme==='dark'?'light':'dark';localStorage.setItem('codex-spend-theme',theme);document.documentElement.dataset.theme=theme;render()}
276
+
277
+ function renderView(){
278
+ const v=document.getElementById('view');if(!v)return;
279
+ v.classList.remove('view');void v.offsetWidth;v.classList.add('view');
280
+ if(activeTab==='overview')v.innerHTML=overviewView();
281
+ else if(activeTab==='sessions')v.innerHTML=sessionsView();
282
+ else if(activeTab==='prompts')v.innerHTML=promptsView();
283
+ else v.innerHTML=insightsView();
284
+ if(activeTab==='overview'){drawDailyChart();drawModelChart()}
285
+ if(activeTab==='sessions')wireSessions();
286
+ }
287
+
288
+ /* ===== OVERVIEW ===== */
289
+ function overviewView(){return`
290
+ <section class="card section mb"><div class="section-head"><h2>Daily Usage</h2><span class="muted">${DATA.dailyUsage.length} days</span></div><canvas id="dailyChart" height="220"></canvas><div class="legend">${legend()}</div></section>
291
+ <div class="grid row2" style="margin-top:0">
292
+ <section class="card section"><div class="section-head"><h2>Model Share</h2><span class="muted">${DATA.modelBreakdown.length} models</span></div><canvas id="modelChart" height="210"></canvas></section>
293
+ <section class="card section"><div class="section-head"><h2>Top Projects</h2></div>${barList(DATA.projectBreakdown.slice(0,7),'project','totalTokens','openProject')}</section>
294
+ </div>
295
+ <div class="grid row2">
296
+ <section class="card section"><div class="section-head"><h2>By Weekday</h2><span class="muted">avg / session</span></div>${weekdayList()}</section>
297
+ <section class="card section"><div class="section-head"><h2>Tools</h2></div>${barList(DATA.toolBreakdown.slice(0,8),'tool','count',null)}</section>
298
+ </div>`}
299
+ function legend(){return`<span><i class="dot" style="background:var(--blue)"></i>Fresh input</span>${caps().cache?'<span><i class="dot" style="background:var(--green)"></i>Cached input</span>':''}${caps().reasoning?'<span><i class="dot" style="background:var(--mauve)"></i>Reasoning</span>':''}<span><i class="dot" style="background:var(--amber)"></i>Output</span>`}
300
+
301
+ /* ===== SESSIONS ===== */
302
+ function sessionsView(){return`
303
+ <section class="card table-card">
304
+ <div class="table-top"><div><h2>Sessions</h2><div class="muted">${DATA.sessions.length} parsed · click a row to inspect</div></div><div class="table-tools"><div class="chips">${modelChips()}</div><input id="search" class="search" placeholder="Search sessions…" value="${esc(query)}"></div></div>
305
+ <table><thead><tr>
306
+ <th data-sort="updatedTimestamp">Updated${arrow('updatedTimestamp')}</th>
307
+ <th data-sort="title">Session${arrow('title')}</th>
308
+ <th data-sort="project">Project${arrow('project')}</th>
309
+ <th data-sort="model">Model${arrow('model')}</th>
310
+ <th class="num" data-sort="totalTokens">Tokens${arrow('totalTokens')}</th>
311
+ <th class="num" data-sort="promptCount">Prompts${arrow('promptCount')}</th>
312
+ <th class="num" data-sort="turnCount">Turns${arrow('turnCount')}</th>
313
+ <th class="num" data-sort="toolCount">Tools${arrow('toolCount')}</th>
314
+ </tr></thead><tbody id="sessionsBody"></tbody></table>
315
+ </section>`}
316
+ function arrow(key){return sort.key===key?`<span class="ar">${sort.dir==='desc'?'▾':'▴'}</span>`:''}
317
+ function modelChips(){const models=DATA.modelBreakdown.map(m=>m.model);return [`<span class="chip ${modelFilter===''?'on':''}" onclick="setModel('')">All</span>`].concat(models.map(m=>`<span class="chip ${modelFilter===m?'on':''}" onclick="setModel('${q1(esc(m))}')">${esc(m)}</span>`)).join('')}
318
+ function setModel(m){modelFilter=m;renderView()}
319
+ function wireSessions(){renderRows();document.querySelectorAll('th[data-sort]').forEach(th=>{th.onclick=()=>{const key=th.dataset.sort;sort={key,dir:sort.key===key&&sort.dir==='desc'?'asc':'desc'};renderView()}});const sb=document.getElementById('search');if(sb)sb.oninput=e=>{query=e.target.value;renderRows()}}
320
+ function renderRows(){const tbody=document.getElementById('sessionsBody');if(!tbody)return;let rows=[...DATA.sessions];if(modelFilter)rows=rows.filter(s=>s.model===modelFilter);if(query.trim()){const qq=query.toLowerCase();rows=rows.filter(s=>(s.title||'').toLowerCase().includes(qq)||(s.project||'').toLowerCase().includes(qq)||(s.model||'').toLowerCase().includes(qq))}rows.sort((a,b)=>{const av=a[sort.key]??'',bv=b[sort.key]??'';const cmp=typeof av==='number'&&typeof bv==='number'?av-bv:String(av).localeCompare(String(bv));return sort.dir==='desc'?-cmp:cmp});tbody.innerHTML=rows.length?rows.map(s=>`<tr class="${s.sessionId===selectedSessionId?'selected':''}" onclick="openSession('${s.sessionId}')"><td>${dateFmt(s.updatedTimestamp||s.timestamp)}<div class="muted">${s.archived?'archived':'active'}</div></td><td><div class="title" title="${esc(s.title)}">${esc(s.title)}</div><div class="muted">${esc(s.sessionId).slice(0,18)}…</div></td><td>${esc(s.project)}</td><td><span class="badge">${esc(s.model)}</span></td><td class="num">${fmt(s.totalTokens)}</td><td class="num">${fmt(s.promptCount)}</td><td class="num">${fmt(s.turnCount)}</td><td class="num">${fmt(s.toolCount)}</td></tr>`).join(''):'<tr><td colspan="8" class="empty">No sessions match this filter.</td></tr>'}
321
+ function openProject(project){query=project;modelFilter='';setTab('sessions')}
322
+
323
+ /* ===== PROMPTS ===== */
324
+ function promptsView(){const items=DATA.topPrompts.slice(0,25);if(!items.length)return'<div class="card empty">No prompt data yet.</div>';const max=Math.max(...items.map(i=>i.totalTokens||0),1);return`<section class="card section"><div class="section-head"><h2>Most Expensive Prompts</h2><span class="muted">top ${items.length} · click to drill in</span></div><div class="plist">${items.map((p,i)=>`<div class="prow" onclick="openSession('${p.sessionId}',${promptRankForTop(p)})"><div class="rk">${i+1}</div><div><div class="pt" title="${esc(p.prompt)}">${esc(p.prompt)}</div><div class="muted" style="margin-top:3px">${esc(p.project)} · ${fmt(p.turnCount)} turns · ${toolText(p.tools)}</div><div class="token-strip" style="width:${Math.max(14,(p.totalTokens/max)*100)}%">${strip(p)}</div></div><div class="num">${fmt(p.totalTokens)}<div class="muted" style="font-weight:600">tokens</div></div></div>`).join('')}</div></section>`}
325
+ function promptRankForTop(p){const s=DATA.sessions.find(x=>x.sessionId===p.sessionId);const match=s?.promptBreakdown.find(x=>x.prompt===p.prompt&&x.totalTokens===p.totalTokens);return match?match.rank:1}
326
+ function toolText(tools){return tools?.length?tools.slice(0,2).map(t=>`${t.tool} ${t.count}`).join(' · '):'no tools'}
327
+ function strip(o){const total=Math.max(o.totalTokens||((o.inputTokens||0)+(o.outputTokens||0)),1),fresh=freshInput(o),cached=o.cachedInputTokens||0,reason=o.reasoningOutputTokens||0,out=visibleOut(o);return`<span class="fresh" style="width:${fresh/total*100}%"></span><span class="cached" style="width:${cached/total*100}%"></span><span class="reason" style="width:${reason/total*100}%"></span><span class="out" style="width:${out/total*100}%"></span>`}
328
+
329
+ /* ===== INSIGHTS ===== */
330
+ function insightsView(){const ins=DATA.insights.length?DATA.insights:[{type:'neutral',title:'Usage parsed',detail:'Open a session to inspect prompt-level token tracking.'}];return`<section class="card section"><div class="section-head"><h2>Insights</h2><span class="muted">${ins.length} findings from your local data</span></div><div class="insights col">${ins.map(insight).join('')}</div></section>`}
331
+ function insightIcon(type){const c={warning:'--amber',info:'--blue',neutral:'--green'}[type]||'--blue';const path={warning:'M12 2 1 21h22z M12 9v5 M12 17v.5',info:'M12 22a10 10 0 1 0 0-20 10 10 0 0 0 0 20 M12 11v6 M12 7v.5',neutral:'M12 22a10 10 0 1 0 0-20 10 10 0 0 0 0 20 M8 12l3 3 5-6'}[type]||'';return`<svg class="ico" viewBox="0 0 24 24" fill="none" stroke="var(${c})" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="${path}"/></svg>`}
332
+ function insight(x){return`<div class="insight ${esc(x.type)}"><div class="ihead">${insightIcon(x.type)}<strong>${esc(x.title)}</strong></div><span class="detail">${esc(x.detail)}</span>${x.action?`<span class="act"><b>Try&nbsp;this:</b><span>${esc(x.action)}</span></span>`:''}</div>`}
333
+
334
+ /* ===== shared widgets ===== */
335
+ function barList(items,nameKey,valueKey,clicker){if(!items.length)return'<div class="empty">No data yet.</div>';const max=Math.max(...items.map(i=>i[valueKey]||0),1);return items.map(item=>`<div class="bar-row ${clicker?'click':''}" ${clicker?`onclick="${clicker}('${q1(esc(String(item[nameKey])))}')"`:''}><div><div class="bar-name" title="${esc(item[nameKey])}">${esc(item[nameKey])}</div><div class="bar-track"><div class="bar-fill" style="width:${Math.max(3,(item[valueKey]||0)/max*100)}%"></div></div></div><div class="num">${fmt(item[valueKey])}</div></div>`).join('')}
336
+ function weekdayList(){const w=DATA.weekdayUsage||[];if(!w.length)return'<div class="empty">No weekday data.</div>';const max=Math.max(...w.map(d=>d.avgTokens||0),1);return`<div class="wk">${w.map(d=>`<div class="wk-row"><span class="d">${d.name.slice(0,3)}</span><div class="wk-track"><div class="wk-fill" style="width:${Math.max(3,(d.avgTokens||0)/max*100)}%"></div></div><span class="num" style="font-size:12px">${fmt(d.avgTokens)}</span></div>`).join('')}</div>`}
337
+
338
+ /* ===== DRAWER (session drilldown) ===== */
339
+ function openSession(id,rank=null){const session=DATA.sessions.find(s=>s.sessionId===id);if(!session)return;selectedSessionId=id;selectedPromptRank=(rank&&session.promptBreakdown.some(p=>p.rank===rank))?rank:(session.promptBreakdown[0]?.rank||1);document.getElementById('drawer').classList.add('open');document.getElementById('scrim').classList.add('open');document.getElementById('drawer').setAttribute('aria-hidden','false');document.body.style.overflow='hidden';renderDrawer();if(document.getElementById('sessionsBody'))renderRows()}
340
+ function closeDrawer(){document.getElementById('drawer').classList.remove('open');document.getElementById('scrim').classList.remove('open');document.getElementById('drawer').setAttribute('aria-hidden','true');document.body.style.overflow=''}
341
+ function selectPrompt(rank){selectedPromptRank=rank;renderDrawer()}
342
+ function renderDrawer(){const session=DATA.sessions.find(s=>s.sessionId===selectedSessionId);if(!session)return;const p=session.promptBreakdown.find(x=>x.rank===selectedPromptRank)||session.promptBreakdown[0];const ctxPct=session.contextWindow&&session.peakInputTokens?Math.round(session.peakInputTokens/session.contextWindow*100):null;
343
+ document.getElementById('drawerContent').innerHTML=`
344
+ <div class="drawer-head"><div><div class="drawer-title">${esc(session.title)}</div><div class="drawer-meta"><span>${esc(session.project)}</span>·<span class="badge">${esc(session.model)}</span>·<span>${fmt(session.totalTokens)} tokens</span>·<span>${fmt(session.promptCount)} prompts</span>${ctxPct!=null?`·<span>peak ctx ${ctxPct}%</span>`:''}</div></div><button class="iconbtn" onclick="closeDrawer()" title="Close">×</button></div>
345
+ <div class="drawer-body">
346
+ <div><div class="muted" style="margin-bottom:8px;font-weight:700">Prompts in session (${session.promptBreakdown.length})</div><div class="prompt-rail">${session.promptBreakdown.map(pp=>`<div class="prail-item ${pp.rank===selectedPromptRank?'active':''}" onclick="selectPrompt(${pp.rank})"><div class="pt">${esc(pp.prompt)}</div><div class="muted" style="margin-top:5px;font-size:11px">${fmt(pp.totalTokens)} · ${fmt(pp.turnCount)} turns</div><div class="token-strip">${strip(pp)}</div></div>`).join('')}</div></div>
347
+ ${p?promptDetail(p):''}
348
+ </div>`;
349
+ if(p)requestAnimationFrame(()=>requestAnimationFrame(()=>drawTurnChart(p)))}
350
+ function promptDetail(p){return`<div><div class="section-head"><h2>Prompt #${p.rank}</h2><span class="badge">${esc(p.model)}</span></div><div class="prompt-full">${esc(p.prompt)}</div></div>
351
+ <div class="mini-stats"><div class="mini"><div class="label">Total</div><div class="value">${fmt(p.totalTokens)}</div></div><div class="mini"><div class="label">Fresh In</div><div class="value">${fmt(freshInput(p))}</div></div><div class="mini"><div class="label">Cached</div><div class="value">${fmt(p.cachedInputTokens)}</div></div><div class="mini"><div class="label">${caps().reasoning?'Reasoning':'Output'}</div><div class="value">${fmt(caps().reasoning?p.reasoningOutputTokens:p.outputTokens)}</div></div><div class="mini"><div class="label">Turns</div><div class="value">${fmt(p.turnCount)}</div></div></div>
352
+ <div class="card section" style="padding:14px"><div class="muted" style="font-weight:700;margin-bottom:6px">Token cost per turn</div><canvas id="turnChart" height="170"></canvas></div>
353
+ ${(p.tools||[]).length?`<div>${p.tools.map(t=>`<span class="tool-chip">${esc(t.tool)} · ${fmt(t.count)}</span>`).join('')}</div>`:''}
354
+ <div class="turns">${p.turns.map((t,i)=>`<div class="turn"><div class="rk">${i+1}</div><div><strong style="font-size:12px">${timeFmt(t.timestamp)}</strong><div class="muted">${esc(t.model)}${t.contextWindow?' · ctx '+fmt(t.contextWindow):''}</div><div class="token-strip">${strip(t)}</div></div><div class="num">${fmt(t.totalTokens)}<div class="muted" style="font-weight:600">${fmt(freshInput(t))} fresh · ${fmt(t.cachedInputTokens)} cached · ${fmt(t.outputTokens)} out</div></div></div>`).join('')}</div>`}
355
+
356
+ /* ===== rate-limit bar ===== */
357
+ function windowLabel(min){if(!min)return'window';if(min%1440===0)return(min/1440)+'-day';if(min%60===0)return(min/60)+'-hour';return min+'-min'}
358
+ function resetText(unixSec){if(!unixSec)return'';const ms=unixSec*1000-Date.now();if(ms<=0)return'resetting now';const m=Math.floor(ms/60000),h=Math.floor(m/60),d=Math.floor(h/24);if(d>=1)return`resets in ${d}d ${h%24}h`;if(h>=1)return`resets in ${h}h ${m%60}m`;return`resets in ${m}m`}
359
+ function rlColor(pct){if(pct==null)return'var(--blue)';if(pct>=80)return'var(--rose)';if(pct>=50)return'var(--amber)';return'var(--green)'}
360
+ function rlSeg(k,win,pct,reset){const p=pct==null?0:pct;return`<div class="rl"><div class="rl-top"><span class="k">${k} · ${windowLabel(win)}</span><span class="v" style="color:${rlColor(pct)}">${pct==null?'n/a':pct+'%'}</span></div><div class="rl-track"><div class="rl-fill" style="width:${Math.min(100,p)}%;background:${rlColor(pct)}"></div></div><div class="rl-reset" data-reset="${reset||''}">${reset?resetText(reset):'no reset recorded'}</div></div>`}
361
+ function ratebar(r){if(!r)return'';return`<div class="ratebar anim"><div class="plan"><span class="muted" style="font-size:11px;font-weight:700;text-transform:uppercase;letter-spacing:.06em">Plan</span><span class="name">${esc(r.planType||'unknown')}</span>${r.reachedType?`<span class="badge" style="background:var(--rose-bg);border-color:var(--rose-border);color:var(--rose)">limit hit</span>`:''}</div>${rlSeg('Primary',r.primaryWindowMinutes,r.primaryUsedPercent,r.primaryResetsAt)}${rlSeg('Weekly',r.secondaryWindowMinutes,r.secondaryUsedPercent,r.secondaryResetsAt)}</div>`}
362
+ function startRlTimer(){if(rlTimer)clearInterval(rlTimer);rlTimer=setInterval(()=>{document.querySelectorAll('.rl-reset[data-reset]').forEach(el=>{const v=el.getAttribute('data-reset');if(v)el.textContent=resetText(Number(v))})},30000)}
363
+
364
+ /* ===== charts ===== */
365
+ function setupCanvas(id,h){const c=document.getElementById(id);if(!c)return null;const ctx=c.getContext('2d'),dpr=Math.min(window.devicePixelRatio||1,2);let w=c.clientWidth||(c.parentElement&&c.parentElement.clientWidth)||600;w=Math.max(120,Math.min(w,2400));h=h||c.height||210;c.width=Math.round(w*dpr);c.height=Math.round(h*dpr);c.style.height=h+'px';ctx.setTransform(dpr,0,0,dpr,0,0);ctx.clearRect(0,0,w,h);return{ctx,width:w,height:h,el:c}}
366
+ /* ---- chart tooltips ---- */
367
+ function showTip(html,cx,cy){const t=document.getElementById('chartTip');if(!t)return;t.innerHTML=html;t.classList.add('on');const r=t.getBoundingClientRect(),pad=16;let x=cx+pad,y=cy+pad;if(x+r.width>window.innerWidth-8)x=cx-r.width-pad;if(y+r.height>window.innerHeight-8)y=cy-r.height-pad;t.style.left=Math.max(8,x)+'px';t.style.top=Math.max(8,y)+'px'}
368
+ function hideTip(){const t=document.getElementById('chartTip');if(t)t.classList.remove('on')}
369
+ function tipRow(color,label,val){return`<div class="r"><span>${color?`<i style="background:${color}"></i>`:''}${esc(label)}</span><b>${val}</b></div>`}
370
+ function bindHover(el,kind,hits,geo){if(!el)return;el.classList.add('hoverable');el.onmousemove=e=>{const rect=el.getBoundingClientRect();const mx=e.clientX-rect.left,my=e.clientY-rect.top;let hit=null;if(kind==='bar'){hit=hits.find(h=>mx>=h.x0&&mx<=h.x1)}else if(kind==='point'){let bd=26;for(const h of hits){const d=Math.abs(mx-h.x);if(d<bd){bd=d;hit=h}}}else if(kind==='wedge'){const dx=mx-geo.cx,dy=my-geo.cy,dist=Math.hypot(dx,dy);if(dist<=geo.r+2&&dist>=geo.inner-2){let a=Math.atan2(dy,dx);if(a<-Math.PI/2)a+=Math.PI*2;hit=hits.find(h=>a>=h.start&&a<h.end)}}if(hit){showTip(hit.html,e.clientX,e.clientY)}else hideTip()};el.onmouseleave=hideTip}
371
+ function drawDailyChart(){const s=setupCanvas('dailyChart',220);if(!s)return;const{ctx,width,height,el}=s,data=DATA.dailyUsage.slice(-30);if(!data.length)return;const pad={l:46,r:14,t:16,b:34},cw=width-pad.l-pad.r,ch=height-pad.t-pad.b,max=Math.max(...data.map(d=>d.totalTokens),1),bw=Math.max(5,cw/data.length*.62),hits=[];ctx.font='11px '+(cssVar('--mono')||'monospace');ctx.fillStyle=themeColor('--soft');ctx.textAlign='right';for(let i=0;i<=3;i++){const y=pad.t+ch-(i/3)*ch;ctx.fillText(fmt(max*i/3),pad.l-8,y+4);ctx.strokeStyle=themeColor('--line-3');ctx.beginPath();ctx.moveTo(pad.l,y);ctx.lineTo(width-pad.r,y);ctx.stroke()}const C={blue:themeColor('--blue'),green:themeColor('--green'),mauve:themeColor('--mauve'),amber:themeColor('--amber')};data.forEach((d,i)=>{const bandX=pad.l+i*cw/data.length,x=bandX+(cw/data.length-bw)/2;let y=pad.t+ch;[[C.blue,freshInput(d)],[C.green,d.cachedInputTokens||0],[C.mauve,d.reasoningOutputTokens||0],[C.amber,visibleOut(d)]].forEach(([color,val])=>{const hh=val/max*ch;y-=hh;ctx.fillStyle=color;ctx.fillRect(x,y,bw,hh)});hits.push({x0:bandX,x1:bandX+cw/data.length,html:`<div class="t">${dateFmt(d.date)} · ${fmt(d.totalTokens)} tokens</div>${tipRow(C.blue,'Fresh input',fmt(freshInput(d)))}${tipRow(C.green,'Cached input',fmt(d.cachedInputTokens||0))}${tipRow(C.mauve,'Reasoning',fmt(d.reasoningOutputTokens||0))}${tipRow(C.amber,'Output',fmt(visibleOut(d)))}${tipRow('','Sessions',fmt(d.sessions||0))}`});if(i%Math.ceil(data.length/6)===0||i===data.length-1){ctx.fillStyle=themeColor('--soft');ctx.textAlign='center';ctx.fillText(dateFmt(d.date),x+bw/2,height-10)}});bindHover(el,'bar',hits)}
372
+ function drawModelChart(){const s=setupCanvas('modelChart',210);if(!s)return;const{ctx,width,el}=s,data=DATA.modelBreakdown.slice(0,8);if(!data.length)return;const colors=[themeColor('--blue'),themeColor('--green'),themeColor('--amber'),themeColor('--mauve'),themeColor('--rose'),themeColor('--brand'),themeColor('--muted'),themeColor('--faint')],total=data.reduce((a,b)=>a+b.totalTokens,0)||1,cx=width/2,cy=88,r=Math.min(74,width/4),inner=r*.56,hits=[];let start=-Math.PI/2;data.forEach((item,i)=>{const ang=item.totalTokens/total*Math.PI*2,col=colors[i%colors.length];ctx.beginPath();ctx.moveTo(cx,cy);ctx.arc(cx,cy,r,start,start+ang);ctx.closePath();ctx.fillStyle=col;ctx.fill();hits.push({start,end:start+ang,html:`<div class="t">${esc(item.model)}</div>${tipRow(col,'Tokens',fmt(item.totalTokens))}${tipRow('','Share',Math.round(item.totalTokens/total*100)+'%')}${tipRow('','Sessions',fmt(item.sessions||0))}`});start+=ang});ctx.beginPath();ctx.arc(cx,cy,inner,0,Math.PI*2);ctx.fillStyle=themeColor('--surface');ctx.fill();ctx.textAlign='center';ctx.fillStyle=themeColor('--ink');ctx.font='800 19px Inter,system-ui';ctx.fillText(fmt(total),cx,cy+2);ctx.fillStyle=themeColor('--soft');ctx.font='650 11px Inter,system-ui';ctx.fillText('tokens',cx,cy+19);data.slice(0,4).forEach((item,i)=>{const x=18+(i%2)*(width/2-8),y=172+Math.floor(i/2)*22;ctx.fillStyle=colors[i];ctx.fillRect(x,y-9,10,10);ctx.fillStyle=themeColor('--soft');ctx.textAlign='left';ctx.font='650 12px Inter,system-ui';ctx.fillText(String(item.model).slice(0,22),x+16,y)});bindHover(el,'wedge',hits,{cx,cy,r,inner})}
373
+ function drawTurnChart(prompt){const s=setupCanvas('turnChart',170);if(!s||!prompt)return;const{ctx,width,height,el}=s,data=prompt.turns||[];if(!data.length){ctx.fillStyle=themeColor('--soft');ctx.font='12px '+(cssVar('--font')||'sans-serif');ctx.textAlign='center';ctx.fillText('No per-turn token data for this prompt.',width/2,height/2);if(el){el.onmousemove=null;el.classList.remove('hoverable')}return}const pad={l:46,r:14,t:16,b:26},cw=width-pad.l-pad.r,ch=height-pad.t-pad.b,max=Math.max(...data.map(t=>t.totalTokens),1),C={blue:themeColor('--blue'),green:themeColor('--green'),mauve:themeColor('--mauve'),amber:themeColor('--amber')},hits=[];ctx.font='10px '+(cssVar('--mono')||'monospace');ctx.fillStyle=themeColor('--soft');ctx.textAlign='right';for(let i=0;i<=3;i++){const y=pad.t+ch-(i/3)*ch;ctx.fillText(fmt(max*i/3),pad.l-8,y+3);ctx.strokeStyle=themeColor('--line-3');ctx.beginPath();ctx.moveTo(pad.l,y);ctx.lineTo(width-pad.r,y);ctx.stroke()}ctx.beginPath();data.forEach((t,i)=>{const x=pad.l+(data.length===1?cw/2:i*cw/(data.length-1)),y=pad.t+ch-(t.totalTokens/max)*ch;if(i===0)ctx.moveTo(x,y);else ctx.lineTo(x,y)});ctx.strokeStyle=C.blue;ctx.lineWidth=2;ctx.stroke();data.forEach((t,i)=>{const x=pad.l+(data.length===1?cw/2:i*cw/(data.length-1)),y=pad.t+ch-(t.totalTokens/max)*ch;ctx.beginPath();ctx.arc(x,y,3,0,Math.PI*2);ctx.fillStyle=t.totalTokens===max?themeColor('--rose'):C.green;ctx.fill();hits.push({x,y,html:`<div class="t">Turn ${i+1} · ${fmt(t.totalTokens)} tokens</div>${timeFmt(t.timestamp)?`<div class="r"><span>${timeFmt(t.timestamp)}</span><b></b></div>`:''}${tipRow(C.blue,'Fresh input',fmt(freshInput(t)))}${tipRow(C.green,'Cached input',fmt(t.cachedInputTokens||0))}${tipRow(C.mauve,'Reasoning',fmt(t.reasoningOutputTokens||0))}${tipRow(C.amber,'Output',fmt(visibleOut(t)))}${t.contextWindow?tipRow('','Context window',fmt(t.contextWindow)):''}`});if(i%Math.ceil(data.length/6)===0||i===data.length-1){ctx.fillStyle=themeColor('--soft');ctx.textAlign='center';ctx.fillText(String(i+1),x,height-8)}});bindHover(el,'point',hits)}
374
+
375
+ /* ===== share card ===== */
376
+ function roundRect(ctx,x,y,w,h,r){ctx.beginPath();ctx.moveTo(x+r,y);ctx.arcTo(x+w,y,x+w,y+h,r);ctx.arcTo(x+w,y+h,x,y+h,r);ctx.arcTo(x,y+h,x,y,r);ctx.arcTo(x,y,x+w,y,r);ctx.closePath()}
377
+ function wrapText(ctx,text,x,y,maxW,lh,maxLines){const words=String(text).split(' ');let line='',lines=0;for(let n=0;n<words.length;n++){const test=line+words[n]+' ';if(ctx.measureText(test).width>maxW&&n>0){ctx.fillText(line.trim(),x,y);line=words[n]+' ';y+=lh;if(++lines>=maxLines-1){ctx.fillText(line.trim()+(n<words.length-1?'…':''),x,y);return}}else line=test}ctx.fillText(line.trim(),x,y)}
378
+ async function openShare(){if(!DATA)return;if(document.fonts&&document.fonts.ready)await document.fonts.ready;const t=DATA.totals,c=document.getElementById('shareCanvas'),ctx=c.getContext('2d'),W=1200,H=630;ctx.clearRect(0,0,W,H);const g=ctx.createLinearGradient(0,0,W,H);g.addColorStop(0,'#16171c');g.addColorStop(1,'#25272f');ctx.fillStyle=g;ctx.fillRect(0,0,W,H);ctx.strokeStyle='rgba(255,255,255,.03)';for(let x=0;x<W;x+=60){ctx.beginPath();ctx.moveTo(x,0);ctx.lineTo(x,H);ctx.stroke()}for(let y=0;y<H;y+=60){ctx.beginPath();ctx.moveTo(0,y);ctx.lineTo(W,y);ctx.stroke()}const glow=ctx.createRadialGradient(220,110,0,220,110,460);glow.addColorStop(0,'rgba(212,164,74,.16)');glow.addColorStop(1,'transparent');ctx.fillStyle=glow;ctx.fillRect(0,0,W,H);
379
+ const sAccent=(DATA.source&&DATA.source.accent)||'#d4a44a',sMark=(DATA.source&&DATA.source.mark)||'CX';ctx.fillStyle=sAccent;roundRect(ctx,60,58,46,46,11);ctx.fill();ctx.fillStyle=accentFg(sAccent);ctx.font='800 20px Inter,system-ui';ctx.textAlign='center';ctx.fillText(sMark,83,89);ctx.textAlign='left';
380
+ ctx.fillStyle='#e3e2df';ctx.font='800 40px Inter,system-ui';ctx.fillText('My '+((DATA.source&&DATA.source.label)||'Agent')+' Usage',122,92);
381
+ if(t.dateRange){ctx.fillStyle='#888a92';ctx.font='500 18px Inter,system-ui';ctx.fillText(`${t.dateRange.from} to ${t.dateRange.to}`,122,120);}
382
+ const ins=DATA.insights||[];if(ins.length){ctx.fillStyle='#e3c281';ctx.font='600 21px Inter,system-ui';wrapText(ctx,ins[0].title,60,176,W-120,28,2);}
383
+ const cachePct=t.totalTokens?Math.round(t.cachedInputTokens/t.totalTokens*100):0,C=caps();
384
+ const fourth=C.reasoning?{l:'REASONING TOKENS',v:fmt(t.reasoningOutputTokens)}:C.cost?{l:'EST. COST',v:t.totalCost>=1000?'$'+(t.totalCost/1000).toFixed(1)+'K':'$'+t.totalCost.toFixed(2)}:{l:'OUTPUT TOKENS',v:fmt(t.outputTokens)};
385
+ const cards=[{l:'TOTAL TOKENS',v:fmt(t.totalTokens)},{l:'SESSIONS',v:fmt(t.totalSessions)},{l:'CACHED INPUT',v:cachePct+'%'},fourth];
386
+ const gy=232,cw=(W-120-40)/2,chH=158;cards.forEach((s,i)=>{const col=i%2,row=Math.floor(i/2),x=60+col*(cw+40),y=gy+row*(chH+20);ctx.fillStyle='rgba(255,255,255,.05)';roundRect(ctx,x,y,cw,chH,16);ctx.fill();ctx.strokeStyle='rgba(255,255,255,.08)';ctx.lineWidth=1;roundRect(ctx,x,y,cw,chH,16);ctx.stroke();ctx.fillStyle='#888a92';ctx.font='600 14px Inter,system-ui';ctx.fillText(s.l,x+24,y+38);ctx.fillStyle='#e3e2df';ctx.font='800 46px Inter,system-ui';ctx.fillText(s.v,x+24,y+102);});
387
+ ctx.fillStyle='#888a92';ctx.font='600 16px Inter,system-ui';ctx.fillText('codex-spend',60,H-40);ctx.textAlign='right';ctx.fillStyle='#5d6068';ctx.font='500 15px Inter,system-ui';ctx.fillText('npx codex-spend',W-60,H-40);ctx.textAlign='left';
388
+ document.getElementById('shareOverlay').classList.add('open');}
389
+ function closeShare(){document.getElementById('shareOverlay').classList.remove('open')}
390
+ function downloadShare(){const c=document.getElementById('shareCanvas'),a=document.createElement('a');a.download='codex-spend.png';a.href=c.toDataURL('image/png');a.click()}
391
+
392
+ async function refresh(){const b=document.querySelector('.btn.primary');if(b)b.textContent='Refreshing…';await fetchData(true)}
393
+ window.addEventListener('keydown',e=>{if(e.key==='Escape'){closeShare();closeDrawer()}});
394
+ window.addEventListener('resize',()=>{if(!DATA||!DATA.sessions.length)return;if(activeTab==='overview'){drawDailyChart();drawModelChart()}if(document.getElementById('drawer').classList.contains('open')){const s=DATA.sessions.find(x=>x.sessionId===selectedSessionId);const p=s?.promptBreakdown.find(x=>x.rank===selectedPromptRank);if(p)drawTurnChart(p)}});
395
+ (async()=>{await loadSources();await fetchData()})().catch(showErr);
396
+ </script>
397
+ </body>
398
+ </html>
package/src/server.js ADDED
@@ -0,0 +1,57 @@
1
+ const express = require('express');
2
+ const path = require('path');
3
+ const registry = require('./adapters');
4
+
5
+ function createServer(options = {}) {
6
+ const app = express();
7
+ const cache = {}; // sourceId -> parsed result
8
+
9
+ function friendlyError(err) {
10
+ if (err.code === 'ENOENT') return { error: 'Agent data directory not found.', code: err.code };
11
+ if (err.code === 'EPERM' || err.code === 'EACCES') return { error: 'Permission denied reading agent data.', code: err.code };
12
+ return { error: err.message || String(err) };
13
+ }
14
+
15
+ function resolveSourceId(req) {
16
+ const requested = req.query.source;
17
+ if (requested && registry.get(requested)) return requested;
18
+ return registry.defaultSourceId();
19
+ }
20
+
21
+ async function readSource(sourceId) {
22
+ const adapter = registry.get(sourceId);
23
+ // Back-compat: a legacy --codex-home still flows to the codex adapter.
24
+ const opts = sourceId === 'codex' ? { codexHome: options.codexHome } : {};
25
+ return adapter.parse(opts);
26
+ }
27
+
28
+ // List every known agent and whether its data is present on this machine.
29
+ app.get('/api/sources', (req, res) => {
30
+ res.json({ sources: registry.list(), default: registry.defaultSourceId() });
31
+ });
32
+
33
+ app.get('/api/data', async (req, res) => {
34
+ const sourceId = resolveSourceId(req);
35
+ try {
36
+ if (!cache[sourceId]) cache[sourceId] = await readSource(sourceId);
37
+ res.json(cache[sourceId]);
38
+ } catch (err) {
39
+ res.status(500).json(friendlyError(err));
40
+ }
41
+ });
42
+
43
+ app.get('/api/refresh', async (req, res) => {
44
+ const sourceId = resolveSourceId(req);
45
+ try {
46
+ cache[sourceId] = await readSource(sourceId);
47
+ res.json({ ok: true, source: sourceId, sessions: cache[sourceId].sessions.length });
48
+ } catch (err) {
49
+ res.status(500).json(friendlyError(err));
50
+ }
51
+ });
52
+
53
+ app.use(express.static(path.join(__dirname, 'public')));
54
+ return app;
55
+ }
56
+
57
+ module.exports = { createServer };