mirror of
https://github.com/siteboon/claudecodeui.git
synced 2025-12-10 23:09:46 +00:00
* feat: Add token budget tracking and multiple improvements ## Features - **Token Budget Visualization**: Added real-time token usage tracking with pie chart display showing percentage used (blue < 50%, orange < 75%, red ≥ 75%) - **Show Thinking Toggle**: Added quick settings option to show/hide reasoning sections in messages - **Cache Clearing Utility**: Added `/clear-cache.html` page for clearing service workers, caches, and storage ## Improvements - **Package Upgrades**: Migrated from deprecated `xterm` to `@xterm/*` scoped packages - **Testing Setup**: Added Playwright for end-to-end testing - **Build Optimization**: Implemented code splitting for React, CodeMirror, and XTerm vendors to improve initial load time - **Deployment Scripts**: Added `scripts/start.sh` and `scripts/stop.sh` for cleaner server management with automatic port conflict resolution - **Vite Update**: Upgraded Vite from 7.0.5 to 7.1.8 ## Bug Fixes - Fixed static file serving to properly handle routes vs assets - Fixed session state reset to preserve token budget on initial load - Updated default Vite dev server port to 5173 (Vite's standard) ## Technical Details - Token budget is parsed from Claude CLI `modelUsage` field in result messages - Budget updates are sent via WebSocket as `token-budget` events - Calculation includes input, output, cache read, and cache creation tokens - Token budget state persists during active sessions but resets on session switch * feat: Add session processing state persistence Fixes issue where "Thinking..." banner and stop button disappear when switching between sessions. Users can now navigate freely while Claude is processing without losing the ability to monitor or stop the session. Features: - Processing state tracked in processingSessions Set (App.jsx) - Backend session status queries via check-session-status WebSocket message - UI state (banner + stop button) restored when returning to processing sessions - Works after page reload by querying backend's authoritative process maps - Proper cleanup when sessions complete in background Backend Changes: - Added sessionId to claude-complete, cursor-result, session-aborted messages - Exported isClaudeSessionActive, isCursorSessionActive helper functions - Exported getActiveClaudeSessions, getActiveCursorSessions for status queries - Added check-session-status and get-active-sessions WebSocket handlers Frontend Changes: - processingSessions state tracking in App.jsx - onSessionProcessing/onSessionNotProcessing callbacks - Session status check on session load and switch - Completion handlers only update UI if message is for current session - Always clean up processing state regardless of which session is active * feat: Make context window size configurable via environment variables Removes hardcoded 160k token limit and makes it configurable through environment variables. This allows easier adjustment for different Claude models or use cases. Changes: - Added CONTEXT_WINDOW env var for backend (default: 160000) - Added VITE_CONTEXT_WINDOW env var for frontend (default: 160000) - Updated .env.example with documentation - Replaced hardcoded values in token usage calculations - Replaced hardcoded values in pie chart display Why 160k? Claude Code reserves ~40k tokens for auto-compact feature, leaving 160k available for actual usage from the 200k context window. * fix: Decode HTML entities in chat message display HTML entities like < and > were showing as-is instead of being decoded to < and > characters. Added decodeHtmlEntities helper function to properly display angle brackets and other special characters. Applied to: - Regular message content - Streaming content deltas - Session history loading - Both string and array content types * refactor: Align package.json with main branch standards - Revert to main branch's package.json scripts structure - Remove custom scripts/start.sh and scripts/stop.sh - Update xterm dependencies to scoped @xterm packages (required for code compatibility) - Replace xterm with @xterm/xterm - Replace xterm-addon-fit with @xterm/addon-fit 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com> * feat: Replace CLI implementation with Claude Agents SDK This commit completes the migration to the Claude Agents SDK, removing the legacy CLI-based implementation and making the SDK the exclusive integration method. Changes: - Remove claude-cli.js legacy implementation - Add claude-sdk.js with full SDK integration - Remove CLAUDE_USE_SDK feature flag (SDK is now always used) - Update server/index.js to use SDK functions directly - Add .serena/ to .gitignore for AI assistant cache Benefits: - Better performance (no child process overhead) - Native session management with interrupt support - Cleaner codebase without CLI/SDK branching - Full feature parity with previous CLI implementation - Maintains compatibility with Cursor integration 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com> * Update server/claude-sdk.js Whoops. This is correct. Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com> * Update server/index.js Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com> * Update src/components/ChatInterface.jsx Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com> * Update src/components/ChatInterface.jsx Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com> * Update src/components/ChatInterface.jsx Left my test code in, but that's fixed. Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com> * fix: Prevent stale token-usage data from updating state on session switch - Add AbortController to cancel in-flight token-usage requests when session/project changes - Capture session/project IDs before fetch and verify they match before updating state - Handle AbortError gracefully without logging as error - Prevents race condition where old session data overwrites current session's token budget 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com> * Update src/components/TokenUsagePie.jsx Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com> --------- Co-authored-by: viper151 <simosmik@gmail.com> Co-authored-by: Claude <noreply@anthropic.com> Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com>
1283 lines
49 KiB
JavaScript
Executable File
1283 lines
49 KiB
JavaScript
Executable File
#!/usr/bin/env node
|
|
// Load environment variables from .env file
|
|
import fs from 'fs';
|
|
import path from 'path';
|
|
import { fileURLToPath } from 'url';
|
|
import { dirname } from 'path';
|
|
|
|
const __filename = fileURLToPath(import.meta.url);
|
|
const __dirname = dirname(__filename);
|
|
|
|
try {
|
|
const envPath = path.join(__dirname, '../.env');
|
|
const envFile = fs.readFileSync(envPath, 'utf8');
|
|
envFile.split('\n').forEach(line => {
|
|
const trimmedLine = line.trim();
|
|
if (trimmedLine && !trimmedLine.startsWith('#')) {
|
|
const [key, ...valueParts] = trimmedLine.split('=');
|
|
if (key && valueParts.length > 0 && !process.env[key]) {
|
|
process.env[key] = valueParts.join('=').trim();
|
|
}
|
|
}
|
|
});
|
|
} catch (e) {
|
|
console.log('No .env file found or error reading it:', e.message);
|
|
}
|
|
|
|
console.log('PORT from env:', process.env.PORT);
|
|
|
|
import express from 'express';
|
|
import { WebSocketServer } from 'ws';
|
|
import os from 'os';
|
|
import http from 'http';
|
|
import cors from 'cors';
|
|
import { promises as fsPromises } from 'fs';
|
|
import { spawn } from 'child_process';
|
|
import pty from 'node-pty';
|
|
import fetch from 'node-fetch';
|
|
import mime from 'mime-types';
|
|
|
|
import { getProjects, getSessions, getSessionMessages, renameProject, deleteSession, deleteProject, addProjectManually, extractProjectDirectory, clearProjectDirectoryCache } from './projects.js';
|
|
import { queryClaudeSDK, abortClaudeSDKSession, isClaudeSDKSessionActive, getActiveClaudeSDKSessions } from './claude-sdk.js';
|
|
import { spawnCursor, abortCursorSession, isCursorSessionActive, getActiveCursorSessions } from './cursor-cli.js';
|
|
import gitRoutes from './routes/git.js';
|
|
import authRoutes from './routes/auth.js';
|
|
import mcpRoutes from './routes/mcp.js';
|
|
import cursorRoutes from './routes/cursor.js';
|
|
import taskmasterRoutes from './routes/taskmaster.js';
|
|
import mcpUtilsRoutes from './routes/mcp-utils.js';
|
|
import { initializeDatabase } from './database/db.js';
|
|
import { validateApiKey, authenticateToken, authenticateWebSocket } from './middleware/auth.js';
|
|
|
|
// File system watcher for projects folder
|
|
let projectsWatcher = null;
|
|
const connectedClients = new Set();
|
|
|
|
// Setup file system watcher for Claude projects folder using chokidar
|
|
async function setupProjectsWatcher() {
|
|
const chokidar = (await import('chokidar')).default;
|
|
const claudeProjectsPath = path.join(process.env.HOME, '.claude', 'projects');
|
|
|
|
if (projectsWatcher) {
|
|
projectsWatcher.close();
|
|
}
|
|
|
|
try {
|
|
// Initialize chokidar watcher with optimized settings
|
|
projectsWatcher = chokidar.watch(claudeProjectsPath, {
|
|
ignored: [
|
|
'**/node_modules/**',
|
|
'**/.git/**',
|
|
'**/dist/**',
|
|
'**/build/**',
|
|
'**/*.tmp',
|
|
'**/*.swp',
|
|
'**/.DS_Store'
|
|
],
|
|
persistent: true,
|
|
ignoreInitial: true, // Don't fire events for existing files on startup
|
|
followSymlinks: false,
|
|
depth: 10, // Reasonable depth limit
|
|
awaitWriteFinish: {
|
|
stabilityThreshold: 100, // Wait 100ms for file to stabilize
|
|
pollInterval: 50
|
|
}
|
|
});
|
|
|
|
// Debounce function to prevent excessive notifications
|
|
let debounceTimer;
|
|
const debouncedUpdate = async (eventType, filePath) => {
|
|
clearTimeout(debounceTimer);
|
|
debounceTimer = setTimeout(async () => {
|
|
try {
|
|
|
|
// Clear project directory cache when files change
|
|
clearProjectDirectoryCache();
|
|
|
|
// Get updated projects list
|
|
const updatedProjects = await getProjects();
|
|
|
|
// Notify all connected clients about the project changes
|
|
const updateMessage = JSON.stringify({
|
|
type: 'projects_updated',
|
|
projects: updatedProjects,
|
|
timestamp: new Date().toISOString(),
|
|
changeType: eventType,
|
|
changedFile: path.relative(claudeProjectsPath, filePath)
|
|
});
|
|
|
|
connectedClients.forEach(client => {
|
|
if (client.readyState === client.OPEN) {
|
|
client.send(updateMessage);
|
|
}
|
|
});
|
|
|
|
} catch (error) {
|
|
console.error('❌ Error handling project changes:', error);
|
|
}
|
|
}, 300); // 300ms debounce (slightly faster than before)
|
|
};
|
|
|
|
// Set up event listeners
|
|
projectsWatcher
|
|
.on('add', (filePath) => debouncedUpdate('add', filePath))
|
|
.on('change', (filePath) => debouncedUpdate('change', filePath))
|
|
.on('unlink', (filePath) => debouncedUpdate('unlink', filePath))
|
|
.on('addDir', (dirPath) => debouncedUpdate('addDir', dirPath))
|
|
.on('unlinkDir', (dirPath) => debouncedUpdate('unlinkDir', dirPath))
|
|
.on('error', (error) => {
|
|
console.error('❌ Chokidar watcher error:', error);
|
|
})
|
|
.on('ready', () => {
|
|
});
|
|
|
|
} catch (error) {
|
|
console.error('❌ Failed to setup projects watcher:', error);
|
|
}
|
|
}
|
|
|
|
|
|
const app = express();
|
|
const server = http.createServer(app);
|
|
|
|
// Single WebSocket server that handles both paths
|
|
const wss = new WebSocketServer({
|
|
server,
|
|
verifyClient: (info) => {
|
|
console.log('WebSocket connection attempt to:', info.req.url);
|
|
|
|
// Extract token from query parameters or headers
|
|
const url = new URL(info.req.url, 'http://localhost');
|
|
const token = url.searchParams.get('token') ||
|
|
info.req.headers.authorization?.split(' ')[1];
|
|
|
|
// Verify token
|
|
const user = authenticateWebSocket(token);
|
|
if (!user) {
|
|
console.log('❌ WebSocket authentication failed');
|
|
return false;
|
|
}
|
|
|
|
// Store user info in the request for later use
|
|
info.req.user = user;
|
|
console.log('✅ WebSocket authenticated for user:', user.username);
|
|
return true;
|
|
}
|
|
});
|
|
|
|
// Make WebSocket server available to routes
|
|
app.locals.wss = wss;
|
|
|
|
app.use(cors());
|
|
app.use(express.json());
|
|
|
|
// Optional API key validation (if configured)
|
|
app.use('/api', validateApiKey);
|
|
|
|
// Authentication routes (public)
|
|
app.use('/api/auth', authRoutes);
|
|
|
|
// Git API Routes (protected)
|
|
app.use('/api/git', authenticateToken, gitRoutes);
|
|
|
|
// MCP API Routes (protected)
|
|
app.use('/api/mcp', authenticateToken, mcpRoutes);
|
|
|
|
// Cursor API Routes (protected)
|
|
app.use('/api/cursor', authenticateToken, cursorRoutes);
|
|
|
|
// TaskMaster API Routes (protected)
|
|
app.use('/api/taskmaster', authenticateToken, taskmasterRoutes);
|
|
|
|
// MCP utilities
|
|
app.use('/api/mcp-utils', authenticateToken, mcpUtilsRoutes);
|
|
|
|
// Static files served after API routes
|
|
app.use(express.static(path.join(__dirname, '../dist')));
|
|
|
|
// API Routes (protected)
|
|
app.get('/api/config', authenticateToken, (req, res) => {
|
|
const host = req.headers.host || `${req.hostname}:${PORT}`;
|
|
const protocol = req.protocol === 'https' || req.get('x-forwarded-proto') === 'https' ? 'wss' : 'ws';
|
|
|
|
console.log('Config API called - Returning host:', host, 'Protocol:', protocol);
|
|
|
|
res.json({
|
|
serverPort: PORT,
|
|
wsUrl: `${protocol}://${host}`
|
|
});
|
|
});
|
|
|
|
app.get('/api/projects', authenticateToken, async (req, res) => {
|
|
try {
|
|
const projects = await getProjects();
|
|
res.json(projects);
|
|
} catch (error) {
|
|
res.status(500).json({ error: error.message });
|
|
}
|
|
});
|
|
|
|
app.get('/api/projects/:projectName/sessions', authenticateToken, async (req, res) => {
|
|
try {
|
|
const { limit = 5, offset = 0 } = req.query;
|
|
const result = await getSessions(req.params.projectName, parseInt(limit), parseInt(offset));
|
|
res.json(result);
|
|
} catch (error) {
|
|
res.status(500).json({ error: error.message });
|
|
}
|
|
});
|
|
|
|
// Get messages for a specific session
|
|
app.get('/api/projects/:projectName/sessions/:sessionId/messages', authenticateToken, async (req, res) => {
|
|
try {
|
|
const { projectName, sessionId } = req.params;
|
|
const { limit, offset } = req.query;
|
|
|
|
// Parse limit and offset if provided
|
|
const parsedLimit = limit ? parseInt(limit, 10) : null;
|
|
const parsedOffset = offset ? parseInt(offset, 10) : 0;
|
|
|
|
const result = await getSessionMessages(projectName, sessionId, parsedLimit, parsedOffset);
|
|
|
|
// Handle both old and new response formats
|
|
if (Array.isArray(result)) {
|
|
// Backward compatibility: no pagination parameters were provided
|
|
res.json({ messages: result });
|
|
} else {
|
|
// New format with pagination info
|
|
res.json(result);
|
|
}
|
|
} catch (error) {
|
|
res.status(500).json({ error: error.message });
|
|
}
|
|
});
|
|
|
|
// Rename project endpoint
|
|
app.put('/api/projects/:projectName/rename', authenticateToken, async (req, res) => {
|
|
try {
|
|
const { displayName } = req.body;
|
|
await renameProject(req.params.projectName, displayName);
|
|
res.json({ success: true });
|
|
} catch (error) {
|
|
res.status(500).json({ error: error.message });
|
|
}
|
|
});
|
|
|
|
// Delete session endpoint
|
|
app.delete('/api/projects/:projectName/sessions/:sessionId', authenticateToken, async (req, res) => {
|
|
try {
|
|
const { projectName, sessionId } = req.params;
|
|
await deleteSession(projectName, sessionId);
|
|
res.json({ success: true });
|
|
} catch (error) {
|
|
res.status(500).json({ error: error.message });
|
|
}
|
|
});
|
|
|
|
// Delete project endpoint (only if empty)
|
|
app.delete('/api/projects/:projectName', authenticateToken, async (req, res) => {
|
|
try {
|
|
const { projectName } = req.params;
|
|
await deleteProject(projectName);
|
|
res.json({ success: true });
|
|
} catch (error) {
|
|
res.status(500).json({ error: error.message });
|
|
}
|
|
});
|
|
|
|
// Create project endpoint
|
|
app.post('/api/projects/create', authenticateToken, async (req, res) => {
|
|
try {
|
|
const { path: projectPath } = req.body;
|
|
|
|
if (!projectPath || !projectPath.trim()) {
|
|
return res.status(400).json({ error: 'Project path is required' });
|
|
}
|
|
|
|
const project = await addProjectManually(projectPath.trim());
|
|
res.json({ success: true, project });
|
|
} catch (error) {
|
|
console.error('Error creating project:', error);
|
|
res.status(500).json({ error: error.message });
|
|
}
|
|
});
|
|
|
|
// Browse filesystem endpoint for project suggestions - uses existing getFileTree
|
|
app.get('/api/browse-filesystem', authenticateToken, async (req, res) => {
|
|
try {
|
|
const { path: dirPath } = req.query;
|
|
|
|
// Default to home directory if no path provided
|
|
const homeDir = os.homedir();
|
|
let targetPath = dirPath ? dirPath.replace('~', homeDir) : homeDir;
|
|
|
|
// Resolve and normalize the path
|
|
targetPath = path.resolve(targetPath);
|
|
|
|
// Security check - ensure path is accessible
|
|
try {
|
|
await fs.promises.access(targetPath);
|
|
const stats = await fs.promises.stat(targetPath);
|
|
|
|
if (!stats.isDirectory()) {
|
|
return res.status(400).json({ error: 'Path is not a directory' });
|
|
}
|
|
} catch (err) {
|
|
return res.status(404).json({ error: 'Directory not accessible' });
|
|
}
|
|
|
|
// Use existing getFileTree function with shallow depth (only direct children)
|
|
const fileTree = await getFileTree(targetPath, 1, 0, false); // maxDepth=1, showHidden=false
|
|
|
|
// Filter only directories and format for suggestions
|
|
const directories = fileTree
|
|
.filter(item => item.type === 'directory')
|
|
.map(item => ({
|
|
path: item.path,
|
|
name: item.name,
|
|
type: 'directory'
|
|
}))
|
|
.slice(0, 20); // Limit results
|
|
|
|
// Add common directories if browsing home directory
|
|
const suggestions = [];
|
|
if (targetPath === homeDir) {
|
|
const commonDirs = ['Desktop', 'Documents', 'Projects', 'Development', 'Dev', 'Code', 'workspace'];
|
|
const existingCommon = directories.filter(dir => commonDirs.includes(dir.name));
|
|
const otherDirs = directories.filter(dir => !commonDirs.includes(dir.name));
|
|
|
|
suggestions.push(...existingCommon, ...otherDirs);
|
|
} else {
|
|
suggestions.push(...directories);
|
|
}
|
|
|
|
res.json({
|
|
path: targetPath,
|
|
suggestions: suggestions
|
|
});
|
|
|
|
} catch (error) {
|
|
console.error('Error browsing filesystem:', error);
|
|
res.status(500).json({ error: 'Failed to browse filesystem' });
|
|
}
|
|
});
|
|
|
|
// Read file content endpoint
|
|
app.get('/api/projects/:projectName/file', authenticateToken, async (req, res) => {
|
|
try {
|
|
const { projectName } = req.params;
|
|
const { filePath } = req.query;
|
|
|
|
console.log('📄 File read request:', projectName, filePath);
|
|
|
|
// Using fsPromises from import
|
|
|
|
// Security check - ensure the path is safe and absolute
|
|
if (!filePath || !path.isAbsolute(filePath)) {
|
|
return res.status(400).json({ error: 'Invalid file path' });
|
|
}
|
|
|
|
const content = await fsPromises.readFile(filePath, 'utf8');
|
|
res.json({ content, path: filePath });
|
|
} catch (error) {
|
|
console.error('Error reading file:', error);
|
|
if (error.code === 'ENOENT') {
|
|
res.status(404).json({ error: 'File not found' });
|
|
} else if (error.code === 'EACCES') {
|
|
res.status(403).json({ error: 'Permission denied' });
|
|
} else {
|
|
res.status(500).json({ error: error.message });
|
|
}
|
|
}
|
|
});
|
|
|
|
// Serve binary file content endpoint (for images, etc.)
|
|
app.get('/api/projects/:projectName/files/content', authenticateToken, async (req, res) => {
|
|
try {
|
|
const { projectName } = req.params;
|
|
const { path: filePath } = req.query;
|
|
|
|
console.log('🖼️ Binary file serve request:', projectName, filePath);
|
|
|
|
// Using fs from import
|
|
// Using mime from import
|
|
|
|
// Security check - ensure the path is safe and absolute
|
|
if (!filePath || !path.isAbsolute(filePath)) {
|
|
return res.status(400).json({ error: 'Invalid file path' });
|
|
}
|
|
|
|
// Check if file exists
|
|
try {
|
|
await fsPromises.access(filePath);
|
|
} catch (error) {
|
|
return res.status(404).json({ error: 'File not found' });
|
|
}
|
|
|
|
// Get file extension and set appropriate content type
|
|
const mimeType = mime.lookup(filePath) || 'application/octet-stream';
|
|
res.setHeader('Content-Type', mimeType);
|
|
|
|
// Stream the file
|
|
const fileStream = fs.createReadStream(filePath);
|
|
fileStream.pipe(res);
|
|
|
|
fileStream.on('error', (error) => {
|
|
console.error('Error streaming file:', error);
|
|
if (!res.headersSent) {
|
|
res.status(500).json({ error: 'Error reading file' });
|
|
}
|
|
});
|
|
|
|
} catch (error) {
|
|
console.error('Error serving binary file:', error);
|
|
if (!res.headersSent) {
|
|
res.status(500).json({ error: error.message });
|
|
}
|
|
}
|
|
});
|
|
|
|
// Save file content endpoint
|
|
app.put('/api/projects/:projectName/file', authenticateToken, async (req, res) => {
|
|
try {
|
|
const { projectName } = req.params;
|
|
const { filePath, content } = req.body;
|
|
|
|
console.log('💾 File save request:', projectName, filePath);
|
|
|
|
// Using fsPromises from import
|
|
|
|
// Security check - ensure the path is safe and absolute
|
|
if (!filePath || !path.isAbsolute(filePath)) {
|
|
return res.status(400).json({ error: 'Invalid file path' });
|
|
}
|
|
|
|
if (content === undefined) {
|
|
return res.status(400).json({ error: 'Content is required' });
|
|
}
|
|
|
|
// Create backup of original file
|
|
try {
|
|
const backupPath = filePath + '.backup.' + Date.now();
|
|
await fsPromises.copyFile(filePath, backupPath);
|
|
console.log('📋 Created backup:', backupPath);
|
|
} catch (backupError) {
|
|
console.warn('Could not create backup:', backupError.message);
|
|
}
|
|
|
|
// Write the new content
|
|
await fsPromises.writeFile(filePath, content, 'utf8');
|
|
|
|
res.json({
|
|
success: true,
|
|
path: filePath,
|
|
message: 'File saved successfully'
|
|
});
|
|
} catch (error) {
|
|
console.error('Error saving file:', error);
|
|
if (error.code === 'ENOENT') {
|
|
res.status(404).json({ error: 'File or directory not found' });
|
|
} else if (error.code === 'EACCES') {
|
|
res.status(403).json({ error: 'Permission denied' });
|
|
} else {
|
|
res.status(500).json({ error: error.message });
|
|
}
|
|
}
|
|
});
|
|
|
|
app.get('/api/projects/:projectName/files', authenticateToken, async (req, res) => {
|
|
try {
|
|
|
|
// Using fsPromises from import
|
|
|
|
// Use extractProjectDirectory to get the actual project path
|
|
let actualPath;
|
|
try {
|
|
actualPath = await extractProjectDirectory(req.params.projectName);
|
|
} catch (error) {
|
|
console.error('Error extracting project directory:', error);
|
|
// Fallback to simple dash replacement
|
|
actualPath = req.params.projectName.replace(/-/g, '/');
|
|
}
|
|
|
|
// Check if path exists
|
|
try {
|
|
await fsPromises.access(actualPath);
|
|
} catch (e) {
|
|
return res.status(404).json({ error: `Project path not found: ${actualPath}` });
|
|
}
|
|
|
|
const files = await getFileTree(actualPath, 10, 0, true);
|
|
const hiddenFiles = files.filter(f => f.name.startsWith('.'));
|
|
res.json(files);
|
|
} catch (error) {
|
|
console.error('❌ File tree error:', error.message);
|
|
res.status(500).json({ error: error.message });
|
|
}
|
|
});
|
|
|
|
// WebSocket connection handler that routes based on URL path
|
|
wss.on('connection', (ws, request) => {
|
|
const url = request.url;
|
|
console.log('🔗 Client connected to:', url);
|
|
|
|
// Parse URL to get pathname without query parameters
|
|
const urlObj = new URL(url, 'http://localhost');
|
|
const pathname = urlObj.pathname;
|
|
|
|
if (pathname === '/shell') {
|
|
handleShellConnection(ws);
|
|
} else if (pathname === '/ws') {
|
|
handleChatConnection(ws);
|
|
} else {
|
|
console.log('❌ Unknown WebSocket path:', pathname);
|
|
ws.close();
|
|
}
|
|
});
|
|
|
|
// Handle chat WebSocket connections
|
|
function handleChatConnection(ws) {
|
|
console.log('💬 Chat WebSocket connected');
|
|
|
|
// Add to connected clients for project updates
|
|
connectedClients.add(ws);
|
|
|
|
ws.on('message', async (message) => {
|
|
try {
|
|
const data = JSON.parse(message);
|
|
|
|
if (data.type === 'claude-command') {
|
|
console.log('💬 User message:', data.command || '[Continue/Resume]');
|
|
console.log('📁 Project:', data.options?.projectPath || 'Unknown');
|
|
console.log('🔄 Session:', data.options?.sessionId ? 'Resume' : 'New');
|
|
|
|
// Use Claude Agents SDK
|
|
await queryClaudeSDK(data.command, data.options, ws);
|
|
} else if (data.type === 'cursor-command') {
|
|
console.log('🖱️ Cursor message:', data.command || '[Continue/Resume]');
|
|
console.log('📁 Project:', data.options?.cwd || 'Unknown');
|
|
console.log('🔄 Session:', data.options?.sessionId ? 'Resume' : 'New');
|
|
console.log('🤖 Model:', data.options?.model || 'default');
|
|
await spawnCursor(data.command, data.options, ws);
|
|
} else if (data.type === 'cursor-resume') {
|
|
// Backward compatibility: treat as cursor-command with resume and no prompt
|
|
console.log('🖱️ Cursor resume session (compat):', data.sessionId);
|
|
await spawnCursor('', {
|
|
sessionId: data.sessionId,
|
|
resume: true,
|
|
cwd: data.options?.cwd
|
|
}, ws);
|
|
} else if (data.type === 'abort-session') {
|
|
console.log('🛑 Abort session request:', data.sessionId);
|
|
const provider = data.provider || 'claude';
|
|
let success;
|
|
|
|
if (provider === 'cursor') {
|
|
success = abortCursorSession(data.sessionId);
|
|
} else {
|
|
// Use Claude Agents SDK
|
|
success = await abortClaudeSDKSession(data.sessionId);
|
|
}
|
|
|
|
ws.send(JSON.stringify({
|
|
type: 'session-aborted',
|
|
sessionId: data.sessionId,
|
|
provider,
|
|
success
|
|
}));
|
|
} else if (data.type === 'cursor-abort') {
|
|
console.log('🛑 Abort Cursor session:', data.sessionId);
|
|
const success = abortCursorSession(data.sessionId);
|
|
ws.send(JSON.stringify({
|
|
type: 'session-aborted',
|
|
sessionId: data.sessionId,
|
|
provider: 'cursor',
|
|
success
|
|
}));
|
|
} else if (data.type === 'check-session-status') {
|
|
// Check if a specific session is currently processing
|
|
const provider = data.provider || 'claude';
|
|
const sessionId = data.sessionId;
|
|
let isActive;
|
|
|
|
if (provider === 'cursor') {
|
|
isActive = isCursorSessionActive(sessionId);
|
|
} else {
|
|
// Use Claude Agents SDK
|
|
isActive = isClaudeSDKSessionActive(sessionId);
|
|
}
|
|
|
|
ws.send(JSON.stringify({
|
|
type: 'session-status',
|
|
sessionId,
|
|
provider,
|
|
isProcessing: isActive
|
|
}));
|
|
} else if (data.type === 'get-active-sessions') {
|
|
// Get all currently active sessions
|
|
const activeSessions = {
|
|
claude: getActiveClaudeSDKSessions(),
|
|
cursor: getActiveCursorSessions()
|
|
};
|
|
ws.send(JSON.stringify({
|
|
type: 'active-sessions',
|
|
sessions: activeSessions
|
|
}));
|
|
}
|
|
} catch (error) {
|
|
console.error('❌ Chat WebSocket error:', error.message);
|
|
ws.send(JSON.stringify({
|
|
type: 'error',
|
|
error: error.message
|
|
}));
|
|
}
|
|
});
|
|
|
|
ws.on('close', () => {
|
|
console.log('🔌 Chat client disconnected');
|
|
// Remove from connected clients
|
|
connectedClients.delete(ws);
|
|
});
|
|
}
|
|
|
|
// Handle shell WebSocket connections
|
|
function handleShellConnection(ws) {
|
|
console.log('🐚 Shell client connected');
|
|
let shellProcess = null;
|
|
|
|
ws.on('message', async (message) => {
|
|
try {
|
|
const data = JSON.parse(message);
|
|
console.log('📨 Shell message received:', data.type);
|
|
|
|
if (data.type === 'init') {
|
|
// Initialize shell with project path and session info
|
|
const projectPath = data.projectPath || process.cwd();
|
|
const sessionId = data.sessionId;
|
|
const hasSession = data.hasSession;
|
|
const provider = data.provider || 'claude';
|
|
const initialCommand = data.initialCommand;
|
|
const isPlainShell = data.isPlainShell || (!!initialCommand && !hasSession) || provider === 'plain-shell';
|
|
|
|
console.log('🚀 Starting shell in:', projectPath);
|
|
console.log('📋 Session info:', hasSession ? `Resume session ${sessionId}` : (isPlainShell ? 'Plain shell mode' : 'New session'));
|
|
console.log('🤖 Provider:', isPlainShell ? 'plain-shell' : provider);
|
|
if (initialCommand) {
|
|
console.log('⚡ Initial command:', initialCommand);
|
|
}
|
|
|
|
// First send a welcome message
|
|
let welcomeMsg;
|
|
if (isPlainShell) {
|
|
welcomeMsg = `\x1b[36mStarting terminal in: ${projectPath}\x1b[0m\r\n`;
|
|
} else {
|
|
const providerName = provider === 'cursor' ? 'Cursor' : 'Claude';
|
|
welcomeMsg = hasSession ?
|
|
`\x1b[36mResuming ${providerName} session ${sessionId} in: ${projectPath}\x1b[0m\r\n` :
|
|
`\x1b[36mStarting new ${providerName} session in: ${projectPath}\x1b[0m\r\n`;
|
|
}
|
|
|
|
ws.send(JSON.stringify({
|
|
type: 'output',
|
|
data: welcomeMsg
|
|
}));
|
|
|
|
try {
|
|
// Prepare the shell command adapted to the platform and provider
|
|
let shellCommand;
|
|
if (isPlainShell) {
|
|
// Plain shell mode - just run the initial command in the project directory
|
|
if (os.platform() === 'win32') {
|
|
shellCommand = `Set-Location -Path "${projectPath}"; ${initialCommand}`;
|
|
} else {
|
|
shellCommand = `cd "${projectPath}" && ${initialCommand}`;
|
|
}
|
|
} else if (provider === 'cursor') {
|
|
// Use cursor-agent command
|
|
if (os.platform() === 'win32') {
|
|
if (hasSession && sessionId) {
|
|
shellCommand = `Set-Location -Path "${projectPath}"; cursor-agent --resume="${sessionId}"`;
|
|
} else {
|
|
shellCommand = `Set-Location -Path "${projectPath}"; cursor-agent`;
|
|
}
|
|
} else {
|
|
if (hasSession && sessionId) {
|
|
shellCommand = `cd "${projectPath}" && cursor-agent --resume="${sessionId}"`;
|
|
} else {
|
|
shellCommand = `cd "${projectPath}" && cursor-agent`;
|
|
}
|
|
}
|
|
} else {
|
|
// Use claude command (default) or initialCommand if provided
|
|
const command = initialCommand || 'claude';
|
|
if (os.platform() === 'win32') {
|
|
if (hasSession && sessionId) {
|
|
// Try to resume session, but with fallback to new session if it fails
|
|
shellCommand = `Set-Location -Path "${projectPath}"; claude --resume ${sessionId}; if ($LASTEXITCODE -ne 0) { claude }`;
|
|
} else {
|
|
shellCommand = `Set-Location -Path "${projectPath}"; ${command}`;
|
|
}
|
|
} else {
|
|
if (hasSession && sessionId) {
|
|
shellCommand = `cd "${projectPath}" && claude --resume ${sessionId} || claude`;
|
|
} else {
|
|
shellCommand = `cd "${projectPath}" && ${command}`;
|
|
}
|
|
}
|
|
}
|
|
|
|
console.log('🔧 Executing shell command:', shellCommand);
|
|
|
|
// Use appropriate shell based on platform
|
|
const shell = os.platform() === 'win32' ? 'powershell.exe' : 'bash';
|
|
const shellArgs = os.platform() === 'win32' ? ['-Command', shellCommand] : ['-c', shellCommand];
|
|
|
|
shellProcess = pty.spawn(shell, shellArgs, {
|
|
name: 'xterm-256color',
|
|
cols: 80,
|
|
rows: 24,
|
|
cwd: process.env.HOME || (os.platform() === 'win32' ? process.env.USERPROFILE : '/'),
|
|
env: {
|
|
...process.env,
|
|
TERM: 'xterm-256color',
|
|
COLORTERM: 'truecolor',
|
|
FORCE_COLOR: '3',
|
|
// Override browser opening commands to echo URL for detection
|
|
BROWSER: os.platform() === 'win32' ? 'echo "OPEN_URL:"' : 'echo "OPEN_URL:"'
|
|
}
|
|
});
|
|
|
|
console.log('🟢 Shell process started with PTY, PID:', shellProcess.pid);
|
|
|
|
// Handle data output
|
|
shellProcess.onData((data) => {
|
|
if (ws.readyState === ws.OPEN) {
|
|
let outputData = data;
|
|
|
|
// Check for various URL opening patterns
|
|
const patterns = [
|
|
// Direct browser opening commands
|
|
/(?:xdg-open|open|start)\s+(https?:\/\/[^\s\x1b\x07]+)/g,
|
|
// BROWSER environment variable override
|
|
/OPEN_URL:\s*(https?:\/\/[^\s\x1b\x07]+)/g,
|
|
// Git and other tools opening URLs
|
|
/Opening\s+(https?:\/\/[^\s\x1b\x07]+)/gi,
|
|
// General URL patterns that might be opened
|
|
/Visit:\s*(https?:\/\/[^\s\x1b\x07]+)/gi,
|
|
/View at:\s*(https?:\/\/[^\s\x1b\x07]+)/gi,
|
|
/Browse to:\s*(https?:\/\/[^\s\x1b\x07]+)/gi
|
|
];
|
|
|
|
patterns.forEach(pattern => {
|
|
let match;
|
|
while ((match = pattern.exec(data)) !== null) {
|
|
const url = match[1];
|
|
console.log('🔗 Detected URL for opening:', url);
|
|
|
|
// Send URL opening message to client
|
|
ws.send(JSON.stringify({
|
|
type: 'url_open',
|
|
url: url
|
|
}));
|
|
|
|
// Replace the OPEN_URL pattern with a user-friendly message
|
|
if (pattern.source.includes('OPEN_URL')) {
|
|
outputData = outputData.replace(match[0], `🌐 Opening in browser: ${url}`);
|
|
}
|
|
}
|
|
});
|
|
|
|
// Send regular output
|
|
ws.send(JSON.stringify({
|
|
type: 'output',
|
|
data: outputData
|
|
}));
|
|
}
|
|
});
|
|
|
|
// Handle process exit
|
|
shellProcess.onExit((exitCode) => {
|
|
console.log('🔚 Shell process exited with code:', exitCode.exitCode, 'signal:', exitCode.signal);
|
|
if (ws.readyState === ws.OPEN) {
|
|
ws.send(JSON.stringify({
|
|
type: 'output',
|
|
data: `\r\n\x1b[33mProcess exited with code ${exitCode.exitCode}${exitCode.signal ? ` (${exitCode.signal})` : ''}\x1b[0m\r\n`
|
|
}));
|
|
}
|
|
shellProcess = null;
|
|
});
|
|
|
|
} catch (spawnError) {
|
|
console.error('❌ Error spawning process:', spawnError);
|
|
ws.send(JSON.stringify({
|
|
type: 'output',
|
|
data: `\r\n\x1b[31mError: ${spawnError.message}\x1b[0m\r\n`
|
|
}));
|
|
}
|
|
|
|
} else if (data.type === 'input') {
|
|
// Send input to shell process
|
|
if (shellProcess && shellProcess.write) {
|
|
try {
|
|
shellProcess.write(data.data);
|
|
} catch (error) {
|
|
console.error('Error writing to shell:', error);
|
|
}
|
|
} else {
|
|
console.warn('No active shell process to send input to');
|
|
}
|
|
} else if (data.type === 'resize') {
|
|
// Handle terminal resize
|
|
if (shellProcess && shellProcess.resize) {
|
|
console.log('Terminal resize requested:', data.cols, 'x', data.rows);
|
|
shellProcess.resize(data.cols, data.rows);
|
|
}
|
|
}
|
|
} catch (error) {
|
|
console.error('❌ Shell WebSocket error:', error.message);
|
|
if (ws.readyState === ws.OPEN) {
|
|
ws.send(JSON.stringify({
|
|
type: 'output',
|
|
data: `\r\n\x1b[31mError: ${error.message}\x1b[0m\r\n`
|
|
}));
|
|
}
|
|
}
|
|
});
|
|
|
|
ws.on('close', () => {
|
|
console.log('🔌 Shell client disconnected');
|
|
if (shellProcess && shellProcess.kill) {
|
|
console.log('🔴 Killing shell process:', shellProcess.pid);
|
|
shellProcess.kill();
|
|
}
|
|
});
|
|
|
|
ws.on('error', (error) => {
|
|
console.error('❌ Shell WebSocket error:', error);
|
|
});
|
|
}
|
|
// Audio transcription endpoint
|
|
app.post('/api/transcribe', authenticateToken, async (req, res) => {
|
|
try {
|
|
const multer = (await import('multer')).default;
|
|
const upload = multer({ storage: multer.memoryStorage() });
|
|
|
|
// Handle multipart form data
|
|
upload.single('audio')(req, res, async (err) => {
|
|
if (err) {
|
|
return res.status(400).json({ error: 'Failed to process audio file' });
|
|
}
|
|
|
|
if (!req.file) {
|
|
return res.status(400).json({ error: 'No audio file provided' });
|
|
}
|
|
|
|
const apiKey = process.env.OPENAI_API_KEY;
|
|
if (!apiKey) {
|
|
return res.status(500).json({ error: 'OpenAI API key not configured. Please set OPENAI_API_KEY in server environment.' });
|
|
}
|
|
|
|
try {
|
|
// Create form data for OpenAI
|
|
const FormData = (await import('form-data')).default;
|
|
const formData = new FormData();
|
|
formData.append('file', req.file.buffer, {
|
|
filename: req.file.originalname,
|
|
contentType: req.file.mimetype
|
|
});
|
|
formData.append('model', 'whisper-1');
|
|
formData.append('response_format', 'json');
|
|
formData.append('language', 'en');
|
|
|
|
// Make request to OpenAI
|
|
const response = await fetch('https://api.openai.com/v1/audio/transcriptions', {
|
|
method: 'POST',
|
|
headers: {
|
|
'Authorization': `Bearer ${apiKey}`,
|
|
...formData.getHeaders()
|
|
},
|
|
body: formData
|
|
});
|
|
|
|
if (!response.ok) {
|
|
const errorData = await response.json().catch(() => ({}));
|
|
throw new Error(errorData.error?.message || `Whisper API error: ${response.status}`);
|
|
}
|
|
|
|
const data = await response.json();
|
|
let transcribedText = data.text || '';
|
|
|
|
// Check if enhancement mode is enabled
|
|
const mode = req.body.mode || 'default';
|
|
|
|
// If no transcribed text, return empty
|
|
if (!transcribedText) {
|
|
return res.json({ text: '' });
|
|
}
|
|
|
|
// If default mode, return transcribed text without enhancement
|
|
if (mode === 'default') {
|
|
return res.json({ text: transcribedText });
|
|
}
|
|
|
|
// Handle different enhancement modes
|
|
try {
|
|
const OpenAI = (await import('openai')).default;
|
|
const openai = new OpenAI({ apiKey });
|
|
|
|
let prompt, systemMessage, temperature = 0.7, maxTokens = 800;
|
|
|
|
switch (mode) {
|
|
case 'prompt':
|
|
systemMessage = 'You are an expert prompt engineer who creates clear, detailed, and effective prompts.';
|
|
prompt = `You are an expert prompt engineer. Transform the following rough instruction into a clear, detailed, and context-aware AI prompt.
|
|
|
|
Your enhanced prompt should:
|
|
1. Be specific and unambiguous
|
|
2. Include relevant context and constraints
|
|
3. Specify the desired output format
|
|
4. Use clear, actionable language
|
|
5. Include examples where helpful
|
|
6. Consider edge cases and potential ambiguities
|
|
|
|
Transform this rough instruction into a well-crafted prompt:
|
|
"${transcribedText}"
|
|
|
|
Enhanced prompt:`;
|
|
break;
|
|
|
|
case 'vibe':
|
|
case 'instructions':
|
|
case 'architect':
|
|
systemMessage = 'You are a helpful assistant that formats ideas into clear, actionable instructions for AI agents.';
|
|
temperature = 0.5; // Lower temperature for more controlled output
|
|
prompt = `Transform the following idea into clear, well-structured instructions that an AI agent can easily understand and execute.
|
|
|
|
IMPORTANT RULES:
|
|
- Format as clear, step-by-step instructions
|
|
- Add reasonable implementation details based on common patterns
|
|
- Only include details directly related to what was asked
|
|
- Do NOT add features or functionality not mentioned
|
|
- Keep the original intent and scope intact
|
|
- Use clear, actionable language an agent can follow
|
|
|
|
Transform this idea into agent-friendly instructions:
|
|
"${transcribedText}"
|
|
|
|
Agent instructions:`;
|
|
break;
|
|
|
|
default:
|
|
// No enhancement needed
|
|
break;
|
|
}
|
|
|
|
// Only make GPT call if we have a prompt
|
|
if (prompt) {
|
|
const completion = await openai.chat.completions.create({
|
|
model: 'gpt-4o-mini',
|
|
messages: [
|
|
{ role: 'system', content: systemMessage },
|
|
{ role: 'user', content: prompt }
|
|
],
|
|
temperature: temperature,
|
|
max_tokens: maxTokens
|
|
});
|
|
|
|
transcribedText = completion.choices[0].message.content || transcribedText;
|
|
}
|
|
|
|
} catch (gptError) {
|
|
console.error('GPT processing error:', gptError);
|
|
// Fall back to original transcription if GPT fails
|
|
}
|
|
|
|
res.json({ text: transcribedText });
|
|
|
|
} catch (error) {
|
|
console.error('Transcription error:', error);
|
|
res.status(500).json({ error: error.message });
|
|
}
|
|
});
|
|
} catch (error) {
|
|
console.error('Endpoint error:', error);
|
|
res.status(500).json({ error: 'Internal server error' });
|
|
}
|
|
});
|
|
|
|
// Image upload endpoint
|
|
app.post('/api/projects/:projectName/upload-images', authenticateToken, async (req, res) => {
|
|
try {
|
|
const multer = (await import('multer')).default;
|
|
const path = (await import('path')).default;
|
|
const fs = (await import('fs')).promises;
|
|
const os = (await import('os')).default;
|
|
|
|
// Configure multer for image uploads
|
|
const storage = multer.diskStorage({
|
|
destination: async (req, file, cb) => {
|
|
const uploadDir = path.join(os.tmpdir(), 'claude-ui-uploads', String(req.user.id));
|
|
await fs.mkdir(uploadDir, { recursive: true });
|
|
cb(null, uploadDir);
|
|
},
|
|
filename: (req, file, cb) => {
|
|
const uniqueSuffix = Date.now() + '-' + Math.round(Math.random() * 1E9);
|
|
const sanitizedName = file.originalname.replace(/[^a-zA-Z0-9.-]/g, '_');
|
|
cb(null, uniqueSuffix + '-' + sanitizedName);
|
|
}
|
|
});
|
|
|
|
const fileFilter = (req, file, cb) => {
|
|
const allowedMimes = ['image/jpeg', 'image/png', 'image/gif', 'image/webp', 'image/svg+xml'];
|
|
if (allowedMimes.includes(file.mimetype)) {
|
|
cb(null, true);
|
|
} else {
|
|
cb(new Error('Invalid file type. Only JPEG, PNG, GIF, WebP, and SVG are allowed.'));
|
|
}
|
|
};
|
|
|
|
const upload = multer({
|
|
storage,
|
|
fileFilter,
|
|
limits: {
|
|
fileSize: 5 * 1024 * 1024, // 5MB
|
|
files: 5
|
|
}
|
|
});
|
|
|
|
// Handle multipart form data
|
|
upload.array('images', 5)(req, res, async (err) => {
|
|
if (err) {
|
|
return res.status(400).json({ error: err.message });
|
|
}
|
|
|
|
if (!req.files || req.files.length === 0) {
|
|
return res.status(400).json({ error: 'No image files provided' });
|
|
}
|
|
|
|
try {
|
|
// Process uploaded images
|
|
const processedImages = await Promise.all(
|
|
req.files.map(async (file) => {
|
|
// Read file and convert to base64
|
|
const buffer = await fs.readFile(file.path);
|
|
const base64 = buffer.toString('base64');
|
|
const mimeType = file.mimetype;
|
|
|
|
// Clean up temp file immediately
|
|
await fs.unlink(file.path);
|
|
|
|
return {
|
|
name: file.originalname,
|
|
data: `data:${mimeType};base64,${base64}`,
|
|
size: file.size,
|
|
mimeType: mimeType
|
|
};
|
|
})
|
|
);
|
|
|
|
res.json({ images: processedImages });
|
|
} catch (error) {
|
|
console.error('Error processing images:', error);
|
|
// Clean up any remaining files
|
|
await Promise.all(req.files.map(f => fs.unlink(f.path).catch(() => { })));
|
|
res.status(500).json({ error: 'Failed to process images' });
|
|
}
|
|
});
|
|
} catch (error) {
|
|
console.error('Error in image upload endpoint:', error);
|
|
res.status(500).json({ error: 'Internal server error' });
|
|
}
|
|
});
|
|
|
|
// API endpoint to get token usage for a session by reading its JSONL file
|
|
app.get('/api/sessions/:sessionId/token-usage', authenticateToken, async (req, res) => {
|
|
try {
|
|
const { sessionId } = req.params;
|
|
const { projectPath } = req.query;
|
|
|
|
if (!projectPath) {
|
|
return res.status(400).json({ error: 'projectPath query parameter is required' });
|
|
}
|
|
|
|
// Construct the JSONL file path
|
|
// Claude stores session files in ~/.claude/projects/[encoded-project-path]/[session-id].jsonl
|
|
// The encoding replaces /, spaces, ~, and _ with -
|
|
const homeDir = os.homedir();
|
|
const encodedPath = projectPath.replace(/[\/\s~_]/g, '-');
|
|
const jsonlPath = path.join(homeDir, '.claude', 'projects', encodedPath, `${sessionId}.jsonl`);
|
|
|
|
// Check if file exists
|
|
if (!fs.existsSync(jsonlPath)) {
|
|
return res.status(404).json({ error: 'Session file not found', path: jsonlPath });
|
|
}
|
|
|
|
// Read and parse the JSONL file
|
|
const fileContent = fs.readFileSync(jsonlPath, 'utf8');
|
|
const lines = fileContent.trim().split('\n');
|
|
|
|
const parsedContextWindow = parseInt(process.env.CONTEXT_WINDOW, 10);
|
|
const contextWindow = Number.isFinite(parsedContextWindow) ? parsedContextWindow : 160000;
|
|
let inputTokens = 0;
|
|
let cacheCreationTokens = 0;
|
|
let cacheReadTokens = 0;
|
|
|
|
// Find the latest assistant message with usage data (scan from end)
|
|
for (let i = lines.length - 1; i >= 0; i--) {
|
|
try {
|
|
const entry = JSON.parse(lines[i]);
|
|
|
|
// Only count assistant messages which have usage data
|
|
if (entry.type === 'assistant' && entry.message?.usage) {
|
|
const usage = entry.message.usage;
|
|
|
|
// Use token counts from latest assistant message only
|
|
inputTokens = usage.input_tokens || 0;
|
|
cacheCreationTokens = usage.cache_creation_input_tokens || 0;
|
|
cacheReadTokens = usage.cache_read_input_tokens || 0;
|
|
|
|
break; // Stop after finding the latest assistant message
|
|
}
|
|
} catch (parseError) {
|
|
// Skip lines that can't be parsed
|
|
continue;
|
|
}
|
|
}
|
|
|
|
// Calculate total context usage (excluding output_tokens, as per ccusage)
|
|
const totalUsed = inputTokens + cacheCreationTokens + cacheReadTokens;
|
|
|
|
res.json({
|
|
used: totalUsed,
|
|
total: contextWindow,
|
|
breakdown: {
|
|
input: inputTokens,
|
|
cacheCreation: cacheCreationTokens,
|
|
cacheRead: cacheReadTokens
|
|
}
|
|
});
|
|
} catch (error) {
|
|
console.error('Error reading session token usage:', error);
|
|
res.status(500).json({ error: 'Failed to read session token usage' });
|
|
}
|
|
});
|
|
|
|
// Serve React app for all other routes (excluding static files)
|
|
app.get('*', (req, res) => {
|
|
// Only serve index.html for HTML routes, not for static assets
|
|
// Static assets should already be handled by express.static middleware above
|
|
if (process.env.NODE_ENV === 'production') {
|
|
res.sendFile(path.join(__dirname, '../dist/index.html'));
|
|
} else {
|
|
// In development, redirect to Vite dev server
|
|
res.redirect(`http://localhost:${process.env.VITE_PORT || 5173}`);
|
|
}
|
|
});
|
|
|
|
// Helper function to convert permissions to rwx format
|
|
function permToRwx(perm) {
|
|
const r = perm & 4 ? 'r' : '-';
|
|
const w = perm & 2 ? 'w' : '-';
|
|
const x = perm & 1 ? 'x' : '-';
|
|
return r + w + x;
|
|
}
|
|
|
|
async function getFileTree(dirPath, maxDepth = 3, currentDepth = 0, showHidden = true) {
|
|
// Using fsPromises from import
|
|
const items = [];
|
|
|
|
try {
|
|
const entries = await fsPromises.readdir(dirPath, { withFileTypes: true });
|
|
|
|
for (const entry of entries) {
|
|
// Debug: log all entries including hidden files
|
|
|
|
|
|
// Skip only heavy build directories
|
|
if (entry.name === 'node_modules' ||
|
|
entry.name === 'dist' ||
|
|
entry.name === 'build') continue;
|
|
|
|
const itemPath = path.join(dirPath, entry.name);
|
|
const item = {
|
|
name: entry.name,
|
|
path: itemPath,
|
|
type: entry.isDirectory() ? 'directory' : 'file'
|
|
};
|
|
|
|
// Get file stats for additional metadata
|
|
try {
|
|
const stats = await fsPromises.stat(itemPath);
|
|
item.size = stats.size;
|
|
item.modified = stats.mtime.toISOString();
|
|
|
|
// Convert permissions to rwx format
|
|
const mode = stats.mode;
|
|
const ownerPerm = (mode >> 6) & 7;
|
|
const groupPerm = (mode >> 3) & 7;
|
|
const otherPerm = mode & 7;
|
|
item.permissions = ((mode >> 6) & 7).toString() + ((mode >> 3) & 7).toString() + (mode & 7).toString();
|
|
item.permissionsRwx = permToRwx(ownerPerm) + permToRwx(groupPerm) + permToRwx(otherPerm);
|
|
} catch (statError) {
|
|
// If stat fails, provide default values
|
|
item.size = 0;
|
|
item.modified = null;
|
|
item.permissions = '000';
|
|
item.permissionsRwx = '---------';
|
|
}
|
|
|
|
if (entry.isDirectory() && currentDepth < maxDepth) {
|
|
// Recursively get subdirectories but limit depth
|
|
try {
|
|
// Check if we can access the directory before trying to read it
|
|
await fsPromises.access(item.path, fs.constants.R_OK);
|
|
item.children = await getFileTree(item.path, maxDepth, currentDepth + 1, showHidden);
|
|
} catch (e) {
|
|
// Silently skip directories we can't access (permission denied, etc.)
|
|
item.children = [];
|
|
}
|
|
}
|
|
|
|
items.push(item);
|
|
}
|
|
} catch (error) {
|
|
// Only log non-permission errors to avoid spam
|
|
if (error.code !== 'EACCES' && error.code !== 'EPERM') {
|
|
console.error('Error reading directory:', error);
|
|
}
|
|
}
|
|
|
|
return items.sort((a, b) => {
|
|
if (a.type !== b.type) {
|
|
return a.type === 'directory' ? -1 : 1;
|
|
}
|
|
return a.name.localeCompare(b.name);
|
|
});
|
|
}
|
|
|
|
const PORT = process.env.PORT || 3001;
|
|
|
|
// Initialize database and start server
|
|
async function startServer() {
|
|
try {
|
|
// Initialize authentication database
|
|
await initializeDatabase();
|
|
console.log('✅ Database initialization skipped (testing)');
|
|
|
|
// Log Claude implementation mode
|
|
console.log('🚀 Using Claude Agents SDK for Claude integration');
|
|
|
|
server.listen(PORT, '0.0.0.0', async () => {
|
|
console.log(`Claude Code UI server running on http://0.0.0.0:${PORT}`);
|
|
|
|
// Start watching the projects folder for changes
|
|
await setupProjectsWatcher();
|
|
});
|
|
} catch (error) {
|
|
console.error('❌ Failed to start server:', error);
|
|
process.exit(1);
|
|
}
|
|
}
|
|
|
|
startServer();
|