@zhangferry-dev/tokendash 1.4.0 → 1.5.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.
@@ -48,9 +48,56 @@ function countLines(text) {
48
48
  // Claude Code session scanning & tool extraction
49
49
  // ---------------------------------------------------------------------------
50
50
  const CLAUDE_PROJECTS_DIR = join(homedir(), '.claude', 'projects');
51
+ const projectNameCache = new Map();
52
+ /** Decode Claude's encoded project directory name.
53
+ * Claude encodes paths: /Users/foo/bar → -Users-foo-bar
54
+ * Since '-' replaces '/' and project names can contain '-',
55
+ * we use filesystem checks to find the correct last segment.
56
+ */
51
57
  function extractProjectName(dirName) {
52
- const parts = dirName.replace(/^-/, '').split('-');
53
- return parts[parts.length - 1] || dirName;
58
+ if (!dirName.startsWith('-'))
59
+ return dirName;
60
+ const cached = projectNameCache.get(dirName);
61
+ if (cached)
62
+ return cached;
63
+ const segments = dirName.replace(/^-/, '').split('-').filter(Boolean);
64
+ if (segments.length === 0) {
65
+ projectNameCache.set(dirName, dirName);
66
+ return dirName;
67
+ }
68
+ if (segments.length === 1) {
69
+ projectNameCache.set(dirName, segments[0]);
70
+ return segments[0];
71
+ }
72
+ let bestName = segments[segments.length - 1];
73
+ for (let splitAt = segments.length - 1; splitAt >= 1; splitAt--) {
74
+ const parentSegments = segments.slice(0, splitAt);
75
+ const candidateName = segments.slice(splitAt).join('-');
76
+ let parentPath = '/';
77
+ let valid = true;
78
+ for (const seg of parentSegments) {
79
+ const regular = join(parentPath, seg);
80
+ const hidden = join(parentPath, '.' + seg);
81
+ if (existsSync(regular)) {
82
+ parentPath = regular;
83
+ }
84
+ else if (existsSync(hidden)) {
85
+ parentPath = hidden;
86
+ }
87
+ else {
88
+ valid = false;
89
+ break;
90
+ }
91
+ }
92
+ if (!valid)
93
+ continue;
94
+ if (existsSync(join(parentPath, candidateName)) || existsSync(join(parentPath, '.' + candidateName))) {
95
+ bestName = candidateName;
96
+ break;
97
+ }
98
+ }
99
+ projectNameCache.set(dirName, bestName);
100
+ return bestName;
54
101
  }
55
102
  function matchesProject(dirName, filter) {
56
103
  return extractProjectName(dirName) === extractProjectName(filter);
@@ -273,5 +320,22 @@ export function computeAnalytics(toolCalls, timezone = 'Asia/Shanghai') {
273
320
  toolCallTrend.push(entry);
274
321
  }
275
322
  toolCallTrend.sort((a, b) => a.date.localeCompare(b.date));
323
+ // Fill missing tool values with 0 so chart lines don't break
324
+ if (toolCallTrend.length > 0) {
325
+ const allTools = new Set();
326
+ for (const entry of toolCallTrend) {
327
+ for (const key of Object.keys(entry)) {
328
+ if (key !== 'date')
329
+ allTools.add(key);
330
+ }
331
+ }
332
+ for (const entry of toolCallTrend) {
333
+ for (const tool of allTools) {
334
+ if (entry[tool] === undefined) {
335
+ entry[tool] = 0;
336
+ }
337
+ }
338
+ }
339
+ }
276
340
  return { codeChangeTrend, toolUsageDistribution, productivityKPIs, toolCallTrend };
277
341
  }
@@ -24,13 +24,56 @@ const ClaudeEventSchema = z.object({
24
24
  // Helpers
25
25
  // ---------------------------------------------------------------------------
26
26
  const CLAUDE_PROJECTS_DIR = join(homedir(), '.claude', 'projects');
27
- /** Extract project display name from encoded directory path.
28
- * -Users-zhangferry-AI-Ideas Ideas
29
- * -Users-zhangferry-Desktop-Develop-DailyNewsReport → DailyNewsReport
27
+ const projectNameCache = new Map();
28
+ /** Decode Claude's encoded project directory name.
29
+ * Claude encodes paths: /Users/foo/bar → -Users-foo-bar
30
+ * Since '-' replaces '/' and project names can contain '-',
31
+ * we use filesystem checks to find the correct last segment.
30
32
  */
31
33
  function extractProjectName(dirName) {
32
- const parts = dirName.replace(/^-/, '').split('-');
33
- return parts[parts.length - 1] || dirName;
34
+ if (!dirName.startsWith('-'))
35
+ return dirName;
36
+ const cached = projectNameCache.get(dirName);
37
+ if (cached)
38
+ return cached;
39
+ const segments = dirName.replace(/^-/, '').split('-').filter(Boolean);
40
+ if (segments.length === 0) {
41
+ projectNameCache.set(dirName, dirName);
42
+ return dirName;
43
+ }
44
+ if (segments.length === 1) {
45
+ projectNameCache.set(dirName, segments[0]);
46
+ return segments[0];
47
+ }
48
+ let bestName = segments[segments.length - 1];
49
+ for (let splitAt = segments.length - 1; splitAt >= 1; splitAt--) {
50
+ const parentSegments = segments.slice(0, splitAt);
51
+ const candidateName = segments.slice(splitAt).join('-');
52
+ let parentPath = '/';
53
+ let valid = true;
54
+ for (const seg of parentSegments) {
55
+ const regular = join(parentPath, seg);
56
+ const hidden = join(parentPath, '.' + seg);
57
+ if (existsSync(regular)) {
58
+ parentPath = regular;
59
+ }
60
+ else if (existsSync(hidden)) {
61
+ parentPath = hidden;
62
+ }
63
+ else {
64
+ valid = false;
65
+ break;
66
+ }
67
+ }
68
+ if (!valid)
69
+ continue;
70
+ if (existsSync(join(parentPath, candidateName)) || existsSync(join(parentPath, '.' + candidateName))) {
71
+ bestName = candidateName;
72
+ break;
73
+ }
74
+ }
75
+ projectNameCache.set(dirName, bestName);
76
+ return bestName;
34
77
  }
35
78
  /** Match project display name against a filter (also normalizes the filter) */
36
79
  function matchesProject(dirName, filter) {
@@ -1,5 +1,11 @@
1
1
  import type { DailyResponse, ProjectsResponse, BlockEntry } from '../shared/types.js';
2
2
  export declare function calculateCost(inputTokens: number, cacheReadTokens: number, outputTokens: number, model: string): number;
3
+ /** Decode Claude's encoded project directory name.
4
+ * Claude encodes paths: /Users/foo/bar → -Users-foo-bar
5
+ * Since '-' replaces '/' and project names can contain '-',
6
+ * we use filesystem checks to find the correct last segment.
7
+ */
8
+ export declare function extractProjectName(dirName: string): string;
3
9
  export declare function getDateKey(timestamp: string, tz: string): string;
4
10
  export declare function getHourKey(timestamp: string, tz: string): string;
5
11
  export declare function getDailyResponse(project?: string | null, tz?: string): DailyResponse;
@@ -38,9 +38,58 @@ export function calculateCost(inputTokens, cacheReadTokens, outputTokens, model)
38
38
  // ---------------------------------------------------------------------------
39
39
  const CLAUDE_PROJECTS_DIR = join(homedir(), '.claude', 'projects');
40
40
  const fileCache = new Map();
41
- function extractProjectName(dirName) {
42
- const parts = dirName.replace(/^-/, '').split('-');
43
- return parts[parts.length - 1] || dirName;
41
+ const projectNameCache = new Map();
42
+ /** Decode Claude's encoded project directory name.
43
+ * Claude encodes paths: /Users/foo/bar → -Users-foo-bar
44
+ * Since '-' replaces '/' and project names can contain '-',
45
+ * we use filesystem checks to find the correct last segment.
46
+ */
47
+ export function extractProjectName(dirName) {
48
+ if (!dirName.startsWith('-'))
49
+ return dirName;
50
+ const cached = projectNameCache.get(dirName);
51
+ if (cached)
52
+ return cached;
53
+ const segments = dirName.replace(/^-/, '').split('-').filter(Boolean);
54
+ if (segments.length === 0) {
55
+ projectNameCache.set(dirName, dirName);
56
+ return dirName;
57
+ }
58
+ if (segments.length === 1) {
59
+ projectNameCache.set(dirName, segments[0]);
60
+ return segments[0];
61
+ }
62
+ let bestName = segments[segments.length - 1];
63
+ // Try from right: find the longest last segment that forms a valid path
64
+ for (let splitAt = segments.length - 1; splitAt >= 1; splitAt--) {
65
+ const parentSegments = segments.slice(0, splitAt);
66
+ const candidateName = segments.slice(splitAt).join('-');
67
+ // Build parent path, handling hidden directories (dot prefix)
68
+ let parentPath = '/';
69
+ let valid = true;
70
+ for (const seg of parentSegments) {
71
+ const regular = join(parentPath, seg);
72
+ const hidden = join(parentPath, '.' + seg);
73
+ if (existsSync(regular)) {
74
+ parentPath = regular;
75
+ }
76
+ else if (existsSync(hidden)) {
77
+ parentPath = hidden;
78
+ }
79
+ else {
80
+ valid = false;
81
+ break;
82
+ }
83
+ }
84
+ if (!valid)
85
+ continue;
86
+ if (existsSync(join(parentPath, candidateName)) || existsSync(join(parentPath, '.' + candidateName))) {
87
+ bestName = candidateName;
88
+ break;
89
+ }
90
+ }
91
+ projectNameCache.set(dirName, bestName);
92
+ return bestName;
44
93
  }
45
94
  function matchesProject(dirName, filter) {
46
95
  return extractProjectName(dirName) === extractProjectName(filter);
@@ -228,7 +277,7 @@ export function getProjectsResponse(tz = DEFAULT_TZ) {
228
277
  const projectMap = new Map();
229
278
  for (const e of entries) {
230
279
  const date = getDateKey(e.timestamp, tz);
231
- const projectName = e.projectDir;
280
+ const projectName = extractProjectName(e.projectDir);
232
281
  if (!projectMap.has(projectName)) {
233
282
  projectMap.set(projectName, new Map());
234
283
  }
@@ -1,5 +1,5 @@
1
1
  import type { DailyResponse, ProjectsResponse, BlocksResponse } from '../shared/types.js';
2
- interface ParsedTokenEvent {
2
+ export interface ParsedTokenEvent {
3
3
  timestamp: string;
4
4
  inputTokens: number;
5
5
  cachedInputTokens: number;
@@ -29,19 +29,22 @@ export declare function scanCodexSessions(): string[];
29
29
  /**
30
30
  * Parse a single Codex session JSONL file.
31
31
  *
32
- * CRITICAL INVARIANT: Sum ALL token_count events without any deduplication.
33
- * Each Codex turn emits TWO token_count events with identical last_token_usage
34
- * values (~1-2s apart) one for reasoning, one for output completion.
35
- * Both are distinct billable events. Deduplicating would produce the wrong
36
- * total (4.7M instead of the correct 9.2M).
32
+ * Codex can emit duplicate token_count events for the same turn, with identical
33
+ * total_token_usage and last_token_usage snapshots a few seconds apart. These
34
+ * are repeated status updates, not separate billable usage records, so only the
35
+ * first occurrence of each cumulative total_token_usage snapshot should count.
37
36
  */
38
37
  export declare function parseCodexSession(filepath: string): ParsedSession | null;
39
38
  /** Parse all Codex sessions. */
40
39
  export declare function parseAllSessions(): ParsedSession[];
40
+ export declare function buildCodexResponsesFromSessions(sessions: ParsedSession[], options?: Partial<AggregateOptions>): {
41
+ daily: DailyResponse;
42
+ projects: ProjectsResponse;
43
+ blocks: BlocksResponse;
44
+ };
41
45
  /** Aggregate and return DailyResponse format (for /daily?agent=codex) */
42
46
  export declare function getDailyResponse(options?: Partial<AggregateOptions>): DailyResponse;
43
47
  /** Aggregate and return ProjectsResponse format (for /projects?agent=codex) */
44
48
  export declare function getProjectsResponse(options?: Partial<AggregateOptions>): ProjectsResponse;
45
49
  /** Aggregate and return BlocksResponse format (hourly, for /blocks?agent=codex) */
46
50
  export declare function getBlocksResponse(options?: Partial<AggregateOptions>): BlocksResponse;
47
- export {};
@@ -21,6 +21,15 @@ const TokenCountPayloadSchema = z.object({
21
21
  type: z.literal('token_count'),
22
22
  info: TokenCountInfoSchema,
23
23
  });
24
+ function tokenUsageKey(usage) {
25
+ return [
26
+ usage.input_tokens,
27
+ usage.cached_input_tokens,
28
+ usage.output_tokens,
29
+ usage.reasoning_output_tokens,
30
+ usage.total_tokens,
31
+ ].join(':');
32
+ }
24
33
  // ---------------------------------------------------------------------------
25
34
  // Helpers
26
35
  // ---------------------------------------------------------------------------
@@ -73,11 +82,10 @@ export function scanCodexSessions() {
73
82
  /**
74
83
  * Parse a single Codex session JSONL file.
75
84
  *
76
- * CRITICAL INVARIANT: Sum ALL token_count events without any deduplication.
77
- * Each Codex turn emits TWO token_count events with identical last_token_usage
78
- * values (~1-2s apart) one for reasoning, one for output completion.
79
- * Both are distinct billable events. Deduplicating would produce the wrong
80
- * total (4.7M instead of the correct 9.2M).
85
+ * Codex can emit duplicate token_count events for the same turn, with identical
86
+ * total_token_usage and last_token_usage snapshots a few seconds apart. These
87
+ * are repeated status updates, not separate billable usage records, so only the
88
+ * first occurrence of each cumulative total_token_usage snapshot should count.
81
89
  */
82
90
  export function parseCodexSession(filepath) {
83
91
  let content;
@@ -93,6 +101,7 @@ export function parseCodexSession(filepath) {
93
101
  let model = '';
94
102
  let createdAt = '';
95
103
  const tokenEvents = [];
104
+ const seenTotalUsageSnapshots = new Set();
96
105
  for (const line of lines) {
97
106
  const trimmed = line.trim();
98
107
  if (!trimmed)
@@ -118,7 +127,6 @@ export function parseCodexSession(filepath) {
118
127
  }
119
128
  }
120
129
  // Extract token counts from event_msg with nested token_count payload.
121
- // NEVER deduplicate — see invariant comment above.
122
130
  if (type === 'event_msg') {
123
131
  const payload = obj.payload || {};
124
132
  if (payload.type === 'token_count') {
@@ -131,6 +139,10 @@ export function parseCodexSession(filepath) {
131
139
  const info = parseResult.data.info;
132
140
  if (!info)
133
141
  continue;
142
+ const totalUsageKey = tokenUsageKey(info.total_token_usage);
143
+ if (seenTotalUsageSnapshots.has(totalUsageKey))
144
+ continue;
145
+ seenTotalUsageSnapshots.add(totalUsageKey);
134
146
  const last = info.last_token_usage;
135
147
  tokenEvents.push({
136
148
  timestamp,
@@ -207,8 +219,18 @@ function mergeAcc(a, b) {
207
219
  a.reasoningOutputTokens += b.reasoningOutputTokens;
208
220
  a.totalTokens += b.totalTokens;
209
221
  }
210
- function accToEntry(date, acc, models) {
211
- const cost = calculateCost(acc, models);
222
+ function addAccToBucket(bucket, ev, model) {
223
+ addAcc(bucket.acc, ev);
224
+ if (!model)
225
+ return;
226
+ if (!bucket.models.has(model))
227
+ bucket.models.set(model, emptyAcc());
228
+ addAcc(bucket.models.get(model), ev);
229
+ }
230
+ function accToEntry(date, acc, modelAccs) {
231
+ const modelNames = [...modelAccs.keys()];
232
+ const modelBreakdowns = buildModelBreakdowns(modelAccs);
233
+ const totalCost = modelBreakdowns.reduce((sum, model) => sum + model.cost, 0);
212
234
  return {
213
235
  date,
214
236
  inputTokens: acc.inputTokens,
@@ -216,23 +238,19 @@ function accToEntry(date, acc, models) {
216
238
  cacheCreationTokens: 0,
217
239
  cacheReadTokens: acc.cachedInputTokens,
218
240
  totalTokens: acc.totalTokens,
219
- totalCost: cost,
220
- modelsUsed: [...models],
221
- modelBreakdowns: buildModelBreakdowns(acc, models, cost),
241
+ totalCost,
242
+ modelsUsed: modelNames,
243
+ modelBreakdowns,
222
244
  };
223
245
  }
224
- function buildModelBreakdowns(acc, models, totalCost) {
225
- const modelList = [...models];
226
- if (modelList.length === 0)
227
- return [];
228
- const costPerModel = totalCost / modelList.length;
229
- return modelList.map(name => ({
230
- modelName: name,
246
+ function buildModelBreakdowns(modelAccs) {
247
+ return [...modelAccs.entries()].map(([modelName, acc]) => ({
248
+ modelName,
231
249
  inputTokens: acc.inputTokens,
232
250
  outputTokens: acc.outputTokens,
233
251
  cacheCreationTokens: 0,
234
252
  cacheReadTokens: acc.cachedInputTokens,
235
- cost: costPerModel,
253
+ cost: calculateCost(acc, new Set([modelName])),
236
254
  }));
237
255
  }
238
256
  function groupSessions(sessions, options) {
@@ -266,12 +284,9 @@ function groupSessions(sessions, options) {
266
284
  break;
267
285
  }
268
286
  if (!grouped.has(key)) {
269
- grouped.set(key, { acc: emptyAcc(), models: new Set() });
287
+ grouped.set(key, { acc: emptyAcc(), models: new Map() });
270
288
  }
271
- const entry = grouped.get(key);
272
- addAcc(entry.acc, ev);
273
- if (session.model)
274
- entry.models.add(session.model);
289
+ addAccToBucket(grouped.get(key), ev, session.model);
275
290
  }
276
291
  }
277
292
  return grouped;
@@ -279,23 +294,29 @@ function groupSessions(sessions, options) {
279
294
  // ---------------------------------------------------------------------------
280
295
  // Public API — response builders for route handlers
281
296
  // ---------------------------------------------------------------------------
282
- /** Aggregate and return DailyResponse format (for /daily?agent=codex) */
283
- export function getDailyResponse(options) {
284
- const sessions = parseAllSessions();
297
+ export function buildCodexResponsesFromSessions(sessions, options) {
298
+ return {
299
+ daily: buildDailyResponse(sessions, options),
300
+ projects: buildProjectsResponse(sessions, options),
301
+ blocks: buildBlocksResponse(sessions, options),
302
+ };
303
+ }
304
+ function buildDailyResponse(sessions, options) {
285
305
  const grouped = groupSessions(sessions, { groupBy: 'day', ...options });
286
306
  const daily = [];
287
307
  const totalsAcc = emptyAcc();
308
+ const totalModels = new Map();
288
309
  for (const [date, { acc, models }] of grouped) {
289
310
  daily.push(accToEntry(date, acc, models));
290
311
  mergeAcc(totalsAcc, acc);
312
+ for (const [model, modelAcc] of models) {
313
+ if (!totalModels.has(model))
314
+ totalModels.set(model, emptyAcc());
315
+ mergeAcc(totalModels.get(model), modelAcc);
316
+ }
291
317
  }
292
- // Sort by date ascending
293
318
  daily.sort((a, b) => a.date.localeCompare(b.date));
294
- const models = new Set();
295
- for (const s of sessions)
296
- if (s.model)
297
- models.add(s.model);
298
- const totalCost = calculateCost(totalsAcc, models);
319
+ const totalCost = buildModelBreakdowns(totalModels).reduce((sum, model) => sum + model.cost, 0);
299
320
  return {
300
321
  daily,
301
322
  totals: {
@@ -308,15 +329,16 @@ export function getDailyResponse(options) {
308
329
  },
309
330
  };
310
331
  }
311
- /** Aggregate and return ProjectsResponse format (for /projects?agent=codex) */
312
- export function getProjectsResponse(options) {
313
- const sessions = parseAllSessions();
332
+ function buildProjectsResponse(sessions, options) {
314
333
  const tz = options?.timezone || 'Asia/Shanghai';
315
- const projects = {};
334
+ const projectGroups = new Map();
316
335
  for (const session of sessions) {
317
336
  const projectName = extractProjectName(session.cwd);
318
- // Per-project daily grouping
319
- const dailyMap = new Map();
337
+ if (options?.project && projectName !== options.project)
338
+ continue;
339
+ if (!projectGroups.has(projectName))
340
+ projectGroups.set(projectName, new Map());
341
+ const dailyMap = projectGroups.get(projectName);
320
342
  for (const ev of session.tokenEvents) {
321
343
  const evDate = new Date(ev.timestamp);
322
344
  if (options?.since && evDate < options.since)
@@ -325,32 +347,25 @@ export function getProjectsResponse(options) {
325
347
  continue;
326
348
  const dayKey = getDateKey(ev.timestamp, tz);
327
349
  if (!dailyMap.has(dayKey)) {
328
- dailyMap.set(dayKey, { acc: emptyAcc(), models: new Set() });
350
+ dailyMap.set(dayKey, { acc: emptyAcc(), models: new Map() });
329
351
  }
330
- addAcc(dailyMap.get(dayKey).acc, ev);
331
- if (session.model)
332
- dailyMap.get(dayKey).models.add(session.model);
333
- }
334
- if (!projects[projectName])
335
- projects[projectName] = [];
336
- for (const [date, { acc, models }] of dailyMap) {
337
- projects[projectName].push(accToEntry(date, acc, models));
352
+ addAccToBucket(dailyMap.get(dayKey), ev, session.model);
338
353
  }
339
354
  }
340
- // Sort each project's daily entries
341
- for (const key of Object.keys(projects)) {
342
- projects[key].sort((a, b) => a.date.localeCompare(b.date));
355
+ const projects = {};
356
+ for (const [projectName, dailyMap] of projectGroups) {
357
+ projects[projectName] = [...dailyMap.entries()]
358
+ .sort(([a], [b]) => a.localeCompare(b))
359
+ .map(([date, { acc, models }]) => accToEntry(date, acc, models));
343
360
  }
344
361
  return { projects };
345
362
  }
346
- /** Aggregate and return BlocksResponse format (hourly, for /blocks?agent=codex) */
347
- export function getBlocksResponse(options) {
348
- const sessions = parseAllSessions();
363
+ function buildBlocksResponse(sessions, options) {
349
364
  const grouped = groupSessions(sessions, { groupBy: 'hour', ...options });
350
365
  const blocks = [];
351
366
  let idx = 0;
352
367
  for (const [hourKey, { acc, models }] of grouped) {
353
- const cost = calculateCost(acc, models);
368
+ const cost = buildModelBreakdowns(models).reduce((sum, model) => sum + model.cost, 0);
354
369
  const [datePart, timePart] = hourKey.split(' ');
355
370
  const hour = timePart.split(':')[0];
356
371
  blocks.push({
@@ -369,11 +384,22 @@ export function getBlocksResponse(options) {
369
384
  },
370
385
  totalTokens: acc.totalTokens,
371
386
  costUSD: cost,
372
- models: [...models],
387
+ models: [...models.keys()],
373
388
  });
374
389
  idx++;
375
390
  }
376
- // Sort by startTime
377
391
  blocks.sort((a, b) => a.startTime.localeCompare(b.startTime));
378
392
  return { blocks };
379
393
  }
394
+ /** Aggregate and return DailyResponse format (for /daily?agent=codex) */
395
+ export function getDailyResponse(options) {
396
+ return buildDailyResponse(parseAllSessions(), options);
397
+ }
398
+ /** Aggregate and return ProjectsResponse format (for /projects?agent=codex) */
399
+ export function getProjectsResponse(options) {
400
+ return buildProjectsResponse(parseAllSessions(), options);
401
+ }
402
+ /** Aggregate and return BlocksResponse format (hourly, for /blocks?agent=codex) */
403
+ export function getBlocksResponse(options) {
404
+ return buildBlocksResponse(parseAllSessions(), options);
405
+ }
@@ -1,4 +1,8 @@
1
1
  import type { Express } from 'express';
2
+ export declare function resolveStaticAssetBaseDir(moduleUrl?: string, baseDir?: string): {
3
+ baseDir: string;
4
+ isProduction: boolean;
5
+ };
2
6
  export declare function createApp(_port: number, baseDir?: string): Express;
3
7
  declare function main(): Promise<void>;
4
8
  export { main };
@@ -1,7 +1,7 @@
1
1
  import express from 'express';
2
- import { readFileSync } from 'node:fs';
2
+ import { existsSync, readFileSync } from 'node:fs';
3
3
  import { fileURLToPath } from 'node:url';
4
- import { dirname, join } from 'node:path';
4
+ import { basename, dirname, join, resolve } from 'node:path';
5
5
  import { registerApiRoutes } from './routes/api.js';
6
6
  import { detectAvailableAgents } from './agentDetection.js';
7
7
  import open from 'open';
@@ -117,22 +117,38 @@ async function listenWithPortFallback(app, preferredPort) {
117
117
  }
118
118
  throw new Error(`Could not find an available port starting from ${preferredPort}`);
119
119
  }
120
+ export function resolveStaticAssetBaseDir(moduleUrl = import.meta.url, baseDir) {
121
+ if (baseDir)
122
+ return { baseDir: resolve(baseDir), isProduction: true };
123
+ const moduleDir = dirname(fileURLToPath(moduleUrl));
124
+ const isProduction = moduleUrl.includes('/dist/');
125
+ if (!isProduction)
126
+ return { baseDir: resolve(moduleDir), isProduction: false };
127
+ // The CLI entrypoint runs from dist/server/index.js while the Vite assets are
128
+ // emitted to dist/client. Resolve the production asset base to dist instead
129
+ // of dist/server so / resolves to dist/client/index.html in installed npm
130
+ // packages. Electron passes dist explicitly and is unaffected by this branch.
131
+ if (basename(moduleDir) === 'server') {
132
+ return { baseDir: resolve(dirname(moduleDir)), isProduction: true };
133
+ }
134
+ return { baseDir: resolve(moduleDir), isProduction: true };
135
+ }
120
136
  export function createApp(_port, baseDir) {
121
137
  const app = express();
122
138
  const router = express.Router();
123
139
  // Register API routes
124
140
  registerApiRoutes(router);
125
141
  app.use('/api', router);
126
- // Resolve paths: use provided baseDir or compute from import.meta.url
127
- const _baseDir = baseDir ?? dirname(fileURLToPath(import.meta.url));
128
- const isProduction = baseDir
129
- ? true
130
- : import.meta.url.includes('dist/');
142
+ const { baseDir: _baseDir, isProduction } = resolveStaticAssetBaseDir(import.meta.url, baseDir);
131
143
  const popoverPath = isProduction
132
144
  ? join(_baseDir, 'client', 'popover.html')
133
145
  : join(_baseDir, '..', '..', 'public', 'popover.html');
134
- app.get('/popover.html', (_req, res) => {
135
- res.sendFile(popoverPath);
146
+ app.get('/popover.html', (_req, res, next) => {
147
+ if (!existsSync(popoverPath)) {
148
+ next();
149
+ return;
150
+ }
151
+ res.type('html').send(readFileSync(popoverPath, 'utf8'));
136
152
  });
137
153
  // Check if running from dist (production build)
138
154
  if (isProduction) {