pi-cost 0.1.1 → 0.2.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.
Files changed (72) hide show
  1. package/CHANGELOG.md +29 -0
  2. package/README.md +10 -1
  3. package/extensions/cost.ts +53 -83
  4. package/lib/app.js +113 -0
  5. package/lib/args.js +17 -0
  6. package/lib/cli.js +593 -0
  7. package/lib/cost-dir.js +62 -0
  8. package/lib/costs.js +799 -0
  9. package/lib/net-guard.js +178 -0
  10. package/lib/pi-parsers-cost.js +234 -66
  11. package/lib/pi-pricing.js +35 -8
  12. package/lib/themes.js +90 -0
  13. package/package.json +16 -7
  14. package/public/app.js +2232 -448
  15. package/public/fonts/OFL-IBM-Plex.txt +93 -0
  16. package/public/fonts/OFL-Playfair-Display.txt +93 -0
  17. package/public/fonts/fonts.css +244 -0
  18. package/public/fonts/ibm-plex-mono-cyrillic-400.woff2 +0 -0
  19. package/public/fonts/ibm-plex-mono-cyrillic-500.woff2 +0 -0
  20. package/public/fonts/ibm-plex-mono-cyrillic-600.woff2 +0 -0
  21. package/public/fonts/ibm-plex-mono-cyrillic-ext-400.woff2 +0 -0
  22. package/public/fonts/ibm-plex-mono-cyrillic-ext-500.woff2 +0 -0
  23. package/public/fonts/ibm-plex-mono-cyrillic-ext-600.woff2 +0 -0
  24. package/public/fonts/ibm-plex-mono-latin-400.woff2 +0 -0
  25. package/public/fonts/ibm-plex-mono-latin-500.woff2 +0 -0
  26. package/public/fonts/ibm-plex-mono-latin-600.woff2 +0 -0
  27. package/public/fonts/ibm-plex-mono-latin-ext-400.woff2 +0 -0
  28. package/public/fonts/ibm-plex-mono-latin-ext-500.woff2 +0 -0
  29. package/public/fonts/ibm-plex-mono-latin-ext-600.woff2 +0 -0
  30. package/public/fonts/ibm-plex-mono-vietnamese-400.woff2 +0 -0
  31. package/public/fonts/ibm-plex-mono-vietnamese-500.woff2 +0 -0
  32. package/public/fonts/ibm-plex-mono-vietnamese-600.woff2 +0 -0
  33. package/public/fonts/playfair-display-cyrillic-400.woff2 +0 -0
  34. package/public/fonts/playfair-display-latin-400.woff2 +0 -0
  35. package/public/fonts/playfair-display-latin-ext-400.woff2 +0 -0
  36. package/public/fonts/playfair-display-vietnamese-400.woff2 +0 -0
  37. package/public/index.html +68 -46
  38. package/public/style.css +319 -8
  39. package/public/sw.js +2 -2
  40. package/server.js +58 -660
  41. package/themes/ayu-dark.json +21 -0
  42. package/themes/ayu-light.json +21 -0
  43. package/themes/catppuccin-dark.json +21 -0
  44. package/themes/catppuccin-light.json +21 -0
  45. package/themes/dracula-dark.json +21 -0
  46. package/themes/dracula-light.json +21 -0
  47. package/themes/everforest-dark.json +21 -0
  48. package/themes/everforest-light.json +21 -0
  49. package/themes/github-dark.json +21 -0
  50. package/themes/github-light.json +21 -0
  51. package/themes/gruvbox-dark.json +21 -0
  52. package/themes/gruvbox-light.json +21 -0
  53. package/themes/kanagawa-dark.json +21 -0
  54. package/themes/kanagawa-light.json +21 -0
  55. package/themes/monokai-dark.json +21 -0
  56. package/themes/monokai-light.json +21 -0
  57. package/themes/night-owl-dark.json +21 -0
  58. package/themes/night-owl-light.json +21 -0
  59. package/themes/nord-dark.json +21 -0
  60. package/themes/nord-light.json +21 -0
  61. package/themes/one-dark-dark.json +21 -0
  62. package/themes/one-dark-light.json +21 -0
  63. package/themes/rose-pine-dark.json +21 -0
  64. package/themes/rose-pine-light.json +21 -0
  65. package/themes/solarized-dark.json +21 -0
  66. package/themes/solarized-light.json +21 -0
  67. package/themes/synthwave-dark.json +21 -0
  68. package/themes/synthwave-light.json +21 -0
  69. package/themes/tokyo-night-dark.json +21 -0
  70. package/themes/tokyo-night-light.json +21 -0
  71. package/themes/vitesse-dark.json +21 -0
  72. package/themes/vitesse-light.json +21 -0
