BMAD-METHOD/tools/installer/lib/claude-code-session-manager.js

394 lines
12 KiB
JavaScript

const path = require('path');
const fs = require('fs');
const crypto = require('crypto');
/**
* Claude Code CLI Session Manager
* Provides automatic session management, heartbeat tracking, and context restoration
* for Claude Code CLI users of the BMAD collaborative workspace system.
*/
class ClaudeCodeSessionManager {
constructor(workspaceDir) {
this.workspaceDir = workspaceDir;
this.sessionsDir = path.join(workspaceDir, '.workspace', 'sessions');
this.sessionId = null;
this.heartbeatInterval = null;
this.sessionData = null;
this.isClaudeCodeSession = process.env.CLAUDE_CODE_SESSION || false;
}
/**
* Initialize Claude Code CLI session with automatic registration
*/
async initializeSession(agentType = 'dev', projectContext = {}) {
try {
// Generate unique session ID
this.sessionId = `claude-code-${Date.now()}-${crypto.randomBytes(4).toString('hex')}`;
// Ensure sessions directory exists
if (!fs.existsSync(this.sessionsDir)) {
fs.mkdirSync(this.sessionsDir, { recursive: true });
}
// Create session data
this.sessionData = {
sessionId: this.sessionId,
agentType: agentType,
ide: 'claude-code',
startTime: new Date().toISOString(),
lastActivity: new Date().toISOString(),
status: 'active',
projectContext: projectContext,
workspaceVersion: '1.0',
capabilities: {
nativeCommands: true,
autoHandoff: true,
contextAware: true,
autoMaintenance: true
},
metrics: {
commandsExecuted: 0,
contextSwitches: 0,
handoffsInitiated: 0,
handoffsReceived: 0
}
};
// Write session file
const sessionFile = path.join(this.sessionsDir, `${this.sessionId}.json`);
fs.writeFileSync(sessionFile, JSON.stringify(this.sessionData, null, 2));
// Start heartbeat monitoring
this.startHeartbeat();
// Auto-load workspace context if available
await this.loadWorkspaceContext();
console.log(`🚀 Claude Code CLI session initialized: ${this.sessionId}`);
console.log(`📍 Agent: ${agentType} | Project: ${projectContext.name || 'Unknown'}`);
return {
sessionId: this.sessionId,
status: 'initialized',
capabilities: this.sessionData.capabilities
};
} catch (error) {
console.error('Failed to initialize Claude Code CLI session:', error.message);
return { status: 'failed', error: error.message };
}
}
/**
* Start automatic heartbeat monitoring
*/
startHeartbeat() {
// Update heartbeat every 30 seconds
this.heartbeatInterval = setInterval(() => {
this.updateHeartbeat();
}, 30000);
// Also update on process events
process.on('beforeExit', () => this.cleanupSession());
process.on('SIGINT', () => this.cleanupSession());
process.on('SIGTERM', () => this.cleanupSession());
}
/**
* Update session heartbeat
*/
updateHeartbeat() {
if (!this.sessionId || !this.sessionData) return;
try {
this.sessionData.lastActivity = new Date().toISOString();
const sessionFile = path.join(this.sessionsDir, `${this.sessionId}.json`);
if (fs.existsSync(sessionFile)) {
fs.writeFileSync(sessionFile, JSON.stringify(this.sessionData, null, 2));
}
} catch (error) {
console.warn('Failed to update session heartbeat:', error.message);
}
}
/**
* Register command execution
*/
registerCommandExecution(command, context = {}) {
if (!this.sessionData) return;
this.sessionData.metrics.commandsExecuted++;
this.sessionData.lastCommand = {
command: command,
timestamp: new Date().toISOString(),
context: context
};
this.updateHeartbeat();
}
/**
* Prepare agent handoff with context transfer
*/
async prepareAgentHandoff(targetAgent, handoffContext = {}) {
if (!this.sessionData) return null;
try {
// Increment handoff metrics
this.sessionData.metrics.handoffsInitiated++;
// Load current workspace context
const workspaceContext = await this.loadWorkspaceContext();
// Generate handoff package
const handoffData = {
sourceSession: this.sessionId,
sourceAgent: this.sessionData.agentType,
targetAgent: targetAgent,
timestamp: new Date().toISOString(),
workspaceContext: workspaceContext,
sessionContext: {
metrics: this.sessionData.metrics,
recentCommands: this.sessionData.lastCommand,
projectContext: this.sessionData.projectContext
},
handoffContext: handoffContext,
continuity: {
sessionId: this.sessionId,
resumable: true,
contextVersion: workspaceContext?.version || '1.0'
}
};
// Save handoff package
const handoffId = `${this.sessionData.agentType}-to-${targetAgent}-${Date.now()}`;
const handoffFile = path.join(this.workspaceDir, '.workspace', 'handoffs', `${handoffId}.json`);
if (!fs.existsSync(path.dirname(handoffFile))) {
fs.mkdirSync(path.dirname(handoffFile), { recursive: true });
}
fs.writeFileSync(handoffFile, JSON.stringify(handoffData, null, 2));
console.log(`🔄 Handoff prepared: ${this.sessionData.agentType}${targetAgent}`);
console.log(`📦 Handoff package: ${handoffId}.json`);
return {
handoffId: handoffId,
targetAgent: targetAgent,
status: 'prepared',
contextPreserved: true
};
} catch (error) {
console.error('Failed to prepare agent handoff:', error.message);
return { status: 'failed', error: error.message };
}
}
/**
* Restore session from handoff
*/
async restoreFromHandoff(handoffId) {
try {
const handoffFile = path.join(this.workspaceDir, '.workspace', 'handoffs', `${handoffId}.json`);
if (!fs.existsSync(handoffFile)) {
throw new Error(`Handoff package not found: ${handoffId}`);
}
const handoffData = JSON.parse(fs.readFileSync(handoffFile, 'utf8'));
// Update session data with handoff context
if (this.sessionData) {
this.sessionData.metrics.handoffsReceived++;
this.sessionData.restoredFrom = {
handoffId: handoffId,
sourceAgent: handoffData.sourceAgent,
timestamp: new Date().toISOString()
};
// Merge project context
this.sessionData.projectContext = {
...this.sessionData.projectContext,
...handoffData.sessionContext.projectContext
};
}
console.log(`♻️ Session restored from handoff: ${handoffData.sourceAgent}${this.sessionData?.agentType}`);
return {
status: 'restored',
sourceAgent: handoffData.sourceAgent,
contextVersion: handoffData.continuity.contextVersion
};
} catch (error) {
console.error('Failed to restore from handoff:', error.message);
return { status: 'failed', error: error.message };
}
}
/**
* Load workspace context for session continuity
*/
async loadWorkspaceContext() {
try {
const contextFile = path.join(this.workspaceDir, '.workspace', 'context', 'shared-context.md');
if (fs.existsSync(contextFile)) {
const contextContent = fs.readFileSync(contextFile, 'utf8');
return {
content: contextContent,
lastModified: fs.statSync(contextFile).mtime.toISOString(),
version: '1.0'
};
}
return null;
} catch (error) {
console.warn('Failed to load workspace context:', error.message);
return null;
}
}
/**
* Get current session status
*/
getSessionStatus() {
if (!this.sessionData) {
return { status: 'inactive' };
}
return {
sessionId: this.sessionId,
agentType: this.sessionData.agentType,
status: this.sessionData.status,
startTime: this.sessionData.startTime,
lastActivity: this.sessionData.lastActivity,
metrics: this.sessionData.metrics,
capabilities: this.sessionData.capabilities
};
}
/**
* Perform workspace integrity check
*/
async performIntegrityCheck() {
const results = {
timestamp: new Date().toISOString(),
checks: [],
status: 'healthy',
issues: []
};
try {
// Check workspace directory structure
const requiredDirs = ['sessions', 'context', 'handoffs', 'decisions', 'progress', 'quality'];
const workspaceRoot = path.join(this.workspaceDir, '.workspace');
for (const dir of requiredDirs) {
const dirPath = path.join(workspaceRoot, dir);
const exists = fs.existsSync(dirPath);
results.checks.push({
type: 'directory',
path: dir,
status: exists ? 'ok' : 'missing'
});
if (!exists) {
results.issues.push(`Missing directory: .workspace/${dir}`);
fs.mkdirSync(dirPath, { recursive: true });
results.checks[results.checks.length - 1].status = 'repaired';
}
}
// Check session file integrity
if (this.sessionId) {
const sessionFile = path.join(this.sessionsDir, `${this.sessionId}.json`);
const sessionExists = fs.existsSync(sessionFile);
results.checks.push({
type: 'session',
sessionId: this.sessionId,
status: sessionExists ? 'ok' : 'corrupted'
});
if (!sessionExists && this.sessionData) {
fs.writeFileSync(sessionFile, JSON.stringify(this.sessionData, null, 2));
results.checks[results.checks.length - 1].status = 'repaired';
}
}
// Check for orphaned sessions (older than 2 hours with no activity)
if (fs.existsSync(this.sessionsDir)) {
const sessionFiles = fs.readdirSync(this.sessionsDir).filter(f => f.endsWith('.json'));
const cutoffTime = Date.now() - (2 * 60 * 60 * 1000); // 2 hours ago
for (const sessionFile of sessionFiles) {
const sessionPath = path.join(this.sessionsDir, sessionFile);
try {
const sessionData = JSON.parse(fs.readFileSync(sessionPath, 'utf8'));
const lastActivity = new Date(sessionData.lastActivity).getTime();
if (lastActivity < cutoffTime) {
results.issues.push(`Orphaned session: ${sessionData.sessionId}`);
fs.unlinkSync(sessionPath);
results.checks.push({
type: 'cleanup',
sessionId: sessionData.sessionId,
status: 'removed'
});
}
} catch (error) {
results.issues.push(`Corrupted session file: ${sessionFile}`);
fs.unlinkSync(sessionPath);
results.checks.push({
type: 'cleanup',
file: sessionFile,
status: 'removed'
});
}
}
}
results.status = results.issues.length === 0 ? 'healthy' : 'repaired';
return results;
} catch (error) {
results.status = 'failed';
results.error = error.message;
return results;
}
}
/**
* Clean up session on exit
*/
cleanupSession() {
if (this.heartbeatInterval) {
clearInterval(this.heartbeatInterval);
this.heartbeatInterval = null;
}
if (this.sessionId && this.sessionData) {
try {
// Mark session as completed
this.sessionData.status = 'completed';
this.sessionData.endTime = new Date().toISOString();
const sessionFile = path.join(this.sessionsDir, `${this.sessionId}.json`);
fs.writeFileSync(sessionFile, JSON.stringify(this.sessionData, null, 2));
console.log(`📝 Claude Code CLI session completed: ${this.sessionId}`);
} catch (error) {
console.warn('Failed to cleanup session:', error.message);
}
}
}
}
module.exports = ClaudeCodeSessionManager;