Fix project isolation: Make loadChatHistory respect active project sessions

- Modified loadChatHistory() to check for active project before fetching all sessions
- When active project exists, use project.sessions instead of fetching from API
- Added detailed console logging to debug session filtering
- This prevents ALL sessions from appearing in every project's sidebar

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
uroma
2026-01-22 14:43:05 +00:00
Unverified
parent b82837aa5f
commit 55aafbae9a
6463 changed files with 1115462 additions and 4486 deletions

507
routes/session-routes.js Normal file
View File

@@ -0,0 +1,507 @@
/**
* Session Routes
*
* RESTful API endpoints for managing Claude Code sessions.
* All routes are scoped to a specific session via :sessionId parameter.
*/
const express = require('express');
const eventBus = require('../services/event-bus');
const { validateSessionId, validateCommand, validateResponse, validateOperations } = require('../middleware/validation');
const router = express.Router();
// Middleware to ensure services are available
router.use((req, res, next) => {
if (!req.app.locals.claudeService) {
return res.status(500).json({
error: 'Service not available',
message: 'claudeService not initialized'
});
}
next();
});
/**
* Send command/prompt to session
* POST /api/session/:sessionId/prompt
*
* Sends a command or prompt to the Claude Code session.
* The response will be streamed via SSE to connected clients.
*
* Request:
* {
* "command": "ls -la",
* "context": { "optional": "context data" }
* }
*
* Response:
* {
* "success": true,
* "sessionId": "session-123",
* "message": "Command sent",
* "timestamp": 1234567890
* }
*/
router.post('/session/:sessionId/prompt', validateSessionId, validateCommand, async (req, res) => {
const { sessionId } = req.params;
const { command, context } = req.body;
console.log(`[SessionRoutes] Sending command to session ${sessionId}: ${command.substring(0, 100)}...`);
try {
// Send command to Claude service
await req.app.locals.claudeService.sendCommand(sessionId, command);
// Emit command-sent event
eventBus.emit('command-sent', {
sessionId,
command: command.substring(0, 100) + (command.length > 100 ? '...' : ''),
timestamp: Date.now()
});
// Return immediately (actual response comes via SSE)
res.json({
success: true,
sessionId,
message: 'Command sent',
timestamp: Date.now()
});
console.log(`[SessionRoutes] Command sent successfully to session ${sessionId}`);
} catch (error) {
console.error(`[SessionRoutes] Error sending command to session ${sessionId}:`, error);
// Emit error event
eventBus.emit('session-error', {
sessionId,
error: error.message,
code: 'COMMAND_SEND_ERROR',
recoverable: false
});
res.status(500).json({
error: 'Failed to send command',
message: error.message,
sessionId
});
}
});
/**
* Get session status
* GET /api/session/:sessionId/status
*
* Returns current status and information about the session.
*
* Response:
* {
* "sessionId": "session-123",
* "status": "running",
* "mode": "full",
* "createdAt": "2025-01-21T10:00:00.000Z",
* "lastActivity": "2025-01-21T10:05:00.000Z",
* "pid": 12345,
* "uptime": 300000
* }
*/
router.get('/session/:sessionId/status', validateSessionId, (req, res) => {
const { sessionId } = req.params;
try {
const session = req.app.locals.claudeService.getSession(sessionId);
if (!session) {
return res.status(404).json({
error: 'Session not found',
sessionId
});
}
const uptime = Date.now() - new Date(session.createdAt).getTime();
res.json({
sessionId: session.id,
status: session.status || 'unknown',
mode: session.mode || 'unknown',
createdAt: session.createdAt,
lastActivity: session.lastActivity || session.createdAt,
pid: process.pid,
uptime
});
console.log(`[SessionRoutes] Status retrieved for session ${sessionId}: ${session.status}`);
} catch (error) {
console.error(`[SessionRoutes] Error getting status for session ${sessionId}:`, error);
res.status(500).json({
error: 'Failed to get session status',
message: error.message,
sessionId
});
}
});
/**
* Get session context files
* GET /api/session/:sessionId/context
*
* Returns the files currently in the session's context.
*
* Response:
* {
* "sessionId": "session-123",
* "context": [
* { "path": "/path/to/file.js", "content": "..." }
* ],
* "timestamp": 1234567890
* }
*/
router.get('/session/:sessionId/context', validateSessionId, async (req, res) => {
const { sessionId } = req.params;
try {
// Assuming claudeService has a getContext method
// If not, this will need to be implemented
const context = await req.app.locals.claudeService.getSessionContext?.(sessionId) || { files: [] };
res.json({
sessionId,
context: context.files,
timestamp: Date.now()
});
console.log(`[SessionRoutes] Context retrieved for session ${sessionId}: ${context.files.length} files`);
} catch (error) {
console.error(`[SessionRoutes] Error getting context for session ${sessionId}:`, error);
res.status(500).json({
error: 'Failed to get session context',
message: error.message,
sessionId
});
}
});
/**
* Preview operations for session
* POST /api/session/:sessionId/operations/preview
*
* Parses a Claude response to detect file operations.
*
* Request:
* {
* "response": "I'll create a new file..."
* }
*
* Response:
* {
* "success": true,
* "operations": [
* { "type": "write", "path": "/path/to/file.js", "content": "..." }
* ],
* "count": 1
* }
*/
router.post('/session/:sessionId/operations/preview', validateSessionId, validateResponse, async (req, res) => {
const { sessionId } = req.params;
const { response } = req.body;
console.log(`[SessionRoutes] Previewing operations for session ${sessionId}`);
try {
const operations = await req.app.locals.claudeService.previewOperations(sessionId, response);
// Emit operations-detected event
eventBus.emit('operations-detected', {
sessionId,
operations,
response: response.substring(0, 200) + '...'
});
res.json({
success: true,
operations,
count: operations.length
});
console.log(`[SessionRoutes] Previewed ${operations.length} operations for session ${sessionId}`);
} catch (error) {
console.error(`[SessionRoutes] Error previewing operations for session ${sessionId}:`, error);
// Emit error event
eventBus.emit('session-error', {
sessionId,
error: error.message,
code: 'PREVIEW_ERROR',
recoverable: true
});
res.status(500).json({
error: 'Failed to preview operations',
message: error.message,
sessionId
});
}
});
/**
* Execute operations for session
* POST /api/session/:sessionId/operations/execute
*
* Executes confirmed file operations.
*
* Request:
* {
* "operations": [
* { "type": "write", "path": "/path/to/file.js", "content": "..." }
* ]
* }
*
* Response:
* {
* "success": true,
* "results": [
* { "success": true, "path": "/path/to/file.js", "operation": "write" }
* ],
* "executed": 1
* }
*/
router.post('/session/:sessionId/operations/execute', validateSessionId, validateOperations, async (req, res) => {
const { sessionId } = req.params;
const { operations } = req.body;
console.log(`[SessionRoutes] Executing ${operations.length} operations for session ${sessionId}`);
try {
const results = await req.app.locals.claudeService.executeOperations(sessionId, operations);
// Emit operations-executed event
eventBus.emit('operations-executed', {
sessionId,
results,
count: results.length
});
res.json({
success: true,
results,
executed: results.length
});
console.log(`[SessionRoutes] Executed ${results.length} operations for session ${sessionId}`);
} catch (error) {
console.error(`[SessionRoutes] Error executing operations for session ${sessionId}:`, error);
// Emit operations-error event
eventBus.emit('operations-error', {
sessionId,
error: error.message,
operations
});
res.status(500).json({
error: 'Failed to execute operations',
message: error.message,
sessionId
});
}
});
/**
* Delete/terminate session
* DELETE /api/session/:sessionId
*
* Terminates the Claude Code session and cleans up resources.
*
* Response:
* {
* "success": true,
* "message": "Session deleted",
* "sessionId": "session-123"
* }
*/
router.delete('/session/:sessionId', validateSessionId, async (req, res) => {
const { sessionId } = req.params;
console.log(`[SessionRoutes] Deleting session ${sessionId}`);
try {
await req.app.locals.claudeService.deleteSession(sessionId);
// Emit session-deleted event
eventBus.emit('session-deleted', {
sessionId,
timestamp: Date.now()
});
res.json({
success: true,
message: 'Session deleted',
sessionId
});
console.log(`[SessionRoutes] Session ${sessionId} deleted successfully`);
} catch (error) {
console.error(`[SessionRoutes] Error deleting session ${sessionId}:`, error);
res.status(500).json({
error: 'Failed to delete session',
message: error.message,
sessionId
});
}
});
/**
* Duplicate session
* POST /api/session/:sessionId/duplicate
*
* Creates a copy of the session with a new ID.
*
* Request:
* {
* "name": "Copy of session-123" // optional
* }
*
* Response:
* {
* "success": true,
* "newSessionId": "session-456",
* "originalSessionId": "session-123"
* }
*/
router.post('/session/:sessionId/duplicate', validateSessionId, async (req, res) => {
const { sessionId } = req.params;
const { name } = req.body;
console.log(`[SessionRoutes] Duplicating session ${sessionId}`);
try {
const result = await req.app.locals.claudeService.duplicateSession(sessionId, name);
// Emit session-created event
eventBus.emit('session-created', {
sessionId: result.newSessionId,
sourceSessionId: sessionId,
mode: 'duplicate'
});
res.json({
success: true,
newSessionId: result.newSessionId,
originalSessionId: sessionId,
name: result.name
});
console.log(`[SessionRoutes] Session ${sessionId} duplicated as ${result.newSessionId}`);
} catch (error) {
console.error(`[SessionRoutes] Error duplicating session ${sessionId}:`, error);
res.status(500).json({
error: 'Failed to duplicate session',
message: error.message,
sessionId
});
}
});
/**
* Fork session
* POST /api/session/:sessionId/fork
*
* Creates a new session with the same context but starts fresh.
*
* Response:
* {
* "success": true,
* "newSessionId": "session-789",
* "forkedFrom": "session-123"
* }
*/
router.post('/session/:sessionId/fork', validateSessionId, async (req, res) => {
const { sessionId } = req.params;
console.log(`[SessionRoutes] Forking session ${sessionId}`);
try {
const result = await req.app.locals.claudeService.forkSession(sessionId);
// Emit session-created event
eventBus.emit('session-created', {
sessionId: result.newSessionId,
sourceSessionId: sessionId,
mode: 'fork'
});
res.json({
success: true,
newSessionId: result.newSessionId,
forkedFrom: sessionId
});
console.log(`[SessionRoutes] Session ${sessionId} forked as ${result.newSessionId}`);
} catch (error) {
console.error(`[SessionRoutes] Error forking session ${sessionId}:`, error);
res.status(500).json({
error: 'Failed to fork session',
message: error.message,
sessionId
});
}
});
/**
* Move session to project
* POST /api/session/:sessionId/move
*
* Moves a session to a different project.
*
* Request:
* {
* "projectId": "project-456"
* }
*
* Response:
* {
* "success": true,
* "sessionId": "session-123",
* "projectId": "project-456"
* }
*/
router.post('/session/:sessionId/move', validateSessionId, async (req, res) => {
const { sessionId } = req.params;
const { projectId } = req.body;
if (!projectId) {
return res.status(400).json({
error: 'Project ID is required'
});
}
console.log(`[SessionRoutes] Moving session ${sessionId} to project ${projectId}`);
try {
await req.app.locals.claudeService.moveSession(sessionId, projectId);
// Emit session-moved event
eventBus.emit('session-moved', {
sessionId,
projectId,
timestamp: Date.now()
});
res.json({
success: true,
sessionId,
projectId
});
console.log(`[SessionRoutes] Session ${sessionId} moved to project ${projectId}`);
} catch (error) {
console.error(`[SessionRoutes] Error moving session ${sessionId}:`, error);
res.status(500).json({
error: 'Failed to move session',
message: error.message,
sessionId
});
}
});
module.exports = router;

