@siteboon/claude-code-ui 1.16.4 → 1.17.1

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/server/index.js CHANGED
@@ -63,8 +63,24 @@ import { initializeDatabase } from './database/db.js';
63
63
  import { validateApiKey, authenticateToken, authenticateWebSocket } from './middleware/auth.js';
64
64
  import { IS_PLATFORM } from './constants/config.js';
65
65
 
66
- // File system watcher for projects folder
67
- let projectsWatcher = null;
66
+ // File system watchers for provider project/session folders
67
+ const PROVIDER_WATCH_PATHS = [
68
+ { provider: 'claude', rootPath: path.join(os.homedir(), '.claude', 'projects') },
69
+ { provider: 'cursor', rootPath: path.join(os.homedir(), '.cursor', 'chats') },
70
+ { provider: 'codex', rootPath: path.join(os.homedir(), '.codex', 'sessions') }
71
+ ];
72
+ const WATCHER_IGNORED_PATTERNS = [
73
+ '**/node_modules/**',
74
+ '**/.git/**',
75
+ '**/dist/**',
76
+ '**/build/**',
77
+ '**/*.tmp',
78
+ '**/*.swp',
79
+ '**/.DS_Store'
80
+ ];
81
+ const WATCHER_DEBOUNCE_MS = 300;
82
+ let projectsWatchers = [];
83
+ let projectsWatcherDebounceTimer = null;
68
84
  const connectedClients = new Set();
69
85
  let isGetProjectsRunning = false; // Flag to prevent reentrant calls
70
86
 
@@ -81,94 +97,110 @@ function broadcastProgress(progress) {
81
97
  });
82
98
  }
83
99
 
