BMAD-METHOD/bmad-claude-integration/core/session-manager.js

364 lines
10 KiB
JavaScript

#!/usr/bin/env node
const fs = require('fs').promises;
const path = require('path');
const crypto = require('crypto');
class SessionManager {
constructor(messageQueue, elicitationBroker, options = {}) {
this.messageQueue = messageQueue;
this.elicitationBroker = elicitationBroker;
this.basePath = options.basePath || path.join(process.env.HOME, '.bmad');
this.sessionsPath = path.join(this.basePath, 'sessions');
this.activeSessions = new Map();
this.sessionOrder = []; // Track order of sessions for switching
}
async initialize() {
await fs.mkdir(this.sessionsPath, { recursive: true });
await this.loadActiveSessions();
}
generateSessionId() {
return `session-${Date.now()}-${crypto.randomBytes(4).toString('hex')}`;
}
async createAgentSession(agentName, context = {}) {
const sessionId = this.generateSessionId();
const session = {
id: sessionId,
agent: agentName,
status: 'active',
created: new Date().toISOString(),
lastActivity: Date.now(),
context: {
...context,
conversationHistory: [],
elicitationSessions: []
},
ui: {
color: this.getAgentColor(agentName),
icon: this.getAgentIcon(agentName),
displayName: this.getAgentDisplayName(agentName)
}
};
this.activeSessions.set(sessionId, session);
this.sessionOrder.push(sessionId);
await this.saveSession(session);
return session;
}
async switchSession(sessionId) {
if (!this.activeSessions.has(sessionId)) {
throw new Error(`Session ${sessionId} not found`);
}
// Move session to front of order
this.sessionOrder = this.sessionOrder.filter(id => id !== sessionId);
this.sessionOrder.unshift(sessionId);
const session = this.activeSessions.get(sessionId);
session.lastActivity = Date.now();
await this.saveSession(session);
return session;
}
async suspendSession(sessionId, reason = 'user_switch') {
const session = this.activeSessions.get(sessionId);
if (!session) return;
session.status = 'suspended';
session.suspendedAt = Date.now();
session.suspendReason = reason;
// If in elicitation, save state
if (session.currentElicitation) {
session.context.suspendedElicitation = {
sessionId: session.currentElicitation,
state: await this.elicitationBroker.loadSession(session.currentElicitation)
};
}
await this.saveSession(session);
}
async resumeSession(sessionId) {
const session = this.activeSessions.get(sessionId);
if (!session) {
// Try to load from disk
const loadedSession = await this.loadSession(sessionId);
if (loadedSession) {
this.activeSessions.set(sessionId, loadedSession);
return loadedSession;
}
throw new Error(`Session ${sessionId} not found`);
}
session.status = 'active';
session.resumedAt = Date.now();
delete session.suspendedAt;
delete session.suspendReason;
// Resume elicitation if needed
if (session.context.suspendedElicitation) {
session.currentElicitation = session.context.suspendedElicitation.sessionId;
delete session.context.suspendedElicitation;
}
await this.saveSession(session);
return session;
}
async addToConversation(sessionId, entry) {
const session = this.activeSessions.get(sessionId);
if (!session) {
throw new Error(`Session ${sessionId} not found`);
}
const conversationEntry = {
...entry,
timestamp: new Date().toISOString(),
id: `conv-${Date.now()}-${crypto.randomBytes(4).toString('hex')}`
};
session.context.conversationHistory.push(conversationEntry);
session.lastActivity = Date.now();
await this.saveSession(session);
return conversationEntry;
}
async startElicitation(sessionId, elicitationSessionId) {
const session = this.activeSessions.get(sessionId);
if (!session) {
throw new Error(`Session ${sessionId} not found`);
}
session.currentElicitation = elicitationSessionId;
session.context.elicitationSessions.push({
id: elicitationSessionId,
startedAt: Date.now()
});
await this.saveSession(session);
}
async completeElicitation(sessionId, elicitationSessionId, result) {
const session = this.activeSessions.get(sessionId);
if (!session) {
throw new Error(`Session ${sessionId} not found`);
}
if (session.currentElicitation === elicitationSessionId) {
delete session.currentElicitation;
}
const elicitationRecord = session.context.elicitationSessions.find(
e => e.id === elicitationSessionId
);
if (elicitationRecord) {
elicitationRecord.completedAt = Date.now();
elicitationRecord.result = result;
}
await this.saveSession(session);
}
formatSessionPrompt(session) {
const header = `\n${'='.repeat(60)}\n` +
`${session.ui.icon} **${session.ui.displayName}** Session\n` +
`Session ID: ${session.id}\n` +
`${'='.repeat(60)}\n`;
let prompt = header;
if (session.status === 'suspended') {
prompt += `\n⚠️ This session is currently suspended. Would you like to resume?\n`;
}
if (session.currentElicitation) {
prompt += `\n📝 Currently in elicitation phase...\n`;
}
return prompt;
}
formatSessionList() {
const sessions = Array.from(this.activeSessions.values());
if (sessions.length === 0) {
return 'No active sessions.';
}
let output = '**Active BMAD Sessions:**\n\n';
sessions.forEach((session, index) => {
const isActive = this.sessionOrder[0] === session.id;
const status = isActive ? '🟢' : (session.status === 'suspended' ? '🟡' : '⚪');
const lastActive = new Date(session.lastActivity).toLocaleTimeString();
output += `${status} **${index + 1}. ${session.ui.icon} ${session.ui.displayName}**\n`;
output += ` Session: ${session.id}\n`;
output += ` Status: ${session.status} | Last active: ${lastActive}\n`;
if (session.currentElicitation) {
output += ` 📝 In elicitation phase\n`;
}
output += '\n';
});
output += '\n💡 Use `/switch <number>` to switch between sessions\n';
output += '💡 Use `/suspend` to pause current session\n';
output += '💡 Use `/sessions` to see this list again\n';
return output;
}
getAgentColor(agentName) {
const colors = {
'bmad-master': 'purple',
'bmad-orchestrator': 'blue',
'pm': 'green',
'architect': 'orange',
'dev': 'cyan',
'qa': 'red',
'ux-expert': 'pink',
'sm': 'yellow'
};
return colors[agentName] || 'gray';
}
getAgentIcon(agentName) {
const icons = {
'bmad-master': '🧙',
'bmad-orchestrator': '🎭',
'pm': '📋',
'architect': '🏗️',
'dev': '💻',
'qa': '🐛',
'ux-expert': '🎨',
'sm': '🏃'
};
return icons[agentName] || '🤖';
}
getAgentDisplayName(agentName) {
const names = {
'bmad-master': 'BMAD Master',
'bmad-orchestrator': 'BMAD Orchestrator',
'pm': 'Project Manager',
'architect': 'Architect',
'dev': 'Developer',
'qa': 'QA Engineer',
'ux-expert': 'UX Expert',
'sm': 'Scrum Master'
};
return names[agentName] || agentName;
}
async saveSession(session) {
const sessionPath = path.join(this.sessionsPath, `${session.id}.json`);
await fs.writeFile(sessionPath, JSON.stringify(session, null, 2));
}
async loadSession(sessionId) {
const sessionPath = path.join(this.sessionsPath, `${sessionId}.json`);
try {
const content = await fs.readFile(sessionPath, 'utf8');
return JSON.parse(content);
} catch (error) {
return null;
}
}
async loadActiveSessions() {
try {
const files = await fs.readdir(this.sessionsPath);
for (const file of files) {
if (file.endsWith('.json')) {
const session = await this.loadSession(file.replace('.json', ''));
if (session && (session.status === 'active' || session.status === 'suspended')) {
this.activeSessions.set(session.id, session);
this.sessionOrder.push(session.id);
}
}
}
// Sort by last activity
this.sessionOrder.sort((a, b) => {
const sessionA = this.activeSessions.get(a);
const sessionB = this.activeSessions.get(b);
return (sessionB?.lastActivity || 0) - (sessionA?.lastActivity || 0);
});
} catch (error) {
// Directory doesn't exist yet
}
}
async cleanup() {
const now = Date.now();
const maxAge = 24 * 60 * 60 * 1000; // 24 hours
for (const [sessionId, session] of this.activeSessions.entries()) {
if (now - session.lastActivity > maxAge && session.status !== 'active') {
this.activeSessions.delete(sessionId);
this.sessionOrder = this.sessionOrder.filter(id => id !== sessionId);
const sessionPath = path.join(this.sessionsPath, `${sessionId}.json`);
await fs.unlink(sessionPath).catch(() => {});
}
}
}
}
// CLI interface for testing
if (require.main === module) {
const BMADMessageQueue = require('./message-queue');
const ElicitationBroker = require('./elicitation-broker');
const queue = new BMADMessageQueue();
const broker = new ElicitationBroker(queue);
const manager = new SessionManager(queue, broker);
const commands = {
async init() {
await manager.initialize();
console.log('Session manager initialized');
},
async create(agent) {
const session = await manager.createAgentSession(agent);
console.log(`Session created: ${session.id}`);
console.log(manager.formatSessionPrompt(session));
},
async list() {
await manager.initialize();
console.log(manager.formatSessionList());
},
async switch(sessionId) {
const session = await manager.switchSession(sessionId);
console.log(manager.formatSessionPrompt(session));
},
async suspend(sessionId) {
await manager.suspendSession(sessionId);
console.log(`Session ${sessionId} suspended`);
}
};
const [,, command, ...args] = process.argv;
if (commands[command]) {
commands[command](...args).catch(console.error);
} else {
console.log('Usage: session-manager.js <command> [args]');
console.log('Commands:', Object.keys(commands).join(', '));
}
}
module.exports = SessionManager;