#!/usr/bin/env node
import { assertRequiredPilotDeckEnv } from './load-env.js';
import { installGlobalProxy } from './utils/proxy.js';
installGlobalProxy();
import fs from 'fs';
import path from 'path';
import { fileURLToPath } from 'url';
import { dirname } from 'path';
import net from 'net';
const __filename = fileURLToPath(import.meta.url);
const __dirname = dirname(__filename);
const installMode = fs.existsSync(path.join(__dirname, '..', '.git')) ? 'git' : 'npm';
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}`,
};
assertRequiredPilotDeckEnv();
console.log('SERVER_PORT from runtime config:', process.env.SERVER_PORT);
import express from 'express';
import { WebSocketServer, WebSocket } from 'ws';
import bcrypt from 'bcrypt';
import crypto from 'crypto';
import os from 'os';
import http from 'http';
import cors from 'cors';
import { promises as fsPromises } from 'fs';
import { spawn, exec } from 'child_process';
import pty from 'node-pty';
import fetch from 'node-fetch';
import mime from 'mime-types';
import JSZip from 'jszip';
import { readPermissionSettings } from './services/permissionSettings.js';
import { getProjects, getProjectCronJobsOverview, getSessions, renameProject, deleteSession, deleteProject, addProjectManually, extractProjectDirectory, clearProjectDirectoryCache, searchConversations } from './projects.js';
import {
runChatViaGateway,
abortViaGateway,
decidePermissionViaGateway,
grantSessionPermissionViaGateway,
isSessionActiveViaGateway,
getActiveTurnSnapshotFramesViaGateway,
getActiveSessionIdsViaGateway,
elicitationRespondViaGateway,
getRouterDashboardData,
getRouterSessionStats,
getRouterStatsSummary,
getPilotDeckGateway,
registerAlwaysOnNotificationForwarding,
getSessionTokenBudget,
} from './pilotdeck-bridge.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 taskmasterRoutes from './routes/taskmaster.js';
import memoryRoutes, { MEMORY_DASHBOARD_DIR } from './routes/memory.js';
import mcpUtilsRoutes from './routes/mcp-utils.js';
import commandsRoutes from './routes/commands.js';
import skillsRoutes from './routes/skills.js';
import settingsRoutes from './routes/settings.js';
import configRoutes from './routes/config.js';
import { startPilotDeckConfigWatcher, stopPilotDeckConfigWatcher } from './services/pilotdeckConfigWatcher.js';
import { getAlwaysOnDashboardEvents } from './services/always-on-events.js';
import agentRoutes from './routes/agent.js';
import projectsRoutes, { WORKSPACES_ROOT, validateWorkspacePath } from './routes/projects.js';
import userRoutes from './routes/user.js';
import pluginsRoutes from './routes/plugins.js';
import messagesRoutes from './routes/messages.js';
import { closeMemoryServices, startMemoryScheduler, stopMemoryScheduler } from './services/memoryService.js';
import { createNormalizedMessage } from './pilotdeck-message.js';
import { startEnabledPluginServers, stopAllPlugins, getPluginPort } from './utils/plugin-process-manager.js';
import { initializeDatabase, sessionNamesDb, applyCustomSessionNames, userDb } from './database/db.js';
import { configureWebPush } from './services/vapid-keys.js';
import { sendCronDaemonRequest } from './services/cron-daemon-owner.js';
import { createAlwaysOnHeartbeatManager } from './always-on-heartbeat.js';
import { runServerStartupBeforeListen, startServerAfterStartup } from './services/server-startup.js';
import { validateApiKey, authenticateToken, authenticateWebSocket } from './middleware/auth.js';
import { DISABLE_LOCAL_AUTH, IS_PLATFORM } from './constants/config.js';
import { getConnectableHost } from '../shared/networkHosts.js';
const VALID_PROVIDERS = ['pilotdeck'];
const PROVIDER_WATCH_PATHS = [
{
provider: 'pilotdeck',
rootPath: path.join(
process.env.PILOT_HOME || path.join(os.homedir(), '.pilotdeck'),
'projects',
),
},
];
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();
const alwaysOnHeartbeat = createAlwaysOnHeartbeatManager({
getActivePilotDeckSessions: () => []
});
registerAlwaysOnNotificationForwarding(connectedClients);
let isGetProjectsRunning = false;
let pilotDeckProxyProcess = null;
function resolveBunExecutable() {
const candidates = [
process.env.BUN_BIN,
process.env.BUN,
process.env.BUN_INSTALL ? path.join(process.env.BUN_INSTALL, 'bin', 'bun') : null,
path.join(os.homedir(), '.bun', 'bin', 'bun'),
'/opt/homebrew/bin/bun',
'/usr/local/bin/bun',
'bun',
].filter(Boolean);
for (const candidate of candidates) {
if (candidate === 'bun' || fs.existsSync(candidate)) {
return candidate;
}
}
return 'bun';
}
function isLocalPortListening(port, host = '127.0.0.1', timeoutMs = 400) {
return new Promise(resolve => {
const socket = net.createConnection({ port, host });
const finalize = (isOpen) => {
socket.destroy();
resolve(isOpen);
};
socket.setTimeout(timeoutMs);
socket.once('connect', () => finalize(true));
socket.once('timeout', () => finalize(false));
socket.once('error', () => finalize(false));
});
}
async function waitForLocalPort(port, host = '127.0.0.1', timeoutMs = 4000) {
const deadline = Date.now() + timeoutMs;
while (Date.now() < deadline) {
if (await isLocalPortListening(port, host)) {
return true;
}
await new Promise(resolve => setTimeout(resolve, 120));
}
return false;
}
async function ensurePilotDeckProxyRunning() {
return;
const proxyPort = parseInt(process.env.PROXY_PORT || process.env.PILOTDECK_PROXY_PORT || '18080', 10);
if (!proxyPort) return;
if (await isLocalPortListening(proxyPort)) {
console.log(`${c.info('[INFO]')} Reusing existing PilotDeck-friendly proxy on http://127.0.0.1:${proxyPort}`);
return;
}
console.error(`[ERROR] PilotDeck proxy did not become ready on http://127.0.0.1:${proxyPort}`);
}
async function stopPilotDeckProxy() {
if (!pilotDeckProxyProcess) {
return;
}
const proxyProcess = pilotDeckProxyProcess;
pilotDeckProxyProcess = null;
if (proxyProcess.exitCode !== null || proxyProcess.signalCode !== null) {
return;
}
await new Promise(resolve => {
const timeout = setTimeout(() => {
proxyProcess.kill('SIGKILL');
}, 2000);
proxyProcess.once('exit', () => {
clearTimeout(timeout);
resolve();
});
proxyProcess.kill('SIGTERM');
});
}
process.on('pilotdeck:restart-proxy', async (done) => {
try {
await stopPilotDeckProxy();
await ensurePilotDeckProxyRunning();
if (typeof done === 'function') {
done(null);
}
} catch (error) {
if (typeof done === 'function') {
done(error);
}
}
});
function broadcastProgress(progress) {
const message = JSON.stringify({
type: 'loading_progress',
...progress
});
connectedClients.forEach(client => {
if (client.readyState === WebSocket.OPEN) {
client.send(message);
}
});
}
function broadcastConfigReloaded(payload) {
const message = JSON.stringify({ type: 'config:reloaded', ...payload });
connectedClients.forEach((client) => {
if (client.readyState === WebSocket.OPEN) {
client.send(message);
}
});
}
process.on('pilotdeck:config-broadcast', broadcastConfigReloaded);
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 () => {
if (isGetProjectsRunning) {
return;
}
try {
isGetProjectsRunning = true;
clearProjectDirectoryCache();
const updatedProjects = await getProjects(broadcastProgress);
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 {
await fsPromises.mkdir(rootPath, { recursive: true });
const watcher = chokidar.watch(rootPath, {
ignored: WATCHER_IGNORED_PATTERNS,
persistent: true,
ignoreInitial: true,
followSymlinks: false,
depth: 10,
awaitWriteFinish: {
stabilityThreshold: 100,
pollInterval: 50
}
});
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) || [];
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:')
);
}
const wss = new WebSocketServer({
server,
verifyClient: (info) => {
console.log('WebSocket connection attempt to:', info.req.url);
if (IS_PLATFORM || DISABLE_LOCAL_AUTH) {
const user = authenticateWebSocket(null);
if (!user) {
console.log('[WARN] WebSocket auth bypass: No user found in database');
return false;
}
info.req.user = user;
console.log('[OK] WebSocket authenticated (bypass) for user:', user.username);
return true;
}
const url = new URL(info.req.url, 'http://localhost');
const token = url.searchParams.get('token') ||
info.req.headers.authorization?.split(' ')[1];
const user = authenticateWebSocket(token);
if (!user) {
console.log('[WARN] WebSocket authentication failed');
return false;
}
info.req.user = user;
console.log('[OK] WebSocket authenticated for user:', user.username);
return true;
}
});
app.locals.wss = wss;
app.use(cors({ exposedHeaders: ['X-Refreshed-Token'] }));
app.use(express.json({
limit: '50mb',
type: (req) => {
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 }));
app.get('/health', (req, res) => {
res.json({
status: 'ok',
timestamp: new Date().toISOString(),
installMode
});
});
app.use('/api', validateApiKey);
app.use('/api/auth', authRoutes);
app.use('/api/projects', authenticateToken, projectsRoutes);
app.use('/api/git', authenticateToken, gitRoutes);
app.use('/api/mcp', authenticateToken, mcpRoutes);
app.use('/api/taskmaster', authenticateToken, taskmasterRoutes);
app.use('/api/memory', authenticateToken, memoryRoutes);
app.use('/api/mcp-utils', authenticateToken, mcpUtilsRoutes);
app.use('/api/commands', authenticateToken, commandsRoutes);
app.use('/api/skills', authenticateToken, skillsRoutes);
app.use('/api/settings', authenticateToken, settingsRoutes);
app.use('/api/config', authenticateToken, configRoutes);
app.use('/api/user', authenticateToken, userRoutes);
app.use('/api/plugins', authenticateToken, pluginsRoutes);
app.use('/api/sessions', authenticateToken, messagesRoutes);
app.use('/api/agent', agentRoutes);
app.get('/api/agents/runtime-config', authenticateToken, (_req, res) => {
const permSettings = readPermissionSettings();
res.json({
pilotdeck: { provider: 'pilotdeck' },
permissions: {
skipPermissions: permSettings.skipPermissions,
effectiveMode: permSettings.skipPermissions ? 'bypassPermissions' : 'default',
},
});
});
const PROVIDER_REMOVED_PATHS = ['/api/cursor', '/api/codex', '/api/gemini', '/api/cli'];
for (const removedPrefix of PROVIDER_REMOVED_PATHS) {
app.use(removedPrefix, (_req, res) => {
res.status(410).json({
error: 'endpoint_removed',
message: `Provider endpoint ${removedPrefix} was removed during the PilotDeck-only migration.`,
});
});
}
app.get('/api/ccr/dashboard', authenticateToken, (_req, res) => {
try {
res.json(getRouterDashboardData());
} catch (error) {
console.error('[router-dashboard] failed:', error);
res.status(500).json({ error: error?.message || 'router-dashboard failed' });
}
});
app.get('/api/always-on/events', authenticateToken, async (req, res) => {
try {
const limit = Number.parseInt(req.query?.limit || '', 10);
const since = req.query?.since || undefined;
const result = await getAlwaysOnDashboardEvents({
limit: Number.isFinite(limit) ? limit : 200,
since: typeof since === 'string' ? since : undefined,
});
res.json(result);
} catch (error) {
console.error('[always-on-events] failed:', error);
res.status(500).json({ error: error?.message || 'always-on-events failed' });
}
});
app.get('/api/always-on/cron-jobs', authenticateToken, async (_req, res) => {
try {
const result = await getProjectCronJobsOverview();
res.json(result);
} catch (error) {
console.error('[always-on-cron-jobs] failed:', error);
res.status(500).json({ error: error?.message || 'always-on-cron-jobs failed' });
}
});
app.post('/api/always-on/cron-jobs/:taskId/run-now', authenticateToken, async (req, res) => {
try {
const gateway = await getPilotDeckGateway();
const result = await gateway.cronRunNow({ taskId: req.params.taskId });
res.json(result);
} catch (error) {
console.error('[always-on-cron-run-now] failed:', error);
res.status(500).json({ error: error?.message || 'cron run-now failed' });
}
});
app.post('/api/always-on/cron-jobs/:taskId/stop', authenticateToken, async (req, res) => {
try {
const gateway = await getPilotDeckGateway();
const result = await gateway.cronStop({ taskId: req.params.taskId });
res.json(result);
} catch (error) {
console.error('[always-on-cron-stop] failed:', error);
res.status(500).json({ error: error?.message || 'cron stop failed' });
}
});
app.delete('/api/always-on/cron-jobs/:taskId', authenticateToken, async (req, res) => {
try {
const gateway = await getPilotDeckGateway();
const result = await gateway.cronDelete({ taskId: req.params.taskId, stopRunning: true });
res.json(result);
} catch (error) {
console.error('[always-on-cron-delete] failed:', error);
res.status(500).json({ error: error?.message || 'cron delete failed' });
}
});
app.get('/api/ccr/health', authenticateToken, (_req, res) => {
res.json({
status: 'ok',
timestamp: new Date().toISOString(),
port: null,
embedded: true,
backend: 'pilotdeck-router',
});
});
app.get('/api/ccr/config', authenticateToken, (_req, res) => {
res.json(null);
});
app.get('/api/ccr/stats/summary', authenticateToken, (_req, res) => {
try {
res.json(getRouterStatsSummary());
} catch (error) {
res.status(500).json({ error: error?.message || 'router-stats-summary failed' });
}
});
app.get('/api/ccr/stats/sessions/:sessionId', authenticateToken, (req, res) => {
try {
const stats = getRouterSessionStats(req.params.sessionId);
if (!stats) {
return res.status(404).json({ error: 'session_not_found' });
}
res.json(stats);
} catch (error) {
res.status(500).json({ error: error?.message || 'router-stats-session failed' });
}
});
app.post('/api/ccr/stats/reset', authenticateToken, (_req, res) => {
res.status(501).json({
error: 'not_implemented',
message: 'Per-project router stats reset is not exposed yet; restart the PilotDeck server to clear in-memory state.',
});
});
app.put('/api/ccr/config', authenticateToken, (_req, res) => {
res.status(501).json({
error: 'not_implemented',
message: 'Routing configuration is owned by PilotDeck config (~/.pilotdeck/pilotdeck.yaml). Edit it directly via /api/config.',
});
});
app.get('/memory-dashboard', authenticateToken, (req, res) => {
const indexPath = path.join(MEMORY_DASHBOARD_DIR, 'index.html');
if (!fs.existsSync(indexPath)) {
res.status(404).type('text/plain').send('Memory dashboard assets not bundled.');
return;
}
res.sendFile(indexPath);
});
app.use('/memory-dashboard', authenticateToken, express.static(MEMORY_DASHBOARD_DIR, {
setHeaders: (res, filePath) => {
if (filePath.endsWith('.html')) {
res.setHeader('Cache-Control', 'no-cache, no-store, must-revalidate');
res.setHeader('Pragma', 'no-cache');
res.setHeader('Expires', '0');
}
}
}));
app.use('/memory-dashboard', (_req, res) => {
res.status(404).type('text/plain').send('Not found in memory-dashboard.');
});
app.use(express.static(path.join(__dirname, '../public')));
app.use(express.static(path.join(__dirname, '../dist'), {
setHeaders: (res, filePath) => {
if (filePath.endsWith('.html')) {
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)$/)) {
res.setHeader('Cache-Control', 'public, max-age=31536000, immutable');
}
}
}));
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, 'pilotdeck');
res.json(result);
} catch (error) {
res.status(500).json({ error: error.message });
}
});
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 });
}
});
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, {
sessionKind: req.query.sessionKind || null,
parentSessionId: req.query.parentSessionId || null,
relativeTranscriptPath: req.query.relativeTranscriptPath || null,
});
sessionNamesDb.deleteName(sessionId, 'pilotdeck');
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 });
}
});
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 });
}
});
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 });
}
});
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 });
}
});
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;
};
function resolvePathInProject(projectRoot, targetPath = '') {
const resolved = path.isAbsolute(targetPath)
? path.resolve(targetPath)
: path.resolve(projectRoot, targetPath);
const normalizedRoot = path.resolve(projectRoot);
if (resolved !== normalizedRoot && !resolved.startsWith(normalizedRoot + path.sep)) {
return { valid: false, error: 'Path must be under project root' };
}
return { valid: true, resolved };
}
function setPreviewContentType(res, filePath) {
const mimeType = mime.lookup(filePath) || 'application/octet-stream';
const charset = mimeType.startsWith('text/') || mimeType === 'application/javascript' || mimeType === 'application/json'
? '; charset=utf-8'
: '';
res.setHeader('Content-Type', `${mimeType}${charset}`);
}
async function addDirectoryToZip(zip, directoryPath, rootPath) {
const entries = await fsPromises.readdir(directoryPath, { withFileTypes: true });
for (const entry of entries) {
const absolutePath = path.join(directoryPath, entry.name);
const relativePath = path.relative(rootPath, absolutePath).split(path.sep).join('/');
if (!relativePath) {
continue;
}
if (entry.isDirectory()) {
zip.folder(relativePath);
await addDirectoryToZip(zip, absolutePath, rootPath);
continue;
}
if (entry.isFile()) {
const [content, stats] = await Promise.all([
fsPromises.readFile(absolutePath),
fsPromises.stat(absolutePath),
]);
zip.file(relativePath, content, { date: stats.mtime });
}
}
}
function getSafeZipFilename(projectName) {
const safeName = String(projectName || 'project')
.replace(/[\\/:*?"<>|\x00-\x1f]/g, '-')
.replace(/^\.+$/, 'project')
.trim() || 'project';
return `${safeName}.zip`;
}
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);
const defaultRoot = WORKSPACES_ROOT;
let targetPath = dirPath ? expandWorkspacePath(dirPath) : defaultRoot;
targetPath = path.resolve(targetPath);
const resolvedPath = targetPath;
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' });
}
const fileTree = await getFileTree(resolvedPath, 1, 0, false);
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);
});
const suggestions = [];
let resolvedWorkspaceRoot = defaultRoot;
try {
resolvedWorkspaceRoot = await fsPromises.realpath(defaultRoot);
} catch (error) {
}
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) {
}
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' });
}
});
app.get('/api/projects/:projectName/file', authenticateToken, async (req, res) => {
try {
const { projectName } = req.params;
const { filePath } = req.query;
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.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 });
}
}
});
app.get('/api/projects/:projectName/files/content', authenticateToken, async (req, res) => {
try {
const { projectName } = req.params;
const { path: filePath } = req.query;
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.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' });
}
try {
await fsPromises.access(resolved);
} catch (error) {
return res.status(404).json({ error: 'File not found' });
}
const mimeType = mime.lookup(resolved) || 'application/octet-stream';
res.setHeader('Content-Type', mimeType);
if (req.query.download) {
const basename = path.basename(resolved);
res.setHeader('Content-Disposition', `attachment; filename="${basename}"`);
}
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 });
}
}
});
app.get('/api/projects/:projectName/preview/*', authenticateToken, async (req, res) => {
try {
const { projectName } = req.params;
const relativeFilePath = req.params[0] || 'index.html';
const projectRoot = await extractProjectDirectory(projectName).catch(() => null);
if (!projectRoot) {
return res.status(404).json({ error: 'Project not found' });
}
const resolvedResult = resolvePathInProject(projectRoot, relativeFilePath);
if (!resolvedResult.valid) {
return res.status(403).json({ error: resolvedResult.error });
}
let resolved = resolvedResult.resolved;
let stats = await fsPromises.stat(resolved).catch(() => null);
if (stats?.isDirectory()) {
resolved = path.join(resolved, 'index.html');
stats = await fsPromises.stat(resolved).catch(() => null);
}
if (!stats || !stats.isFile()) {
return res.status(404).type('text/plain').send('Preview file not found.');
}
res.setHeader('Cache-Control', 'no-cache, no-store, must-revalidate');
setPreviewContentType(res, resolved);
fs.createReadStream(resolved).pipe(res);
} catch (error) {
console.error('Error serving project preview:', error);
res.status(500).json({ error: error.message });
}
});
app.get('/api/projects/:projectName/download', authenticateToken, async (req, res) => {
try {
const { projectName } = req.params;
const projectRoot = await extractProjectDirectory(projectName).catch(() => null);
if (!projectRoot) {
return res.status(404).json({ error: 'Project not found' });
}
const rootStats = await fsPromises.stat(projectRoot).catch(() => null);
if (!rootStats?.isDirectory()) {
return res.status(404).json({ error: 'Project directory not found' });
}
const zip = new JSZip();
await addDirectoryToZip(zip, projectRoot, projectRoot);
const filename = getSafeZipFilename(projectName);
const asciiFilename = filename.replace(/[^\x20-\x7e]/g, '_');
res.setHeader('Content-Type', 'application/zip');
res.setHeader(
'Content-Disposition',
`attachment; filename="${asciiFilename}"; filename*=UTF-8''${encodeURIComponent(filename)}`,
);
const zipStream = zip.generateNodeStream({
type: 'nodebuffer',
compression: 'DEFLATE',
compressionOptions: { level: 6 },
});
zipStream.on('error', (error) => {
console.error('Error streaming project zip:', error);
if (!res.headersSent) {
res.status(500).json({ error: 'Failed to generate project archive' });
} else {
res.end();
}
});
zipStream.pipe(res);
} catch (error) {
console.error('Error downloading project archive:', error);
if (!res.headersSent) {
res.status(500).json({ error: error.message });
}
}
});
app.put('/api/projects/:projectName/file', authenticateToken, async (req, res) => {
try {
const { projectName } = req.params;
const { filePath, content } = req.body;
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' });
}
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' });
}
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 {
let actualPath;
try {
actualPath = await extractProjectDirectory(req.params.projectName);
} catch (error) {
console.error('Error extracting project directory:', error);
actualPath = req.params.projectName.replace(/-/g, '/');
}
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);
res.json(files);
} catch (error) {
console.error('[ERROR] File tree error:', error.message);
res.status(500).json({ error: error.message });
}
});
* 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' };
}
const invalidChars = /[<>:"/\\|?*\x00-\x1f]/;
if (invalidChars.test(name)) {
return { valid: false, error: 'Filename contains invalid characters' };
}
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' };
}
if (/^\.+$/.test(name)) {
return { valid: false, error: 'Filename cannot be only dots' };
}
return { valid: true };
}
app.post('/api/projects/:projectName/files/create', authenticateToken, async (req, res) => {
try {
const { projectName } = req.params;
const { path: parentPath, type, name } = req.body;
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 });
}
const projectRoot = await extractProjectDirectory(projectName).catch(() => null);
if (!projectRoot) {
return res.status(404).json({ error: 'Project not found' });
}
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;
try {
await fsPromises.access(resolvedPath);
return res.status(409).json({ error: `${type === 'file' ? 'File' : 'Directory'} already exists` });
} catch {
}
if (type === 'directory') {
await fsPromises.mkdir(resolvedPath, { recursive: false });
} else {
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 });
}
}
});
app.put('/api/projects/:projectName/files/rename', authenticateToken, async (req, res) => {
try {
const { projectName } = req.params;
const { oldPath, newName } = req.body;
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 });
}
const projectRoot = await extractProjectDirectory(projectName).catch(() => null);
if (!projectRoot) {
return res.status(404).json({ error: 'Project not found' });
}
const oldValidation = validatePathInProject(projectRoot, oldPath);
if (!oldValidation.valid) {
return res.status(403).json({ error: oldValidation.error });
}
const resolvedOldPath = oldValidation.resolved;
try {
await fsPromises.access(resolvedOldPath);
} catch {
return res.status(404).json({ error: 'File or directory not found' });
}
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 });
}
try {
await fsPromises.access(resolvedNewPath);
return res.status(409).json({ error: 'A file or directory with this name already exists' });
} catch {
}
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 });
}
}
});
app.delete('/api/projects/:projectName/files', authenticateToken, async (req, res) => {
try {
const { projectName } = req.params;
const { path: targetPath, type } = req.body;
if (!targetPath) {
return res.status(400).json({ error: 'Path is required' });
}
const projectRoot = await extractProjectDirectory(projectName).catch(() => null);
if (!projectRoot) {
return res.status(404).json({ error: 'Project not found' });
}
const validation = validatePathInProject(projectRoot, targetPath);
if (!validation.valid) {
return res.status(403).json({ error: validation.error });
}
const resolvedPath = validation.resolved;
let stats;
try {
stats = await fsPromises.stat(resolvedPath);
} catch {
return res.status(404).json({ error: 'File or directory not found' });
}
if (resolvedPath === path.resolve(projectRoot)) {
return res.status(403).json({ error: 'Cannot delete project root directory' });
}
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 });
}
}
});
const uploadFilesHandler = async (req, res) => {
const multer = (await import('multer')).default;
const uploadMiddleware = multer({
storage: multer.diskStorage({
destination: (req, file, cb) => {
cb(null, os.tmpdir());
},
filename: (req, file, cb) => {
const uniqueSuffix = Date.now() + '-' + Math.round(Math.random() * 1E9);
cb(null, `upload-${uniqueSuffix}`);
}
}),
limits: {
fileSize: 50 * 1024 * 1024,
files: 20
}
});
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;
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' });
}
const projectRoot = await extractProjectDirectory(projectName).catch(() => null);
if (!projectRoot) {
return res.status(404).json({ error: 'Project not found' });
}
console.log('[DEBUG] Project root:', projectRoot);
const targetDir = targetPath || '';
let resolvedTargetDir;
console.log('[DEBUG] Target dir:', JSON.stringify(targetDir));
if (!targetDir || targetDir === '.' || targetDir === './') {
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);
}
try {
await fsPromises.access(resolvedTargetDir);
} catch {
await fsPromises.mkdir(resolvedTargetDir, { recursive: true });
}
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];
const fileName = (filePaths && filePaths[i]) ? filePaths[i] : file.originalname;
console.log('[DEBUG] Processing file:', fileName, '(originalname:', file.originalname + ')');
const destPath = path.join(resolvedTargetDir, fileName);
const destValidation = validatePathInProject(projectRoot, destPath);
if (!destValidation.valid) {
console.log('[DEBUG] Destination validation failed for:', destPath);
await fsPromises.unlink(file.path).catch(() => {});
continue;
}
const parentDir = path.dirname(destPath);
try {
await fsPromises.access(parentDir);
} catch {
await fsPromises.mkdir(parentDir, { recursive: true });
}
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);
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);
* Proxy an authenticated client WebSocket to a plugin's internal WS server.
* Auth is enforced by verifyClient before this function is reached.
*/
function handlePluginWsProxy(clientWs, pathname) {
const pluginName = pathname.replace('/plugin-ws/', '');
if (!pluginName || /[^a-zA-Z0-9_-]/.test(pluginName)) {
clientWs.close(4400, 'Invalid plugin name');
return;
}
const port = getPluginPort(pluginName);
if (!port) {
clientWs.close(4404, 'Plugin not running');
return;
}
const upstream = new WebSocket(`ws://127.0.0.1:${port}/ws`);
upstream.on('open', () => {
console.log(`[Plugins] WS proxy connected to "${pluginName}" on port ${port}`);
});
upstream.on('message', (data) => {
if (clientWs.readyState === WebSocket.OPEN) clientWs.send(data);
});
clientWs.on('message', (data) => {
if (upstream.readyState === WebSocket.OPEN) upstream.send(data);
});
upstream.on('close', () => { if (clientWs.readyState === WebSocket.OPEN) clientWs.close(); });
clientWs.on('close', () => { if (upstream.readyState === WebSocket.OPEN) upstream.close(); });
upstream.on('error', (err) => {
console.error(`[Plugins] WS proxy error for "${pluginName}":`, err.message);
if (clientWs.readyState === WebSocket.OPEN) clientWs.close(4502, 'Upstream error');
});
clientWs.on('error', () => {
if (upstream.readyState === WebSocket.OPEN) upstream.close();
});
}
wss.on('connection', (ws, request) => {
const url = request.url;
console.log('[INFO] Client connected to:', url);
const urlObj = new URL(url, 'http://localhost');
const pathname = urlObj.pathname;
if (pathname === '/shell') {
handleShellConnection(ws);
} else if (pathname === '/ws') {
handleChatConnection(ws, request);
} else if (pathname.startsWith('/plugin-ws/')) {
handlePluginWsProxy(ws, pathname);
} else {
console.log('[WARN] Unknown WebSocket path:', pathname);
ws.close();
}
});
* WebSocket Writer - Wrapper for WebSocket to match SSEStreamWriter interface
*
* Provider files use `createNormalizedMessage()` from `providers/types.js` and
* adapter `normalizeMessage()` to produce unified NormalizedMessage events.
* The writer simply serialises and sends.
*/
class WebSocketWriter {
constructor(ws, userId = null) {
this.ws = ws;
this.sessionId = null;
this.userId = userId;
this.isWebSocketWriter = true;
}
send(data) {
const message = JSON.stringify(data);
if (this.ws.readyState === 1) {
this.ws.send(message);
return;
}
connectedClients.forEach((client) => {
if (client.readyState !== 1) return;
if (client.__pilotdeckUserId !== this.userId) return;
client.send(message);
});
}
updateWebSocket(newRawWs) {
this.ws = newRawWs;
}
setSessionId(sessionId) {
this.sessionId = sessionId;
}
getSessionId() {
return this.sessionId;
}
}
function handleChatConnection(ws, request) {
console.log('[INFO] Chat WebSocket connected');
const userId = request?.user?.id ?? request?.user?.userId ?? null;
ws.__pilotdeckUserId = userId;
connectedClients.add(ws);
let cleanedUp = false;
const writer = new WebSocketWriter(ws, userId);
ws.on('message', async (message) => {
try {
const data = JSON.parse(message);
if (data.type === 'always-on-presence') {
await alwaysOnHeartbeat.handlePresence(ws, data);
} else if (data.type === 'always-on-presence-clear') {
await alwaysOnHeartbeat.clearPresence(ws);
} else if (
data.type === 'pilotdeck-command' ||
data.type === 'claude-command' ||
data.type === 'cursor-command' ||
data.type === 'codex-command' ||
data.type === 'gemini-command'
) {
console.log('[DEBUG] User message:', data.command || '[Continue/Resume]');
console.log('📁 Project:', data.options?.projectPath || data.options?.cwd || 'Unknown');
console.log('🔄 Session:', data.options?.sessionId ? 'Resume' : 'New');
const providerHint = data.options?.providerHint || data.type.replace('-command', '');
await runChatViaGateway(data.command, data.options, writer, providerHint);
} else if (data.type === 'abort-session') {
console.log('[DEBUG] Abort session request:', data.sessionId);
const provider = data.provider || 'pilotdeck';
const success = await abortViaGateway(data.sessionId, provider);
writer.send(createNormalizedMessage({ kind: 'complete', exitCode: success ? 0 : 1, aborted: true, success, sessionId: data.sessionId, provider }));
} else if (
data.type === 'claude-permission-response' ||
data.type === 'permission-response'
) {
if (data.requestId) {
await decidePermissionViaGateway(
data.requestId,
data.allow ? 'allow' : 'deny',
{
remember: Boolean(data.rememberEntry),
reason: data.message,
},
);
}
} else if (data.type === 'session-permission-grant') {
await grantSessionPermissionViaGateway(data.sessionId, data.entry);
} else if (data.type === 'elicitation-response') {
if (data.requestId) {
await elicitationRespondViaGateway(data.requestId, data.answer);
}
} else if (data.type === 'check-session-status') {
const sessionId = data.sessionId;
const isProcessing = isSessionActiveViaGateway(sessionId);
const activeTurnMessages = isProcessing
? await getActiveTurnSnapshotFramesViaGateway(sessionId, data.provider || 'pilotdeck')
: [];
writer.send({
type: 'session-status',
sessionId,
provider: data.provider || 'pilotdeck',
isProcessing,
activeTurnMessages,
tokenBudget: getSessionTokenBudget(sessionId),
});
} else if (data.type === 'get-pending-permissions') {
writer.send({
type: 'pending-permissions-response',
sessionId: data.sessionId,
data: [],
});
} else if (data.type === 'get-active-sessions') {
const ids = getActiveSessionIdsViaGateway();
writer.send({
type: 'active-sessions',
sessions: { claude: ids, cursor: [], codex: [], gemini: [], pilotdeck: ids },
});
}
} catch (error) {
console.error('[ERROR] Chat WebSocket error:', error.message);
writer.send({
type: 'error',
error: error.message
});
}
});
const cleanup = () => {
if (cleanedUp) return;
cleanedUp = true;
connectedClients.delete(ws);
void alwaysOnHeartbeat.clearPresence(ws);
};
ws.on('close', (code, reason) => {
const reasonText = reason?.toString?.() || '';
console.log(`🔌 Chat client disconnected code=${code}${reasonText ? ` reason=${reasonText}` : ''}`);
cleanup();
});
ws.on('error', () => {
cleanup();
});
}
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 || 'pilotdeck';
const initialCommand = data.initialCommand;
const isPlainShell = data.isPlainShell || (!!initialCommand && !hasSession) || provider === 'plain-shell';
urlDetectionBuffer = '';
announcedAuthUrls.clear();
const isLoginCommand = initialCommand && (
initialCommand.includes('setup-token') ||
initialCommand.includes('cursor-agent login') ||
initialCommand.includes('auth login')
);
const commandSuffix = isPlainShell && initialCommand
? `_cmd_${Buffer.from(initialCommand).toString('base64').slice(0, 16)}`
: '';
ptySessionKey = `${projectPath}_${sessionId || 'default'}${commandSuffix}`;
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);
}
let welcomeMsg;
if (isPlainShell) {
welcomeMsg = `\x1b[36mStarting terminal in: ${projectPath}\x1b[0m\r\n`;
} else {
const providerName = provider === 'pilotdeck' ? 'PilotDeck' : (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 {
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;
}
const safeSessionIdPattern = /^[a-zA-Z0-9_.\-:]+$/;
if (sessionId && !safeSessionIdPattern.test(sessionId)) {
ws.send(JSON.stringify({ type: 'error', message: 'Invalid session ID' }));
return;
}
let shellCommand;
if (isPlainShell) {
shellCommand = initialCommand;
} else if (provider === 'cursor') {
if (hasSession && sessionId) {
shellCommand = `cursor-agent --resume="${sessionId}"`;
} else {
shellCommand = 'cursor-agent';
}
} else if (provider === 'codex') {
if (hasSession && sessionId) {
if (os.platform() === 'win32') {
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 {
const sess = sessionManager.getSession(sessionId);
if (sess && sess.cliSessionId) {
resumeId = sess.cliSessionId;
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 if (provider === 'pilotdeck') {
const command = initialCommand || 'pilotdeck';
if (hasSession && sessionId) {
if (os.platform() === 'win32') {
shellCommand = `pilotdeck --resume "${sessionId}"; if ($LASTEXITCODE -ne 0) { pilotdeck }`;
} else {
shellCommand = `pilotdeck --resume "${sessionId}" || pilotdeck`;
}
} else {
shellCommand = command;
}
} else {
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);
const shell = os.platform() === 'win32' ? 'powershell.exe' : 'bash';
const shellArgs = os.platform() === 'win32' ? ['-Command', shellCommand] : ['-c', shellCommand];
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
});
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);
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);
}
session.ws.send(JSON.stringify({
type: 'output',
data: outputData
}));
}
});
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') {
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') {
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);
});
}
const CHAT_ATTACHMENT_IMAGE_MIMES = new Set([
'image/jpeg',
'image/png',
'image/gif',
'image/webp',
'image/svg+xml',
]);
function sanitizeAttachmentFilename(name, fallback = 'attachment') {
const baseName = path.basename(String(name || fallback));
const sanitized = baseName
.replace(/[<>:"/\\|?*\x00-\x1f]/g, '_')
.replace(/^\.+$/, fallback)
.slice(0, 180)
.trim();
return sanitized || fallback;
}
function normalizeUploadedFilename(name, fallback = 'attachment') {
const original = String(name || fallback);
try {
const decoded = Buffer.from(original, 'latin1').toString('utf8');
const looksMojibake = /[ÃÂÄÅÆÇÈÉÊËÌÍÎÏÐÑÒÓÔÕÖØÙÚÛÜÝÞßàáâãäåæçèéêëìíîïðñòóôõöøùûüýþÿ]/.test(original);
if (looksMojibake && decoded && !decoded.includes('�')) {
return decoded;
}
} catch {
}
return original;
}
async function moveUploadedAttachment(file, attachmentDir, index) {
const originalName = normalizeUploadedFilename(file.originalname, `attachment-${index + 1}`);
file.originalname = originalName;
const safeName = sanitizeAttachmentFilename(originalName, `attachment-${index + 1}`);
const ext = path.extname(safeName);
const stem = ext ? safeName.slice(0, -ext.length) : safeName;
let candidate = `${index + 1}-${safeName}`;
let destination = path.join(attachmentDir, candidate);
let suffix = 1;
while (true) {
try {
await fsPromises.access(destination);
candidate = `${index + 1}-${stem}-${suffix}${ext}`;
destination = path.join(attachmentDir, candidate);
suffix += 1;
} catch {
break;
}
}
await fsPromises.copyFile(file.path, destination);
await fsPromises.unlink(file.path);
return {
name: originalName,
path: destination,
size: file.size,
mimeType: file.mimetype || mime.lookup(originalName) || 'application/octet-stream',
};
}
app.post('/api/projects/:projectName/upload-attachments', authenticateToken, async (req, res) => {
let multerUpload;
try {
const multer = (await import('multer')).default;
const uploadRoot = path.join(os.tmpdir(), 'pilotdeck-chat-attachments', String(req.user.id));
const storage = multer.diskStorage({
destination: async (_req, _file, cb) => {
try {
await fsPromises.mkdir(uploadRoot, { recursive: true });
cb(null, uploadRoot);
} catch (error) {
cb(error);
}
},
filename: (_req, file, cb) => {
const uniqueSuffix = `${Date.now()}-${Math.round(Math.random() * 1E9)}`;
file.originalname = normalizeUploadedFilename(file.originalname);
cb(null, `${uniqueSuffix}-${sanitizeAttachmentFilename(file.originalname)}`);
},
});
multerUpload = multer({
storage,
limits: {
fileSize: 20 * 1024 * 1024,
files: 10,
},
}).array('attachments', 10);
} catch (error) {
console.error('Error configuring attachment upload:', error);
return res.status(500).json({ error: 'Internal server error' });
}
multerUpload(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 attachments provided' });
}
let attachmentDir = null;
try {
const projectRoot = await extractProjectDirectory(req.params.projectName);
const targetDir = path.join(projectRoot, '.tmp', 'chat-attachments', `${Date.now()}-${crypto.randomBytes(4).toString('hex')}`);
const validation = validatePathInProject(projectRoot, targetDir);
if (!validation.valid) {
throw new Error(validation.error || 'Invalid attachment target');
}
attachmentDir = validation.resolved;
const images = [];
const files = [];
await fsPromises.mkdir(attachmentDir, { recursive: true });
for (const [index, file] of req.files.entries()) {
if (CHAT_ATTACHMENT_IMAGE_MIMES.has(file.mimetype)) {
const originalName = normalizeUploadedFilename(file.originalname);
const buffer = await fsPromises.readFile(file.path);
await fsPromises.unlink(file.path).catch(() => { });
images.push({
name: originalName,
data: `data:${file.mimetype};base64,${buffer.toString('base64')}`,
size: file.size,
mimeType: file.mimetype,
});
continue;
}
files.push(await moveUploadedAttachment(file, attachmentDir, index));
}
if (files.length === 0 && attachmentDir) {
await fsPromises.rm(attachmentDir, { recursive: true, force: true }).catch(() => { });
}
res.json({ images, files });
} catch (error) {
console.error('Error processing attachments:', error);
await Promise.all((req.files || []).map(file => fsPromises.unlink(file.path).catch(() => { })));
if (attachmentDir) {
await fsPromises.rm(attachmentDir, { recursive: true, force: true }).catch(() => { });
}
res.status(500).json({ error: 'Failed to process attachments' });
}
});
});
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;
const storage = multer.diskStorage({
destination: async (req, file, cb) => {
const uploadDir = path.join(os.tmpdir(), 'pilotdeck-image-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,
files: 5
}
});
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 {
const processedImages = await Promise.all(
req.files.map(async (file) => {
const buffer = await fs.readFile(file.path);
const base64 = buffer.toString('base64');
const mimeType = file.mimetype;
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);
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' });
}
});
app.get('/api/projects/:projectName/sessions/:sessionId/token-usage', authenticateToken, async (req, res) => {
try {
const { projectName, sessionId } = req.params;
const { provider = 'pilotdeck' } = req.query;
const homeDir = os.homedir();
if (provider === 'pilotdeck' || /^web[:_-]s_/.test(sessionId)) {
return res.json(getSessionTokenBudget(sessionId));
}
const safeSessionId = String(sessionId).replace(/[^a-zA-Z0-9._-]/g, '');
if (!safeSessionId || safeSessionId !== String(sessionId)) {
return res.status(400).json({ error: 'Invalid sessionId' });
}
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'
});
}
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'
});
}
if (provider === 'codex') {
const codexSessionsDir = path.join(homeDir, '.codex', 'sessions');
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) {
}
return null;
};
const sessionFilePath = await findSessionFile(codexSessionsDir);
if (!sessionFilePath) {
return res.status(404).json({ error: 'Codex session file not found', sessionId: safeSessionId });
}
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;
for (let i = lines.length - 1; i >= 0; i--) {
try {
const entry = JSON.parse(lines[i]);
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;
}
} catch (parseError) {
continue;
}
}
return res.json({
used: totalTokens,
total: contextWindow
});
}
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' });
}
const encodedPath = projectPath.replace(/[^a-zA-Z0-9-]/g, '-');
const projectDir = path.join(homeDir, '.pilotdeck', 'projects', encodedPath);
const jsonlPath = path.join(projectDir, `${safeSessionId}.jsonl`);
const rel = path.relative(path.resolve(projectDir), path.resolve(jsonlPath));
if (rel.startsWith('..') || path.isAbsolute(rel)) {
return res.status(400).json({ error: 'Invalid path' });
}
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;
}
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;
for (let i = lines.length - 1; i >= 0; i--) {
try {
const entry = JSON.parse(lines[i]);
if (entry.type === 'assistant' && entry.message?.usage) {
const usage = entry.message.usage;
inputTokens = usage.input_tokens || 0;
cacheCreationTokens = usage.cache_creation_input_tokens || 0;
cacheReadTokens = usage.cache_read_input_tokens || 0;
break;
}
} catch (parseError) {
continue;
}
}
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' });
}
});
app.get('*', (req, res) => {
const ext = path.extname(req.path);
if (ext && /^\.(js|css|map|json|ico|png|jpg|jpeg|gif|svg|webp|woff2?|ttf|eot|mp4|webm)$/.test(ext)) {
return res.status(404).send('Not found');
}
const indexPath = path.join(__dirname, '../dist/index.html');
if (fs.existsSync(indexPath)) {
res.setHeader('Cache-Control', 'no-cache, no-store, must-revalidate');
res.setHeader('Pragma', 'no-cache');
res.setHeader('Expires', '0');
res.sendFile(indexPath);
} else {
const redirectHost = getConnectableHost(req.hostname);
res.redirect(`${req.protocol}://${redirectHost}:${VITE_PORT}`);
}
});
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) {
const items = [];
try {
const entries = await fsPromises.readdir(dirPath, { withFileTypes: true });
for (const entry of entries) {
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'
};
try {
const stats = await fsPromises.stat(itemPath);
item.size = stats.size;
item.modified = stats.mtime.toISOString();
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) {
item.size = 0;
item.modified = null;
item.permissions = '000';
item.permissionsRwx = '---------';
}
if (entry.isDirectory() && currentDepth < maxDepth) {
try {
await fsPromises.access(item.path, fs.constants.R_OK);
item.children = await getFileTree(item.path, maxDepth, currentDepth + 1, showHidden);
} catch (e) {
item.children = [];
}
}
items.push(item);
}
} catch (error) {
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 SERVER_PORT = process.env.SERVER_PORT || 3001;
const HOST = process.env.HOST || '0.0.0.0';
const DISPLAY_HOST = getConnectableHost(HOST);
const VITE_PORT = process.env.VITE_PORT || 5173;
async function ensureLocalUserWhenAuthDisabled() {
if (!DISABLE_LOCAL_AUTH || userDb.hasUsers()) {
return;
}
const passwordHash = await bcrypt.hash(crypto.randomBytes(32).toString('hex'), 12);
userDb.createUser('local', passwordHash);
console.log(`${c.info('[INFO]')} Web UI login is disabled (default). Using built-in user. Set PILOTDECK_DISABLE_LOCAL_AUTH=0 to require username/password.`);
}
async function startServer() {
try {
await startServerAfterStartup({
startupFn: async () => {
await runServerStartupBeforeListen({
initializeDatabaseFn: initializeDatabase,
ensureLocalUserWhenAuthDisabledFn: ensureLocalUserWhenAuthDisabled,
configureWebPushFn: configureWebPush
});
},
listenFn: async () => {
const distIndexPath = path.join(__dirname, '../dist/index.html');
const isProduction = fs.existsSync(distIndexPath);
console.log(`${c.info('[INFO]')} Chat execution routed through PilotDeck gateway (src/gateway).`);
console.log('');
if (isProduction) {
console.log(`${c.info('[INFO]')} To run in production mode, go to http://${DISPLAY_HOST}:${SERVER_PORT}`);
} else {
console.log(`${c.info('[INFO]')} No production frontend build found; development mode expects Vite at http://${DISPLAY_HOST}:${VITE_PORT}`);
}
server.listen(SERVER_PORT, HOST, async () => {
const appInstallPath = path.join(__dirname, '..');
console.log('');
console.log(c.dim('═'.repeat(63)));
console.log(` ${c.bright('PilotDeck Server - Ready')}`);
console.log(c.dim('═'.repeat(63)));
console.log('');
console.log(`${c.info('[INFO]')} Server URL: ${c.bright('http://' + DISPLAY_HOST + ':' + SERVER_PORT)}`);
console.log(`${c.info('[INFO]')} Installed at: ${c.dim(appInstallPath)}`);
console.log(`${c.tip('[TIP]')} Run "pilotdeck status" for full configuration details`);
console.log('');
const skipAutoOpen =
process.env.PILOTDECK_DESKTOP === '1'
|| process.env.PILOTDECK_SKIP_BROWSER_OPEN === '1';
if (!skipAutoOpen) {
const serverUrl = `http://${DISPLAY_HOST === '0.0.0.0' ? 'localhost' : DISPLAY_HOST}:${SERVER_PORT}`;
const openCmd = process.platform === 'darwin' ? 'open'
: process.platform === 'win32' ? 'start'
: 'xdg-open';
exec(`${openCmd} "${serverUrl}"`, () => {});
}
await setupProjectsWatcher();
await ensurePilotDeckProxyRunning();
startMemoryScheduler();
startEnabledPluginServers().catch(err => {
console.error('[Plugins] Error during startup:', err.message);
});
await startPilotDeckConfigWatcher({
onEvent: (payload) => {
process.emit('pilotdeck:config-broadcast', payload);
},
});
});
}
});
let shutdownPromise = null;
const gracefulShutdown = async () => {
if (shutdownPromise) {
return shutdownPromise;
}
shutdownPromise = (async () => {
try {
stopMemoryScheduler();
closeMemoryServices();
stopPilotDeckConfigWatcher();
await stopPilotDeckProxy();
await stopAllPlugins();
try {
const { shutdownGlobalChrome, stopChromeHealthCheck } = await import('./utils/globalChrome.js');
stopChromeHealthCheck();
shutdownGlobalChrome();
} catch { }
} finally {
process.exit(0);
}
})();
return shutdownPromise;
};
process.on('SIGTERM', () => void gracefulShutdown());
process.on('SIGINT', () => void gracefulShutdown());
} catch (error) {
console.error('[ERROR] Failed to start server:', error);
process.exit(1);
}
}
startServer();