package/server.js CHANGED
@@ -1,674 +1,72 @@
1
1
  #!/usr/bin/env node
2
2
  'use strict';
3
3
 
4
- const http = require('node:http');
5
- const express = require('express');
6
- const path = require('node:path');
7
- const fsp = require('node:fs/promises');
8
- const os = require('node:os');
9
-
4
+ const { getArg, hasFlag } = require('./lib/args');
10
5
  const parsers = require('./lib/pi-parsers-cost');
11
- const pricing = require('./lib/pi-pricing');
12
-
13
- // #region CLI_ARGS
14
-
15
- function getArg(name) {
16
- const eqIdx = process.argv.findIndex((a) => a === `--${name}` || a.startsWith(`--${name}=`));
17
- if (eqIdx === -1) return null;
18
- const arg = process.argv[eqIdx];
19
- if (arg.includes('=')) return arg.split('=').slice(1).join('=');
20
- return process.argv[eqIdx + 1] || null;
21
- }
22
-
23
- const PORT = parseInt(getArg('port') || process.env.PORT || '5461', 10);
24
-
25
- // #endregion
26
-
27
- // #region THEMES
28
-
29
- const BUILTIN_THEME_DIR = path.join(__dirname, 'themes');
30
- const COST_DIR = path.join(os.homedir(), '.pi', 'agent', 'cost');
31
- const SETTINGS_PATH = path.join(COST_DIR, 'settings.json');
32
-
33
- function readSettings() {
34
- try {
35
- const raw = require('node:fs').readFileSync(SETTINGS_PATH, 'utf8');
36
- const obj = JSON.parse(raw);
37
- return obj && typeof obj === 'object' ? obj : {};
38
- } catch (e) {
39
- if (e.code !== 'ENOENT') console.warn(`themes: cannot read ${SETTINGS_PATH}: ${e.message}`);
40
- return {};
41
- }
42
- }
43
-
44
- const SETTINGS = readSettings();
45
- const SETTINGS_THEMES = (SETTINGS && SETTINGS.themes) || {};
46
-
47
- const USER_THEME_DIR = process.env.COST_THEME_DIR
48
- || SETTINGS_THEMES.dir
49
- || path.join(COST_DIR, 'themes');
50
- const LIGHT_THEME_ID = process.env.COST_LIGHT_THEME || SETTINGS_THEMES.light || 'pi-light';
51
- const DARK_THEME_ID = process.env.COST_DARK_THEME || SETTINGS_THEMES.dark || 'pi-dark';
52
-
53
- const REQUIRED_COLOR_KEYS = [
54
- 'bgDeep', 'bgSurface', 'bgElevated', 'bgHover',
55
- 'border',
56
- 'textPrimary', 'textSecondary', 'textTertiary', 'textMuted',
57
- 'accent', 'accentText',
58
- 'success', 'warning', 'error',
59
- ];
60
-
61
- const themes = new Map();
62
-
63
- async function loadThemesFromDir(dir, builtin) {
64
- let entries;
65
- try { entries = await fsp.readdir(dir); }
66
- catch (e) {
67
- if (e.code !== 'ENOENT') console.warn(`themes: cannot read ${dir}: ${e.message}`);
68
- return;
69
- }
70
- const jsons = entries.filter((f) => f.toLowerCase().endsWith('.json'));
71
- await Promise.all(jsons.map(async (f) => {
72
- const full = path.join(dir, f);
73
- const id = f.replace(/\.json$/i, '');
74
- try {
75
- const raw = await fsp.readFile(full, 'utf8');
76
- const obj = JSON.parse(raw);
77
- if (obj.mode !== 'light' && obj.mode !== 'dark') {
78
- console.warn(`themes: skip ${full}: invalid mode`); return;
79
- }
80
- const colors = obj.colors || {};
81
- const missing = REQUIRED_COLOR_KEYS.filter((k) => typeof colors[k] !== 'string');
82
- if (missing.length) {
83
- console.warn(`themes: skip ${full}: missing colors ${missing.join(',')}`); return;
84
- }
85
- themes.set(id, {
86
- id,
87
- name: obj.name || id,
88
- displayName: obj.displayName || obj.name || id,
89
- mode: obj.mode,
90
- colors,
91
- builtin,
92
- });
93
- } catch (e) {
94
- console.warn(`themes: skip ${full}: ${e.message}`);
95
- }
96
- }));
97
- }
98
-
99
- async function loadAllThemes() {
100
- themes.clear();
101
- await loadThemesFromDir(BUILTIN_THEME_DIR, true);
102
- await loadThemesFromDir(USER_THEME_DIR, false);
103
- }
104
-
105
- function resolveActiveTheme(mode) {
106
- const requestedId = mode === 'light' ? LIGHT_THEME_ID : DARK_THEME_ID;
107
- const fallbackId = mode === 'light' ? 'pi-light' : 'pi-dark';
108
- return themes.get(requestedId) || themes.get(fallbackId) || null;
109
- }
110
-
111
- // #endregion
112
-
113
- // #region CACHE
114
6
 
