mirror of
https://github.com/siteboon/claudecodeui.git
synced 2026-03-12 01:17:48 +00:00
* fix(shell): copy terminal selections from xterm buffer
The shell was delegating Cmd/Ctrl+C to document.execCommand('copy'),
which copied the rendered DOM selection instead of xterm's logical
buffer text. Wrapped values like login URLs could pick up row
whitespace or line breaks and break when pasted.
Route keyboard copy through terminal.getSelection() and the shared
clipboard helper. Also intercept native copy events on the terminal
container so mouse selection and browser copy actions use the same
normalized terminal text.
Remove the copy listener during teardown to avoid leaking handlers
across terminal reinitialization.
* fix(shell): restore terminal focus when switching to the shell tab
Pass shell activity state from MainContent through StandaloneShell and use it
inside Shell to explicitly focus the xterm instance once the terminal is both
initialized and connected.
Previously, switching to the Shell tab left focus on the tab button because
isActive was being ignored and the terminal never called focus() after the tab
activation lifecycle completed. As a result, users had to click inside the
terminal before keyboard input would be accepted.
This change wires isActive through the shell stack, removes the unused prop
handling in Shell, and adds a focus effect that runs when the shell becomes
active and ready. The effect uses both requestAnimationFrame and a zero-delay
timeout so focus is applied reliably after rendering and connection state
updates settle.
This restores immediate typing when opening the shell tab and also improves the
reconnect path by re-focusing the terminal after the shell connection is ready.
* fix(shell): remove fallback command for codex and claude session resumes
The `|| claude` and `|| codex` fallback commands were causing errors as they are not valid commands.
* fix: use fallback while resuming codex and claude sessions for linux and windows
* feat(git): add revert latest local commit action in git panel
Add a complete revert-local-commit flow so users can undo the most recent
local commit directly from the Git header, placed before the refresh icon.
Backend
- add POST /api/git/revert-local-commit endpoint in server/routes/git.js
- validate project input and repository state before executing git operations
- revert latest commit with `git reset --soft HEAD~1` to keep changes staged
- handle initial-commit edge case by deleting HEAD ref when no parent exists
- return clear success and error responses for UI consumption
Frontend
- add useRevertLocalCommit hook to encapsulate API call and loading state
- wire hook into GitPanel and refresh git data after successful revert
- add new toolbar action in GitPanelHeader before refresh icon
- route action through existing confirmation modal flow
- disable action while request is in flight and show activity indicator
Shared UI and typing updates
- extend ConfirmActionType with `revertLocalCommit`
- add confirmation title, label, and style mappings for new action
- render RotateCcw icon for revert action in ConfirmActionModal
Result
- users can safely undo the latest local commit from the UI
- reverted commit changes remain staged for immediate recommit/edit workflows
* fix: run cursor with --trust if workspace trust prompt is detected, and retry once
* fix(git): handle repositories without commits across status and remote flows
Improve git route behavior for repositories initialized with `git init` but with
no commits yet. Previously, several routes called `git rev-parse --abbrev-ref HEAD`,
which fails before the first commit and caused noisy console errors plus a broken
Git panel state.
What changed
- add `getGitErrorDetails` helper to normalize git process failure text
- add `isMissingHeadRevisionError` helper to detect no-HEAD/no-revision cases
- add `getCurrentBranchName` helper:
- uses `git symbolic-ref --short HEAD` first (works before first commit)
- falls back to `git rev-parse --abbrev-ref HEAD` for detached HEAD and edge cases
- add `repositoryHasCommits` helper using `git rev-parse --verify HEAD`
Status route improvements
- replace inline branch/HEAD error handling with shared helpers
- keep returning valid branch + `hasCommits: false` for fresh repositories
Remote status improvements
- avoid hard failure when repository has no commits
- return a safe, non-error payload with:
- `hasUpstream: false`
- `ahead: 0`, `behind: 0`
- detected remote name when remotes exist
- message: "Repository has no commits yet"
- preserve existing upstream detection behavior for repositories with commits
Consistency updates
- switch fetch/pull/push/publish branch lookup to shared `getCurrentBranchName`
to ensure the same branch-resolution behavior everywhere
Result
- `git init` repositories no longer trigger `rev-parse HEAD` ambiguity failures
- Git panel remains usable before the first commit
- backend branch detection is centralized and consistent across git operations
* fix(git): resolve file paths against repo root for paths with spaces
Fix path resolution for git file operations when project directories include spaces
or when API calls are issued from subdirectories inside a repository.
Problem
- operations like commit/discard/diff could receive file paths that were valid from
repo root but were executed from a nested cwd
- this produced pathspec errors like:
- warning: could not open directory '4/4/'
- fatal: pathspec '4/hello_world.ts' did not match any files
Root cause
- file arguments were passed directly to git commands using the project cwd
- inconsistent path forms (repo-root-relative vs cwd-relative) were not normalized
Changes
- remove unsafe fallback decode in `getActualProjectPath`; fail explicitly when the
real project path cannot be resolved
- add repository/file-path helpers:
- `getRepositoryRootPath`
- `normalizeRepositoryRelativeFilePath`
- `parseStatusFilePaths`
- `buildFilePathCandidates`
- `resolveRepositoryFilePath`
- update file-based git endpoints to resolve paths before executing commands:
- GET `/diff`
- GET `/file-with-diff`
- POST `/commit`
- POST `/generate-commit-message`
- POST `/discard`
- POST `/delete-untracked`
- stage/restore/reset operations now use `--` before pathspecs for safer argument
separation
Behavioral impact
- git operations now work reliably for repositories under directories containing spaces
- file operations are consistent even when project cwd is a subdirectory of repo root
- endpoint responses continue to preserve existing payload shapes
Verification
- syntax check: `node --check server/routes/git.js`
- typecheck: `npm run typecheck`
- reproduced failing scenario in a temp path with spaces; confirmed root-resolved
path staging succeeds where subdir-cwd pathspec previously failed
* fix(git-ui): prevent large commit diffs from freezing the history tab
Harden commit diff loading/rendering so opening a very large commit no longer hangs
the browser tab.
Problem
- commit history diff viewer rendered every diff line as a React node
- very large commits could create thousands of nodes and lock the UI thread
- backend always returned full commit patch payloads, amplifying frontend pressure
Backend safeguards
- add `COMMIT_DIFF_CHARACTER_LIMIT` (500,000 chars) in git routes
- update GET `/api/git/commit-diff` to truncate oversized diff payloads
- include `isTruncated` flag in response for observability/future UI handling
- append truncation marker text when server-side limit is applied
Frontend safeguards
- update `GitDiffViewer` to use bounded preview rendering:
- character cap: 200,000
- line cap: 1,500
- move diff preprocessing into `useMemo` for stable, one-pass preview computation
- show a clear "Large diff preview" notice when truncation is active
Impact
- commit diff expansion remains responsive even for high-change commits
- UI still shows useful diff content while avoiding tab lockups
- changes apply to shared diff viewer usage and improve resilience broadly
Validation
- `node --check server/routes/git.js`
- `npm run typecheck`
- `npx eslint src/components/git-panel/view/shared/GitDiffViewer.tsx`
* fix(cursor-chat): stabilize first-run UX and clean cursor message rendering
Fix three Cursor chat regressions observed on first message runs:
1. Full-screen UI refresh/flicker after first response.
2. Internal wrapper tags rendered in user messages.
3. Duplicate assistant message on response finalization.
Root causes
- Project refresh from chat completion used the global loading path,
toggling app-level loading UI.
- Cursor history conversion rendered raw internal wrapper payloads
as user-visible message text.
- Cursor response handling could finalize through overlapping stream/
result paths, and stdout chunk parsing could split JSON lines.
Changes
- Added non-blocking project refresh plumbing for chat/session flows.
- Introduced fetch options in useProjectsState (showLoadingState flag).
- Added refreshProjectsSilently() to update metadata without global loading UI.
- Wired window.refreshProjects to refreshProjectsSilently in AppContent.
- Added Cursor user-message sanitization during history conversion.
- Added extractCursorUserQuery() to keep only <user_query> payload.
- Added sanitizeCursorUserMessageText() to strip internal wrappers:
<user_info>, <agent_skills>, <available_skills>,
<environment_context>, <environment_info>.
- Applied sanitization only for role === 'user' in
convertCursorSessionMessages().
- Hardened Cursor backend stream parsing and finalization.
- Added line-buffered stdout parser for chunk-split JSON payloads.
- Flushed trailing unterminated stdout line on process close.
- Removed redundant content_block_stop emission on Cursor result.
- Added frontend duplicate guard in cursor-result handling.
- Skips a second assistant bubble when final result text equals
already-rendered streamed content.
Code comments
- Added focused comments describing silent refresh behavior,
tag stripping rationale, duplicate guard behavior, and line buffering.
Validation
- ESLint passes for touched files.
- Production build succeeds.
Files
- server/cursor-cli.js
- src/components/app/AppContent.tsx
- src/components/chat/hooks/useChatRealtimeHandlers.ts
- src/components/chat/utils/messageTransforms.ts
- src/hooks/useProjectsState.ts
---------
Co-authored-by: Haileyesus <something@gmail.com>
Co-authored-by: Simos Mikelatos <simosmik@gmail.com>
2551 lines
100 KiB
JavaScript
Executable File
2551 lines
100 KiB
JavaScript
Executable File
#!/usr/bin/env node
|
|
// Load environment variables before other imports execute
|
|
import './load-env.js';
|
|
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);
|
|
|
|
const installMode = fs.existsSync(path.join(__dirname, '..', '.git')) ? 'git' : 'npm';
|
|
|
|
// ANSI color codes for terminal output
|
|
const colors = {
|
|
reset: '\x1b[0m',
|
|
bright: '\x1b[1m',
|
|
cyan: '\x1b[36m',
|
|
green: '\x1b[32m',
|
|
yellow: '\x1b[33m',
|
|
blue: '\x1b[34m',
|
|
dim: '\x1b[2m',
|
|
};
|
|
|
|
const c = {
|
|
info: (text) => `${colors.cyan}${text}${colors.reset}`,
|
|
ok: (text) => `${colors.green}${text}${colors.reset}`,
|
|
warn: (text) => `${colors.yellow}${text}${colors.reset}`,
|
|
tip: (text) => `${colors.blue}${text}${colors.reset}`,
|
|
bright: (text) => `${colors.bright}${text}${colors.reset}`,
|
|
dim: (text) => `${colors.dim}${text}${colors.reset}`,
|
|
};
|
|
|
|
console.log('PORT from env:', process.env.PORT);
|
|
|
|
import express from 'express';
|
|
import { WebSocketServer, WebSocket } 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, searchConversations } from './projects.js';
|
|
import { queryClaudeSDK, abortClaudeSDKSession, isClaudeSDKSessionActive, getActiveClaudeSDKSessions, resolveToolApproval, getPendingApprovalsForSession, reconnectSessionWriter } from './claude-sdk.js';
|
|
import { spawnCursor, abortCursorSession, isCursorSessionActive, getActiveCursorSessions } from './cursor-cli.js';
|
|
import { queryCodex, abortCodexSession, isCodexSessionActive, getActiveCodexSessions } from './openai-codex.js';
|
|
import { spawnGemini, abortGeminiSession, isGeminiSessionActive, getActiveGeminiSessions } from './gemini-cli.js';
|
|
import sessionManager from './sessionManager.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 commandsRoutes from './routes/commands.js';
|
|
import settingsRoutes from './routes/settings.js';
|
|
import agentRoutes from './routes/agent.js';
|
|
import projectsRoutes, { WORKSPACES_ROOT, validateWorkspacePath } from './routes/projects.js';
|
|
import cliAuthRoutes from './routes/cli-auth.js';
|
|
import userRoutes from './routes/user.js';
|
|
import codexRoutes from './routes/codex.js';
|
|
import geminiRoutes from './routes/gemini.js';
|
|
import pluginsRoutes from './routes/plugins.js';
|
|
import { startEnabledPluginServers, stopAllPlugins } from './utils/plugin-process-manager.js';
|
|
import { initializeDatabase, sessionNamesDb, applyCustomSessionNames } from './database/db.js';
|
|
import { validateApiKey, authenticateToken, authenticateWebSocket } from './middleware/auth.js';
|
|
import { IS_PLATFORM } from './constants/config.js';
|
|
|
|
const VALID_PROVIDERS = ['claude', 'codex', 'cursor', 'gemini'];
|
|
|
|
// File system watchers for provider project/session folders
|
|
const PROVIDER_WATCH_PATHS = [
|
|
{ provider: 'claude', rootPath: path.join(os.homedir(), '.claude', 'projects') },
|
|
{ provider: 'cursor', rootPath: path.join(os.homedir(), '.cursor', 'chats') },
|
|
{ provider: 'codex', rootPath: path.join(os.homedir(), '.codex', 'sessions') },
|
|
{ provider: 'gemini', rootPath: path.join(os.homedir(), '.gemini', 'projects') },
|
|
{ provider: 'gemini_sessions', rootPath: path.join(os.homedir(), '.gemini', 'sessions') }
|
|
];
|
|
const WATCHER_IGNORED_PATTERNS = [
|
|
'**/node_modules/**',
|
|
'**/.git/**',
|
|
'**/dist/**',
|
|
'**/build/**',
|
|
'**/*.tmp',
|
|
'**/*.swp',
|
|
'**/.DS_Store'
|
|
];
|
|
const WATCHER_DEBOUNCE_MS = 300;
|
|
let projectsWatchers = [];
|
|
let projectsWatcherDebounceTimer = null;
|
|
const connectedClients = new Set();
|
|
let isGetProjectsRunning = false; // Flag to prevent reentrant calls
|
|
|
|
// Broadcast progress to all connected WebSocket clients
|
|
function broadcastProgress(progress) {
|
|
const message = JSON.stringify({
|
|
type: 'loading_progress',
|
|
...progress
|
|
});
|
|
connectedClients.forEach(client => {
|
|
if (client.readyState === WebSocket.OPEN) {
|
|
client.send(message);
|
|
}
|
|
});
|
|
}
|
|
|
|
// Setup file system watchers for Claude, Cursor, and Codex project/session folders
|
|
async function setupProjectsWatcher() {
|
|
const chokidar = (await import('chokidar')).default;
|
|
|
|
if (projectsWatcherDebounceTimer) {
|
|
clearTimeout(projectsWatcherDebounceTimer);
|
|
projectsWatcherDebounceTimer = null;
|
|
}
|
|
|
|
await Promise.all(
|
|
projectsWatchers.map(async (watcher) => {
|
|
try {
|
|
await watcher.close();
|
|
} catch (error) {
|
|
console.error('[WARN] Failed to close watcher:', error);
|
|
}
|
|
})
|
|
);
|
|
projectsWatchers = [];
|
|
|
|
const debouncedUpdate = (eventType, filePath, provider, rootPath) => {
|
|
if (projectsWatcherDebounceTimer) {
|
|
clearTimeout(projectsWatcherDebounceTimer);
|
|
}
|
|
|
|
projectsWatcherDebounceTimer = setTimeout(async () => {
|
|
// Prevent reentrant calls
|
|
if (isGetProjectsRunning) {
|
|
return;
|
|
}
|
|
|
|
try {
|
|
isGetProjectsRunning = true;
|
|
|
|
// Clear project directory cache when files change
|
|
clearProjectDirectoryCache();
|
|
|
|
// Get updated projects list
|
|
const updatedProjects = await getProjects(broadcastProgress);
|
|
|
|
// 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(rootPath, filePath),
|
|
watchProvider: provider
|
|
});
|
|
|
|
connectedClients.forEach(client => {
|
|
if (client.readyState === WebSocket.OPEN) {
|
|
client.send(updateMessage);
|
|
}
|
|
});
|
|
|
|
} catch (error) {
|
|
console.error('[ERROR] Error handling project changes:', error);
|
|
} finally {
|
|
isGetProjectsRunning = false;
|
|
}
|
|
}, WATCHER_DEBOUNCE_MS);
|
|
};
|
|
|
|
for (const { provider, rootPath } of PROVIDER_WATCH_PATHS) {
|
|
try {
|
|
// chokidar v4 emits ENOENT via the "error" event for missing roots and will not auto-recover.
|
|
// Ensure provider folders exist before creating the watcher so watching stays active.
|
|
await fsPromises.mkdir(rootPath, { recursive: true });
|
|
|
|
// Initialize chokidar watcher with optimized settings
|
|
const watcher = chokidar.watch(rootPath, {
|
|
ignored: WATCHER_IGNORED_PATTERNS,
|
|
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
|
|
}
|
|
});
|
|
|
|
// Set up event listeners
|
|
watcher
|
|
.on('add', (filePath) => debouncedUpdate('add', filePath, provider, rootPath))
|
|
.on('change', (filePath) => debouncedUpdate('change', filePath, provider, rootPath))
|
|
.on('unlink', (filePath) => debouncedUpdate('unlink', filePath, provider, rootPath))
|
|
.on('addDir', (dirPath) => debouncedUpdate('addDir', dirPath, provider, rootPath))
|
|
.on('unlinkDir', (dirPath) => debouncedUpdate('unlinkDir', dirPath, provider, rootPath))
|
|
.on('error', (error) => {
|
|
console.error(`[ERROR] ${provider} watcher error:`, error);
|
|
})
|
|
.on('ready', () => {
|
|
});
|
|
|
|
projectsWatchers.push(watcher);
|
|
} catch (error) {
|
|
console.error(`[ERROR] Failed to setup ${provider} watcher for ${rootPath}:`, error);
|
|
}
|
|
}
|
|
|
|
if (projectsWatchers.length === 0) {
|
|
console.error('[ERROR] Failed to setup any provider watchers');
|
|
}
|
|
}
|
|
|
|
|
|
const app = express();
|
|
const server = http.createServer(app);
|
|
|
|
const ptySessionsMap = new Map();
|
|
const PTY_SESSION_TIMEOUT = 30 * 60 * 1000;
|
|
const SHELL_URL_PARSE_BUFFER_LIMIT = 32768;
|
|
const ANSI_ESCAPE_SEQUENCE_REGEX = /\x1B(?:[@-Z\\-_]|\[[0-?]*[ -/]*[@-~]|\][^\x07]*(?:\x07|\x1B\\))/g;
|
|
const TRAILING_URL_PUNCTUATION_REGEX = /[)\]}>.,;:!?]+$/;
|
|
|
|
function stripAnsiSequences(value = '') {
|
|
return value.replace(ANSI_ESCAPE_SEQUENCE_REGEX, '');
|
|
}
|
|
|
|
function normalizeDetectedUrl(url) {
|
|
if (!url || typeof url !== 'string') return null;
|
|
|
|
const cleaned = url.trim().replace(TRAILING_URL_PUNCTUATION_REGEX, '');
|
|
if (!cleaned) return null;
|
|
|
|
try {
|
|
const parsed = new URL(cleaned);
|
|
if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') {
|
|
return null;
|
|
}
|
|
return parsed.toString();
|
|
} catch {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
function extractUrlsFromText(value = '') {
|
|
const directMatches = value.match(/https?:\/\/[^\s<>"'`\\\x1b\x07]+/gi) || [];
|
|
|
|
// Handle wrapped terminal URLs split across lines by terminal width.
|
|
const wrappedMatches = [];
|
|
const continuationRegex = /^[A-Za-z0-9\-._~:/?#\[\]@!$&'()*+,;=%]+$/;
|
|
const lines = value.split(/\r?\n/);
|
|
for (let i = 0; i < lines.length; i++) {
|
|
const line = lines[i].trim();
|
|
const startMatch = line.match(/https?:\/\/[^\s<>"'`\\\x1b\x07]+/i);
|
|
if (!startMatch) continue;
|
|
|
|
let combined = startMatch[0];
|
|
let j = i + 1;
|
|
while (j < lines.length) {
|
|
const continuation = lines[j].trim();
|
|
if (!continuation) break;
|
|
if (!continuationRegex.test(continuation)) break;
|
|
combined += continuation;
|
|
j++;
|
|
}
|
|
|
|
wrappedMatches.push(combined.replace(/\r?\n\s*/g, ''));
|
|
}
|
|
|
|
return Array.from(new Set([...directMatches, ...wrappedMatches]));
|
|
}
|
|
|
|
function shouldAutoOpenUrlFromOutput(value = '') {
|
|
const normalized = value.toLowerCase();
|
|
return (
|
|
normalized.includes('browser didn\'t open') ||
|
|
normalized.includes('open this url') ||
|
|
normalized.includes('continue in your browser') ||
|
|
normalized.includes('press enter to open') ||
|
|
normalized.includes('open_url:')
|
|
);
|
|
}
|
|
|
|
// Single WebSocket server that handles both paths
|
|
const wss = new WebSocketServer({
|
|
server,
|
|
verifyClient: (info) => {
|
|
console.log('WebSocket connection attempt to:', info.req.url);
|
|
|
|
// Platform mode: always allow connection
|
|
if (IS_PLATFORM) {
|
|
const user = authenticateWebSocket(null); // Will return first user
|
|
if (!user) {
|
|
console.log('[WARN] Platform mode: No user found in database');
|
|
return false;
|
|
}
|
|
info.req.user = user;
|
|
console.log('[OK] Platform mode WebSocket authenticated for user:', user.username);
|
|
return true;
|
|
}
|
|
|
|
// Normal mode: verify token
|
|
// 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('[WARN] WebSocket authentication failed');
|
|
return false;
|
|
}
|
|
|
|
// Store user info in the request for later use
|
|
info.req.user = user;
|
|
console.log('[OK] WebSocket authenticated for user:', user.username);
|
|
return true;
|
|
}
|
|
});
|
|
|
|
// Make WebSocket server available to routes
|
|
app.locals.wss = wss;
|
|
|
|
app.use(cors({ exposedHeaders: ['X-Refreshed-Token'] }));
|
|
app.use(express.json({
|
|
limit: '50mb',
|
|
type: (req) => {
|
|
// Skip multipart/form-data requests (for file uploads like images)
|
|
const contentType = req.headers['content-type'] || '';
|
|
if (contentType.includes('multipart/form-data')) {
|
|
return false;
|
|
}
|
|
return contentType.includes('json');
|
|
}
|
|
}));
|
|
app.use(express.urlencoded({ limit: '50mb', extended: true }));
|
|
|
|
// Public health check endpoint (no authentication required)
|
|
app.get('/health', (req, res) => {
|
|
res.json({
|
|
status: 'ok',
|
|
timestamp: new Date().toISOString(),
|
|
installMode
|
|
});
|
|
});
|
|
|
|
// Optional API key validation (if configured)
|
|
app.use('/api', validateApiKey);
|
|
|
|
// Authentication routes (public)
|
|
app.use('/api/auth', authRoutes);
|
|
|
|
// Projects API Routes (protected)
|
|
app.use('/api/projects', authenticateToken, projectsRoutes);
|
|
|
|
// 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);
|
|
|
|
// Commands API Routes (protected)
|
|
app.use('/api/commands', authenticateToken, commandsRoutes);
|
|
|
|
// Settings API Routes (protected)
|
|
app.use('/api/settings', authenticateToken, settingsRoutes);
|
|
|
|
// CLI Authentication API Routes (protected)
|
|
app.use('/api/cli', authenticateToken, cliAuthRoutes);
|
|
|
|
// User API Routes (protected)
|
|
app.use('/api/user', authenticateToken, userRoutes);
|
|
|
|
// Codex API Routes (protected)
|
|
app.use('/api/codex', authenticateToken, codexRoutes);
|
|
|
|
// Gemini API Routes (protected)
|
|
app.use('/api/gemini', authenticateToken, geminiRoutes);
|
|
|
|
// Plugins API Routes (protected)
|
|
app.use('/api/plugins', authenticateToken, pluginsRoutes);
|
|
|
|
// Agent API Routes (uses API key authentication)
|
|
app.use('/api/agent', agentRoutes);
|
|
|
|
// Serve public files (like api-docs.html)
|
|
app.use(express.static(path.join(__dirname, '../public')));
|
|
|
|
// Static files served after API routes
|
|
// Add cache control: HTML files should not be cached, but assets can be cached
|
|
app.use(express.static(path.join(__dirname, '../dist'), {
|
|
setHeaders: (res, filePath) => {
|
|
if (filePath.endsWith('.html')) {
|
|
// Prevent HTML caching to avoid service worker issues after builds
|
|
res.setHeader('Cache-Control', 'no-cache, no-store, must-revalidate');
|
|
res.setHeader('Pragma', 'no-cache');
|
|
res.setHeader('Expires', '0');
|
|
} else if (filePath.match(/\.(js|css|woff2?|ttf|eot|svg|png|jpg|jpeg|gif|ico)$/)) {
|
|
// Cache static assets for 1 year (they have hashed names)
|
|
res.setHeader('Cache-Control', 'public, max-age=31536000, immutable');
|
|
}
|
|
}
|
|
}));
|
|
|
|
// API Routes (protected)
|
|
// /api/config endpoint removed - no longer needed
|
|
// Frontend now uses window.location for WebSocket URLs
|
|
|
|
// System update endpoint
|
|
app.post('/api/system/update', authenticateToken, async (req, res) => {
|
|
try {
|
|
// Get the project root directory (parent of server directory)
|
|
const projectRoot = path.join(__dirname, '..');
|
|
|
|
console.log('Starting system update from directory:', projectRoot);
|
|
|
|
// Run the update command based on install mode
|
|
const updateCommand = installMode === 'git'
|
|
? 'git checkout main && git pull && npm install'
|
|
: 'npm install -g @siteboon/claude-code-ui@latest';
|
|
|
|
const child = spawn('sh', ['-c', updateCommand], {
|
|
cwd: installMode === 'git' ? projectRoot : os.homedir(),
|
|
env: process.env
|
|
});
|
|
|
|
let output = '';
|
|
let errorOutput = '';
|
|
|
|
child.stdout.on('data', (data) => {
|
|
const text = data.toString();
|
|
output += text;
|
|
console.log('Update output:', text);
|
|
});
|
|
|
|
child.stderr.on('data', (data) => {
|
|
const text = data.toString();
|
|
errorOutput += text;
|
|
console.error('Update error:', text);
|
|
});
|
|
|
|
child.on('close', (code) => {
|
|
if (code === 0) {
|
|
res.json({
|
|
success: true,
|
|
output: output || 'Update completed successfully',
|
|
message: 'Update completed. Please restart the server to apply changes.'
|
|
});
|
|
} else {
|
|
res.status(500).json({
|
|
success: false,
|
|
error: 'Update command failed',
|
|
output: output,
|
|
errorOutput: errorOutput
|
|
});
|
|
}
|
|
});
|
|
|
|
child.on('error', (error) => {
|
|
console.error('Update process error:', error);
|
|
res.status(500).json({
|
|
success: false,
|
|
error: error.message
|
|
});
|
|
});
|
|
|
|
} catch (error) {
|
|
console.error('System update error:', error);
|
|
res.status(500).json({
|
|
success: false,
|
|
error: error.message
|
|
});
|
|
}
|
|
});
|
|
|
|
app.get('/api/projects', authenticateToken, async (req, res) => {
|
|
try {
|
|
const projects = await getProjects(broadcastProgress);
|
|
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));
|
|
applyCustomSessionNames(result.sessions, 'claude');
|
|
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;
|
|
console.log(`[API] Deleting session: ${sessionId} from project: ${projectName}`);
|
|
await deleteSession(projectName, sessionId);
|
|
sessionNamesDb.deleteName(sessionId, 'claude');
|
|
console.log(`[API] Session ${sessionId} deleted successfully`);
|
|
res.json({ success: true });
|
|
} catch (error) {
|
|
console.error(`[API] Error deleting session ${req.params.sessionId}:`, error);
|
|
res.status(500).json({ error: error.message });
|
|
}
|
|
});
|
|
|
|
// Rename session endpoint
|
|
app.put('/api/sessions/:sessionId/rename', authenticateToken, async (req, res) => {
|
|
try {
|
|
const { sessionId } = req.params;
|
|
const safeSessionId = String(sessionId).replace(/[^a-zA-Z0-9._-]/g, '');
|
|
if (!safeSessionId || safeSessionId !== String(sessionId)) {
|
|
return res.status(400).json({ error: 'Invalid sessionId' });
|
|
}
|
|
const { summary, provider } = req.body;
|
|
if (!summary || typeof summary !== 'string' || summary.trim() === '') {
|
|
return res.status(400).json({ error: 'Summary is required' });
|
|
}
|
|
if (summary.trim().length > 500) {
|
|
return res.status(400).json({ error: 'Summary must not exceed 500 characters' });
|
|
}
|
|
if (!provider || !VALID_PROVIDERS.includes(provider)) {
|
|
return res.status(400).json({ error: `Provider must be one of: ${VALID_PROVIDERS.join(', ')}` });
|
|
}
|
|
sessionNamesDb.setName(safeSessionId, provider, summary.trim());
|
|
res.json({ success: true });
|
|
} catch (error) {
|
|
console.error(`[API] Error renaming session ${req.params.sessionId}:`, error);
|
|
res.status(500).json({ error: error.message });
|
|
}
|
|
});
|
|
|
|
// Delete project endpoint (force=true to delete with sessions)
|
|
app.delete('/api/projects/:projectName', authenticateToken, async (req, res) => {
|
|
try {
|
|
const { projectName } = req.params;
|
|
const force = req.query.force === 'true';
|
|
await deleteProject(projectName, force);
|
|
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 });
|
|
}
|
|
});
|
|
|
|
// Search conversations content (SSE streaming)
|
|
app.get('/api/search/conversations', authenticateToken, async (req, res) => {
|
|
const query = typeof req.query.q === 'string' ? req.query.q.trim() : '';
|
|
const parsedLimit = Number.parseInt(String(req.query.limit), 10);
|
|
const limit = Number.isNaN(parsedLimit) ? 50 : Math.max(1, Math.min(parsedLimit, 100));
|
|
|
|
if (query.length < 2) {
|
|
return res.status(400).json({ error: 'Query must be at least 2 characters' });
|
|
}
|
|
|
|
res.writeHead(200, {
|
|
'Content-Type': 'text/event-stream',
|
|
'Cache-Control': 'no-cache',
|
|
'Connection': 'keep-alive',
|
|
'X-Accel-Buffering': 'no',
|
|
});
|
|
|
|
let closed = false;
|
|
const abortController = new AbortController();
|
|
req.on('close', () => { closed = true; abortController.abort(); });
|
|
|
|
try {
|
|
await searchConversations(query, limit, ({ projectResult, totalMatches, scannedProjects, totalProjects }) => {
|
|
if (closed) return;
|
|
if (projectResult) {
|
|
res.write(`event: result\ndata: ${JSON.stringify({ projectResult, totalMatches, scannedProjects, totalProjects })}\n\n`);
|
|
} else {
|
|
res.write(`event: progress\ndata: ${JSON.stringify({ totalMatches, scannedProjects, totalProjects })}\n\n`);
|
|
}
|
|
}, abortController.signal);
|
|
if (!closed) {
|
|
res.write(`event: done\ndata: {}\n\n`);
|
|
}
|
|
} catch (error) {
|
|
console.error('Error searching conversations:', error);
|
|
if (!closed) {
|
|
res.write(`event: error\ndata: ${JSON.stringify({ error: 'Search failed' })}\n\n`);
|
|
}
|
|
} finally {
|
|
if (!closed) {
|
|
res.end();
|
|
}
|
|
}
|
|
});
|
|
|
|
const expandWorkspacePath = (inputPath) => {
|
|
if (!inputPath) return inputPath;
|
|
if (inputPath === '~') {
|
|
return WORKSPACES_ROOT;
|
|
}
|
|
if (inputPath.startsWith('~/') || inputPath.startsWith('~\\')) {
|
|
return path.join(WORKSPACES_ROOT, inputPath.slice(2));
|
|
}
|
|
return inputPath;
|
|
};
|
|
|
|
// Browse filesystem endpoint for project suggestions - uses existing getFileTree
|
|
app.get('/api/browse-filesystem', authenticateToken, async (req, res) => {
|
|
try {
|
|
const { path: dirPath } = req.query;
|
|
|
|
console.log('[API] Browse filesystem request for path:', dirPath);
|
|
console.log('[API] WORKSPACES_ROOT is:', WORKSPACES_ROOT);
|
|
// Default to home directory if no path provided
|
|
const defaultRoot = WORKSPACES_ROOT;
|
|
let targetPath = dirPath ? expandWorkspacePath(dirPath) : defaultRoot;
|
|
|
|
// Resolve and normalize the path
|
|
targetPath = path.resolve(targetPath);
|
|
|
|
// Security check - ensure path is within allowed workspace root
|
|
const validation = await validateWorkspacePath(targetPath);
|
|
if (!validation.valid) {
|
|
return res.status(403).json({ error: validation.error });
|
|
}
|
|
const resolvedPath = validation.resolvedPath || targetPath;
|
|
|
|
// Security check - ensure path is accessible
|
|
try {
|
|
await fs.promises.access(resolvedPath);
|
|
const stats = await fs.promises.stat(resolvedPath);
|
|
|
|
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(resolvedPath, 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'
|
|
}))
|
|
.sort((a, b) => {
|
|
const aHidden = a.name.startsWith('.');
|
|
const bHidden = b.name.startsWith('.');
|
|
if (aHidden && !bHidden) return 1;
|
|
if (!aHidden && bHidden) return -1;
|
|
return a.name.localeCompare(b.name);
|
|
});
|
|
|
|
// Add common directories if browsing home directory
|
|
const suggestions = [];
|
|
let resolvedWorkspaceRoot = defaultRoot;
|
|
try {
|
|
resolvedWorkspaceRoot = await fsPromises.realpath(defaultRoot);
|
|
} catch (error) {
|
|
// Use default root as-is if realpath fails
|
|
}
|
|
if (resolvedPath === resolvedWorkspaceRoot) {
|
|
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: resolvedPath,
|
|
suggestions: suggestions
|
|
});
|
|
|
|
} catch (error) {
|
|
console.error('Error browsing filesystem:', error);
|
|
res.status(500).json({ error: 'Failed to browse filesystem' });
|
|
}
|
|
});
|
|
|
|
app.post('/api/create-folder', authenticateToken, async (req, res) => {
|
|
try {
|
|
const { path: folderPath } = req.body;
|
|
if (!folderPath) {
|
|
return res.status(400).json({ error: 'Path is required' });
|
|
}
|
|
const expandedPath = expandWorkspacePath(folderPath);
|
|
const resolvedInput = path.resolve(expandedPath);
|
|
const validation = await validateWorkspacePath(resolvedInput);
|
|
if (!validation.valid) {
|
|
return res.status(403).json({ error: validation.error });
|
|
}
|
|
const targetPath = validation.resolvedPath || resolvedInput;
|
|
const parentDir = path.dirname(targetPath);
|
|
try {
|
|
await fs.promises.access(parentDir);
|
|
} catch (err) {
|
|
return res.status(404).json({ error: 'Parent directory does not exist' });
|
|
}
|
|
try {
|
|
await fs.promises.access(targetPath);
|
|
return res.status(409).json({ error: 'Folder already exists' });
|
|
} catch (err) {
|
|
// Folder doesn't exist, which is what we want
|
|
}
|
|
try {
|
|
await fs.promises.mkdir(targetPath, { recursive: false });
|
|
res.json({ success: true, path: targetPath });
|
|
} catch (mkdirError) {
|
|
if (mkdirError.code === 'EEXIST') {
|
|
return res.status(409).json({ error: 'Folder already exists' });
|
|
}
|
|
throw mkdirError;
|
|
}
|
|
} catch (error) {
|
|
console.error('Error creating folder:', error);
|
|
res.status(500).json({ error: 'Failed to create folder' });
|
|
}
|
|
});
|
|
|
|
// Read file content endpoint
|
|
app.get('/api/projects/:projectName/file', authenticateToken, async (req, res) => {
|
|
try {
|
|
const { projectName } = req.params;
|
|
const { filePath } = req.query;
|
|
|
|
|
|
// Security: ensure the requested path is inside the project root
|
|
if (!filePath) {
|
|
return res.status(400).json({ error: 'Invalid file path' });
|
|
}
|
|
|
|
const projectRoot = await extractProjectDirectory(projectName).catch(() => null);
|
|
if (!projectRoot) {
|
|
return res.status(404).json({ error: 'Project not found' });
|
|
}
|
|
|
|
// Handle both absolute and relative paths
|
|
const resolved = path.isAbsolute(filePath)
|
|
? path.resolve(filePath)
|
|
: path.resolve(projectRoot, filePath);
|
|
const normalizedRoot = path.resolve(projectRoot) + path.sep;
|
|
if (!resolved.startsWith(normalizedRoot)) {
|
|
return res.status(403).json({ error: 'Path must be under project root' });
|
|
}
|
|
|
|
const content = await fsPromises.readFile(resolved, 'utf8');
|
|
res.json({ content, path: resolved });
|
|
} 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;
|
|
|
|
|
|
// Security: ensure the requested path is inside the project root
|
|
if (!filePath) {
|
|
return res.status(400).json({ error: 'Invalid file path' });
|
|
}
|
|
|
|
const projectRoot = await extractProjectDirectory(projectName).catch(() => null);
|
|
if (!projectRoot) {
|
|
return res.status(404).json({ error: 'Project not found' });
|
|
}
|
|
|
|
const resolved = path.resolve(filePath);
|
|
const normalizedRoot = path.resolve(projectRoot) + path.sep;
|
|
if (!resolved.startsWith(normalizedRoot)) {
|
|
return res.status(403).json({ error: 'Path must be under project root' });
|
|
}
|
|
|
|
// Check if file exists
|
|
try {
|
|
await fsPromises.access(resolved);
|
|
} catch (error) {
|
|
return res.status(404).json({ error: 'File not found' });
|
|
}
|
|
|
|
// Get file extension and set appropriate content type
|
|
const mimeType = mime.lookup(resolved) || 'application/octet-stream';
|
|
res.setHeader('Content-Type', mimeType);
|
|
|
|
// Stream the file
|
|
const fileStream = fs.createReadStream(resolved);
|
|
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;
|
|
|
|
|
|
// Security: ensure the requested path is inside the project root
|
|
if (!filePath) {
|
|
return res.status(400).json({ error: 'Invalid file path' });
|
|
}
|
|
|
|
if (content === undefined) {
|
|
return res.status(400).json({ error: 'Content is required' });
|
|
}
|
|
|
|
const projectRoot = await extractProjectDirectory(projectName).catch(() => null);
|
|
if (!projectRoot) {
|
|
return res.status(404).json({ error: 'Project not found' });
|
|
}
|
|
|
|
// Handle both absolute and relative paths
|
|
const resolved = path.isAbsolute(filePath)
|
|
? path.resolve(filePath)
|
|
: path.resolve(projectRoot, filePath);
|
|
const normalizedRoot = path.resolve(projectRoot) + path.sep;
|
|
if (!resolved.startsWith(normalizedRoot)) {
|
|
return res.status(403).json({ error: 'Path must be under project root' });
|
|
}
|
|
|
|
// Write the new content
|
|
await fsPromises.writeFile(resolved, content, 'utf8');
|
|
|
|
res.json({
|
|
success: true,
|
|
path: resolved,
|
|
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('[ERROR] File tree error:', error.message);
|
|
res.status(500).json({ error: error.message });
|
|
}
|
|
});
|
|
|
|
// ============================================================================
|
|
// FILE OPERATIONS API ENDPOINTS
|
|
// ============================================================================
|
|
|
|
/**
|
|
* Validate that a path is within the project root
|
|
* @param {string} projectRoot - The project root path
|
|
* @param {string} targetPath - The path to validate
|
|
* @returns {{ valid: boolean, resolved?: string, error?: string }}
|
|
*/
|
|
function validatePathInProject(projectRoot, targetPath) {
|
|
const resolved = path.isAbsolute(targetPath)
|
|
? path.resolve(targetPath)
|
|
: path.resolve(projectRoot, targetPath);
|
|
const normalizedRoot = path.resolve(projectRoot) + path.sep;
|
|
if (!resolved.startsWith(normalizedRoot)) {
|
|
return { valid: false, error: 'Path must be under project root' };
|
|
}
|
|
return { valid: true, resolved };
|
|
}
|
|
|
|
/**
|
|
* Validate filename - check for invalid characters
|
|
* @param {string} name - The filename to validate
|
|
* @returns {{ valid: boolean, error?: string }}
|
|
*/
|
|
function validateFilename(name) {
|
|
if (!name || !name.trim()) {
|
|
return { valid: false, error: 'Filename cannot be empty' };
|
|
}
|
|
// Check for invalid characters (Windows + Unix)
|
|
const invalidChars = /[<>:"/\\|?*\x00-\x1f]/;
|
|
if (invalidChars.test(name)) {
|
|
return { valid: false, error: 'Filename contains invalid characters' };
|
|
}
|
|
// Check for reserved names (Windows)
|
|
const reserved = /^(CON|PRN|AUX|NUL|COM[1-9]|LPT[1-9])$/i;
|
|
if (reserved.test(name)) {
|
|
return { valid: false, error: 'Filename is a reserved name' };
|
|
}
|
|
// Check for dots only
|
|
if (/^\.+$/.test(name)) {
|
|
return { valid: false, error: 'Filename cannot be only dots' };
|
|
}
|
|
return { valid: true };
|
|
}
|
|
|
|
// POST /api/projects/:projectName/files/create - Create new file or directory
|
|
app.post('/api/projects/:projectName/files/create', authenticateToken, async (req, res) => {
|
|
try {
|
|
const { projectName } = req.params;
|
|
const { path: parentPath, type, name } = req.body;
|
|
|
|
// Validate input
|
|
if (!name || !type) {
|
|
return res.status(400).json({ error: 'Name and type are required' });
|
|
}
|
|
|
|
if (!['file', 'directory'].includes(type)) {
|
|
return res.status(400).json({ error: 'Type must be "file" or "directory"' });
|
|
}
|
|
|
|
const nameValidation = validateFilename(name);
|
|
if (!nameValidation.valid) {
|
|
return res.status(400).json({ error: nameValidation.error });
|
|
}
|
|
|
|
// Get project root
|
|
const projectRoot = await extractProjectDirectory(projectName).catch(() => null);
|
|
if (!projectRoot) {
|
|
return res.status(404).json({ error: 'Project not found' });
|
|
}
|
|
|
|
// Build and validate target path
|
|
const targetDir = parentPath || '';
|
|
const targetPath = targetDir ? path.join(targetDir, name) : name;
|
|
const validation = validatePathInProject(projectRoot, targetPath);
|
|
if (!validation.valid) {
|
|
return res.status(403).json({ error: validation.error });
|
|
}
|
|
|
|
const resolvedPath = validation.resolved;
|
|
|
|
// Check if already exists
|
|
try {
|
|
await fsPromises.access(resolvedPath);
|
|
return res.status(409).json({ error: `${type === 'file' ? 'File' : 'Directory'} already exists` });
|
|
} catch {
|
|
// Doesn't exist, which is what we want
|
|
}
|
|
|
|
// Create file or directory
|
|
if (type === 'directory') {
|
|
await fsPromises.mkdir(resolvedPath, { recursive: false });
|
|
} else {
|
|
// Ensure parent directory exists
|
|
const parentDir = path.dirname(resolvedPath);
|
|
try {
|
|
await fsPromises.access(parentDir);
|
|
} catch {
|
|
await fsPromises.mkdir(parentDir, { recursive: true });
|
|
}
|
|
await fsPromises.writeFile(resolvedPath, '', 'utf8');
|
|
}
|
|
|
|
res.json({
|
|
success: true,
|
|
path: resolvedPath,
|
|
name,
|
|
type,
|
|
message: `${type === 'file' ? 'File' : 'Directory'} created successfully`
|
|
});
|
|
} catch (error) {
|
|
console.error('Error creating file/directory:', error);
|
|
if (error.code === 'EACCES') {
|
|
res.status(403).json({ error: 'Permission denied' });
|
|
} else if (error.code === 'ENOENT') {
|
|
res.status(404).json({ error: 'Parent directory not found' });
|
|
} else {
|
|
res.status(500).json({ error: error.message });
|
|
}
|
|
}
|
|
});
|
|
|
|
// PUT /api/projects/:projectName/files/rename - Rename file or directory
|
|
app.put('/api/projects/:projectName/files/rename', authenticateToken, async (req, res) => {
|
|
try {
|
|
const { projectName } = req.params;
|
|
const { oldPath, newName } = req.body;
|
|
|
|
// Validate input
|
|
if (!oldPath || !newName) {
|
|
return res.status(400).json({ error: 'oldPath and newName are required' });
|
|
}
|
|
|
|
const nameValidation = validateFilename(newName);
|
|
if (!nameValidation.valid) {
|
|
return res.status(400).json({ error: nameValidation.error });
|
|
}
|
|
|
|
// Get project root
|
|
const projectRoot = await extractProjectDirectory(projectName).catch(() => null);
|
|
if (!projectRoot) {
|
|
return res.status(404).json({ error: 'Project not found' });
|
|
}
|
|
|
|
// Validate old path
|
|
const oldValidation = validatePathInProject(projectRoot, oldPath);
|
|
if (!oldValidation.valid) {
|
|
return res.status(403).json({ error: oldValidation.error });
|
|
}
|
|
|
|
const resolvedOldPath = oldValidation.resolved;
|
|
|
|
// Check if old path exists
|
|
try {
|
|
await fsPromises.access(resolvedOldPath);
|
|
} catch {
|
|
return res.status(404).json({ error: 'File or directory not found' });
|
|
}
|
|
|
|
// Build and validate new path
|
|
const parentDir = path.dirname(resolvedOldPath);
|
|
const resolvedNewPath = path.join(parentDir, newName);
|
|
const newValidation = validatePathInProject(projectRoot, resolvedNewPath);
|
|
if (!newValidation.valid) {
|
|
return res.status(403).json({ error: newValidation.error });
|
|
}
|
|
|
|
// Check if new path already exists
|
|
try {
|
|
await fsPromises.access(resolvedNewPath);
|
|
return res.status(409).json({ error: 'A file or directory with this name already exists' });
|
|
} catch {
|
|
// Doesn't exist, which is what we want
|
|
}
|
|
|
|
// Rename
|
|
await fsPromises.rename(resolvedOldPath, resolvedNewPath);
|
|
|
|
res.json({
|
|
success: true,
|
|
oldPath: resolvedOldPath,
|
|
newPath: resolvedNewPath,
|
|
newName,
|
|
message: 'Renamed successfully'
|
|
});
|
|
} catch (error) {
|
|
console.error('Error renaming file/directory:', error);
|
|
if (error.code === 'EACCES') {
|
|
res.status(403).json({ error: 'Permission denied' });
|
|
} else if (error.code === 'ENOENT') {
|
|
res.status(404).json({ error: 'File or directory not found' });
|
|
} else if (error.code === 'EXDEV') {
|
|
res.status(400).json({ error: 'Cannot move across different filesystems' });
|
|
} else {
|
|
res.status(500).json({ error: error.message });
|
|
}
|
|
}
|
|
});
|
|
|
|
// DELETE /api/projects/:projectName/files - Delete file or directory
|
|
app.delete('/api/projects/:projectName/files', authenticateToken, async (req, res) => {
|
|
try {
|
|
const { projectName } = req.params;
|
|
const { path: targetPath, type } = req.body;
|
|
|
|
// Validate input
|
|
if (!targetPath) {
|
|
return res.status(400).json({ error: 'Path is required' });
|
|
}
|
|
|
|
// Get project root
|
|
const projectRoot = await extractProjectDirectory(projectName).catch(() => null);
|
|
if (!projectRoot) {
|
|
return res.status(404).json({ error: 'Project not found' });
|
|
}
|
|
|
|
// Validate path
|
|
const validation = validatePathInProject(projectRoot, targetPath);
|
|
if (!validation.valid) {
|
|
return res.status(403).json({ error: validation.error });
|
|
}
|
|
|
|
const resolvedPath = validation.resolved;
|
|
|
|
// Check if path exists and get stats
|
|
let stats;
|
|
try {
|
|
stats = await fsPromises.stat(resolvedPath);
|
|
} catch {
|
|
return res.status(404).json({ error: 'File or directory not found' });
|
|
}
|
|
|
|
// Prevent deleting the project root itself
|
|
if (resolvedPath === path.resolve(projectRoot)) {
|
|
return res.status(403).json({ error: 'Cannot delete project root directory' });
|
|
}
|
|
|
|
// Delete based on type
|
|
if (stats.isDirectory()) {
|
|
await fsPromises.rm(resolvedPath, { recursive: true, force: true });
|
|
} else {
|
|
await fsPromises.unlink(resolvedPath);
|
|
}
|
|
|
|
res.json({
|
|
success: true,
|
|
path: resolvedPath,
|
|
type: stats.isDirectory() ? 'directory' : 'file',
|
|
message: 'Deleted successfully'
|
|
});
|
|
} catch (error) {
|
|
console.error('Error deleting file/directory:', error);
|
|
if (error.code === 'EACCES') {
|
|
res.status(403).json({ error: 'Permission denied' });
|
|
} else if (error.code === 'ENOENT') {
|
|
res.status(404).json({ error: 'File or directory not found' });
|
|
} else if (error.code === 'ENOTEMPTY') {
|
|
res.status(400).json({ error: 'Directory is not empty' });
|
|
} else {
|
|
res.status(500).json({ error: error.message });
|
|
}
|
|
}
|
|
});
|
|
|
|
// POST /api/projects/:projectName/files/upload - Upload files
|
|
// Dynamic import of multer for file uploads
|
|
const uploadFilesHandler = async (req, res) => {
|
|
// Dynamic import of multer
|
|
const multer = (await import('multer')).default;
|
|
|
|
const uploadMiddleware = multer({
|
|
storage: multer.diskStorage({
|
|
destination: (req, file, cb) => {
|
|
cb(null, os.tmpdir());
|
|
},
|
|
filename: (req, file, cb) => {
|
|
// Use a unique temp name, but preserve original name in file.originalname
|
|
// Note: file.originalname may contain path separators for folder uploads
|
|
const uniqueSuffix = Date.now() + '-' + Math.round(Math.random() * 1E9);
|
|
// For temp file, just use a safe unique name without the path
|
|
cb(null, `upload-${uniqueSuffix}`);
|
|
}
|
|
}),
|
|
limits: {
|
|
fileSize: 50 * 1024 * 1024, // 50MB limit
|
|
files: 20 // Max 20 files at once
|
|
}
|
|
});
|
|
|
|
// Use multer middleware
|
|
uploadMiddleware.array('files', 20)(req, res, async (err) => {
|
|
if (err) {
|
|
console.error('Multer error:', err);
|
|
if (err.code === 'LIMIT_FILE_SIZE') {
|
|
return res.status(400).json({ error: 'File too large. Maximum size is 50MB.' });
|
|
}
|
|
if (err.code === 'LIMIT_FILE_COUNT') {
|
|
return res.status(400).json({ error: 'Too many files. Maximum is 20 files.' });
|
|
}
|
|
return res.status(500).json({ error: err.message });
|
|
}
|
|
|
|
try {
|
|
const { projectName } = req.params;
|
|
const { targetPath, relativePaths } = req.body;
|
|
|
|
// Parse relative paths if provided (for folder uploads)
|
|
let filePaths = [];
|
|
if (relativePaths) {
|
|
try {
|
|
filePaths = JSON.parse(relativePaths);
|
|
} catch (e) {
|
|
console.log('[DEBUG] Failed to parse relativePaths:', relativePaths);
|
|
}
|
|
}
|
|
|
|
console.log('[DEBUG] File upload request:', {
|
|
projectName,
|
|
targetPath: JSON.stringify(targetPath),
|
|
targetPathType: typeof targetPath,
|
|
filesCount: req.files?.length,
|
|
relativePaths: filePaths
|
|
});
|
|
|
|
if (!req.files || req.files.length === 0) {
|
|
return res.status(400).json({ error: 'No files provided' });
|
|
}
|
|
|
|
// Get project root
|
|
const projectRoot = await extractProjectDirectory(projectName).catch(() => null);
|
|
if (!projectRoot) {
|
|
return res.status(404).json({ error: 'Project not found' });
|
|
}
|
|
|
|
console.log('[DEBUG] Project root:', projectRoot);
|
|
|
|
// Validate and resolve target path
|
|
// If targetPath is empty or '.', use project root directly
|
|
const targetDir = targetPath || '';
|
|
let resolvedTargetDir;
|
|
|
|
console.log('[DEBUG] Target dir:', JSON.stringify(targetDir));
|
|
|
|
if (!targetDir || targetDir === '.' || targetDir === './') {
|
|
// Empty path means upload to project root
|
|
resolvedTargetDir = path.resolve(projectRoot);
|
|
console.log('[DEBUG] Using project root as target:', resolvedTargetDir);
|
|
} else {
|
|
const validation = validatePathInProject(projectRoot, targetDir);
|
|
if (!validation.valid) {
|
|
console.log('[DEBUG] Path validation failed:', validation.error);
|
|
return res.status(403).json({ error: validation.error });
|
|
}
|
|
resolvedTargetDir = validation.resolved;
|
|
console.log('[DEBUG] Resolved target dir:', resolvedTargetDir);
|
|
}
|
|
|
|
// Ensure target directory exists
|
|
try {
|
|
await fsPromises.access(resolvedTargetDir);
|
|
} catch {
|
|
await fsPromises.mkdir(resolvedTargetDir, { recursive: true });
|
|
}
|
|
|
|
// Move uploaded files from temp to target directory
|
|
const uploadedFiles = [];
|
|
console.log('[DEBUG] Processing files:', req.files.map(f => ({ originalname: f.originalname, path: f.path })));
|
|
for (let i = 0; i < req.files.length; i++) {
|
|
const file = req.files[i];
|
|
// Use relative path if provided (for folder uploads), otherwise use originalname
|
|
const fileName = (filePaths && filePaths[i]) ? filePaths[i] : file.originalname;
|
|
console.log('[DEBUG] Processing file:', fileName, '(originalname:', file.originalname + ')');
|
|
const destPath = path.join(resolvedTargetDir, fileName);
|
|
|
|
// Validate destination path
|
|
const destValidation = validatePathInProject(projectRoot, destPath);
|
|
if (!destValidation.valid) {
|
|
console.log('[DEBUG] Destination validation failed for:', destPath);
|
|
// Clean up temp file
|
|
await fsPromises.unlink(file.path).catch(() => {});
|
|
continue;
|
|
}
|
|
|
|
// Ensure parent directory exists (for nested files from folder upload)
|
|
const parentDir = path.dirname(destPath);
|
|
try {
|
|
await fsPromises.access(parentDir);
|
|
} catch {
|
|
await fsPromises.mkdir(parentDir, { recursive: true });
|
|
}
|
|
|
|
// Move file (copy + unlink to handle cross-device scenarios)
|
|
await fsPromises.copyFile(file.path, destPath);
|
|
await fsPromises.unlink(file.path);
|
|
|
|
uploadedFiles.push({
|
|
name: fileName,
|
|
path: destPath,
|
|
size: file.size,
|
|
mimeType: file.mimetype
|
|
});
|
|
}
|
|
|
|
res.json({
|
|
success: true,
|
|
files: uploadedFiles,
|
|
targetPath: resolvedTargetDir,
|
|
message: `Uploaded ${uploadedFiles.length} file(s) successfully`
|
|
});
|
|
} catch (error) {
|
|
console.error('Error uploading files:', error);
|
|
// Clean up any remaining temp files
|
|
if (req.files) {
|
|
for (const file of req.files) {
|
|
await fsPromises.unlink(file.path).catch(() => {});
|
|
}
|
|
}
|
|
if (error.code === 'EACCES') {
|
|
res.status(403).json({ error: 'Permission denied' });
|
|
} else {
|
|
res.status(500).json({ error: error.message });
|
|
}
|
|
}
|
|
});
|
|
};
|
|
|
|
app.post('/api/projects/:projectName/files/upload', authenticateToken, uploadFilesHandler);
|
|
|
|
// WebSocket connection handler that routes based on URL path
|
|
wss.on('connection', (ws, request) => {
|
|
const url = request.url;
|
|
console.log('[INFO] 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('[WARN] Unknown WebSocket path:', pathname);
|
|
ws.close();
|
|
}
|
|
});
|
|
|
|
/**
|
|
* WebSocket Writer - Wrapper for WebSocket to match SSEStreamWriter interface
|
|
*/
|
|
class WebSocketWriter {
|
|
constructor(ws) {
|
|
this.ws = ws;
|
|
this.sessionId = null;
|
|
this.isWebSocketWriter = true; // Marker for transport detection
|
|
}
|
|
|
|
send(data) {
|
|
if (this.ws.readyState === 1) { // WebSocket.OPEN
|
|
// Providers send raw objects, we stringify for WebSocket
|
|
this.ws.send(JSON.stringify(data));
|
|
}
|
|
}
|
|
|
|
updateWebSocket(newRawWs) {
|
|
this.ws = newRawWs;
|
|
}
|
|
|
|
setSessionId(sessionId) {
|
|
this.sessionId = sessionId;
|
|
}
|
|
|
|
getSessionId() {
|
|
return this.sessionId;
|
|
}
|
|
}
|
|
|
|
// Handle chat WebSocket connections
|
|
function handleChatConnection(ws) {
|
|
console.log('[INFO] Chat WebSocket connected');
|
|
|
|
// Add to connected clients for project updates
|
|
connectedClients.add(ws);
|
|
|
|
// Wrap WebSocket with writer for consistent interface with SSEStreamWriter
|
|
const writer = new WebSocketWriter(ws);
|
|
|
|
ws.on('message', async (message) => {
|
|
try {
|
|
const data = JSON.parse(message);
|
|
|
|
if (data.type === 'claude-command') {
|
|
console.log('[DEBUG] 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, writer);
|
|
} else if (data.type === 'cursor-command') {
|
|
console.log('[DEBUG] 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, writer);
|
|
} else if (data.type === 'codex-command') {
|
|
console.log('[DEBUG] Codex message:', data.command || '[Continue/Resume]');
|
|
console.log('📁 Project:', data.options?.projectPath || data.options?.cwd || 'Unknown');
|
|
console.log('🔄 Session:', data.options?.sessionId ? 'Resume' : 'New');
|
|
console.log('🤖 Model:', data.options?.model || 'default');
|
|
await queryCodex(data.command, data.options, writer);
|
|
} else if (data.type === 'gemini-command') {
|
|
console.log('[DEBUG] Gemini message:', data.command || '[Continue/Resume]');
|
|
console.log('📁 Project:', data.options?.projectPath || data.options?.cwd || 'Unknown');
|
|
console.log('🔄 Session:', data.options?.sessionId ? 'Resume' : 'New');
|
|
console.log('🤖 Model:', data.options?.model || 'default');
|
|
await spawnGemini(data.command, data.options, writer);
|
|
} else if (data.type === 'cursor-resume') {
|
|
// Backward compatibility: treat as cursor-command with resume and no prompt
|
|
console.log('[DEBUG] Cursor resume session (compat):', data.sessionId);
|
|
await spawnCursor('', {
|
|
sessionId: data.sessionId,
|
|
resume: true,
|
|
cwd: data.options?.cwd
|
|
}, writer);
|
|
} else if (data.type === 'abort-session') {
|
|
console.log('[DEBUG] Abort session request:', data.sessionId);
|
|
const provider = data.provider || 'claude';
|
|
let success;
|
|
|
|
if (provider === 'cursor') {
|
|
success = abortCursorSession(data.sessionId);
|
|
} else if (provider === 'codex') {
|
|
success = abortCodexSession(data.sessionId);
|
|
} else if (provider === 'gemini') {
|
|
success = abortGeminiSession(data.sessionId);
|
|
} else {
|
|
// Use Claude Agents SDK
|
|
success = await abortClaudeSDKSession(data.sessionId);
|
|
}
|
|
|
|
writer.send({
|
|
type: 'session-aborted',
|
|
sessionId: data.sessionId,
|
|
provider,
|
|
success
|
|
});
|
|
} else if (data.type === 'claude-permission-response') {
|
|
// Relay UI approval decisions back into the SDK control flow.
|
|
// This does not persist permissions; it only resolves the in-flight request,
|
|
// introduced so the SDK can resume once the user clicks Allow/Deny.
|
|
if (data.requestId) {
|
|
resolveToolApproval(data.requestId, {
|
|
allow: Boolean(data.allow),
|
|
updatedInput: data.updatedInput,
|
|
message: data.message,
|
|
rememberEntry: data.rememberEntry
|
|
});
|
|
}
|
|
} else if (data.type === 'cursor-abort') {
|
|
console.log('[DEBUG] Abort Cursor session:', data.sessionId);
|
|
const success = abortCursorSession(data.sessionId);
|
|
writer.send({
|
|
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 if (provider === 'codex') {
|
|
isActive = isCodexSessionActive(sessionId);
|
|
} else if (provider === 'gemini') {
|
|
isActive = isGeminiSessionActive(sessionId);
|
|
} else {
|
|
// Use Claude Agents SDK
|
|
isActive = isClaudeSDKSessionActive(sessionId);
|
|
if (isActive) {
|
|
// Reconnect the session's writer to the new WebSocket so
|
|
// subsequent SDK output flows to the refreshed client.
|
|
reconnectSessionWriter(sessionId, ws);
|
|
}
|
|
}
|
|
|
|
writer.send({
|
|
type: 'session-status',
|
|
sessionId,
|
|
provider,
|
|
isProcessing: isActive
|
|
});
|
|
} else if (data.type === 'get-pending-permissions') {
|
|
// Return pending permission requests for a session
|
|
const sessionId = data.sessionId;
|
|
if (sessionId && isClaudeSDKSessionActive(sessionId)) {
|
|
const pending = getPendingApprovalsForSession(sessionId);
|
|
writer.send({
|
|
type: 'pending-permissions-response',
|
|
sessionId,
|
|
data: pending
|
|
});
|
|
}
|
|
} else if (data.type === 'get-active-sessions') {
|
|
// Get all currently active sessions
|
|
const activeSessions = {
|
|
claude: getActiveClaudeSDKSessions(),
|
|
cursor: getActiveCursorSessions(),
|
|
codex: getActiveCodexSessions(),
|
|
gemini: getActiveGeminiSessions()
|
|
};
|
|
writer.send({
|
|
type: 'active-sessions',
|
|
sessions: activeSessions
|
|
});
|
|
}
|
|
} catch (error) {
|
|
console.error('[ERROR] Chat WebSocket error:', error.message);
|
|
writer.send({
|
|
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;
|
|
let ptySessionKey = null;
|
|
let urlDetectionBuffer = '';
|
|
const announcedAuthUrls = new Set();
|
|
|
|
ws.on('message', async (message) => {
|
|
try {
|
|
const data = JSON.parse(message);
|
|
console.log('📨 Shell message received:', data.type);
|
|
|
|
if (data.type === 'init') {
|
|
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';
|
|
urlDetectionBuffer = '';
|
|
announcedAuthUrls.clear();
|
|
|
|
// Login commands (Claude/Cursor auth) should never reuse cached sessions
|
|
const isLoginCommand = initialCommand && (
|
|
initialCommand.includes('setup-token') ||
|
|
initialCommand.includes('cursor-agent login') ||
|
|
initialCommand.includes('auth login')
|
|
);
|
|
|
|
// Include command hash in session key so different commands get separate sessions
|
|
const commandSuffix = isPlainShell && initialCommand
|
|
? `_cmd_${Buffer.from(initialCommand).toString('base64').slice(0, 16)}`
|
|
: '';
|
|
ptySessionKey = `${projectPath}_${sessionId || 'default'}${commandSuffix}`;
|
|
|
|
// Kill any existing login session before starting fresh
|
|
if (isLoginCommand) {
|
|
const oldSession = ptySessionsMap.get(ptySessionKey);
|
|
if (oldSession) {
|
|
console.log('🧹 Cleaning up existing login session:', ptySessionKey);
|
|
if (oldSession.timeoutId) clearTimeout(oldSession.timeoutId);
|
|
if (oldSession.pty && oldSession.pty.kill) oldSession.pty.kill();
|
|
ptySessionsMap.delete(ptySessionKey);
|
|
}
|
|
}
|
|
|
|
const existingSession = isLoginCommand ? null : ptySessionsMap.get(ptySessionKey);
|
|
if (existingSession) {
|
|
console.log('♻️ Reconnecting to existing PTY session:', ptySessionKey);
|
|
shellProcess = existingSession.pty;
|
|
|
|
clearTimeout(existingSession.timeoutId);
|
|
|
|
ws.send(JSON.stringify({
|
|
type: 'output',
|
|
data: `\x1b[36m[Reconnected to existing session]\x1b[0m\r\n`
|
|
}));
|
|
|
|
if (existingSession.buffer && existingSession.buffer.length > 0) {
|
|
console.log(`📜 Sending ${existingSession.buffer.length} buffered messages`);
|
|
existingSession.buffer.forEach(bufferedData => {
|
|
ws.send(JSON.stringify({
|
|
type: 'output',
|
|
data: bufferedData
|
|
}));
|
|
});
|
|
}
|
|
|
|
existingSession.ws = ws;
|
|
|
|
return;
|
|
}
|
|
|
|
console.log('[INFO] 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' : (provider === 'codex' ? 'Codex' : (provider === 'gemini' ? 'Gemini' : '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 {
|
|
// Validate projectPath — resolve to absolute and verify it exists
|
|
const resolvedProjectPath = path.resolve(projectPath);
|
|
try {
|
|
const stats = fs.statSync(resolvedProjectPath);
|
|
if (!stats.isDirectory()) {
|
|
throw new Error('Not a directory');
|
|
}
|
|
} catch (pathErr) {
|
|
ws.send(JSON.stringify({ type: 'error', message: 'Invalid project path' }));
|
|
return;
|
|
}
|
|
|
|
// Validate sessionId — only allow safe characters
|
|
const safeSessionIdPattern = /^[a-zA-Z0-9_.\-:]+$/;
|
|
if (sessionId && !safeSessionIdPattern.test(sessionId)) {
|
|
ws.send(JSON.stringify({ type: 'error', message: 'Invalid session ID' }));
|
|
return;
|
|
}
|
|
|
|
// Build shell command — use cwd for project path (never interpolate into shell string)
|
|
let shellCommand;
|
|
if (isPlainShell) {
|
|
// Plain shell mode - run the initial command in the project directory
|
|
shellCommand = initialCommand;
|
|
} else if (provider === 'cursor') {
|
|
if (hasSession && sessionId) {
|
|
shellCommand = `cursor-agent --resume="${sessionId}"`;
|
|
} else {
|
|
shellCommand = 'cursor-agent';
|
|
}
|
|
} else if (provider === 'codex') {
|
|
// Use codex command; attempt to resume and fall back to a new session when the resume fails.
|
|
if (hasSession && sessionId) {
|
|
if (os.platform() === 'win32') {
|
|
// PowerShell syntax for fallback
|
|
shellCommand = `codex resume "${sessionId}"; if ($LASTEXITCODE -ne 0) { codex }`;
|
|
} else {
|
|
shellCommand = `codex resume "${sessionId}" || codex`;
|
|
}
|
|
} else {
|
|
shellCommand = 'codex';
|
|
}
|
|
} else if (provider === 'gemini') {
|
|
const command = initialCommand || 'gemini';
|
|
let resumeId = sessionId;
|
|
if (hasSession && sessionId) {
|
|
try {
|
|
// Gemini CLI enforces its own native session IDs, unlike other agents that accept arbitrary string names.
|
|
// The UI only knows about its internal generated `sessionId` (e.g. gemini_1234).
|
|
// We must fetch the mapping from the backend session manager to pass the native `cliSessionId` to the shell.
|
|
const sess = sessionManager.getSession(sessionId);
|
|
if (sess && sess.cliSessionId) {
|
|
resumeId = sess.cliSessionId;
|
|
// Validate the looked-up CLI session ID too
|
|
if (!safeSessionIdPattern.test(resumeId)) {
|
|
resumeId = null;
|
|
}
|
|
}
|
|
} catch (err) {
|
|
console.error('Failed to get Gemini CLI session ID:', err);
|
|
}
|
|
}
|
|
|
|
if (hasSession && resumeId) {
|
|
shellCommand = `${command} --resume "${resumeId}"`;
|
|
} else {
|
|
shellCommand = command;
|
|
}
|
|
} else {
|
|
// Claude (default provider)
|
|
const command = initialCommand || 'claude';
|
|
if (hasSession && sessionId) {
|
|
if (os.platform() === 'win32') {
|
|
shellCommand = `claude --resume "${sessionId}"; if ($LASTEXITCODE -ne 0) { claude }`;
|
|
} else {
|
|
shellCommand = `claude --resume "${sessionId}" || claude`;
|
|
}
|
|
} else {
|
|
shellCommand = 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];
|
|
|
|
// Use terminal dimensions from client if provided, otherwise use defaults
|
|
const termCols = data.cols || 80;
|
|
const termRows = data.rows || 24;
|
|
console.log('📐 Using terminal dimensions:', termCols, 'x', termRows);
|
|
|
|
shellProcess = pty.spawn(shell, shellArgs, {
|
|
name: 'xterm-256color',
|
|
cols: termCols,
|
|
rows: termRows,
|
|
cwd: resolvedProjectPath,
|
|
env: {
|
|
...process.env,
|
|
TERM: 'xterm-256color',
|
|
COLORTERM: 'truecolor',
|
|
FORCE_COLOR: '3'
|
|
}
|
|
});
|
|
|
|
console.log('🟢 Shell process started with PTY, PID:', shellProcess.pid);
|
|
|
|
ptySessionsMap.set(ptySessionKey, {
|
|
pty: shellProcess,
|
|
ws: ws,
|
|
buffer: [],
|
|
timeoutId: null,
|
|
projectPath,
|
|
sessionId
|
|
});
|
|
|
|
// Handle data output
|
|
shellProcess.onData((data) => {
|
|
const session = ptySessionsMap.get(ptySessionKey);
|
|
if (!session) return;
|
|
|
|
if (session.buffer.length < 5000) {
|
|
session.buffer.push(data);
|
|
} else {
|
|
session.buffer.shift();
|
|
session.buffer.push(data);
|
|
}
|
|
|
|
if (session.ws && session.ws.readyState === WebSocket.OPEN) {
|
|
let outputData = data;
|
|
|
|
const cleanChunk = stripAnsiSequences(data);
|
|
urlDetectionBuffer = `${urlDetectionBuffer}${cleanChunk}`.slice(-SHELL_URL_PARSE_BUFFER_LIMIT);
|
|
|
|
outputData = outputData.replace(
|
|
/OPEN_URL:\s*(https?:\/\/[^\s\x1b\x07]+)/g,
|
|
'[INFO] Opening in browser: $1'
|
|
);
|
|
|
|
const emitAuthUrl = (detectedUrl, autoOpen = false) => {
|
|
const normalizedUrl = normalizeDetectedUrl(detectedUrl);
|
|
if (!normalizedUrl) return;
|
|
|
|
const isNewUrl = !announcedAuthUrls.has(normalizedUrl);
|
|
if (isNewUrl) {
|
|
announcedAuthUrls.add(normalizedUrl);
|
|
session.ws.send(JSON.stringify({
|
|
type: 'auth_url',
|
|
url: normalizedUrl,
|
|
autoOpen
|
|
}));
|
|
}
|
|
|
|
};
|
|
|
|
const normalizedDetectedUrls = extractUrlsFromText(urlDetectionBuffer)
|
|
.map((url) => normalizeDetectedUrl(url))
|
|
.filter(Boolean);
|
|
|
|
// Prefer the most complete URL if shorter prefix variants are also present.
|
|
const dedupedDetectedUrls = Array.from(new Set(normalizedDetectedUrls)).filter((url, _, urls) =>
|
|
!urls.some((otherUrl) => otherUrl !== url && otherUrl.startsWith(url))
|
|
);
|
|
|
|
dedupedDetectedUrls.forEach((url) => emitAuthUrl(url, false));
|
|
|
|
if (shouldAutoOpenUrlFromOutput(cleanChunk) && dedupedDetectedUrls.length > 0) {
|
|
const bestUrl = dedupedDetectedUrls.reduce((longest, current) =>
|
|
current.length > longest.length ? current : longest
|
|
);
|
|
emitAuthUrl(bestUrl, true);
|
|
}
|
|
|
|
// Send regular output
|
|
session.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);
|
|
const session = ptySessionsMap.get(ptySessionKey);
|
|
if (session && session.ws && session.ws.readyState === WebSocket.OPEN) {
|
|
session.ws.send(JSON.stringify({
|
|
type: 'output',
|
|
data: `\r\n\x1b[33mProcess exited with code ${exitCode.exitCode}${exitCode.signal ? ` (${exitCode.signal})` : ''}\x1b[0m\r\n`
|
|
}));
|
|
}
|
|
if (session && session.timeoutId) {
|
|
clearTimeout(session.timeoutId);
|
|
}
|
|
ptySessionsMap.delete(ptySessionKey);
|
|
shellProcess = null;
|
|
});
|
|
|
|
} catch (spawnError) {
|
|
console.error('[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('[ERROR] Shell WebSocket error:', error.message);
|
|
if (ws.readyState === WebSocket.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 (ptySessionKey) {
|
|
const session = ptySessionsMap.get(ptySessionKey);
|
|
if (session) {
|
|
console.log('⏳ PTY session kept alive, will timeout in 30 minutes:', ptySessionKey);
|
|
session.ws = null;
|
|
|
|
session.timeoutId = setTimeout(() => {
|
|
console.log('⏰ PTY session timeout, killing process:', ptySessionKey);
|
|
if (session.pty && session.pty.kill) {
|
|
session.pty.kill();
|
|
}
|
|
ptySessionsMap.delete(ptySessionKey);
|
|
}, PTY_SESSION_TIMEOUT);
|
|
}
|
|
}
|
|
});
|
|
|
|
ws.on('error', (error) => {
|
|
console.error('[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' });
|
|
}
|
|
});
|
|
|
|
// Get token usage for a specific session
|
|
app.get('/api/projects/:projectName/sessions/:sessionId/token-usage', authenticateToken, async (req, res) => {
|
|
try {
|
|
const { projectName, sessionId } = req.params;
|
|
const { provider = 'claude' } = req.query;
|
|
const homeDir = os.homedir();
|
|
|
|
// Allow only safe characters in sessionId
|
|
const safeSessionId = String(sessionId).replace(/[^a-zA-Z0-9._-]/g, '');
|
|
if (!safeSessionId || safeSessionId !== String(sessionId)) {
|
|
return res.status(400).json({ error: 'Invalid sessionId' });
|
|
}
|
|
|
|
// Handle Cursor sessions - they use SQLite and don't have token usage info
|
|
if (provider === 'cursor') {
|
|
return res.json({
|
|
used: 0,
|
|
total: 0,
|
|
breakdown: { input: 0, cacheCreation: 0, cacheRead: 0 },
|
|
unsupported: true,
|
|
message: 'Token usage tracking not available for Cursor sessions'
|
|
});
|
|
}
|
|
|
|
// Handle Gemini sessions - they are raw logs in our current setup
|
|
if (provider === 'gemini') {
|
|
return res.json({
|
|
used: 0,
|
|
total: 0,
|
|
breakdown: { input: 0, cacheCreation: 0, cacheRead: 0 },
|
|
unsupported: true,
|
|
message: 'Token usage tracking not available for Gemini sessions'
|
|
});
|
|
}
|
|
|
|
// Handle Codex sessions
|
|
if (provider === 'codex') {
|
|
const codexSessionsDir = path.join(homeDir, '.codex', 'sessions');
|
|
|
|
// Find the session file by searching for the session ID
|
|
const findSessionFile = async (dir) => {
|
|
try {
|
|
const entries = await fsPromises.readdir(dir, { withFileTypes: true });
|
|
for (const entry of entries) {
|
|
const fullPath = path.join(dir, entry.name);
|
|
if (entry.isDirectory()) {
|
|
const found = await findSessionFile(fullPath);
|
|
if (found) return found;
|
|
} else if (entry.name.includes(safeSessionId) && entry.name.endsWith('.jsonl')) {
|
|
return fullPath;
|
|
}
|
|
}
|
|
} catch (error) {
|
|
// Skip directories we can't read
|
|
}
|
|
return null;
|
|
};
|
|
|
|
const sessionFilePath = await findSessionFile(codexSessionsDir);
|
|
|
|
if (!sessionFilePath) {
|
|
return res.status(404).json({ error: 'Codex session file not found', sessionId: safeSessionId });
|
|
}
|
|
|
|
// Read and parse the Codex JSONL file
|
|
let fileContent;
|
|
try {
|
|
fileContent = await fsPromises.readFile(sessionFilePath, 'utf8');
|
|
} catch (error) {
|
|
if (error.code === 'ENOENT') {
|
|
return res.status(404).json({ error: 'Session file not found', path: sessionFilePath });
|
|
}
|
|
throw error;
|
|
}
|
|
const lines = fileContent.trim().split('\n');
|
|
let totalTokens = 0;
|
|
let contextWindow = 200000; // Default for Codex/OpenAI
|
|
|
|
// Find the latest token_count event with info (scan from end)
|
|
for (let i = lines.length - 1; i >= 0; i--) {
|
|
try {
|
|
const entry = JSON.parse(lines[i]);
|
|
|
|
// Codex stores token info in event_msg with type: "token_count"
|
|
if (entry.type === 'event_msg' && entry.payload?.type === 'token_count' && entry.payload?.info) {
|
|
const tokenInfo = entry.payload.info;
|
|
if (tokenInfo.total_token_usage) {
|
|
totalTokens = tokenInfo.total_token_usage.total_tokens || 0;
|
|
}
|
|
if (tokenInfo.model_context_window) {
|
|
contextWindow = tokenInfo.model_context_window;
|
|
}
|
|
break; // Stop after finding the latest token count
|
|
}
|
|
} catch (parseError) {
|
|
// Skip lines that can't be parsed
|
|
continue;
|
|
}
|
|
}
|
|
|
|
return res.json({
|
|
used: totalTokens,
|
|
total: contextWindow
|
|
});
|
|
}
|
|
|
|
// Handle Claude sessions (default)
|
|
// Extract actual project path
|
|
let projectPath;
|
|
try {
|
|
projectPath = await extractProjectDirectory(projectName);
|
|
} catch (error) {
|
|
console.error('Error extracting project directory:', error);
|
|
return res.status(500).json({ error: 'Failed to determine project path' });
|
|
}
|
|
|
|
// Construct the JSONL file path
|
|
// Claude stores session files in ~/.claude/projects/[encoded-project-path]/[session-id].jsonl
|
|
// The encoding replaces any non-alphanumeric character (except -) with -
|
|
const encodedPath = projectPath.replace(/[^a-zA-Z0-9-]/g, '-');
|
|
const projectDir = path.join(homeDir, '.claude', 'projects', encodedPath);
|
|
|
|
const jsonlPath = path.join(projectDir, `${safeSessionId}.jsonl`);
|
|
|
|
// Constrain to projectDir
|
|
const rel = path.relative(path.resolve(projectDir), path.resolve(jsonlPath));
|
|
if (rel.startsWith('..') || path.isAbsolute(rel)) {
|
|
return res.status(400).json({ error: 'Invalid path' });
|
|
}
|
|
|
|
// Read and parse the JSONL file
|
|
let fileContent;
|
|
try {
|
|
fileContent = await fsPromises.readFile(jsonlPath, 'utf8');
|
|
} catch (error) {
|
|
if (error.code === 'ENOENT') {
|
|
return res.status(404).json({ error: 'Session file not found', path: jsonlPath });
|
|
}
|
|
throw error; // Re-throw other errors to be caught by outer try-catch
|
|
}
|
|
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) => {
|
|
// Skip requests for static assets (files with extensions)
|
|
if (path.extname(req.path)) {
|
|
return res.status(404).send('Not found');
|
|
}
|
|
|
|
// Only serve index.html for HTML routes, not for static assets
|
|
// Static assets should already be handled by express.static middleware above
|
|
const indexPath = path.join(__dirname, '../dist/index.html');
|
|
|
|
// Check if dist/index.html exists (production build available)
|
|
if (fs.existsSync(indexPath)) {
|
|
// Set no-cache headers for HTML to prevent service worker issues
|
|
res.setHeader('Cache-Control', 'no-cache, no-store, must-revalidate');
|
|
res.setHeader('Pragma', 'no-cache');
|
|
res.setHeader('Expires', '0');
|
|
res.sendFile(indexPath);
|
|
} else {
|
|
// In development, redirect to Vite dev server only if dist doesn't exist
|
|
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 heavy build directories and VCS directories
|
|
if (entry.name === 'node_modules' ||
|
|
entry.name === 'dist' ||
|
|
entry.name === 'build' ||
|
|
entry.name === '.git' ||
|
|
entry.name === '.svn' ||
|
|
entry.name === '.hg') 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;
|
|
const HOST = process.env.HOST || '0.0.0.0';
|
|
// Show localhost in URL when binding to all interfaces (0.0.0.0 isn't a connectable address)
|
|
const DISPLAY_HOST = HOST === '0.0.0.0' ? 'localhost' : HOST;
|
|
|
|
// Initialize database and start server
|
|
async function startServer() {
|
|
try {
|
|
// Initialize authentication database
|
|
await initializeDatabase();
|
|
|
|
// Check if running in production mode (dist folder exists)
|
|
const distIndexPath = path.join(__dirname, '../dist/index.html');
|
|
const isProduction = fs.existsSync(distIndexPath);
|
|
|
|
// Log Claude implementation mode
|
|
console.log(`${c.info('[INFO]')} Using Claude Agents SDK for Claude integration`);
|
|
console.log(`${c.info('[INFO]')} Running in ${c.bright(isProduction ? 'PRODUCTION' : 'DEVELOPMENT')} mode`);
|
|
|
|
if (!isProduction) {
|
|
console.log(`${c.warn('[WARN]')} Note: Requests will be proxied to Vite dev server at ${c.dim('http://localhost:' + (process.env.VITE_PORT || 5173))}`);
|
|
}
|
|
|
|
server.listen(PORT, HOST, async () => {
|
|
const appInstallPath = path.join(__dirname, '..');
|
|
|
|
console.log('');
|
|
console.log(c.dim('═'.repeat(63)));
|
|
console.log(` ${c.bright('Claude Code UI Server - Ready')}`);
|
|
console.log(c.dim('═'.repeat(63)));
|
|
console.log('');
|
|
console.log(`${c.info('[INFO]')} Server URL: ${c.bright('http://' + DISPLAY_HOST + ':' + PORT)}`);
|
|
console.log(`${c.info('[INFO]')} Installed at: ${c.dim(appInstallPath)}`);
|
|
console.log(`${c.tip('[TIP]')} Run "cloudcli status" for full configuration details`);
|
|
console.log('');
|
|
|
|
// Start watching the projects folder for changes
|
|
await setupProjectsWatcher();
|
|
|
|
// Start server-side plugin processes for enabled plugins
|
|
startEnabledPluginServers().catch(err => {
|
|
console.error('[Plugins] Error during startup:', err.message);
|
|
});
|
|
});
|
|
|
|
// Clean up plugin processes on shutdown
|
|
const shutdownPlugins = async () => {
|
|
await stopAllPlugins();
|
|
process.exit(0);
|
|
};
|
|
process.on('SIGTERM', () => void shutdownPlugins());
|
|
process.on('SIGINT', () => void shutdownPlugins());
|
|
} catch (error) {
|
|
console.error('[ERROR] Failed to start server:', error);
|
|
process.exit(1);
|
|
}
|
|
}
|
|
|
|
startServer();
|