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:
507
routes/session-routes.js
Normal file
507
routes/session-routes.js
Normal 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
638
routes/sessions-routes.js
Normal 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
138
routes/sse-routes.js
Normal 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;
|
||||
Reference in New Issue
Block a user