638
routes/sessions-routes.js Normal file
View File

@@ -0,0 +1,638 @@
/**
* Sessions Routes (Plural)
*
* Endpoints for listing and creating Claude Code sessions.
* This complements session-routes.js which handles individual session operations.
*
* Mounted at /claude/api, routes are accessed as:
* - GET /claude/api/claude/sessions (list)
* - POST /claude/api/claude/sessions (create)
* - GET /claude/api/claude/sessions/:sessionId (get)
* - POST /claude/api/claude/sessions/:sessionId/prompt (send)
*/
const express = require('express');
const router = express.Router();
// Create sub-router for /claude prefix
const claudeRouter = express.Router();
/**
* List all sessions
* GET /claude/sessions
*
* Returns both active and historical sessions.
*
* Response:
* {
* "active": [...],
* "historical": [...],
* "archived": [...] (if ?archived=true)
* }
*/
claudeRouter.get('/sessions', (req, res) => {
const claudeService = req.app.locals.claudeService;
const { archived } = req.query;
if (!claudeService) {
return res.status(500).json({
error: 'Service not available',
message: 'claudeService not initialized'
});
}
try {
// Get active sessions
const activeSessions = claudeService.listSessions();
// Get historical sessions
let historicalSessions = claudeService.loadHistoricalSessions();
// Filter out archived sessions from normal list
historicalSessions = historicalSessions.filter(s => !s.metadata?.archived);
// If requesting archived sessions, return only archived
if (archived === 'true') {
const allHistorical = claudeService.loadHistoricalSessions();
const archivedSessions = allHistorical.filter(s => s.metadata?.archived);
return res.json({
archived: archivedSessions
});
}
res.json({
active: activeSessions,
historical: historicalSessions
});
console.log(`[SessionsRoutes] Listed ${activeSessions.length} active, ${historicalSessions.length} historical sessions`);
} catch (error) {
console.error('[SessionsRoutes] Error listing sessions:', error);
res.status(500).json({
error: 'Failed to list sessions',
message: error.message
});
}
});
/**
* Create a new session
* POST /claude/sessions
*
* Request:
* {
* "workingDir": "/path/to/directory",
* "mode": "code" | "full",
* "metadata": { ... }
* }
*
* Response:
* {
* "id": "session-123",
* "status": "running",
* "createdAt": "...",
* "workingDir": "..."
* }
*/
claudeRouter.post('/sessions', (req, res) => {
const claudeService = req.app.locals.claudeService;
if (!claudeService) {
return res.status(500).json({
error: 'Service not available',
message: 'claudeService not initialized'
});
}
const { workingDir, mode, metadata } = req.body;
try {
const session = claudeService.createSession({
workingDir: workingDir || req.app.locals.VAULT_PATH || '/home/uroma/obsidian-vault',
mode,
metadata
});
// Emit session-created event
const eventBus = require('../services/event-bus');
eventBus.emit('session-created', {
sessionId: session.id,
mode: mode || 'code',
timestamp: Date.now()
});
res.status(201).json({
id: session.id,
status: session.status,
createdAt: session.createdAt,
workingDir: session.workingDir,
metadata: session.metadata
});
console.log(`[SessionsRoutes] Created session ${session.id}`);
} catch (error) {
console.error('[SessionsRoutes] Error creating session:', error);
res.status(500).json({
error: 'Failed to create session',
message: error.message
});
}
});
/**
* Get session details
* GET /claude/sessions/:sessionId
*
* Returns detailed information about a specific session.
*/
claudeRouter.get('/sessions/:sessionId', (req, res) => {
const claudeService = req.app.locals.claudeService;
if (!claudeService) {
return res.status(500).json({
error: 'Service not available',
message: 'claudeService not initialized'
});
}
const { sessionId } = req.params;
try {
const session = claudeService.getSession(sessionId);
if (!session) {
return res.status(404).json({
error: 'Session not found',
sessionId
});
}
res.json(session);
console.log(`[SessionsRoutes] Retrieved session ${sessionId}`);
} catch (error) {
console.error(`[SessionsRoutes] Error getting session ${sessionId}:`, error);
// Check if it's a "not found" error
if (error.message.includes('not found')) {
return res.status(404).json({
error: 'Session not found',
message: error.message,
sessionId
});
}
res.status(500).json({
error: 'Failed to get session',
message: error.message,
sessionId
});
}
});
/**
* Send prompt to session
* POST /claude/sessions/:sessionId/prompt
*
* This forwards to the session-routes handler for consistency.
* AUTO-RECREATES historical sessions as active sessions.
*/
claudeRouter.post('/sessions/:sessionId/prompt', (req, res) => {
const { sessionId } = req.params;
const { command, context } = req.body;
const eventBus = require('../services/event-bus');
// Validate session ID
if (!sessionId || sessionId.length < 10) {
return res.status(400).json({
error: 'Invalid session ID',
sessionId
});
}
// Validate command
if (!command || typeof command !== 'string' || command.trim().length === 0) {
return res.status(400).json({
error: 'Command is required',
sessionId
});
}
const claudeService = req.app.locals.claudeService;
console.log(`[SessionsRoutes] Sending command to session ${sessionId}: ${command.substring(0, 100)}...`);
try {
// Check if session is active
const activeSession = claudeService.sessions.get(sessionId);
if (!activeSession) {
// Session not in active sessions - check if it's historical
try {
const historicalSession = claudeService.getSession(sessionId);
if (historicalSession && historicalSession.metadata?.historical) {
// ============================================================
// AUTO-RECREATE: Historical session detected, create new active one
// ============================================================
console.log(`[SessionsRoutes] 🔄 Auto-recreating historical session ${sessionId}`);
const newSession = claudeService.createSession({
workingDir: historicalSession.workingDir || '/home/uroma',
metadata: {
...historicalSession.metadata,
recreatedFrom: sessionId,
originalSessionId: sessionId
}
});
console.log(`[SessionsRoutes] ✅ Created new active session ${newSession.id} replacing historical ${sessionId}`);
// Send command to new session
claudeService.sendCommand(newSession.id, command);
eventBus.emit('command-sent', {
sessionId: newSession.id,
command: command.substring(0, 100) + (command.length > 100 ? '...' : ''),
timestamp: Date.now()
});
// Return with new session ID
return res.json({
success: true,
sessionId: newSession.id,
message: 'Session was historical - created new active session',
newSession: true,
timestamp: Date.now()
});
}
} catch (historicalError) {
// Not a historical session, continue to normal error
}
throw new Error(`Session ${sessionId} not found or not active`);
}
// Send command to Claude service (synchronous)
claudeService.sendCommand(sessionId, command);
// Emit command-sent event
eventBus.emit('command-sent', {
sessionId,
command: command.substring(0, 100) + (command.length > 100 ? '...' : ''),
timestamp: Date.now()
});
// Return immediately (actual response comes via SSE)
res.json({
success: true,
sessionId,
message: 'Command sent',
timestamp: Date.now()
});
console.log(`[SessionsRoutes] Command sent successfully to session ${sessionId}`);
} catch (error) {
console.error(`[SessionsRoutes] Error sending command to session ${sessionId}:`, error);
// Emit error event
eventBus.emit('session-error', {
sessionId,
error: error.message,
code: 'COMMAND_SEND_ERROR',
recoverable: false
});
res.status(500).json({
error: 'Failed to send command',
message: error.message,
sessionId
});
}
});
/**
* Delete session
* DELETE /claude/sessions/:sessionId
*/
claudeRouter.delete('/sessions/:sessionId', (req, res) => {
const claudeService = req.app.locals.claudeService;
const { sessionId } = req.params;
if (!claudeService) {
return res.status(500).json({
error: 'Service not available',
message: 'claudeService not initialized'
});
}
try {
const session = claudeService.sessions.get(sessionId);
if (!session) {
return res.status(404).json({
error: 'Session not found',
sessionId
});
}
// Terminate if running
if (session.process && !session.process.killed) {
session.process.kill();
}
claudeService.sessions.delete(sessionId);
const eventBus = require('../services/event-bus');
eventBus.emit('session-deleted', {
sessionId,
timestamp: Date.now()
});
res.json({
success: true,
message: 'Session deleted',
sessionId
});
console.log(`[SessionsRoutes] Session ${sessionId} deleted successfully`);
} catch (error) {
console.error(`[SessionsRoutes] Error deleting session ${sessionId}:`, error);
res.status(500).json({
error: 'Failed to delete session',
message: error.message,
sessionId
});
}
});
/**
* Archive session (soft delete)
* PATCH /claude/sessions/:sessionId/archive
*/
claudeRouter.patch('/sessions/:sessionId/archive', async (req, res) => {
const claudeService = req.app.locals.claudeService;
const { sessionId } = req.params;
if (!claudeService) {
return res.status(500).json({
error: 'Service not available',
message: 'claudeService not initialized'
});
}
try {
// First check active sessions
let session = claudeService.sessions.get(sessionId);
// If not found in active, check historical sessions
if (!session) {
try {
session = claudeService.getSession(sessionId);
} catch (e) {
// Session not found anywhere
return res.status(404).json({
error: 'Session not found',
sessionId
});
}
}
if (!session) {
return res.status(404).json({
error: 'Session not found',
sessionId
});
}
// Add archivedAt timestamp to metadata
if (!session.metadata) {
session.metadata = {};
}
session.metadata.archivedAt = new Date().toISOString();
session.metadata.archived = true;
// Save to historical sessions
const fs = require('fs').promises;
const path = require('path');
const sessionsDir = path.join(req.app.locals.VAULT_PATH, '.claude', 'sessions');
// Ensure directory exists
await fs.mkdir(sessionsDir, { recursive: true });
// Save session as archived
const sessionFile = path.join(sessionsDir, `${sessionId}.json`);
await fs.writeFile(sessionFile, JSON.stringify({
id: session.id,
status: 'archived',
createdAt: session.createdAt,
archivedAt: session.metadata.archivedAt,
workingDir: session.workingDir,
metadata: session.metadata,
outputBuffer: session.outputBuffer || []
}, null, 2));
// Remove from active sessions (if it was active)
if (claudeService.sessions.has(sessionId)) {
// Terminate if running
if (session.process && !session.process.killed) {
session.process.kill();
}
claudeService.sessions.delete(sessionId);
}
const eventBus = require('../services/event-bus');
eventBus.emit('session-archived', {
sessionId,
timestamp: Date.now()
});
res.json({
success: true,
message: 'Session archived',
sessionId
});
console.log(`[SessionsRoutes] Session ${sessionId} archived successfully`);
} catch (error) {
console.error(`[SessionsRoutes] Error archiving session ${sessionId}:`, error);
res.status(500).json({
error: 'Failed to archive session',
message: error.message,
sessionId
});
}
});
/**
* Unarchive session
* PATCH /claude/sessions/:sessionId/unarchive
*/
claudeRouter.patch('/sessions/:sessionId/unarchive', async (req, res) => {
const claudeService = req.app.locals.claudeService;
const { sessionId } = req.params;
if (!claudeService) {
return res.status(500).json({
error: 'Service not available',
message: 'claudeService not initialized'
});
}
try {
// Load the archived session file
const fs = require('fs').promises;
const path = require('path');
const sessionsDir = path.join(req.app.locals.VAULT_PATH, '.claude', 'sessions');
const sessionFile = path.join(sessionsDir, `${sessionId}.json`);
const sessionData = JSON.parse(await fs.readFile(sessionFile, 'utf8'));
// Remove archived flags
delete sessionData.metadata.archivedAt;
delete sessionData.metadata.archived;
// Update status
sessionData.status = 'historical';
// Save back as historical (not archived)
await fs.writeFile(sessionFile, JSON.stringify(sessionData, null, 2));
const eventBus = require('../services/event-bus');
eventBus.emit('session-unarchived', {
sessionId,
timestamp: Date.now()
});
res.json({
success: true,
message: 'Session unarchived',
sessionId
});
console.log(`[SessionsRoutes] Session ${sessionId} unarchived successfully`);
} catch (error) {
console.error(`[SessionsRoutes] Error unarchiving session ${sessionId}:`, error);
res.status(500).json({
error: 'Failed to unarchive session',
message: error.message,
sessionId
});
}
});
/**
* Merge multiple sessions
* POST /claude/sessions/merge
*/
claudeRouter.post('/sessions/merge', async (req, res) => {
const claudeService = req.app.locals.claudeService;
const { sessionIds } = req.body;
if (!claudeService) {
return res.status(500).json({
error: 'Service not available',
message: 'claudeService not initialized'
});
}
if (!sessionIds || !Array.isArray(sessionIds) || sessionIds.length < 2) {
return res.status(400).json({
error: 'At least 2 session IDs required',
sessionIds
});
}
try {
const fs = require('fs').promises;
const path = require('path');
const sessionsDir = path.join(req.app.locals.VAULT_PATH, '.claude', 'sessions');
// Load all sessions
const sessions = [];
const metadata = {
mergedFrom: sessionIds,
mergedAt: new Date().toISOString()
};
for (const sessionId of sessionIds) {
const sessionFile = path.join(sessionsDir, `${sessionId}.json`);
try {
const sessionData = JSON.parse(await fs.readFile(sessionFile, 'utf8'));
sessions.push(sessionData);
// Collect all unique metadata
if (sessionData.metadata) {
Object.assign(metadata, sessionData.metadata);
}
} catch (error) {
console.warn(`[SessionsRoutes] Could not load session ${sessionId}:`, error.message);
}
}
if (sessions.length === 0) {
return res.status(404).json({
error: 'No valid sessions found',
sessionIds
});
}
// Combine output buffers from all sessions
const combinedOutput = [];
for (const session of sessions) {
if (session.outputBuffer && Array.isArray(session.outputBuffer)) {
combinedOutput.push(...session.outputBuffer);
}
}
// Get working directory from first session
const workingDir = sessions[0].workingDir || req.app.locals.VAULT_PATH || '/home/uroma';
// Create new merged session
const newSession = claudeService.createSession({
workingDir,
metadata: {
...metadata,
project: `Merged (${sessions.length} sessions)`
}
});
// Add combined output buffer to the new session
if (combinedOutput.length > 0) {
newSession.outputBuffer = combinedOutput;
}
const eventBus = require('../services/event-bus');
eventBus.emit('sessions-merged', {
sessionIds,
newSessionId: newSession.id,
timestamp: Date.now()
});
res.json({
success: true,
session: {
id: newSession.id,
status: newSession.status,
createdAt: newSession.createdAt,
workingDir: newSession.workingDir,
metadata: newSession.metadata
},
message: `Merged ${sessions.length} sessions into ${newSession.id}`
});
console.log(`[SessionsRoutes] Merged ${sessions.length} sessions into ${newSession.id}`);
} catch (error) {
console.error('[SessionsRoutes] Error merging sessions:', error);
res.status(500).json({
error: 'Failed to merge sessions',
message: error.message
});
}
});
// Mount the /claude sub-router
router.use('/claude', claudeRouter);
module.exports = router;