115
- const CACHE_TTL = 30_000;
116
- let dataCache = new Map();
117
-
118
- function cacheGet(key) {
119
- const hit = dataCache.get(key);
120
- if (!hit) return null;
121
- if (Date.now() - hit.ts > CACHE_TTL) { dataCache.delete(key); return null; }
122
- return hit.payload;
123
- }
124
- function cacheSet(key, payload) { dataCache.set(key, { ts: Date.now(), payload }); }
125
- function cacheClear() { dataCache.clear(); }
126
-
127
- // #endregion
128
-
129
- // #region AGGREGATION
130
-
131
- function rangeToCutoff(range, now) {
132
- if (range === 'today') {
133
- const d = new Date(now); d.setHours(0, 0, 0, 0); return d;
134
- }
135
- if (!range || !Number.isFinite(range)) return null;
136
- const d = new Date(now); d.setDate(d.getDate() - range); return d;
137
- }
138
-
139
- function normalizePath(p) {
140
- return p ? String(p).replace(/\//g, '\\').toLowerCase() : '';
141
- }
142
-
143
- function localDateStr(d) {
144
- const y = d.getFullYear();
145
- const m = String(d.getMonth() + 1).padStart(2, '0');
146
- const day = String(d.getDate()).padStart(2, '0');
147
- return `${y}-${m}-${day}`;
148
- }
149
-
150
- function fillDaily(daily, start, end) {
151
- const out = [];
152
- for (let d = new Date(start); d <= end; d.setDate(d.getDate() + 1)) {
153
- const ds = localDateStr(d);
154
- out.push({ date: ds, cost: daily[ds]?.cost || 0 });
155
- }
156
- return out;
157
- }
158
-
159
- // path(lowercased) → uuid. Populated lazily; refreshed when the underlying
160
- // directory listing changes. Used to turn a subagent run's `sessionFile` path
161
- // into a clickable session id.
162
- let nestedIndexPromise = null;
163
- function loadNestedIndex(force = false) {
164
- if (!force && nestedIndexPromise) return nestedIndexPromise;
165
- nestedIndexPromise = (async () => {
166
- const files = await parsers.listNestedSubagentFiles();
167
- const map = new Map();
168
- await Promise.all(files.map(async (f) => {
169
- try {
170
- const { entries } = await parsers.getCachedSession(f.file, { mtimeMs: f.mtimeMs, size: f.size });
171
- const sess = entries.find((e) => e.type === 'session');
172
- if (sess && sess.id) map.set(normalizePath(f.file), { id: sess.id, ...f });
173
- } catch {}
174
- }));
175
- return map;
176
- })();
177
- return nestedIndexPromise;
178
- }
179
-
180
- async function findSessionFileById(id) {
181
- const top = await parsers.listSessionFiles();
182
- for (const f of top) if (f.file.includes(id)) return f;
183
- const nested = await loadNestedIndex();
184
- for (const entry of nested.values()) {
185
- if (entry.id === id) return { file: entry.file, projectDir: entry.projectDir, cwd: parsers.decodeProjectDir(entry.projectDir), mtime: entry.mtime, mtimeMs: entry.mtimeMs, size: entry.size };
186
- }
187
- for (const f of top) {
188
- try {
189
- const { entries } = await parsers.getCachedSession(f.file, { mtimeMs: f.mtimeMs, size: f.size });
190
- const sess = entries.find((e) => e.type === 'session');
191
- if (sess && sess.id === id) return f;
192
- } catch {}
193
- }
194
- return null;
195
- }
196
-
197
- async function buildSessionDetail(meta, pricingMap) {
198
- const cached = await parsers.getCachedSession(meta.file, { mtimeMs: meta.mtimeMs, size: meta.size });
199
- const entries = cached.entries;
200
- const summary = cached.summary;
201
- const messages = [];
202
- const seen = new Set();
203
- let cumulative = 0;
204
- let totalCost = 0;
205
- const modelsSeen = new Set();
206
- let sourceCounts = { jsonl: 0, pricing: 0, none: 0 };
207
-
208
- for (const u of parsers.enumerateMessageUsages(entries)) {
209
- if (u.messageId) {
210
- if (seen.has(u.messageId)) continue;
211
- seen.add(u.messageId);
212
- }
213
- const est = pricing.estimate(pricingMap, u.provider, u.model, u.usage);
214
- cumulative += est.cost;
215
- totalCost += est.cost;
216
- sourceCounts[est.source]++;
217
- if (u.model) modelsSeen.add(u.model);
218
- messages.push({
219
- ts: u.ts,
220
- provider: u.provider,
221
- model: u.model,
222
- input: u.usage.input || 0,
223
- output: u.usage.output || 0,
224
- cacheRead: u.usage.cacheRead || 0,
225
- cacheWrite: u.usage.cacheWrite || 0,
226
- cost: est.cost,
227
- cumulativeCost: cumulative,
228
- costSource: est.source,
229
- modelKey: est.modelKey,
230
- });
231
- }
7
+ parsers.configure({ sessionDir: getArg('session-dir') });
8
+
9
+ const cli = require('./lib/cli');
10
+ if (cli.isCommand(process.argv[2])) {
11
+ cli.main(process.argv.slice(2)).then((code) => { process.exitCode = code; });
12
+ } else {
13
+ startServer();
14
+ }
15
+
16
+ function startServer() {
17
+ const pricing = require('./lib/pi-pricing');
18
+ const { createApp } = require('./lib/app');
19
+ const { createNetGuard } = require('./lib/net-guard');
20
+ const { createThemeStore } = require('./lib/themes');
21
+ const { writeServerRecord, removeServerRecord } = require('./lib/cost-dir');
22
+
23
+ const portArg = getArg('port');
24
+ const PORT = parseInt(portArg || process.env.PORT || '5461', 10);
25
+ // An explicit --port binds exactly, so a host that picked the port (pi-kanban) can rely on it.
26
+ const STRICT_PORT = portArg !== null;
27
+ const OPEN = hasFlag('open') && !hasFlag('no-open');
28
+ const COST_DIR = parsers.getCostDir();
29
+
30
+ const net = createNetGuard({
31
+ appName: 'pi-cost',
32
+ bindHost: getArg('host') || process.env.HOST || '127.0.0.1',
33
+ allowedHosts: getArg('allowed-hosts') || process.env.ALLOWED_HOSTS || '',
34
+ });
35
+ const themes = createThemeStore(COST_DIR);
36
+ const app = createApp({ net, themes });
232
37
 
233
- // Inline subagent runs (pi >= v3 records each run inside the parent's
234
- // JSONL as a `subagent` toolResult). Merged into `messages` as pseudo-rows
235
- // so the UI shows a single unified timeline. Subscription providers report
236
- // usage.cost = 0, so we always estimate.
237
- const stripSuffix = (m) => (m ? String(m).replace(/:(low|medium|high)$/, '') : m);
238
- let inlineSubCost = 0;
239
- let inlineSubTokens = 0;
240
- let inlineSubCount = 0;
241
- const nestedIndex = await loadNestedIndex();
242
- for (const r of parsers.enumerateSubagentRuns(entries)) {
243
- const model = stripSuffix(r.model);
244
- const est = pricing.estimate(pricingMap, null, model, r.usage);
245
- const nestedEntry = r.jsonlPath ? nestedIndex.get(normalizePath(r.jsonlPath)) : null;
246
- const subagentSessionId = nestedEntry ? nestedEntry.id : null;
247
- inlineSubCost += est.cost;
248
- const tk = (r.usage.input || 0) + (r.usage.output || 0)
249
- + (r.usage.cacheRead || 0) + (r.usage.cacheWrite || 0);
250
- inlineSubTokens += tk;
251
- inlineSubCount++;
252
- sourceCounts[est.source]++;
253
- if (model) modelsSeen.add(model);
254
- messages.push({
255
- ts: r.ts,
256
- provider: null,
257
- model,
258
- input: r.usage.input || 0,
259
- output: r.usage.output || 0,
260
- cacheRead: r.usage.cacheRead || 0,
261
- cacheWrite: r.usage.cacheWrite || 0,
262
- cost: est.cost,
263
- cumulativeCost: 0, // recomputed below after sort
264
- costSource: est.source,
265
- modelKey: est.modelKey,
266
- agent: r.agent,
267
- task: r.task,
268
- subagentSessionId,
269
- turns: r.turns,
270
- });
271
- }
38
+ themes.ready.then(() => console.log(`themes loaded: ${themes.themes.size} (user dir: ${themes.config.dir})`));
272
39
 
273
- // Reorder by timestamp and recompute cumulative cost so subagent runs slot
274
- // into the timeline between assistant messages.
275
- messages.sort((a, b) => (a.ts || '').localeCompare(b.ts || ''));
276
- let cum = 0;
277
- for (const m of messages) { cum += m.cost; m.cumulativeCost = cum; }
278
- totalCost += inlineSubCost;
40
+ process.on('exit', () => removeServerRecord(COST_DIR, process.pid));
41
+ for (const sig of ['SIGINT', 'SIGTERM']) process.on(sig, () => process.exit(0));
279
42
 
280
- return {
281
- id: (summary.sessionEntry && summary.sessionEntry.id) || parsers.slugFromFile(meta.file),
282
- parentSession: summary.parentSession,
283
- inlineSubCost,
284
- inlineSubTokens,
285
- inlineSubCount,
286
- customTitle: summary.customTitle,
287
- firstUserText: summary.firstUserText,
288
- provider: summary.provider,
289
- model: summary.model,
290
- models: [...modelsSeen],
291
- totalCost,
292
- totalInput: summary.totalInput,
293
- totalOutput: summary.totalOutput,
294
- totalCacheRead: summary.totalCacheRead,
295
- totalCacheWrite: summary.totalCacheWrite,
296
- totalTokens: summary.totalTokens,
297
- messageCount: messages.length,
298
- firstTimestamp: summary.firstTimestamp,
299
- lastTimestamp: summary.lastTimestamp || meta.mtime.toISOString(),
300
- sourceCounts,
301
- messages,
302
- projectDir: meta.projectDir,
303
- cwd: (summary.sessionEntry && summary.sessionEntry.cwd) || meta.cwd,
304
- jsonlPath: meta.file,
43
+ const onReady = (port) => {
44
+ console.log(`pi-cost listening on http://localhost:${port}`);
45
+ console.log(`Sessions dir: ${parsers.getSessionRoots().map((r) => r.dir).join(', ')}`);
46
+ const warning = net.exposureWarning();
47
+ if (warning) console.log(warning);
48
+ writeServerRecord(COST_DIR, { pid: process.pid, port, sessionRoots: parsers.getSessionRoots() });
49
+ if (OPEN) import('open').then((mod) => mod.default(`http://localhost:${port}`)).catch(() => {});
305
50
  };
306
- }
307
-
308
- // Aggregate everything. Caller filters by range.
309
- async function buildAll(range) {
310
- const cacheKey = `all_${range || 'all'}`;
311
- const cached = cacheGet(cacheKey);
312
- if (cached) return cached;
313
-
314
- const now = new Date();
315
- const cutoff = rangeToCutoff(range, now);
316
- const cutoffMs = cutoff ? cutoff.getTime() : 0;
317
- const pricingMap = await pricing.fetchPricing();
318
51
 
319
- const files = await parsers.listSessionFiles();
320
- // Use mtime as a quick file-level filter — sessions older than cutoff can't have in-range messages.
321
- const eligible = cutoffMs ? files.filter((f) => f.mtimeMs >= cutoffMs - 24 * 3600 * 1000) : files;
322
-
323
- // Bounded-concurrency parse — JSONL files are I/O bound; >8 risks FD pressure.
324
- const CONCURRENCY = 8;
325
- const sessions = [];
326
- for (let i = 0; i < eligible.length; i += CONCURRENCY) {
327
- const chunk = eligible.slice(i, i + CONCURRENCY);
328
- const settled = await Promise.all(chunk.map(async (meta) => {
329
- try { return { meta, detail: await buildSessionDetail(meta, pricingMap) }; }
330
- catch (err) { console.warn('[pi-cost] failed to read', meta.file, err.message); return null; }
331
- }));
332
- for (const s of settled) if (s) sessions.push(s);
333
- }
334
-
335
- // Project rollup.
336
- const projects = new Map();
337
- const daily = {};
338
- const modelTotals = {};
339
- let totalCost = 0;
340
- let totalInput = 0, totalOutput = 0, totalCacheRead = 0, totalCacheWrite = 0;
341
- let totalSessions = 0;
342
- let totalMessages = 0;
343
- const sourceCounts = { jsonl: 0, pricing: 0, none: 0 };
344
-
345
- const sessionSummaries = [];
346
-
347
- for (const { meta, detail } of sessions) {
348
- const inRange = cutoff
349
- ? detail.messages.filter((m) => m.ts && new Date(m.ts).getTime() >= cutoffMs)
350
- : detail.messages;
351
- if (inRange.length === 0 && cutoff) continue;
352
-
353
- let sCost = 0, sIn = 0, sOut = 0, sCR = 0, sCW = 0;
354
- const projDaily = projects.get(meta.projectDir)?.daily || {};
355
- for (const m of inRange) {
356
- sCost += m.cost;
357
- sIn += m.input;
358
- sOut += m.output;
359
- sCR += m.cacheRead;
360
- sCW += m.cacheWrite;
361
- const day = localDateStr(new Date(m.ts));
362
- daily[day] = daily[day] || { cost: 0 };
363
- daily[day].cost += m.cost;
364
- projDaily[day] = (projDaily[day] || 0) + m.cost;
365
- if (m.model) modelTotals[m.model] = (modelTotals[m.model] || 0) + m.cost;
366
- sourceCounts[m.costSource] = (sourceCounts[m.costSource] || 0) + 1;
367
- }
368
-
369
- totalCost += sCost;
370
- totalInput += sIn; totalOutput += sOut; totalCacheRead += sCR; totalCacheWrite += sCW;
371
- totalSessions++;
372
- totalMessages += inRange.length;
373
-
374
- const proj = projects.get(meta.projectDir) || {
375
- projectDir: meta.projectDir,
376
- cwd: detail.cwd,
377
- name: path.basename(detail.cwd) || detail.cwd,
378
- totalCost: 0,
379
- sessions: 0,
380
- totalTokens: 0,
381
- lastActive: null,
382
- models: new Map(),
383
- daily: projDaily,
384
- };
385
- proj.totalCost += sCost;
386
- proj.sessions++;
387
- proj.totalTokens += sIn + sOut + sCR + sCW;
388
- const last = inRange[inRange.length - 1]?.ts || detail.lastTimestamp;
389
- if (!proj.lastActive || (last && last > proj.lastActive)) proj.lastActive = last;
390
- for (const m of inRange) {
391
- if (!m.model) continue;
392
- proj.models.set(m.model, (proj.models.get(m.model) || 0) + m.cost);
393
- }
394
- projects.set(meta.projectDir, proj);
395
-
396
- sessionSummaries.push({
397
- id: detail.id,
398
- projectDir: meta.projectDir,
399
- cwd: detail.cwd,
400
- customTitle: detail.customTitle,
401
- firstUserText: detail.firstUserText,
402
- provider: detail.provider,
403
- model: detail.model,
404
- models: detail.models,
405
- totalCost: sCost,
406
- totalTokens: sIn + sOut + sCR + sCW,
407
- messageCount: inRange.length,
408
- firstTimestamp: detail.firstTimestamp,
409
- lastTimestamp: last,
410
- parentSession: detail.parentSession,
411
- jsonlPath: meta.file,
412
- inlineSubCost: detail.inlineSubCost || 0,
413
- inlineSubTokens: detail.inlineSubTokens || 0,
414
- inlineSubCount: detail.inlineSubCount || 0,
52
+ const listen = (port) => {
53
+ const server = net.listenLoopback(app, port, onReady, { maxHeaderSize: 32768 });
54
+ server.on('error', (err) => {
55
+ if (err.code !== 'EADDRINUSE') throw err;
56
+ if (STRICT_PORT) {
57
+ console.error(`Port ${port} in use`);
58
+ process.exit(1);
59
+ }
60
+ console.log(`Port ${port} in use, trying random port...`);
61
+ listen(0);
415
62
  });
416
- }
417
-
418
- // Subagent rollup: subagents are separate JSONL files whose `parentSession`
419
- // points to the parent's file path. Project/overview totals already include
420
- // them; here we surface a per-parent aggregate so the detail view can show
421
- // "self + spawned" cost.
422
- const childrenByParent = new Map();
423
- for (const s of sessionSummaries) {
424
- if (!s.parentSession) continue;
425
- const key = normalizePath(s.parentSession);
426
- if (!childrenByParent.has(key)) childrenByParent.set(key, []);
427
- childrenByParent.get(key).push(s);
428
- }
429
- for (const s of sessionSummaries) {
430
- const kids = childrenByParent.get(normalizePath(s.jsonlPath)) || [];
431
- s.subagentCount = kids.length + (s.inlineSubCount || 0);
432
- s.subagentCost = kids.reduce((a, c) => a + c.totalCost, 0) + (s.inlineSubCost || 0);
433
- s.subagentTokens = kids.reduce((a, c) => a + c.totalTokens, 0) + (s.inlineSubTokens || 0);
434
- s.subagentIds = kids.map((c) => c.id);
435
- }
436
-
437
- const todayStr = localDateStr(now);
438
- const todayCost = daily[todayStr]?.cost || 0;
439
-
440
- const dailyStart = cutoff ? new Date(cutoff) : (() => {
441
- const dates = Object.keys(daily).sort();
442
- return dates.length ? new Date(dates[0] + 'T00:00:00') : new Date(now);
443
- })();
444
- dailyStart.setHours(0, 0, 0, 0);
445
- const dailyArr = fillDaily(daily, dailyStart, now);
446
-
447
- const modelDistribution = Object.entries(modelTotals)
448
- .filter(([, c]) => c > 0)
449
- .map(([model, cost]) => ({ model, cost }))
450
- .sort((a, b) => b.cost - a.cost);
451
-
452
- const projectsArr = [...projects.values()].map((p) => ({
453
- projectDir: p.projectDir,
454
- cwd: p.cwd,
455
- name: p.name,
456
- totalCost: p.totalCost,
457
- sessions: p.sessions,
458
- totalTokens: p.totalTokens,
459
- lastActive: p.lastActive,
460
- primaryModel: [...p.models.entries()].sort((a, b) => b[1] - a[1])[0]?.[0] || 'unknown',
461
- models: [...p.models.keys()],
462
- daily: p.daily,
463
- modelTotals: Object.fromEntries(p.models),
464
- })).sort((a, b) => b.totalCost - a.totalCost);
465
-
466
- const cacheInputAll = totalInput + totalCacheRead + totalCacheWrite;
467
- const cacheEfficiency = cacheInputAll > 0 ? totalCacheRead / cacheInputAll : 0;
468
-
469
- const result = {
470
- summary: {
471
- totalCost, todayCost,
472
- totalSessions, totalMessages,
473
- totalInput, totalOutput, totalCacheRead, totalCacheWrite,
474
- totalTokens: totalInput + totalOutput + totalCacheRead + totalCacheWrite,
475
- cacheEfficiency,
476
- sourceCounts,
477
- },
478
- daily: dailyArr,
479
- modelDistribution,
480
- projects: projectsArr,
481
- sessions: sessionSummaries,
482
- pricingMeta: pricing.getMeta(),
483
- generatedAt: new Date().toISOString(),
484
63
  };
485
64
 
486
- cacheSet(cacheKey, result);
487
- return result;
488
- }
489
-
490
- // #endregion
491
-
492
- // #region EXPRESS
493
-
494
- const app = express();
495
- app.use(express.json());
496
- app.use(express.static(path.join(__dirname, 'public')));
497
-
498
- function parseRange(raw, fallback = null) {
499
- if (raw === 'today') return 'today';
500
- const n = parseInt(raw, 10);
501
- return Number.isFinite(n) ? n : fallback;
502
- }
503
-
504
- app.get('/api/themes', (_req, res) => {
505
- res.json({
506
- light: resolveActiveTheme('light'),
507
- dark: resolveActiveTheme('dark'),
508
- config: { lightId: LIGHT_THEME_ID, darkId: DARK_THEME_ID, themeDir: USER_THEME_DIR },
509
- });
510
- });
511
-
512
- app.get('/pi-config', (_req, res) => {
513
- res.json({
514
- piDir: parsers.getPiDir(),
515
- sessionsDir: parsers.getSessionsDir(),
516
- port: PORT,
517
- });
518
- });
519
-
520
- app.get('/api/overview', async (req, res) => {
521
- try {
522
- const range = parseRange(req.query.range, 30);
523
- const all = await buildAll(range);
524
- res.json({
525
- summary: all.summary,
526
- daily: all.daily,
527
- modelDistribution: all.modelDistribution,
528
- projects: all.projects.map(stripInternal),
529
- pricingMeta: all.pricingMeta,
530
- generatedAt: all.generatedAt,
531
- });
532
- } catch (err) {
533
- console.error('[api] overview:', err);
534
- res.status(500).json({ error: err.message });
535
- }
536
- });
537
-
538
- function stripInternal(p) {
539
- const { daily: _d, modelTotals: _m, ...rest } = p;
540
- return rest;
65
+ listen(PORT);
66
+ pricing.fetchPricing()
67
+ .then(() => {
68
+ const meta = pricing.getMeta();
69
+ console.log(`pricing: ${meta.modelCount} models (${meta.source})`);
70
+ })
71
+ .catch(() => {});
541
72
  }
542
-
543
- app.get('/api/projects', async (req, res) => {
544
- try {
545
- const range = parseRange(req.query.range);
546
- const all = await buildAll(range);
547
- res.json({ projects: all.projects.map(stripInternal) });
548
- } catch (err) {
549
- console.error('[api] projects:', err);
550
- res.status(500).json({ error: err.message });
551
- }
552
- });
553
-
554
- app.get('/api/projects/:encoded/sessions', async (req, res) => {
555
- try {
556
- const range = parseRange(req.query.range);
557
- const all = await buildAll(range);
558
- const decoded = decodeURIComponent(req.params.encoded);
559
- const project = all.projects.find((p) => p.projectDir === decoded);
560
- const sessions = all.sessions
561
- .filter((s) => s.projectDir === decoded)
562
- .sort((a, b) => (b.lastTimestamp || '').localeCompare(a.lastTimestamp || ''));
563
-
564
- const now = new Date();
565
- const cutoff = rangeToCutoff(range, now);
566
- const dailyStart = cutoff ? new Date(cutoff) : new Date(now.getTime() - 30 * 86400 * 1000);
567
- dailyStart.setHours(0, 0, 0, 0);
568
- const dailyMap = {};
569
- for (const [day, cost] of Object.entries(project?.daily || {})) dailyMap[day] = { cost };
570
- const dailyArr = fillDaily(dailyMap, dailyStart, now);
571
-
572
- const modelDistribution = Object.entries(project?.modelTotals || {})
573
- .map(([model, cost]) => ({ model, cost }))
574
- .sort((a, b) => b.cost - a.cost);
575
-
576
- res.json({ project, sessions, daily: dailyArr, modelDistribution });
577
- } catch (err) {
578
- console.error('[api] project sessions:', err);
579
- res.status(500).json({ error: err.message });
580
- }
581
- });
582
-
583
- app.get('/api/sessions/:id', async (req, res) => {
584
- try {
585
- const meta = await findSessionFileById(req.params.id);
586
- if (!meta) return res.status(404).json({ error: 'Session not found' });
587
- const pricingMap = await pricing.fetchPricing();
588
- const detail = await buildSessionDetail(meta, pricingMap);
589
- detail.pricingMeta = pricing.getMeta();
590
- detail.encodedProjectDir = encodeURIComponent(meta.projectDir);
591
-
592
- // Sibling subagents (legacy: separate JSONL files with parentSession).
593
- // Inline runs (pi v3 nested format) are already merged into detail.messages.
594
- const all = await buildAll(null);
595
- const self = all.sessions.find((s) => s.id === detail.id);
596
- detail.childSessions = self
597
- ? all.sessions
598
- .filter((s) => s.parentSession && normalizePath(s.parentSession) === normalizePath(self.jsonlPath))
599
- .map((c) => ({
600
- id: c.id, customTitle: c.customTitle, firstUserText: c.firstUserText,
601
- totalCost: c.totalCost, totalTokens: c.totalTokens, messageCount: c.messageCount,
602
- model: c.model, provider: c.provider, lastTimestamp: c.lastTimestamp,
603
- }))
604
- : [];
605
-
606
- res.json(detail);
607
- } catch (err) {
608
- console.error('[api] session detail:', err);
609
- res.status(500).json({ error: err.message });
610
- }
611
- });
612
-
613
- app.get('/api/pricing', async (_req, res) => {
614
- try {
615
- const map = await pricing.fetchPricing();
616
- const models = [];
617
- for (const [key, info] of map) {
618
- models.push({
619
- key,
620
- input: info.input_cost_per_token,
621
- output: info.output_cost_per_token,
622
- cacheRead: info.cache_read_input_token_cost,
623
- cacheWrite: info.cache_creation_input_token_cost,
624
- });
625
- }
626
- res.json({ models, ...pricing.getMeta() });
627
- } catch (err) {
628
- res.status(500).json({ error: err.message });
629
- }
630
- });
631
-
632
- app.post('/api/refresh', async (_req, res) => {
633
- cacheClear();
634
- parsers.invalidateSessionCache();
635
- pricing.refresh();
636
- nestedIndexPromise = null;
637
- res.json({ ok: true });
638
- });
639
-
640
- // #endregion
641
-
642
- // #region STARTUP
643
-
644
- loadAllThemes().then(() => {
645
- console.log(`themes loaded: ${themes.size} (user dir: ${USER_THEME_DIR})`);
646
- }).catch((e) => console.warn('themes: load failed:', e.message));
647
-
648
- const server = http.createServer({ maxHeaderSize: 32768 }, app).listen(PORT, () => {
649
- const actualPort = server.address().port;
650
- console.log(`pi-cost dashboard running at http://localhost:${actualPort}`);
651
- console.log(`Sessions dir: ${parsers.getSessionsDir()}`);
652
- if (process.argv.includes('--open')) {
653
- import('open').then((mod) => mod.default(`http://localhost:${actualPort}`)).catch(() => {});
654
- }
655
- });
656
-
657
- server.on('error', (err) => {
658
- if (err.code === 'EADDRINUSE') {
659
- console.log(`Port ${PORT} in use, trying random port...`);
660
- const fallback = http.createServer({ maxHeaderSize: 32768 }, app).listen(0, () => {
661
- const p = fallback.address().port;
662
- console.log(`pi-cost dashboard running at http://localhost:${p}`);
663
- if (process.argv.includes('--open')) {
664
- import('open').then((mod) => mod.default(`http://localhost:${p}`)).catch(() => {});
665
- }
666
- });
667
- } else {
668
- throw err;
669
- }
670
- });
671
-
672
- pricing.fetchPricing().catch(() => {});
673
-
674
- // #endregion