84
- // Setup file system watcher for Claude projects folder using chokidar
100
+ // Setup file system watchers for Claude, Cursor, and Codex project/session folders
85
101
  async function setupProjectsWatcher() {
86
102
  const chokidar = (await import('chokidar')).default;
87
- const claudeProjectsPath = path.join(os.homedir(), '.claude', 'projects');
88
103
 
89
- if (projectsWatcher) {
90
- projectsWatcher.close();
104
+ if (projectsWatcherDebounceTimer) {
105
+ clearTimeout(projectsWatcherDebounceTimer);
106
+ projectsWatcherDebounceTimer = null;
91
107
  }
92
108
 
93
- try {
94
- // Initialize chokidar watcher with optimized settings
95
- projectsWatcher = chokidar.watch(claudeProjectsPath, {
96
- ignored: [
97
- '**/node_modules/**',
98
- '**/.git/**',
99
- '**/dist/**',
100
- '**/build/**',
101
- '**/*.tmp',
102
- '**/*.swp',
103
- '**/.DS_Store'
104
- ],
105
- persistent: true,
106
- ignoreInitial: true, // Don't fire events for existing files on startup
107
- followSymlinks: false,
108
- depth: 10, // Reasonable depth limit
109
- awaitWriteFinish: {
110
- stabilityThreshold: 100, // Wait 100ms for file to stabilize
111
- pollInterval: 50
109
+ await Promise.all(
110
+ projectsWatchers.map(async (watcher) => {
111
+ try {
112
+ await watcher.close();
113
+ } catch (error) {
114
+ console.error('[WARN] Failed to close watcher:', error);
112
115
  }
113
- });
114
-
115
- // Debounce function to prevent excessive notifications
116
- let debounceTimer;
117
- const debouncedUpdate = async (eventType, filePath) => {
118
- clearTimeout(debounceTimer);
119
- debounceTimer = setTimeout(async () => {
120
- // Prevent reentrant calls
121
- if (isGetProjectsRunning) {
122
- return;
123
- }
116
+ })
117
+ );
118
+ projectsWatchers = [];
124
119
 
125
- try {
126
- isGetProjectsRunning = true;
120
+ const debouncedUpdate = (eventType, filePath, provider, rootPath) => {
121
+ if (projectsWatcherDebounceTimer) {
122
+ clearTimeout(projectsWatcherDebounceTimer);
123
+ }
127
124
 
128
- // Clear project directory cache when files change
129
- clearProjectDirectoryCache();
125
+ projectsWatcherDebounceTimer = setTimeout(async () => {
126
+ // Prevent reentrant calls
127
+ if (isGetProjectsRunning) {
128
+ return;
129
+ }
130
130
 
131
- // Get updated projects list
132
- const updatedProjects = await getProjects(broadcastProgress);
131
+ try {
132
+ isGetProjectsRunning = true;
133
+
134
+ // Clear project directory cache when files change
135
+ clearProjectDirectoryCache();
136
+
137
+ // Get updated projects list
138
+ const updatedProjects = await getProjects(broadcastProgress);
139
+
140
+ // Notify all connected clients about the project changes
141
+ const updateMessage = JSON.stringify({
142
+ type: 'projects_updated',
143
+ projects: updatedProjects,
144
+ timestamp: new Date().toISOString(),
145
+ changeType: eventType,
146
+ changedFile: path.relative(rootPath, filePath),
147
+ watchProvider: provider
148
+ });
133
149
 
134
- // Notify all connected clients about the project changes
135
- const updateMessage = JSON.stringify({
136
- type: 'projects_updated',
137
- projects: updatedProjects,
138
- timestamp: new Date().toISOString(),
139
- changeType: eventType,
140
- changedFile: path.relative(claudeProjectsPath, filePath)
141
- });
150
+ connectedClients.forEach(client => {
151
+ if (client.readyState === WebSocket.OPEN) {
152
+ client.send(updateMessage);
153
+ }
154
+ });
142
155
 
143
- connectedClients.forEach(client => {
144
- if (client.readyState === WebSocket.OPEN) {
145
- client.send(updateMessage);
146
- }
147
- });
156
+ } catch (error) {
157
+ console.error('[ERROR] Error handling project changes:', error);
158
+ } finally {
159
+ isGetProjectsRunning = false;
160
+ }
161
+ }, WATCHER_DEBOUNCE_MS);
162
+ };
148
163
 
149
- } catch (error) {
150
- console.error('[ERROR] Error handling project changes:', error);
151
- } finally {
152
- isGetProjectsRunning = false;
164
+ for (const { provider, rootPath } of PROVIDER_WATCH_PATHS) {
165
+ try {
166
+ // chokidar v4 emits ENOENT via the "error" event for missing roots and will not auto-recover.
167
+ // Ensure provider folders exist before creating the watcher so watching stays active.
168
+ await fsPromises.mkdir(rootPath, { recursive: true });
169
+
170
+ // Initialize chokidar watcher with optimized settings
171
+ const watcher = chokidar.watch(rootPath, {
172
+ ignored: WATCHER_IGNORED_PATTERNS,
173
+ persistent: true,
174
+ ignoreInitial: true, // Don't fire events for existing files on startup
175
+ followSymlinks: false,
176
+ depth: 10, // Reasonable depth limit
177
+ awaitWriteFinish: {
178
+ stabilityThreshold: 100, // Wait 100ms for file to stabilize
179
+ pollInterval: 50
153
180
  }
154
- }, 300); // 300ms debounce (slightly faster than before)
155
- };
156
-
157
- // Set up event listeners
158
- projectsWatcher
159
- .on('add', (filePath) => debouncedUpdate('add', filePath))
160
- .on('change', (filePath) => debouncedUpdate('change', filePath))
161
- .on('unlink', (filePath) => debouncedUpdate('unlink', filePath))
162
- .on('addDir', (dirPath) => debouncedUpdate('addDir', dirPath))
163
- .on('unlinkDir', (dirPath) => debouncedUpdate('unlinkDir', dirPath))
164
- .on('error', (error) => {
165
- console.error('[ERROR] Chokidar watcher error:', error);
166
- })
167
- .on('ready', () => {
168
181
  });
169
182
 
170
- } catch (error) {
171
- console.error('[ERROR] Failed to setup projects watcher:', error);
183
+ // Set up event listeners
184
+ watcher
185
+ .on('add', (filePath) => debouncedUpdate('add', filePath, provider, rootPath))
186
+ .on('change', (filePath) => debouncedUpdate('change', filePath, provider, rootPath))
187
+ .on('unlink', (filePath) => debouncedUpdate('unlink', filePath, provider, rootPath))
188
+ .on('addDir', (dirPath) => debouncedUpdate('addDir', dirPath, provider, rootPath))
189
+ .on('unlinkDir', (dirPath) => debouncedUpdate('unlinkDir', dirPath, provider, rootPath))
190
+ .on('error', (error) => {
191
+ console.error(`[ERROR] ${provider} watcher error:`, error);
192
+ })
193
+ .on('ready', () => {
194
+ });
195
+
196
+ projectsWatchers.push(watcher);
197
+ } catch (error) {
198
+ console.error(`[ERROR] Failed to setup ${provider} watcher for ${rootPath}:`, error);
199
+ }
200
+ }
201
+
202
+ if (projectsWatchers.length === 0) {
203
+ console.error('[ERROR] Failed to setup any provider watchers');
172
204
  }
173
205
  }
174
206
 
@@ -203,6 +203,7 @@ export async function queryCodex(command, options = {}, ws) {
203
203
  let codex;
204
204
  let thread;
205
205
  let currentSessionId = sessionId;
206
+ const abortController = new AbortController();
206
207
 
207
208
  try {
208
209
  // Initialize Codex SDK
@@ -232,6 +233,7 @@ export async function queryCodex(command, options = {}, ws) {
232
233
  thread,
233
234
  codex,
234
235
  status: 'running',
236
+ abortController,
235
237
  startedAt: new Date().toISOString()
236
238
  });
237
239
 
@@ -243,7 +245,9 @@ export async function queryCodex(command, options = {}, ws) {
243
245
  });
244
246
 
245
247
  // Execute with streaming
246
- const streamedTurn = await thread.runStreamed(command);
248
+ const streamedTurn = await thread.runStreamed(command, {
249
+ signal: abortController.signal
250
+ });
247
251
 
248
252
  for await (const event of streamedTurn.events) {
249
253
  // Check if session was aborted
@@ -286,20 +290,27 @@ export async function queryCodex(command, options = {}, ws) {
286
290
  });
287
291
 
288
292
  } catch (error) {
289
- console.error('[Codex] Error:', error);
290
-
291
- sendMessage(ws, {
292
- type: 'codex-error',
293
- error: error.message,
294
- sessionId: currentSessionId
295
- });
293
+ const session = currentSessionId ? activeCodexSessions.get(currentSessionId) : null;
294
+ const wasAborted =
295
+ session?.status === 'aborted' ||
296
+ error?.name === 'AbortError' ||
297
+ String(error?.message || '').toLowerCase().includes('aborted');
298
+
299
+ if (!wasAborted) {
300
+ console.error('[Codex] Error:', error);
301
+ sendMessage(ws, {
302
+ type: 'codex-error',
303
+ error: error.message,
304
+ sessionId: currentSessionId
305
+ });
306
+ }
296
307
 
297
308
  } finally {
298
309
  // Update session status
299
310
  if (currentSessionId) {
300
311
  const session = activeCodexSessions.get(currentSessionId);
301
312
  if (session) {
302
- session.status = 'completed';
313
+ session.status = session.status === 'aborted' ? 'aborted' : 'completed';
303
314
  }
304
315
  }
305
316
  }
@@ -318,9 +329,11 @@ export function abortCodexSession(sessionId) {
318
329
  }
319
330
 
320
331
  session.status = 'aborted';
321
-
322
- // The SDK doesn't have a direct abort method, but marking status
323
- // will cause the streaming loop to exit
332
+ try {
333
+ session.abortController?.abort();
334
+ } catch (error) {
335
+ console.warn(`[Codex] Failed to abort session ${sessionId}:`, error);
336
+ }
324
337
 
325
338
  return true;
326
339
  }
@@ -384,6 +384,7 @@ async function getProjects(progressCallback = null) {
384
384
  const config = await loadProjectConfig();
385
385
  const projects = [];
386
386
  const existingProjects = new Set();
387
+ const codexSessionsIndexRef = { sessionsByProject: null };
387
388
  let totalProjects = 0;
388
389
  let processedProjects = 0;
389
390
  let directories = [];
@@ -419,8 +420,6 @@ async function getProjects(progressCallback = null) {
419
420
  });
420
421
  }
421
422
 
422
- const projectPath = path.join(claudeDir, entry.name);
423
-
424
423
  // Extract actual project directory from JSONL sessions
425
424
  const actualProjectDir = await extractProjectDirectory(entry.name);
426
425
 
@@ -435,7 +434,11 @@ async function getProjects(progressCallback = null) {
435
434
  displayName: customName || autoDisplayName,
436
435
  fullPath: fullPath,
437
436
  isCustomName: !!customName,
438
- sessions: []
437
+ sessions: [],
438
+ sessionMeta: {
439
+ hasMore: false,
440
+ total: 0
441
+ }
439
442
  };
440
443
 
441
444
  // Try to get sessions for this project (just first 5 for performance)
@@ -448,6 +451,10 @@ async function getProjects(progressCallback = null) {
448
451
  };
449
452
  } catch (e) {
450
453
  console.warn(`Could not load sessions for project ${entry.name}:`, e.message);
454
+ project.sessionMeta = {
455
+ hasMore: false,
456
+ total: 0
457
+ };
451
458
  }
452
459
 
453
460
  // Also fetch Cursor sessions for this project
@@ -460,7 +467,9 @@ async function getProjects(progressCallback = null) {
460
467
 
461
468
  // Also fetch Codex sessions for this project
462
469
  try {
463
- project.codexSessions = await getCodexSessions(actualProjectDir);
470
+ project.codexSessions = await getCodexSessions(actualProjectDir, {
471
+ indexRef: codexSessionsIndexRef,
472
+ });
464
473
  } catch (e) {
465
474
  console.warn(`Could not load Codex sessions for project ${entry.name}:`, e.message);
466
475
  project.codexSessions = [];
@@ -525,7 +534,7 @@ async function getProjects(progressCallback = null) {
525
534
  }
526
535
  }
527
536
 
528
- const project = {
537
+ const project = {
529
538
  name: projectName,
530
539
  path: actualProjectDir,
531
540
  displayName: projectConfig.displayName || await generateDisplayName(projectName, actualProjectDir),
@@ -533,9 +542,13 @@ async function getProjects(progressCallback = null) {
533
542
  isCustomName: !!projectConfig.displayName,
534
543
  isManuallyAdded: true,
535
544
  sessions: [],
545
+ sessionMeta: {
546
+ hasMore: false,
547
+ total: 0
548
+ },
536
549
  cursorSessions: [],
537
550
  codexSessions: []
538
- };
551
+ };
539
552
 
540
553
  // Try to fetch Cursor sessions for manual projects too
541
554
  try {
@@ -546,7 +559,9 @@ async function getProjects(progressCallback = null) {
546
559
 
547
560
  // Try to fetch Codex sessions for manual projects too
548
561
  try {
549
- project.codexSessions = await getCodexSessions(actualProjectDir);
562
+ project.codexSessions = await getCodexSessions(actualProjectDir, {
563
+ indexRef: codexSessionsIndexRef,
564
+ });
550
565
  } catch (e) {
551
566
  console.warn(`Could not load Codex sessions for manual project ${projectName}:`, e.message);
552
567
  }
@@ -1244,75 +1259,114 @@ async function getCursorSessions(projectPath) {
1244
1259
  }
1245
1260
 
1246
1261
 
1247
- // Fetch Codex sessions for a given project path
1248
- async function getCodexSessions(projectPath, options = {}) {
1249
- const { limit = 5 } = options;
1262
+ function normalizeComparablePath(inputPath) {
1263
+ if (!inputPath || typeof inputPath !== 'string') {
1264
+ return '';
1265
+ }
1266
+
1267
+ const withoutLongPathPrefix = inputPath.startsWith('\\\\?\\')
1268
+ ? inputPath.slice(4)
1269
+ : inputPath;
1270
+ const normalized = path.normalize(withoutLongPathPrefix.trim());
1271
+
1272
+ if (!normalized) {
1273
+ return '';
1274
+ }
1275
+
1276
+ const resolved = path.resolve(normalized);
1277
+ return process.platform === 'win32' ? resolved.toLowerCase() : resolved;
1278
+ }
1279
+
1280
+ async function findCodexJsonlFiles(dir) {
1281
+ const files = [];
1282
+
1250
1283
  try {
1251
- const codexSessionsDir = path.join(os.homedir(), '.codex', 'sessions');
1252
- const sessions = [];
1284
+ const entries = await fs.readdir(dir, { withFileTypes: true });
1285
+ for (const entry of entries) {
1286
+ const fullPath = path.join(dir, entry.name);
1287
+ if (entry.isDirectory()) {
1288
+ files.push(...await findCodexJsonlFiles(fullPath));
1289
+ } else if (entry.name.endsWith('.jsonl')) {
1290
+ files.push(fullPath);
1291
+ }
1292
+ }
1293
+ } catch (error) {
1294
+ // Skip directories we can't read
1295
+ }
1253
1296
 
1254
- // Check if the directory exists
1297
+ return files;
1298
+ }
1299
+
1300
+ async function buildCodexSessionsIndex() {
1301
+ const codexSessionsDir = path.join(os.homedir(), '.codex', 'sessions');
1302
+ const sessionsByProject = new Map();
1303
+
1304
+ try {
1305
+ await fs.access(codexSessionsDir);
1306
+ } catch (error) {
1307
+ return sessionsByProject;
1308
+ }
1309
+
1310
+ const jsonlFiles = await findCodexJsonlFiles(codexSessionsDir);
1311
+
1312
+ for (const filePath of jsonlFiles) {
1255
1313
  try {
1256
- await fs.access(codexSessionsDir);
1257
- } catch (error) {
1258
- // No Codex sessions directory
1259
- return [];
1260
- }
1314
+ const sessionData = await parseCodexSessionFile(filePath);
1315
+ if (!sessionData || !sessionData.id) {
1316
+ continue;
1317
+ }
1261
1318
 
1262
- // Recursively find all .jsonl files in the sessions directory
1263
- const findJsonlFiles = async (dir) => {
1264
- const files = [];
1265
- try {
1266
- const entries = await fs.readdir(dir, { withFileTypes: true });
1267
- for (const entry of entries) {
1268
- const fullPath = path.join(dir, entry.name);
1269
- if (entry.isDirectory()) {
1270
- files.push(...await findJsonlFiles(fullPath));
1271
- } else if (entry.name.endsWith('.jsonl')) {
1272
- files.push(fullPath);
1273
- }
1274
- }
1275
- } catch (error) {
1276
- // Skip directories we can't read
1319
+ const normalizedProjectPath = normalizeComparablePath(sessionData.cwd);
1320
+ if (!normalizedProjectPath) {
1321
+ continue;
1277
1322
  }
1278
- return files;
1279
- };
1280
1323
 
1281
- const jsonlFiles = await findJsonlFiles(codexSessionsDir);
1324
+ const session = {
1325
+ id: sessionData.id,
1326
+ summary: sessionData.summary || 'Codex Session',
1327
+ messageCount: sessionData.messageCount || 0,
1328
+ lastActivity: sessionData.timestamp ? new Date(sessionData.timestamp) : new Date(),
1329
+ cwd: sessionData.cwd,
1330
+ model: sessionData.model,
1331
+ filePath,
1332
+ provider: 'codex',
1333
+ };
1282
1334
 
1283
- // Process each file to find sessions matching the project path
1284
- for (const filePath of jsonlFiles) {
1285
- try {
1286
- const sessionData = await parseCodexSessionFile(filePath);
1287
-
1288
- // Check if this session matches the project path
1289
- // Handle Windows long paths with \\?\ prefix
1290
- const sessionCwd = sessionData?.cwd || '';
1291
- const cleanSessionCwd = sessionCwd.startsWith('\\\\?\\') ? sessionCwd.slice(4) : sessionCwd;
1292
- const cleanProjectPath = projectPath.startsWith('\\\\?\\') ? projectPath.slice(4) : projectPath;
1293
-
1294
- if (sessionData && (sessionData.cwd === projectPath || cleanSessionCwd === cleanProjectPath || path.relative(cleanSessionCwd, cleanProjectPath) === '')) {
1295
- sessions.push({
1296
- id: sessionData.id,
1297
- summary: sessionData.summary || 'Codex Session',
1298
- messageCount: sessionData.messageCount || 0,
1299
- lastActivity: sessionData.timestamp ? new Date(sessionData.timestamp) : new Date(),
1300
- cwd: sessionData.cwd,
1301
- model: sessionData.model,
1302
- filePath: filePath,
1303
- provider: 'codex'
1304
- });
1305
- }
1306
- } catch (error) {
1307
- console.warn(`Could not parse Codex session file ${filePath}:`, error.message);
1335
+ if (!sessionsByProject.has(normalizedProjectPath)) {
1336
+ sessionsByProject.set(normalizedProjectPath, []);
1308
1337
  }
1338
+
1339
+ sessionsByProject.get(normalizedProjectPath).push(session);
1340
+ } catch (error) {
1341
+ console.warn(`Could not parse Codex session file ${filePath}:`, error.message);
1309
1342
  }
1343
+ }
1310
1344
 
1311
- // Sort sessions by last activity (newest first)
1345
+ for (const sessions of sessionsByProject.values()) {
1312
1346
  sessions.sort((a, b) => new Date(b.lastActivity) - new Date(a.lastActivity));
1347
+ }
1348
+
1349
+ return sessionsByProject;
1350
+ }
1351
+
1352
+ // Fetch Codex sessions for a given project path
1353
+ async function getCodexSessions(projectPath, options = {}) {
1354
+ const { limit = 5, indexRef = null } = options;
1355
+ try {
1356
+ const normalizedProjectPath = normalizeComparablePath(projectPath);
1357
+ if (!normalizedProjectPath) {
1358
+ return [];
1359
+ }
1360
+
1361
+ if (indexRef && !indexRef.sessionsByProject) {
1362
+ indexRef.sessionsByProject = await buildCodexSessionsIndex();
1363
+ }
1364
+
1365
+ const sessionsByProject = indexRef?.sessionsByProject || await buildCodexSessionsIndex();
1366
+ const sessions = sessionsByProject.get(normalizedProjectPath) || [];
1313
1367
 
1314
1368
  // Return limited sessions for performance (0 = unlimited for deletion)
1315
- return limit > 0 ? sessions.slice(0, limit) : sessions;
1369
+ return limit > 0 ? sessions.slice(0, limit) : [...sessions];
1316
1370
 
1317
1371
  } catch (error) {
1318
1372
  console.error('Error fetching Codex sessions:', error);
@@ -209,6 +209,86 @@ Custom commands can be created in:
209
209
  };
210
210
  },
211
211
 
212
+ '/cost': async (args, context) => {
213
+ const tokenUsage = context?.tokenUsage || {};
214
+ const provider = context?.provider || 'claude';
215
+ const model =
216
+ context?.model ||
217
+ (provider === 'cursor'
218
+ ? CURSOR_MODELS.DEFAULT
219
+ : provider === 'codex'
220
+ ? CODEX_MODELS.DEFAULT
221
+ : CLAUDE_MODELS.DEFAULT);
222
+
223
+ const used = Number(tokenUsage.used ?? tokenUsage.totalUsed ?? tokenUsage.total_tokens ?? 0) || 0;
224
+ const total =
225
+ Number(
226
+ tokenUsage.total ??
227
+ tokenUsage.contextWindow ??
228
+ parseInt(process.env.CONTEXT_WINDOW || '160000', 10),
229
+ ) || 160000;
230
+ const percentage = total > 0 ? Number(((used / total) * 100).toFixed(1)) : 0;
231
+
232
+ const inputTokensRaw =
233
+ Number(
234
+ tokenUsage.inputTokens ??
235
+ tokenUsage.input ??
236
+ tokenUsage.cumulativeInputTokens ??
237
+ tokenUsage.promptTokens ??
238
+ 0,
239
+ ) || 0;
240
+ const outputTokens =
241
+ Number(
242
+ tokenUsage.outputTokens ??
243
+ tokenUsage.output ??
244
+ tokenUsage.cumulativeOutputTokens ??
245
+ tokenUsage.completionTokens ??
246
+ 0,
247
+ ) || 0;
248
+ const cacheTokens =
249
+ Number(
250
+ tokenUsage.cacheReadTokens ??
251
+ tokenUsage.cacheCreationTokens ??
252
+ tokenUsage.cacheTokens ??
253
+ tokenUsage.cachedTokens ??
254
+ 0,
255
+ ) || 0;
256
+
257
+ // If we only have total used tokens, treat them as input for display/estimation.
258
+ const inputTokens =
259
+ inputTokensRaw > 0 || outputTokens > 0 || cacheTokens > 0 ? inputTokensRaw + cacheTokens : used;
260
+
261
+ // Rough default rates by provider (USD / 1M tokens).
262
+ const pricingByProvider = {
263
+ claude: { input: 3, output: 15 },
264
+ cursor: { input: 3, output: 15 },
265
+ codex: { input: 1.5, output: 6 },
266
+ };
267
+ const rates = pricingByProvider[provider] || pricingByProvider.claude;
268
+
269
+ const inputCost = (inputTokens / 1_000_000) * rates.input;
270
+ const outputCost = (outputTokens / 1_000_000) * rates.output;
271
+ const totalCost = inputCost + outputCost;
272
+
273
+ return {
274
+ type: 'builtin',
275
+ action: 'cost',
276
+ data: {
277
+ tokenUsage: {
278
+ used,
279
+ total,
280
+ percentage,
281
+ },
282
+ cost: {
283
+ input: inputCost.toFixed(4),
284
+ output: outputCost.toFixed(4),
285
+ total: totalCost.toFixed(4),
286
+ },
287
+ model,
288
+ },
289
+ };
290
+ },
291
+
212
292
  '/status': async (args, context) => {
213
293
  // Read version from package.json
214
294
  const packageJsonPath = path.join(path.dirname(__dirname), '..', 'package.json');