claude-code-web
Version:
Web-based interface for Claude Code CLI accessible via browser
1,289 lines (1,122 loc) • 41.5 kB
JavaScript
const express = require('express');
const http = require('http');
const https = require('https');
const fs = require('fs');
const path = require('path');
const WebSocket = require('ws');
const os = require('os');
const cors = require('cors');
const { v4: uuidv4 } = require('uuid');
const ClaudeBridge = require('./claude-bridge');
const CodexBridge = require('./codex-bridge');
const SessionStore = require('./utils/session-store');
const UsageReader = require('./usage-reader');
const CodexUsageReader = require('./codex-usage-reader');
const UsageAnalytics = require('./usage-analytics');
class ClaudeCodeWebServer {
constructor(options = {}) {
this.port = options.port || 32352;
this.auth = options.auth;
this.noAuth = options.noAuth || false;
this.dev = options.dev || false;
this.useHttps = options.https || false;
this.certFile = options.cert;
this.keyFile = options.key;
this.folderMode = options.folderMode !== false; // Default to true
this.selectedWorkingDir = null;
this.baseFolder = process.cwd(); // The folder where the app runs from
// Session duration in hours (default to 5 hours from first message)
this.sessionDurationHours = parseFloat(process.env.CLAUDE_SESSION_HOURS || options.sessionHours || 5);
this.app = express();
this.claudeSessions = new Map(); // Persistent sessions (claude or codex)
this.webSocketConnections = new Map(); // Maps WebSocket connection ID to session info
this.claudeBridge = new ClaudeBridge();
this.codexBridge = new CodexBridge();
this.sessionStore = new SessionStore();
this.usageReader = new UsageReader(this.sessionDurationHours);
this.codexUsageReader = new CodexUsageReader();
this.usageAnalytics = new UsageAnalytics({
sessionDurationHours: this.sessionDurationHours,
plan: options.plan || process.env.CLAUDE_PLAN || 'max20',
customCostLimit: parseFloat(process.env.CLAUDE_COST_LIMIT || options.customCostLimit || 50.00)
});
this.autoSaveInterval = null;
this.startTime = Date.now(); // Track server start time
// Commands directory (in user's home)
this.commandsDir = path.join(os.homedir(), '.claude-code-web', 'commands');
this.setupExpress();
this.loadPersistedSessions();
this.setupAutoSave();
}
async loadPersistedSessions() {
try {
const sessions = await this.sessionStore.loadSessions();
this.claudeSessions = sessions;
if (sessions.size > 0) {
console.log(`Loaded ${sessions.size} persisted sessions`);
}
} catch (error) {
console.error('Failed to load persisted sessions:', error);
}
}
setupAutoSave() {
// Auto-save sessions every 30 seconds
this.autoSaveInterval = setInterval(() => {
this.saveSessionsToDisk();
}, 30000);
// Also save on process exit
process.on('SIGINT', () => this.handleShutdown());
process.on('SIGTERM', () => this.handleShutdown());
process.on('beforeExit', () => this.saveSessionsToDisk());
}
async saveSessionsToDisk() {
if (this.claudeSessions.size > 0) {
await this.sessionStore.saveSessions(this.claudeSessions);
}
}
async handleShutdown() {
console.log('\nGracefully shutting down...');
await this.saveSessionsToDisk();
if (this.autoSaveInterval) {
clearInterval(this.autoSaveInterval);
}
this.close();
process.exit(0);
}
isPathWithinBase(targetPath) {
try {
const resolvedTarget = path.resolve(targetPath);
const resolvedBase = path.resolve(this.baseFolder);
return resolvedTarget.startsWith(resolvedBase);
} catch (error) {
return false;
}
}
validatePath(targetPath) {
if (!targetPath) {
return { valid: false, error: 'Path is required' };
}
const resolvedPath = path.resolve(targetPath);
if (!this.isPathWithinBase(resolvedPath)) {
return {
valid: false,
error: 'Access denied: Path is outside the allowed directory'
};
}
return { valid: true, path: resolvedPath };
}
setupExpress() {
this.app.use(cors());
this.app.use(express.json());
// Serve manifest.json with correct MIME type
this.app.get('/manifest.json', (req, res) => {
res.setHeader('Content-Type', 'application/manifest+json');
res.sendFile(path.join(__dirname, 'public', 'manifest.json'));
});
this.app.use(express.static(path.join(__dirname, 'public')));
// PWA Icon routes - generate icons dynamically
const iconSizes = [16, 32, 144, 180, 192, 512];
iconSizes.forEach(size => {
this.app.get(`/icon-${size}.png`, (req, res) => {
const svg = `
<svg width="${size}" height="${size}" viewBox="0 0 ${size} ${size}" xmlns="http://www.w3.org/2000/svg">
<rect width="${size}" height="${size}" fill="#1a1a1a" rx="${size * 0.1}"/>
<text x="50%" y="50%" dominant-baseline="middle" text-anchor="middle"
font-family="monospace" font-size="${size * 0.4}px" font-weight="bold" fill="#ff6b00">
CC
</text>
</svg>
`;
const svgBuffer = Buffer.from(svg);
res.setHeader('Content-Type', 'image/svg+xml');
res.setHeader('Cache-Control', 'public, max-age=31536000');
res.send(svgBuffer);
});
});
// Auth status endpoint - always accessible
this.app.get('/auth-status', (req, res) => {
res.json({
authRequired: !this.noAuth && !!this.auth,
authenticated: false
});
});
// Auth verify endpoint - check if token is valid
this.app.post('/auth-verify', (req, res) => {
if (this.noAuth || !this.auth) {
return res.json({ valid: true }); // No auth required
}
const { token } = req.body;
const valid = token === this.auth;
if (valid) {
res.json({ valid: true });
} else {
res.status(401).json({ valid: false, error: 'Invalid token' });
}
});
if (!this.noAuth && this.auth) {
this.app.use((req, res, next) => {
const token = req.headers.authorization || req.query.token;
if (token !== `Bearer ${this.auth}` && token !== this.auth) {
return res.status(401).json({ error: 'Unauthorized' });
}
next();
});
}
// Commands API: list available markdown commands from ~/.claude-code-web/commands
this.app.get('/api/commands/list', (req, res) => {
try {
const dir = this.commandsDir;
const items = [];
const walk = (baseDir, rel = '') => {
const full = path.join(baseDir, rel);
if (!fs.existsSync(full)) return;
const entries = fs.readdirSync(full, { withFileTypes: true });
for (const entry of entries) {
if (entry.name.startsWith('.')) continue; // skip hidden
const entryRel = path.join(rel, entry.name);
const entryFull = path.join(baseDir, entryRel);
if (entry.isDirectory()) {
walk(baseDir, entryRel);
} else if (/\.md$/i.test(entry.name)) {
// Build label: strip .md, replace slashes with spaces, capitalize words
const withoutExt = entryRel.replace(/\.md$/i, '');
const label = withoutExt
.replace(/[\\/]+/g, ' ')
.replace(/[-_]+/g, ' ')
.split(' ')
.filter(Boolean)
.map(w => w.charAt(0).toUpperCase() + w.slice(1))
.join(' ');
items.push({ path: withoutExt.replace(/\\/g, '/'), label });
}
}
};
walk(dir);
res.json({ items });
} catch (error) {
res.status(500).json({ error: 'Failed to list commands', message: error.message });
}
});
// Commands API: get file content by relative path (within commandsDir)
this.app.get('/api/commands/content', (req, res) => {
try {
const rel = (req.query.p || '').toString();
if (!rel) return res.status(400).json({ error: 'Missing path parameter' });
// Only allow .md files
const safeRel = rel.replace(/^[\\/]+/, '') + '.md';
const fullPath = path.resolve(this.commandsDir, safeRel);
if (!fullPath.startsWith(path.resolve(this.commandsDir))) {
return res.status(403).json({ error: 'Access denied' });
}
if (!fs.existsSync(fullPath)) {
return res.status(404).json({ error: 'Not found' });
}
const content = fs.readFileSync(fullPath, 'utf8');
res.type('text/markdown').send(content);
} catch (error) {
res.status(500).json({ error: 'Failed to read command', message: error.message });
}
});
this.app.get('/api/health', (req, res) => {
res.json({
status: 'ok',
claudeSessions: this.claudeSessions.size,
activeConnections: this.webSocketConnections.size
});
});
// Get session persistence info
this.app.get('/api/sessions/persistence', async (req, res) => {
const metadata = await this.sessionStore.getSessionMetadata();
res.json({
...metadata,
currentSessions: this.claudeSessions.size,
autoSaveEnabled: true,
autoSaveInterval: 30000
});
});
// List all Claude sessions
this.app.get('/api/sessions/list', (req, res) => {
const sessionList = Array.from(this.claudeSessions.entries()).map(([id, session]) => ({
id,
name: session.name,
created: session.created,
active: session.active,
workingDir: session.workingDir,
connectedClients: session.connections.size,
lastActivity: session.lastActivity
}));
res.json({ sessions: sessionList });
});
// Create a new session
this.app.post('/api/sessions/create', (req, res) => {
const { name, workingDir } = req.body;
const sessionId = uuidv4();
// Validate working directory if provided
let validWorkingDir = this.baseFolder;
if (workingDir) {
const validation = this.validatePath(workingDir);
if (!validation.valid) {
return res.status(403).json({
error: validation.error,
message: 'Cannot create session with working directory outside the allowed area'
});
}
validWorkingDir = validation.path;
} else if (this.selectedWorkingDir) {
validWorkingDir = this.selectedWorkingDir;
}
const session = {
id: sessionId,
name: name || `Session ${new Date().toLocaleString()}`,
created: new Date(),
lastActivity: new Date(),
active: false,
agent: null, // 'claude' | 'codex' when started
workingDir: validWorkingDir,
connections: new Set(),
outputBuffer: [],
maxBufferSize: 1000
};
this.claudeSessions.set(sessionId, session);
// Save sessions after creating new one
this.saveSessionsToDisk();
if (this.dev) {
console.log(`Created new session: ${sessionId} (${session.name})`);
}
res.json({
success: true,
sessionId,
session: {
id: sessionId,
name: session.name,
workingDir: session.workingDir
}
});
});
// Get session details
this.app.get('/api/sessions/:sessionId', (req, res) => {
const session = this.claudeSessions.get(req.params.sessionId);
if (!session) {
return res.status(404).json({ error: 'Session not found' });
}
res.json({
id: session.id,
name: session.name,
created: session.created,
active: session.active,
workingDir: session.workingDir,
connectedClients: session.connections.size,
lastActivity: session.lastActivity
});
});
// Delete a Claude session
this.app.delete('/api/sessions/:sessionId', (req, res) => {
const sessionId = req.params.sessionId;
const session = this.claudeSessions.get(sessionId);
if (!session) {
return res.status(404).json({ error: 'Session not found' });
}
// Stop Claude process if running
if (session.active) {
this.claudeBridge.stopSession(sessionId);
}
// Disconnect all WebSocket connections for this session
session.connections.forEach(wsId => {
const wsInfo = this.webSocketConnections.get(wsId);
if (wsInfo && wsInfo.ws.readyState === WebSocket.OPEN) {
wsInfo.ws.send(JSON.stringify({
type: 'session_deleted',
message: 'Session has been deleted'
}));
wsInfo.ws.close();
}
});
this.claudeSessions.delete(sessionId);
// Save sessions after deletion
this.saveSessionsToDisk();
res.json({ success: true, message: 'Session deleted' });
});
this.app.get('/api/config', (req, res) => {
res.json({
folderMode: this.folderMode,
selectedWorkingDir: this.selectedWorkingDir,
baseFolder: this.baseFolder
});
});
this.app.post('/api/create-folder', (req, res) => {
const { parentPath, folderName } = req.body;
if (!folderName || !folderName.trim()) {
return res.status(400).json({ message: 'Folder name is required' });
}
if (folderName.includes('/') || folderName.includes('\\')) {
return res.status(400).json({ message: 'Invalid folder name' });
}
const basePath = parentPath || this.baseFolder;
const fullPath = path.join(basePath, folderName);
// Validate that the parent path and resulting path are within base folder
const parentValidation = this.validatePath(basePath);
if (!parentValidation.valid) {
return res.status(403).json({
message: 'Cannot create folder outside the allowed area'
});
}
const fullValidation = this.validatePath(fullPath);
if (!fullValidation.valid) {
return res.status(403).json({
message: 'Cannot create folder outside the allowed area'
});
}
try {
// Check if folder already exists
if (fs.existsSync(fullValidation.path)) {
return res.status(409).json({ message: 'Folder already exists' });
}
// Create the folder
fs.mkdirSync(fullValidation.path, { recursive: true });
res.json({
success: true,
path: fullValidation.path,
message: `Folder "${folderName}" created successfully`
});
} catch (error) {
console.error('Failed to create folder:', error);
res.status(500).json({
message: `Failed to create folder: ${error.message}`
});
}
});
this.app.get('/api/folders', (req, res) => {
const requestedPath = req.query.path || this.baseFolder;
// Validate the requested path
const validation = this.validatePath(requestedPath);
if (!validation.valid) {
return res.status(403).json({
error: validation.error,
message: 'Access to this directory is not allowed'
});
}
const currentPath = validation.path;
try {
const items = fs.readdirSync(currentPath, { withFileTypes: true });
const folders = items
.filter(item => item.isDirectory())
.filter(item => !item.name.startsWith('.') || req.query.showHidden === 'true')
.map(item => ({
name: item.name,
path: path.join(currentPath, item.name),
isDirectory: true
}))
.sort((a, b) => a.name.localeCompare(b.name));
const parentDir = path.dirname(currentPath);
const canGoUp = this.isPathWithinBase(parentDir) && parentDir !== currentPath;
res.json({
currentPath,
parentPath: canGoUp ? parentDir : null,
folders,
home: this.baseFolder,
baseFolder: this.baseFolder
});
} catch (error) {
res.status(403).json({
error: 'Cannot access directory',
message: error.message
});
}
});
this.app.post('/api/set-working-dir', (req, res) => {
const { path: selectedPath } = req.body;
// Validate the path
const validation = this.validatePath(selectedPath);
if (!validation.valid) {
return res.status(403).json({
error: validation.error,
message: 'Cannot set working directory outside the allowed area'
});
}
const validatedPath = validation.path;
try {
if (!fs.existsSync(validatedPath)) {
return res.status(404).json({ error: 'Directory does not exist' });
}
const stats = fs.statSync(validatedPath);
if (!stats.isDirectory()) {
return res.status(400).json({ error: 'Path is not a directory' });
}
this.selectedWorkingDir = validatedPath;
res.json({
success: true,
workingDir: this.selectedWorkingDir
});
} catch (error) {
res.status(500).json({
error: 'Failed to set working directory',
message: error.message
});
}
});
this.app.post('/api/folders/select', (req, res) => {
try {
const { path: selectedPath } = req.body;
// Validate the path
const validation = this.validatePath(selectedPath);
if (!validation.valid) {
return res.status(403).json({
error: validation.error,
message: 'Cannot select directory outside the allowed area'
});
}
const validatedPath = validation.path;
// Verify the path exists and is a directory
if (!fs.existsSync(validatedPath) || !fs.statSync(validatedPath).isDirectory()) {
return res.status(400).json({
error: 'Invalid directory path'
});
}
// Store the selected working directory
this.selectedWorkingDir = validatedPath;
res.json({
success: true,
workingDir: this.selectedWorkingDir
});
} catch (error) {
res.status(500).json({
error: 'Failed to set working directory',
message: error.message
});
}
});
this.app.post('/api/close-session', (req, res) => {
try {
// Clear the selected working directory
this.selectedWorkingDir = null;
res.json({
success: true,
message: 'Working directory cleared'
});
} catch (error) {
res.status(500).json({
error: 'Failed to clear working directory',
message: error.message
});
}
});
this.app.get('/', (req, res) => {
res.sendFile(path.join(__dirname, 'public', 'index.html'));
});
}
async start() {
let server;
if (this.useHttps) {
if (!this.certFile || !this.keyFile) {
throw new Error('HTTPS requires both --cert and --key options');
}
const cert = fs.readFileSync(this.certFile);
const key = fs.readFileSync(this.keyFile);
server = https.createServer({ cert, key }, this.app);
} else {
server = http.createServer(this.app);
}
this.wss = new WebSocket.Server({
server,
verifyClient: (info) => {
if (!this.noAuth && this.auth) {
const url = new URL(info.req.url, 'ws://localhost');
const token = url.searchParams.get('token');
return token === this.auth;
}
return true;
}
});
this.wss.on('connection', (ws, req) => {
this.handleWebSocketConnection(ws, req);
});
return new Promise((resolve, reject) => {
server.listen(this.port, (err) => {
if (err) {
reject(err);
} else {
this.server = server;
resolve(server);
}
});
});
}
handleWebSocketConnection(ws, req) {
const wsId = uuidv4(); // Unique ID for this WebSocket connection
const url = new URL(req.url, `ws://localhost`);
const claudeSessionId = url.searchParams.get('sessionId');
if (this.dev) {
console.log(`New WebSocket connection: ${wsId}`);
if (claudeSessionId) {
console.log(`Joining Claude session: ${claudeSessionId}`);
}
}
// Store WebSocket connection info
const wsInfo = {
id: wsId,
ws,
claudeSessionId: null,
created: new Date()
};
this.webSocketConnections.set(wsId, wsInfo);
ws.on('message', async (message) => {
try {
const data = JSON.parse(message);
await this.handleMessage(wsId, data);
} catch (error) {
if (this.dev) {
console.error('Error handling message:', error);
}
this.sendToWebSocket(ws, {
type: 'error',
message: 'Failed to process message'
});
}
});
ws.on('close', () => {
if (this.dev) {
console.log(`WebSocket connection closed: ${wsId}`);
}
this.cleanupWebSocketConnection(wsId);
});
ws.on('error', (error) => {
if (this.dev) {
console.error(`WebSocket error for connection ${wsId}:`, error);
}
this.cleanupWebSocketConnection(wsId);
});
// Send initial connection message
this.sendToWebSocket(ws, {
type: 'connected',
connectionId: wsId
});
// If sessionId provided, auto-join that session
if (claudeSessionId && this.claudeSessions.has(claudeSessionId)) {
this.joinClaudeSession(wsId, claudeSessionId);
}
}
async handleMessage(wsId, data) {
const wsInfo = this.webSocketConnections.get(wsId);
if (!wsInfo) return;
switch (data.type) {
case 'create_session':
await this.createAndJoinSession(wsId, data.name, data.workingDir);
break;
case 'join_session':
await this.joinClaudeSession(wsId, data.sessionId);
break;
case 'leave_session':
await this.leaveClaudeSession(wsId);
break;
case 'start_claude':
await this.startClaude(wsId, data.options || {});
break;
case 'start_codex':
await this.startCodex(wsId, data.options || {});
break;
case 'input':
if (wsInfo.claudeSessionId) {
// Verify the session exists and the WebSocket is part of it
const session = this.claudeSessions.get(wsInfo.claudeSessionId);
if (session && session.connections.has(wsId)) {
// Only send if an agent is running in this session
if (session.active && session.agent) {
try {
if (session.agent === 'codex') {
await this.codexBridge.sendInput(wsInfo.claudeSessionId, data.data);
} else {
await this.claudeBridge.sendInput(wsInfo.claudeSessionId, data.data);
}
} catch (error) {
if (this.dev) {
console.error(`Failed to send input to session ${wsInfo.claudeSessionId}:`, error.message);
}
this.sendToWebSocket(wsInfo.ws, {
type: 'error',
message: 'Agent is not running in this session. Please start an agent first.'
});
}
} else {
this.sendToWebSocket(wsInfo.ws, {
type: 'info',
message: 'No agent is running. Choose an option to start.'
});
}
}
}
break;
case 'resize':
if (wsInfo.claudeSessionId) {
// Verify the session exists and the WebSocket is part of it
const session = this.claudeSessions.get(wsInfo.claudeSessionId);
if (session && session.connections.has(wsId)) {
// Only resize if an agent is actually running
if (session.active && session.agent) {
try {
if (session.agent === 'codex') {
await this.codexBridge.resize(wsInfo.claudeSessionId, data.cols, data.rows);
} else {
await this.claudeBridge.resize(wsInfo.claudeSessionId, data.cols, data.rows);
}
} catch (error) {
if (this.dev) {
console.log(`Resize ignored - agent not active in session ${wsInfo.claudeSessionId}`);
}
}
}
}
}
break;
case 'stop':
if (wsInfo.claudeSessionId) {
const session = this.claudeSessions.get(wsInfo.claudeSessionId);
if (session?.agent === 'codex') {
await this.stopCodex(wsInfo.claudeSessionId);
} else {
await this.stopClaude(wsInfo.claudeSessionId);
}
}
break;
case 'ping':
this.sendToWebSocket(wsInfo.ws, { type: 'pong' });
break;
case 'get_usage':
this.handleGetUsage(wsInfo);
break;
default:
if (this.dev) {
console.log(`Unknown message type: ${data.type}`);
}
}
}
async createAndJoinSession(wsId, name, workingDir) {
const wsInfo = this.webSocketConnections.get(wsId);
if (!wsInfo) return;
// Validate working directory if provided
let validWorkingDir = this.baseFolder;
if (workingDir) {
const validation = this.validatePath(workingDir);
if (!validation.valid) {
this.sendToWebSocket(wsInfo.ws, {
type: 'error',
message: 'Cannot create session with working directory outside the allowed area'
});
return;
}
validWorkingDir = validation.path;
} else if (this.selectedWorkingDir) {
validWorkingDir = this.selectedWorkingDir;
}
// Create new Claude session
const sessionId = uuidv4();
const session = {
id: sessionId,
name: name || `Session ${new Date().toLocaleString()}`,
created: new Date(),
lastActivity: new Date(),
active: false,
workingDir: validWorkingDir,
connections: new Set([wsId]),
outputBuffer: [],
sessionStartTime: null, // Will be set when Claude starts
sessionUsage: {
requests: 0,
inputTokens: 0,
outputTokens: 0,
cacheTokens: 0,
totalCost: 0,
models: {}
},
maxBufferSize: 1000
};
this.claudeSessions.set(sessionId, session);
wsInfo.claudeSessionId = sessionId;
// Save sessions after creating new one
this.saveSessionsToDisk();
this.sendToWebSocket(wsInfo.ws, {
type: 'session_created',
sessionId,
sessionName: session.name,
workingDir: session.workingDir
});
}
async joinClaudeSession(wsId, claudeSessionId) {
const wsInfo = this.webSocketConnections.get(wsId);
if (!wsInfo) return;
const session = this.claudeSessions.get(claudeSessionId);
if (!session) {
this.sendToWebSocket(wsInfo.ws, {
type: 'error',
message: 'Session not found'
});
return;
}
// Leave current session if any
if (wsInfo.claudeSessionId) {
await this.leaveClaudeSession(wsId);
}
// Join new session
wsInfo.claudeSessionId = claudeSessionId;
session.connections.add(wsId);
session.lastActivity = new Date();
session.lastAccessed = Date.now();
// Send session info and replay buffer
this.sendToWebSocket(wsInfo.ws, {
type: 'session_joined',
sessionId: claudeSessionId,
sessionName: session.name,
workingDir: session.workingDir,
active: session.active,
outputBuffer: session.outputBuffer.slice(-200) // Send last 200 lines
});
if (this.dev) {
console.log(`WebSocket ${wsId} joined Claude session ${claudeSessionId}`);
}
}
async leaveClaudeSession(wsId) {
const wsInfo = this.webSocketConnections.get(wsId);
if (!wsInfo || !wsInfo.claudeSessionId) return;
const session = this.claudeSessions.get(wsInfo.claudeSessionId);
if (session) {
session.connections.delete(wsId);
session.lastActivity = new Date();
}
wsInfo.claudeSessionId = null;
this.sendToWebSocket(wsInfo.ws, {
type: 'session_left'
});
}
async startClaude(wsId, options) {
const wsInfo = this.webSocketConnections.get(wsId);
if (!wsInfo || !wsInfo.claudeSessionId) {
this.sendToWebSocket(wsInfo.ws, {
type: 'error',
message: 'No session joined'
});
return;
}
const session = this.claudeSessions.get(wsInfo.claudeSessionId);
if (!session) return;
if (session.active) {
this.sendToWebSocket(wsInfo.ws, {
type: 'error',
message: 'An agent is already running in this session'
});
return;
}
// Capture the session ID to avoid closure issues
const sessionId = wsInfo.claudeSessionId;
try {
await this.claudeBridge.startSession(sessionId, {
workingDir: session.workingDir,
onOutput: (data) => {
// Get the current session again to ensure we have the right reference
const currentSession = this.claudeSessions.get(sessionId);
if (!currentSession) return;
// Add to buffer
currentSession.outputBuffer.push(data);
if (currentSession.outputBuffer.length > currentSession.maxBufferSize) {
currentSession.outputBuffer.shift();
}
// Broadcast to all connected clients for THIS specific session
this.broadcastToSession(sessionId, {
type: 'output',
data
});
},
onExit: (code, signal) => {
const currentSession = this.claudeSessions.get(sessionId);
if (currentSession) {
currentSession.active = false;
}
this.broadcastToSession(sessionId, {
type: 'exit',
code,
signal
});
},
onError: (error) => {
const currentSession = this.claudeSessions.get(sessionId);
if (currentSession) {
currentSession.active = false;
}
this.broadcastToSession(sessionId, {
type: 'error',
message: error.message
});
},
...options
});
session.active = true;
session.agent = 'claude';
session.lastActivity = new Date();
// Set session start time if this is the first time Claude is started in this session
if (!session.sessionStartTime) {
session.sessionStartTime = new Date();
}
this.broadcastToSession(sessionId, {
type: 'claude_started',
sessionId: sessionId
});
} catch (error) {
if (this.dev) {
console.error(`Error starting Claude in session ${wsInfo.claudeSessionId}:`, error);
}
this.sendToWebSocket(wsInfo.ws, {
type: 'error',
message: `Failed to start Claude Code: ${error.message}`
});
}
}
async stopClaude(claudeSessionId) {
const session = this.claudeSessions.get(claudeSessionId);
if (!session || !session.active) return;
await this.claudeBridge.stopSession(claudeSessionId);
session.active = false;
session.agent = null;
session.lastActivity = new Date();
this.broadcastToSession(claudeSessionId, {
type: 'claude_stopped'
});
}
async startCodex(wsId, options) {
const wsInfo = this.webSocketConnections.get(wsId);
if (!wsInfo || !wsInfo.claudeSessionId) {
this.sendToWebSocket(wsInfo.ws, {
type: 'error',
message: 'No session joined'
});
return;
}
const session = this.claudeSessions.get(wsInfo.claudeSessionId);
if (!session) return;
if (session.active) {
this.sendToWebSocket(wsInfo.ws, {
type: 'error',
message: 'An agent is already running in this session'
});
return;
}
const sessionId = wsInfo.claudeSessionId;
try {
await this.codexBridge.startSession(sessionId, {
workingDir: session.workingDir,
onOutput: (data) => {
const currentSession = this.claudeSessions.get(sessionId);
if (!currentSession) return;
currentSession.outputBuffer.push(data);
if (currentSession.outputBuffer.length > currentSession.maxBufferSize) {
currentSession.outputBuffer.shift();
}
this.broadcastToSession(sessionId, { type: 'output', data });
},
onExit: (code, signal) => {
const currentSession = this.claudeSessions.get(sessionId);
if (currentSession) {
currentSession.active = false;
currentSession.agent = null;
}
this.broadcastToSession(sessionId, { type: 'exit', code, signal });
},
onError: (error) => {
const currentSession = this.claudeSessions.get(sessionId);
if (currentSession) {
currentSession.active = false;
currentSession.agent = null;
}
this.broadcastToSession(sessionId, { type: 'error', message: error.message });
},
...options
});
session.active = true;
session.agent = 'codex';
session.lastActivity = new Date();
if (!session.sessionStartTime) {
session.sessionStartTime = new Date();
}
this.broadcastToSession(sessionId, {
type: 'codex_started',
sessionId: sessionId
});
} catch (error) {
if (this.dev) {
console.error(`Error starting Codex in session ${wsInfo.claudeSessionId}:`, error);
}
this.sendToWebSocket(wsInfo.ws, {
type: 'error',
message: `Failed to start Codex Code: ${error.message}`
});
}
}
async stopCodex(sessionId) {
const session = this.claudeSessions.get(sessionId);
if (!session || !session.active) return;
await this.codexBridge.stopSession(sessionId);
session.active = false;
session.agent = null;
session.lastActivity = new Date();
this.broadcastToSession(sessionId, { type: 'codex_stopped' });
}
sendToWebSocket(ws, data) {
if (ws && ws.readyState === WebSocket.OPEN) {
ws.send(JSON.stringify(data));
}
}
broadcastToSession(claudeSessionId, data) {
const session = this.claudeSessions.get(claudeSessionId);
if (!session) return;
session.connections.forEach(wsId => {
const wsInfo = this.webSocketConnections.get(wsId);
// Double-check that this WebSocket is actually part of this session
if (wsInfo &&
wsInfo.claudeSessionId === claudeSessionId &&
wsInfo.ws.readyState === WebSocket.OPEN) {
this.sendToWebSocket(wsInfo.ws, data);
}
});
}
cleanupWebSocketConnection(wsId) {
const wsInfo = this.webSocketConnections.get(wsId);
if (!wsInfo) return;
// Remove from Claude session if joined
if (wsInfo.claudeSessionId) {
const session = this.claudeSessions.get(wsInfo.claudeSessionId);
if (session) {
session.connections.delete(wsId);
session.lastActivity = new Date();
// Don't stop Claude if other connections exist
if (session.connections.size === 0 && this.dev) {
console.log(`No more connections to session ${wsInfo.claudeSessionId}`);
}
}
}
this.webSocketConnections.delete(wsId);
}
close() {
// Save sessions before closing
this.saveSessionsToDisk();
// Clear auto-save interval
if (this.autoSaveInterval) {
clearInterval(this.autoSaveInterval);
}
if (this.wss) {
this.wss.close();
}
if (this.server) {
this.server.close();
}
// Stop all Claude sessions
for (const [sessionId, session] of this.claudeSessions.entries()) {
if (session.active) {
this.claudeBridge.stopSession(sessionId);
}
}
// Clear all data
this.claudeSessions.clear();
this.webSocketConnections.clear();
}
async handleGetUsage(wsInfo) {
try {
// Get usage stats for the current Claude session window
const currentSessionStats = await this.usageReader.getCurrentSessionStats();
// Get burn rate calculations
const burnRateData = await this.usageReader.calculateBurnRate(60);
// Get overlapping sessions
const overlappingSessions = await this.usageReader.detectOverlappingSessions();
// Get 24h stats for additional context
const dailyStats = await this.usageReader.getUsageStats(24);
// Get Codex usage over the same rolling window for side-by-side display
let codexStats = null;
try {
codexStats = await this.codexUsageReader.getUsageStats(this.sessionDurationHours);
} catch (_) {
codexStats = null;
}
// Update analytics with current session data
if (currentSessionStats && currentSessionStats.sessionStartTime) {
// Start tracking this session in analytics
this.usageAnalytics.startSession(
currentSessionStats.sessionId,
new Date(currentSessionStats.sessionStartTime)
);
// Add usage data to analytics
if (currentSessionStats.totalTokens > 0) {
this.usageAnalytics.addUsageData({
tokens: currentSessionStats.totalTokens,
inputTokens: currentSessionStats.inputTokens,
outputTokens: currentSessionStats.outputTokens,
cacheCreationTokens: currentSessionStats.cacheCreationTokens,
cacheReadTokens: currentSessionStats.cacheReadTokens,
cost: currentSessionStats.totalCost,
model: Object.keys(currentSessionStats.models)[0] || 'unknown',
sessionId: currentSessionStats.sessionId
});
}
}
// Get comprehensive analytics
const analytics = this.usageAnalytics.getAnalytics();
// Calculate session timer if we have a current session
let sessionTimer = null;
if (currentSessionStats && currentSessionStats.sessionStartTime) {
// Session starts at the hour, not the exact minute
const startTime = new Date(currentSessionStats.sessionStartTime);
const now = new Date();
const elapsedMs = now - startTime;
// Calculate remaining time in session window (5 hours from first message)
const sessionDurationMs = this.sessionDurationHours * 60 * 60 * 1000;
const remainingMs = Math.max(0, sessionDurationMs - elapsedMs);
const hours = Math.floor(elapsedMs / (1000 * 60 * 60));
const minutes = Math.floor((elapsedMs % (1000 * 60 * 60)) / (1000 * 60));
const seconds = Math.floor((elapsedMs % (1000 * 60)) / 1000);
const remainingHours = Math.floor(remainingMs / (1000 * 60 * 60));
const remainingMinutes = Math.floor((remainingMs % (1000 * 60 * 60)) / (1000 * 60));
sessionTimer = {
startTime: currentSessionStats.sessionStartTime,
elapsed: elapsedMs,
remaining: remainingMs,
formatted: `${String(hours).padStart(2, '0')}:${String(minutes).padStart(2, '0')}:${String(seconds).padStart(2, '0')}`,
remainingFormatted: `${String(remainingHours).padStart(2, '0')}:${String(remainingMinutes).padStart(2, '0')}`,
hours,
minutes,
seconds,
remainingMs,
sessionDurationHours: this.sessionDurationHours,
sessionNumber: currentSessionStats.sessionNumber || 1, // Add session number
isExpired: remainingMs === 0,
burnRate: burnRateData.rate,
burnRateConfidence: burnRateData.confidence,
depletionTime: analytics.predictions.depletionTime,
depletionConfidence: analytics.predictions.confidence
};
}
this.sendToWebSocket(wsInfo.ws, {
type: 'usage_update',
sessionStats: currentSessionStats || {
requests: 0,
totalTokens: 0,
totalCost: 0,
message: 'No active Claude session'
},
dailyStats: dailyStats,
sessionTimer: sessionTimer,
analytics: analytics,
burnRate: burnRateData,
overlappingSessions: overlappingSessions.length,
plan: this.usageAnalytics.currentPlan,
limits: this.usageAnalytics.planLimits[this.usageAnalytics.currentPlan],
codexStats
});
} catch (error) {
console.error('Error getting usage stats:', error);
this.sendToWebSocket(wsInfo.ws, {
type: 'error',
message: 'Failed to retrieve usage statistics'
});
}
}
}
async function startServer(options) {
const server = new ClaudeCodeWebServer(options);
return await server.start();
}
module.exports = { startServer, ClaudeCodeWebServer };