138
routes/sse-routes.js Normal file
View File

@@ -0,0 +1,138 @@
/**
* SSE Routes
*
* Server-Sent Events endpoints for real-time session event streaming.
*/
const express = require('express');
const sseManager = require('../services/sse-manager');
const { validateSessionId } = require('../middleware/validation');
const router = express.Router();
/**
* SSE endpoint for session events
* GET /api/session/:sessionId/events
*
* Establishes a Server-Sent Events connection for streaming real-time
* session events to the client.
*
* Events:
* - connected: Initial connection confirmation
* - session-output: Output from Claude Code process
* - session-error: Error from session
* - session-status: Session status update
* - operations-detected: Claude operations detected in response
* - operations-executed: Operations execution results
* - approval-request: Command approval requested
* - approval-confirmed: Command approval confirmed/expired
*
* Example:
* const eventSource = new EventSource('/api/session/session-123/events');
* eventSource.addEventListener('session-output', (e) => {
* const data = JSON.parse(e.data);
* console.log('Output:', data.content);
* });
*/
router.get('/session/:sessionId/events', validateSessionId, (req, res) => {
const { sessionId } = req.params;
console.log(`[SSERoutes] New SSE connection request for session ${sessionId}`);
console.log(`[SSERoutes] Client IP: ${req.ip}`);
console.log(`[SSERoutes] User-Agent: ${req.get('User-Agent')?.substring(0, 100)}`);
// Add SSE connection (response is kept open for streaming)
sseManager.addConnection(sessionId, res, req);
// Note: No response sent here - connection stays open for SSE streaming
});
/**
* Get connection status for a session
* GET /api/session/:sessionId/events/status
*
* Returns information about active SSE connections for a session.
*
* Response:
* {
* "sessionId": "session-123",
* "activeConnections": 2,
* "timestamp": 1234567890
* }
*/
router.get('/session/:sessionId/events/status', validateSessionId, (req, res) => {
const { sessionId } = req.params;
const activeConnections = sseManager.getConnectionCount(sessionId);
res.json({
sessionId,
activeConnections,
timestamp: Date.now()
});
});
/**
* Get global SSE stats (admin endpoint)
* GET /api/sse/stats
*
* Returns overall SSE connection statistics.
*
* Response:
* {
* "totalSessions": 5,
* "totalConnections": 12,
* "sessions": { "session-1": 2, "session-2": 1, ... },
* "totalCreated": 50,
* "totalClosed": 38,
* "activeHeartbeats": 12
* }
*/
router.get('/sse/stats', (req, res) => {
const stats = sseManager.getStats();
res.json(stats);
});
/**
* Test SSE endpoint (for development/testing)
* GET /api/sse/test
*
* Sends test events every second for 10 seconds.
* Only available in development mode.
*/
router.get('/sse/test', (req, res) => {
if (process.env.NODE_ENV === 'production') {
return res.status(404).json({ error: 'Not found' });
}
res.setHeader('Content-Type', 'text/event-stream');
res.setHeader('Cache-Control', 'no-cache');
res.setHeader('Connection', 'keep-alive');
res.flushHeaders();
let count = 0;
const maxEvents = 10;
const interval = setInterval(() => {
count++;
res.write(`event: test\n`);
res.write(`data: ${JSON.stringify({ message: `Test event ${count}`, count, timestamp: Date.now() })}\n`);
res.write(`id: ${Date.now()}\n`);
res.write('\n');
if (count >= maxEvents) {
clearInterval(interval);
res.write('event: end\n');
res.write('data: {"message":"Test complete"}\n\n');
res.end();
}
}, 1000);
req.on('close', () => {
clearInterval(interval);
});
});
module.exports = router;