| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960961962963964965966967968969970971972973974975976977978979980981982983984985986987988989990991992993994995996997998999100010011002100310041005100610071008100910101011101210131014101510161017101810191020102110221023102410251026102710281029103010311032103310341035103610371038103910401041104210431044104510461047104810491050105110521053105410551056105710581059106010611062106310641065106610671068106910701071107210731074107510761077107810791080108110821083108410851086108710881089109010911092109310941095109610971098109911001101110211031104110511061107110811091110111111121113111411151116111711181119112011211122112311241125112611271128112911301131113211331134113511361137113811391140114111421143114411451146114711481149115011511152115311541155115611571158115911601161116211631164116511661167116811691170117111721173117411751176117711781179118011811182118311841185118611871188118911901191119211931194119511961197119811991200120112021203120412051206120712081209121012111212121312141215121612171218121912201221122212231224122512261227122812291230123112321233123412351236123712381239124012411242124312441245124612471248124912501251125212531254125512561257125812591260126112621263126412651266126712681269127012711272127312741275127612771278127912801281128212831284128512861287128812891290129112921293129412951296129712981299130013011302130313041305130613071308130913101311131213131314131513161317131813191320132113221323132413251326132713281329133013311332133313341335133613371338133913401341134213431344134513461347134813491350135113521353135413551356135713581359136013611362136313641365136613671368136913701371137213731374137513761377137813791380138113821383138413851386138713881389139013911392139313941395139613971398139914001401140214031404140514061407140814091410141114121413141414151416141714181419142014211422142314241425142614271428142914301431143214331434143514361437143814391440144114421443144414451446144714481449145014511452145314541455145614571458145914601461146214631464146514661467146814691470147114721473147414751476147714781479148014811482148314841485148614871488148914901491149214931494149514961497149814991500150115021503150415051506150715081509151015111512151315141515151615171518151915201521152215231524152515261527152815291530153115321533153415351536153715381539154015411542154315441545154615471548154915501551155215531554155515561557155815591560156115621563156415651566156715681569157015711572157315741575157615771578157915801581158215831584158515861587158815891590159115921593159415951596159715981599160016011602160316041605160616071608160916101611161216131614161516161617161816191620162116221623162416251626162716281629163016311632163316341635163616371638163916401641164216431644164516461647164816491650165116521653165416551656165716581659166016611662166316641665166616671668166916701671167216731674167516761677167816791680168116821683168416851686168716881689169016911692169316941695169616971698169917001701170217031704170517061707170817091710171117121713171417151716171717181719172017211722172317241725172617271728172917301731173217331734173517361737173817391740174117421743174417451746174717481749175017511752175317541755175617571758175917601761176217631764176517661767176817691770177117721773177417751776177717781779178017811782178317841785178617871788178917901791179217931794179517961797179817991800180118021803180418051806180718081809181018111812181318141815181618171818181918201821182218231824182518261827182818291830183118321833183418351836183718381839184018411842184318441845184618471848184918501851185218531854185518561857185818591860186118621863186418651866186718681869187018711872187318741875187618771878187918801881188218831884188518861887188818891890189118921893189418951896189718981899190019011902190319041905190619071908190919101911191219131914191519161917191819191920192119221923192419251926192719281929193019311932193319341935193619371938193919401941194219431944194519461947194819491950195119521953195419551956195719581959196019611962196319641965196619671968196919701971197219731974197519761977197819791980198119821983198419851986198719881989199019911992199319941995199619971998199920002001200220032004200520062007200820092010201120122013201420152016201720182019202020212022202320242025202620272028202920302031203220332034203520362037203820392040204120422043204420452046204720482049205020512052205320542055205620572058205920602061206220632064206520662067206820692070207120722073207420752076207720782079208020812082208320842085208620872088208920902091209220932094209520962097209820992100210121022103210421052106210721082109211021112112211321142115211621172118211921202121212221232124212521262127212821292130213121322133213421352136213721382139214021412142214321442145214621472148214921502151215221532154215521562157215821592160216121622163216421652166216721682169217021712172217321742175217621772178217921802181218221832184218521862187218821892190219121922193219421952196219721982199220022012202220322042205220622072208220922102211221222132214221522162217221822192220222122222223222422252226222722282229223022312232223322342235223622372238223922402241224222432244224522462247224822492250225122522253225422552256225722582259226022612262226322642265226622672268226922702271227222732274227522762277227822792280228122822283228422852286228722882289229022912292229322942295229622972298229923002301230223032304230523062307230823092310231123122313231423152316231723182319232023212322232323242325232623272328232923302331233223332334233523362337233823392340234123422343234423452346234723482349235023512352235323542355235623572358235923602361236223632364236523662367236823692370237123722373237423752376237723782379238023812382238323842385238623872388238923902391239223932394239523962397239823992400240124022403240424052406240724082409241024112412241324142415241624172418241924202421242224232424242524262427242824292430243124322433243424352436243724382439244024412442244324442445244624472448244924502451245224532454245524562457245824592460246124622463246424652466246724682469247024712472247324742475247624772478247924802481248224832484248524862487248824892490249124922493249424952496249724982499250025012502250325042505250625072508250925102511251225132514251525162517251825192520252125222523252425252526252725282529253025312532253325342535253625372538253925402541254225432544254525462547254825492550255125522553255425552556255725582559256025612562256325642565256625672568256925702571257225732574257525762577257825792580258125822583258425852586258725882589259025912592259325942595259625972598259926002601260226032604260526062607260826092610261126122613261426152616261726182619262026212622262326242625262626272628262926302631263226332634263526362637263826392640264126422643264426452646264726482649265026512652265326542655265626572658265926602661266226632664266526662667266826692670267126722673267426752676267726782679268026812682268326842685268626872688268926902691269226932694269526962697269826992700270127022703270427052706270727082709271027112712271327142715271627172718271927202721272227232724272527262727272827292730273127322733273427352736273727382739274027412742274327442745274627472748274927502751275227532754275527562757275827592760276127622763276427652766276727682769277027712772277327742775277627772778277927802781278227832784278527862787278827892790279127922793279427952796279727982799280028012802280328042805280628072808280928102811281228132814281528162817281828192820282128222823282428252826282728282829283028312832283328342835283628372838283928402841284228432844284528462847284828492850285128522853285428552856285728582859286028612862286328642865286628672868286928702871287228732874287528762877287828792880288128822883288428852886288728882889289028912892289328942895289628972898289929002901290229032904290529062907290829092910291129122913291429152916291729182919292029212922292329242925292629272928292929302931293229332934293529362937293829392940294129422943294429452946294729482949295029512952295329542955295629572958295929602961296229632964296529662967296829692970297129722973297429752976297729782979298029812982298329842985298629872988298929902991299229932994299529962997299829993000300130023003300430053006300730083009301030113012301330143015301630173018301930203021302230233024302530263027302830293030303130323033303430353036303730383039304030413042304330443045304630473048304930503051305230533054305530563057305830593060306130623063306430653066306730683069307030713072307330743075307630773078307930803081308230833084308530863087308830893090309130923093309430953096309730983099310031013102310331043105310631073108310931103111311231133114311531163117311831193120 |
- #!/usr/bin/env node
- // Load environment variables before other imports execute
- import { assertRequiredPilotDeckEnv } from './load-env.js';
- // Install global fetch proxy (PILOTDECK_PROXY / HTTPS_PROXY) before any network calls
- 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';
- // 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}`,
- };
- 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';
- import { contentDispositionAttachment } from './utils/downloadHeaders.js';
- // PilotDeck-only mode: chat execution always goes through src/gateway via
- // cursor-cli, openai-codex, gemini-cli) has been removed.
- const VALID_PROVIDERS = ['pilotdeck'];
- // File-system watchers for the chat transcript root maintained by
- // PilotDeck. Provider-specific watchers (.pilotdeck) were dropped along with the four provider adapters.
- // .gemini) were dropped along with the four provider adapters.
- 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({
- // Legacy four-provider session details have been removed; PilotDeck
- // gateway sessions are tracked by `pilotdeck-bridge.js` instead.
- getActivePilotDeckSessions: () => []
- });
- registerAlwaysOnNotificationForwarding(connectedClients);
- let isGetProjectsRunning = false; // Flag to prevent reentrant calls
- 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() {
- // The legacy in-process proxy bootstrap was tied to a bundled CCR pipeline
- // that we removed during the PilotDeck-only migration.
- // Model traffic now flows through `src/gateway` directly. Returning
- // immediately keeps any callers happy without touching dead code.
- return;
- // The unreachable body below is left as historical scaffolding.
- // eslint-disable-next-line no-unreachable
- 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);
- }
- }
- });
- // 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);
- }
- });
- }
- // Broadcasts ~/.pilotdeck/pilotdeck.yaml reload events (from UI saves or external file edits)
- // to every connected WebSocket client so open Settings tabs refresh instantly.
- 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 () => {
- // 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 / no-login mode: allow connection without token
- if (IS_PLATFORM || DISABLE_LOCAL_AUTH) {
- const user = authenticateWebSocket(null); // Returns first DB user
- 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;
- }
- // 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);
- // TaskMaster API Routes (protected)
- app.use('/api/taskmaster', authenticateToken, taskmasterRoutes);
- // Memory API Routes (protected)
- app.use('/api/memory', authenticateToken, memoryRoutes);
- // MCP utilities
- app.use('/api/mcp-utils', authenticateToken, mcpUtilsRoutes);
- // Commands API Routes (protected)
- app.use('/api/commands', authenticateToken, commandsRoutes);
- // Skills API Routes (protected) — list/edit/install skills surfaced in the
- // top-right Skills tab. Backed by ~/.pilotdeck/skills/ and project-level
- // .pilotdeck/skills/ via PilotDeck plugin runtime.
- app.use('/api/skills', authenticateToken, skillsRoutes);
- // Settings API Routes (protected)
- app.use('/api/settings', authenticateToken, settingsRoutes);
- // PilotDeck unified YAML config routes (protected)
- app.use('/api/config', authenticateToken, configRoutes);
- // User API Routes (protected)
- app.use('/api/user', authenticateToken, userRoutes);
- // Plugins API Routes (protected)
- app.use('/api/plugins', authenticateToken, pluginsRoutes);
- // Unified session messages route (protected) — PilotDeck-only.
- app.use('/api/sessions', authenticateToken, messagesRoutes);
- // Agent API Routes (uses API key authentication)
- app.use('/api/agent', agentRoutes);
- // Legacy four-provider config endpoints have been removed. The runtime
- // model is read from PilotDeck config; fall back to a static stub so any
- // older frontend code paths render without crashing.
- 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',
- },
- });
- });
- // Provider-specific endpoints removed by the PilotDeck-only migration.
- // Returning a structured error keeps any stragglers in the UI from
- // hanging on an unanswered fetch.
- 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.`,
- });
- });
- }
- // PilotDeck routing dashboard. The `/api/ccr/*` URL family was kept for
- // frontend back-compat (Dashboard tab + useRouterSettings) but the data
- // now comes from `src/router/stats/TokenStatsCollector` via the
- 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) => {
- // The legacy CCR YAML schema is no longer the source of truth for
- // model routing — that lives in PilotDeck config now. Return null so
- // the legacy useRouterSettings hook simply renders the "no config"
- // empty state instead of a config editor.
- 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) => {
- // Reset would require reaching into per-project TokenStatsCollector
- // instances; that is not exposed today. Surface a clear hint instead
- // of silently no-oping.
- 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');
- }
- }
- }));
- // Hard 404 boundary: anything still asking for /memory-dashboard/* after the
- // static middleware is a missing asset. Without this, the request would fall
- // through to the SPA wildcard below and return the PilotDeck shell index.html,
- // which the MemoryPanel iframe then renders — recursively nesting the entire
- // app inside itself (see bug: "嵌套显示 + general memory 多次出现").
- app.use('/memory-dashboard', (_req, res) => {
- res.status(404).type('text/plain').send('Not found in memory-dashboard.');
- });
- // 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.
- // /api/system/update was the V1 "Update available" banner backend; the
- // VersionUpgradeModal that consumed it was removed during the V1 cleanup.
- 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 });
- }
- });
- // 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, {
- 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 });
- }
- });
- // 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;
- };
- 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`;
- }
- // 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);
- // Browsing a directory is read-only — we only list its children.
- // The actual workspace-selection validation happens in the
- // create-workspace / clone-progress endpoints, so we don't gate
- // browsing with validateWorkspacePath (which would block navigating
- // through forbidden directories like "/" to reach valid children).
- const 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 raw file bytes for previews and downloads.
- 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' });
- }
- // Match the text reader endpoint so callers can pass either project-relative
- // or absolute paths without changing how the bytes are served.
- 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' });
- }
- // 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);
- if (req.query.download) {
- const basename = path.basename(resolved);
- res.setHeader('Content-Disposition', contentDispositionAttachment(basename));
- }
- // 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 });
- }
- }
- });
- // Serve project files through a stable project-root URL so generated HTML can
- // load sibling CSS, JS and image assets with normal relative paths.
- 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 });
- }
- });
- // Download the complete project as a zip archive.
- 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);
- res.setHeader('Content-Type', 'application/zip');
- res.setHeader('Content-Disposition', contentDispositionAttachment(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 });
- }
- }
- });
- // 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);
- 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);
- /**
- * 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}`);
- });
- // Relay messages bidirectionally
- upstream.on('message', (data) => {
- if (clientWs.readyState === WebSocket.OPEN) clientWs.send(data);
- });
- clientWs.on('message', (data) => {
- if (upstream.readyState === WebSocket.OPEN) upstream.send(data);
- });
- // Propagate close in both directions
- 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();
- });
- }
- // 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, 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; // Marker for transport detection
- }
- send(data) {
- const message = JSON.stringify(data);
- if (this.ws.readyState === 1) { // WebSocket.OPEN
- this.ws.send(message);
- return;
- }
- // A chat turn can outlive the browser WebSocket that submitted it
- // (refresh, reconnect, dev-client hiccup). Keep the gateway stream live
- // by handing subsequent frames to the user's replacement connection.
- connectedClients.forEach((client) => {
- if (client.readyState !== 1) return; // WebSocket.OPEN
- if (client.__pilotdeckUserId !== this.userId) return;
- client.send(message);
- });
- }
- updateWebSocket(newRawWs) {
- this.ws = newRawWs;
- }
- setSessionId(sessionId) {
- this.sessionId = sessionId;
- }
- getSessionId() {
- return this.sessionId;
- }
- }
- // Handle chat WebSocket connections
- function handleChatConnection(ws, request) {
- console.log('[INFO] Chat WebSocket connected');
- // Add to connected clients for project updates
- const userId = request?.user?.id ?? request?.user?.userId ?? null;
- ws.__pilotdeckUserId = userId;
- connectedClients.add(ws);
- // PilotDeck's cron runtime lives inside `pilotdeck server`
- // (src/cron via createCronRuntime); no legacy daemon lease needed.
- let cleanedUp = false;
- // Wrap WebSocket with writer for consistent interface with SSEStreamWriter
- 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' ||
- // Deprecated: legacy per-provider frame types kept for back-compat.
- 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') {
- // Pending-permission introspection is gateway-internal. The
- // permission_request event already contains everything the
- // UI needs, so the response is now an empty stub.
- writer.send({
- type: 'pending-permissions-response',
- sessionId: data.sessionId,
- data: [],
- });
- } else if (data.type === 'get-active-sessions') {
- const ids = getActiveSessionIdsViaGateway();
- // Keep the four-provider keys so the legacy UI store does
- // not need to change shape; everything routes through
- // PilotDeck under the hood.
- 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;
- // Remove from connected clients
- 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();
- });
- }
- // 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 || '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')
- );
- // 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 === '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 {
- // 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 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);
- // 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);
- });
- }
- 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 {
- // Keep the browser-provided name when transcoding is not applicable.
- }
- 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',
- };
- }
- // Mixed chat attachment upload endpoint. Images are returned as data URLs for
- // multimodal input and previews; other files are staged under the project so
- // the gateway can resolve them by path.
- 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' });
- }
- });
- });
- // 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(), '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, // 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 = 'pilotdeck' } = req.query;
- const homeDir = os.homedir();
- // PilotDeck sessions use `web:s_<uuid>` keys; Windows-safe sessions
- // may use `web-s_<uuid>` because ':' is illegal in Windows filenames.
- if (provider === 'pilotdeck' || /^web[:_-]s_/.test(sessionId)) {
- return res.json(getSessionTokenBudget(sessionId));
- }
- // 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
- });
- }
- // 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' });
- }
- const encodedPath = projectPath.replace(/[^a-zA-Z0-9-]/g, '-');
- const projectDir = path.join(homeDir, '.pilotdeck', '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 actual static asset extensions only
- 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');
- }
- // 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
- const redirectHost = getConnectableHost(req.hostname);
- res.redirect(`${req.protocol}://${redirectHost}:${VITE_PORT}`);
- }
- });
- // 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 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;
- const PORT_FALLBACK_ATTEMPTS = 5;
- // Pick a random high port in the 20000–59999 range. Random (rather than the
- // preferred port + 1) because adjacent ports are frequently held by the same
- // multi-port app that already took the preferred one.
- function pickRandomHighPort() {
- return 20000 + Math.floor(Math.random() * 40000);
- }
- // Listen on `preferredPort`; on EADDRINUSE retry on random high ports up to
- // PORT_FALLBACK_ATTEMPTS times. Resolves with the actually-bound port, or null
- // if every attempt was in use. Non-EADDRINUSE errors reject — real failures
- // (bad host, permissions) must not be silently retried.
- function listenWithPortFallback(srv, preferredPort, host) {
- let port = preferredPort;
- let attempt = 0;
- return new Promise((resolve, reject) => {
- const tryListen = () => {
- attempt += 1;
- const onError = (err) => {
- srv.removeListener('listening', onListening);
- if (err && err.code === 'EADDRINUSE') {
- if (attempt >= PORT_FALLBACK_ATTEMPTS) {
- resolve(null);
- return;
- }
- const nextPort = pickRandomHighPort();
- console.log(`${c.warn('[WARN]')} Port ${port} is in use; retrying on random port ${nextPort} (attempt ${attempt}/${PORT_FALLBACK_ATTEMPTS})...`);
- port = nextPort;
- setImmediate(tryListen);
- return;
- }
- reject(err);
- };
- const onListening = () => {
- srv.removeListener('error', onError);
- resolve(srv.address().port);
- };
- srv.once('error', onError);
- srv.once('listening', onListening);
- srv.listen(port, host);
- };
- tryListen();
- });
- }
- 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.`);
- }
- // Initialize database and start server
- async function startServer() {
- try {
- await startServerAfterStartup({
- startupFn: async () => {
- await runServerStartupBeforeListen({
- initializeDatabaseFn: initializeDatabase,
- ensureLocalUserWhenAuthDisabledFn: ensureLocalUserWhenAuthDisabled,
- configureWebPushFn: configureWebPush
- });
- },
- listenFn: async () => {
- // Check if running in production mode (dist folder exists)
- 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]')} Starting in production mode...`);
- } else {
- console.log(`${c.info('[INFO]')} No production frontend build found; development mode expects Vite at http://${DISPLAY_HOST}:${VITE_PORT}`);
- }
- const boundPort = await listenWithPortFallback(server, Number(SERVER_PORT), HOST);
- if (boundPort === null) {
- console.error(`${c.warn('[ERROR]')} Could not bind a port after ${PORT_FALLBACK_ATTEMPTS} attempts (preferred ${SERVER_PORT}). All tried ports were in use. Set SERVER_PORT to a free port and retry.`);
- process.exit(1);
- }
- // Sync the actually-bound port back to the env so other modules
- // that self-reference SERVER_PORT (e.g. routes/taskmaster.js) hit
- // the right port after a fallback.
- process.env.SERVER_PORT = String(boundPort);
- {
- 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 + ':' + boundPort)}`);
- console.log(`${c.info('[INFO]')} Installed at: ${c.dim(appInstallPath)}`);
- console.log(`${c.tip('[TIP]')} Run "pilotdeck status" for full configuration details`);
- console.log('');
- // Desktop shell loads the UI inside Electron; CLI/dev can opt in to
- // auto-open. PILOTDECK_DESKTOP=1 is set by apps/desktop server-manager.
- 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}:${boundPort}`;
- const openCmd = process.platform === 'darwin' ? 'open'
- : process.platform === 'win32' ? 'start'
- : 'xdg-open';
- exec(`${openCmd} "${serverUrl}"`, () => {});
- }
- // Start watching the projects folder for changes
- await setupProjectsWatcher();
- await ensurePilotDeckProxyRunning();
- // Start background memory scheduler for auto index/dream.
- startMemoryScheduler();
- // Start server-side plugin processes for enabled plugins
- startEnabledPluginServers().catch(err => {
- console.error('[Plugins] Error during startup:', err.message);
- });
- // Hot-reload watcher: external edits to ~/.pilotdeck/pilotdeck.yaml
- // (vim, Cursor, another process) trigger a validate+reload and push
- // a "config:reloaded" event to every connected WebSocket client.
- 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();
- // helpers were retired with the four-provider runtime.
- try {
- const { shutdownGlobalChrome, stopChromeHealthCheck } = await import('./utils/globalChrome.js');
- stopChromeHealthCheck();
- shutdownGlobalChrome();
- } catch { /* Chrome may not have been started */ }
- // PilotDeck cron is owned by `pilotdeck server` and shuts
- // down with it; ui/server never spawns its own daemon.
- } 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();
|