Files
SuperCharged-Claude-Code-Up…/services/claude-service.js
uroma 55aafbae9a 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>
2026-01-22 14:43:05 +00:00

907 lines
27 KiB
JavaScript

const { spawn } = require('child_process');
const fs = require('fs');
const path = require('path');
const EventEmitter = require('events');
const os = require('os');
const { SYSTEM_PROMPT } = require('./system-prompt');
const { extractAllTags, generateOperationSummary, getDyadWriteTags } = require('./tag-parser');
const ResponseProcessor = require('./response-processor');
// ============================================================
// HYBRID APPROACH: Import global EventBus for SSE integration
// ============================================================
const eventBus = require('./event-bus');
// ============================================================
// REAL-TIME MONITORING: Import ChatMonitor
// ============================================================
const chatMonitor = require('./chat-monitor');
class ClaudeCodeService extends EventEmitter {
constructor(vaultPath) {
super();
this.vaultPath = vaultPath;
this.sessions = new Map();
this.claudeSessionsDir = path.join(vaultPath, 'Claude Sessions');
this.responseProcessor = new ResponseProcessor(vaultPath);
this.ensureDirectories();
}
ensureDirectories() {
if (!fs.existsSync(this.claudeSessionsDir)) {
fs.mkdirSync(this.claudeSessionsDir, { recursive: true });
}
}
/**
* Create a new Claude Code session
*/
createSession(options = {}) {
const sessionId = `session-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`;
const workingDir = options.workingDir || this.vaultPath;
console.log(`[ClaudeService] Creating session ${sessionId} in ${workingDir}`);
const session = {
id: sessionId,
pid: null,
process: null,
workingDir,
status: 'running',
createdAt: new Date().toISOString(),
lastActivity: new Date().toISOString(),
outputBuffer: [],
context: {
messages: [],
totalTokens: 0,
maxTokens: 200000
},
metadata: options.metadata || {}
};
// Add to sessions map
this.sessions.set(sessionId, session);
console.log(`[ClaudeService] Session ${sessionId} created successfully (using -p mode)`);
// Save session initialization
this.saveSessionToVault(session);
return session;
}
/**
* Execute shell command in session (for Full Stack mode)
* Spawns a shell process, sends command, captures output
*/
async executeShellCommand(sessionId, command) {
const session = this.sessions.get(sessionId);
if (!session) {
throw new Error(`Session ${sessionId} not found`);
}
if (session.status !== 'running') {
throw new Error(`Session ${sessionId} is not running`);
}
console.log(`[ClaudeService] Executing shell command in ${sessionId}:`, command);
// Spawn shell to execute the command
const shell = spawn('bash', ['-c', command], {
cwd: session.workingDir,
stdio: ['ignore', 'pipe', 'pipe'],
env: {
...process.env,
TERM: 'xterm-256color'
}
});
let stdout = '';
let stderr = '';
shell.stdout.on('data', (data) => {
const text = data.toString();
stdout += text;
// Add to output buffer
session.outputBuffer.push({
type: 'shell',
timestamp: new Date().toISOString(),
content: text
});
// Emit for real-time updates
eventBus.emit('session-output', {
sessionId,
type: 'stdout',
content: text
});
});
shell.stderr.on('data', (data) => {
const text = data.toString();
stderr += text;
session.outputBuffer.push({
type: 'stderr',
timestamp: new Date().toISOString(),
content: text
});
eventBus.emit('session-output', {
sessionId,
type: 'stderr',
content: text
});
});
return new Promise((resolve) => {
shell.on('close', (code) => {
const exitCode = code !== null ? code : -1;
console.log(`[ClaudeService] Shell command completed with exit code ${exitCode}`);
resolve({
exitCode,
stdout,
stderr,
success: exitCode === 0
});
});
});
}
/**
* Send command to a session using -p (print) mode
*/
sendCommand(sessionId, command) {
const session = this.sessions.get(sessionId);
if (!session) {
// Check if it's a historical session
try {
const historicalSessions = this.loadHistoricalSessions();
const isHistorical = historicalSessions.some(s => s.id === sessionId);
if (isHistorical) {
throw new Error(`Session ${sessionId} is a historical session and cannot accept new commands. Please start a new chat session.`);
}
} catch (error) {
// Ignore error from checking historical sessions
}
throw new Error(`Session ${sessionId} not found`);
}
if (session.status !== 'running') {
throw new Error(`Session ${sessionId} is not running (status: ${session.status})`);
}
console.log(`[ClaudeService] Sending command to session ${sessionId}:`, command);
// ============================================================
// REAL-TIME MONITORING: Start monitoring for this session
// ============================================================
chatMonitor.startSessionMonitor(sessionId);
chatMonitor.logEvent(sessionId, 'user_message_sent', { command, timestamp: Date.now() });
// Track command in context
session.context.messages.push({
role: 'user',
content: command,
timestamp: new Date().toISOString()
});
// Also save user message to outputBuffer for persistence
session.outputBuffer.push({
type: 'user',
role: 'user',
timestamp: new Date().toISOString(),
content: command
});
session.lastActivity = new Date().toISOString();
eventBus.emit('command-sent', {
sessionId,
command
});
// Prepend system prompt to command for tag-based output
const fullCommand = `${SYSTEM_PROMPT}\n\n${command}`;
// Spawn claude in -p (print) mode for this command
// NOTE: --output-format json is REQUIRED for non-interactive mode
const claude = spawn('claude', ['-p', fullCommand, '--output-format', 'json'], {
cwd: session.workingDir,
stdio: ['ignore', 'pipe', 'pipe'], // Explicitly set stdio to get stdout/stderr
env: {
...process.env,
TERM: 'xterm-256color'
},
timeout: 120000 // 2 minute timeout to prevent hanging
});
// ============================================================
// REAL-TIME MONITORING: Log Claude spawn
// ============================================================
chatMonitor.logEvent(sessionId, 'claude_spawned', {
pid: claude.pid,
command: 'claude -p <command> --output-format json',
timestamp: Date.now()
});
let output = '';
let stderrOutput = '';
let rawStdout = '';
claude.stdout.on('data', (data) => {
const text = data.toString();
rawStdout += text;
console.log(`[ClaudeService] [${sessionId}] stdout:`, text.substring(0, 100));
});
claude.stderr.on('data', (data) => {
const text = data.toString();
console.error(`[ClaudeService] [${sessionId}] stderr:`, text.substring(0, 100));
stderrOutput += text;
session.outputBuffer.push({
type: 'stderr',
timestamp: new Date().toISOString(),
content: text
});
eventBus.emit('session-output', {
sessionId,
type: 'stderr',
content: text
});
});
claude.on('close', (code) => {
console.log(`[ClaudeService] [${sessionId}] Command completed with exit code ${code}`);
// Parse JSON output from Claude CLI
// The --output-format json flag returns: { "type": "result", "result": "...", ... }
try {
const jsonOutput = JSON.parse(rawStdout.trim());
if (jsonOutput.type === 'result' && jsonOutput.result) {
output = jsonOutput.result;
// ============================================================
// REAL-TIME MONITORING: Log JSON parsing success
// ============================================================
chatMonitor.logEvent(sessionId, 'json_parsed', {
resultLength: output.length,
timestamp: Date.now()
});
// Add to output buffer
session.outputBuffer.push({
type: 'stdout',
timestamp: new Date().toISOString(),
content: output
});
// Emit for real-time updates
eventBus.emit('session-output', {
sessionId,
type: 'stdout',
content: output
});
// ============================================================
// REAL-TIME MONITORING: Log SSE emit and AI response
// ============================================================
chatMonitor.logEvent(sessionId, 'sse_emit', {
eventType: 'session-output',
contentLength: output.length,
timestamp: Date.now()
});
chatMonitor.logEvent(sessionId, 'ai_response', {
content: output.substring(0, 200),
fullLength: output.length,
timestamp: Date.now()
});
console.log(`[ClaudeService] [${sessionId}] Parsed JSON output, result length: ${output.length}`);
} else if (jsonOutput.type === 'error') {
// Handle error responses
const errorMsg = jsonOutput.error || jsonOutput.message || 'Unknown error';
console.error(`[ClaudeService] [${sessionId}] Claude CLI error:`, errorMsg);
eventBus.emit('session-output', {
sessionId,
type: 'stderr',
content: `Error: ${errorMsg}`
});
} else {
// Fallback to raw output if JSON structure is unexpected
console.warn(`[ClaudeService] [${sessionId}] Unexpected JSON structure:`, jsonOutput);
output = rawStdout;
}
} catch (parseError) {
// If parsing fails, treat as raw text output
console.warn(`[ClaudeService] [${sessionId}] Failed to parse JSON output, using raw:`, parseError.message);
output = rawStdout;
if (output.trim()) {
// Emit raw output as fallback
session.outputBuffer.push({
type: 'stdout',
timestamp: new Date().toISOString(),
content: output
});
eventBus.emit('session-output', {
sessionId,
type: 'stdout',
content: output
});
}
}
// Add assistant response to context
if (output.trim()) {
session.context.messages.push({
role: 'assistant',
content: output,
timestamp: new Date().toISOString()
});
}
// Parse tags from output
const tags = extractAllTags(output);
if (tags.writes.length > 0 || tags.renames.length > 0 || tags.deletes.length > 0 || tags.dependencies.length > 0) {
const operations = generateOperationSummary(tags);
eventBus.emit('operations-detected', {
sessionId,
response: output,
tags,
operations
});
console.log(`[ClaudeService] Detected ${operations.length} operations requiring approval`);
}
eventBus.emit('command-complete', {
sessionId,
exitCode: code,
output
});
// Save session to vault
this.saveSessionToVault(session);
});
claude.on('error', (error) => {
console.error(`[ClaudeService] [${sessionId}] Process error:`, error);
eventBus.emit('session-error', {
sessionId,
error: error.message
});
});
return { success: true };
}
/**
* Request approval for a command (instead of executing directly)
* @param {string} sessionId - Session ID
* @param {string} command - Command requiring approval
* @param {string} explanation - Human-readable explanation of what will happen
*/
requestApproval(sessionId, command, explanation) {
const session = this.sessions.get(sessionId);
if (!session) {
throw new Error(`Session ${sessionId} not found`);
}
console.log(`[ClaudeService] Requesting approval for session ${sessionId}:`, command);
// Track the pending request in session context
session.context.messages.push({
role: 'system',
content: `[AWAITING APPROVAL: ${command}]`,
timestamp: new Date().toISOString()
});
// Emit approval-request event for WebSocket to handle
eventBus.emit('approval-request', {
sessionId,
command,
explanation
});
return { success: true, awaitingApproval: true };
}
/**
* Get session details (handles both active and historical sessions)
*/
getSession(sessionId) {
// First check active sessions
let session = this.sessions.get(sessionId);
if (session) {
return {
id: session.id,
pid: session.pid,
workingDir: session.workingDir,
status: session.status,
createdAt: session.createdAt,
lastActivity: session.lastActivity,
terminatedAt: session.terminatedAt,
exitCode: session.exitCode,
outputBuffer: session.outputBuffer,
context: session.context,
metadata: session.metadata
};
}
// If not found in active sessions, check historical sessions
try {
const historicalSessions = this.loadHistoricalSessions();
const historical = historicalSessions.find(s => s.id === sessionId);
if (historical) {
// Load the full session from file
let sessionFiles = [];
try {
sessionFiles = fs.readdirSync(this.claudeSessionsDir);
} catch (err) {
console.error('Cannot read sessions directory:', err);
throw new Error('Sessions directory not accessible');
}
const sessionFile = sessionFiles.find(f => f.includes(sessionId));
if (sessionFile) {
const filepath = path.join(this.claudeSessionsDir, sessionFile);
// Check if file exists
if (!fs.existsSync(filepath)) {
console.error('Session file does not exist:', filepath);
throw new Error('Session file not found');
}
const content = fs.readFileSync(filepath, 'utf-8');
// Parse output buffer from markdown
const outputBuffer = this.parseOutputFromMarkdown(content);
// Normalize date properties with fallbacks for frontend compatibility
const createdAt = historical.created_at || historical.createdAt || new Date().toISOString();
const lastActivity = historical.last_activity || historical.lastActivity || historical.created_at || createdAt;
return {
id: historical.id,
pid: null,
workingDir: historical.workingDir,
status: historical.status,
createdAt,
lastActivity,
terminatedAt: historical.terminated_at || historical.terminatedAt || createdAt,
exitCode: null,
outputBuffer,
context: {
messages: [],
totalTokens: 0,
maxTokens: 200000
},
metadata: {
project: historical.project,
historical: true
}
};
} else {
throw new Error('Session file not found in directory');
}
}
} catch (error) {
console.error('Error loading historical session:', error);
// Re-throw with more context
if (error.message.includes('not found')) {
throw error;
}
throw new Error(`Failed to load historical session: ${error.message}`);
}
throw new Error(`Session ${sessionId} not found`);
}
/**
* Parse output buffer from session markdown file
*/
parseOutputFromMarkdown(markdown) {
const outputBuffer = [];
const lines = markdown.split('\n');
let currentSection = null;
let currentContent = [];
let currentTimestamp = null;
for (const line of lines) {
// Check for output sections
if (line.match(/^### \w+ - (\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z)/)) {
// Save previous section if exists
if (currentSection && currentContent.length > 0) {
outputBuffer.push({
type: currentSection,
timestamp: currentTimestamp,
content: currentContent.join('\n')
});
}
// Start new section
const match = line.match(/^### (\w+) - (.+)$/);
if (match) {
currentSection = match[1].toLowerCase();
currentTimestamp = match[2];
currentContent = [];
}
} else if (currentSection && line.match(/^```$/)) {
// End of code block
if (currentContent.length > 0) {
outputBuffer.push({
type: currentSection,
timestamp: currentTimestamp,
content: currentContent.join('\n')
});
}
currentSection = null;
currentContent = [];
} else if (currentSection) {
currentContent.push(line);
}
}
// Don't forget the last section
if (currentSection && currentContent.length > 0) {
outputBuffer.push({
type: currentSection,
timestamp: currentTimestamp,
content: currentContent.join('\n')
});
}
return outputBuffer;
}
/**
* List all sessions
* Sessions in memory are considered active/running even without a process
* because processes are spawned on-demand when commands are sent
*/
listSessions() {
return Array.from(this.sessions.values()).map(session => {
const metadata = this.calculateSessionMetadata(session);
// FIX: Sessions in memory are considered active
// In the new architecture, processes are spawned on-demand
// A session is "running" if it exists in memory and hasn't been stopped
const isRunning = session.status === 'running';
return {
id: session.id,
pid: session.pid,
workingDir: session.workingDir,
status: isRunning ? 'running' : 'stopped',
createdAt: session.createdAt,
lastActivity: session.lastActivity,
metadata: session.metadata,
...metadata
};
});
}
/**
* Calculate enhanced session metadata
*/
calculateSessionMetadata(session) {
const metadata = {
lastMessage: null,
fileCount: 0,
messageCount: 0
};
if (session.outputBuffer && session.outputBuffer.length > 0) {
// Get last message
const lastEntry = session.outputBuffer[session.outputBuffer.length - 1];
metadata.lastMessage = this.extractMessagePreview(lastEntry.content);
// Count dyad-write tags (files created/modified)
metadata.fileCount = session.outputBuffer.reduce((count, entry) => {
const writeTags = getDyadWriteTags(entry.content);
return count + writeTags.length;
}, 0);
metadata.messageCount = session.outputBuffer.length;
}
return metadata;
}
/**
* Extract message preview (first 100 chars, stripped of tags)
*/
extractMessagePreview(content) {
if (!content) {
return 'No messages yet';
}
// Remove dyad tags and strip markdown code blocks (chained for efficiency)
let preview = content
.replace(/<dyad-write[^>]*>[\s\S]*?<\/dyad-write>/g, '[File]')
.replace(/<dyad-[a-z-]+(?:\s+[^>]*)?>/g, '')
.replace(/```[\s\S]*?```/g, '[Code]');
// Get first 100 chars
preview = preview.substring(0, 100);
// Truncate at last word boundary
if (preview.length === 100) {
const lastSpace = preview.lastIndexOf(' ');
if (lastSpace > 50) {
preview = preview.substring(0, lastSpace);
} else {
// If no good word boundary, truncate at a safe point
preview = preview.substring(0, 97);
}
preview += '...';
}
return preview.trim() || 'No messages yet';
}
/**
* Terminate a session
*/
terminateSession(sessionId) {
const session = this.sessions.get(sessionId);
if (!session) {
throw new Error(`Session ${sessionId} not found`);
}
if (session.status !== 'running') {
throw new Error(`Session ${sessionId} is not running`);
}
session.process.kill();
session.status = 'terminating';
return { success: true };
}
/**
* Parse context updates from output
*/
parseContextUpdate(session, output) {
// Look for token usage patterns
const tokenMatch = output.match(/(\d+) tokens? used/i);
if (tokenMatch) {
const tokens = parseInt(tokenMatch[1]);
session.context.totalTokens = tokens;
}
// Look for assistant responses
if (output.includes('Claude:') || output.includes('Assistant:')) {
session.context.messages.push({
role: 'assistant',
content: output,
timestamp: new Date().toISOString()
});
}
}
/**
* Save session to Obsidian vault
*/
saveSessionToVault(session) {
const date = new Date().toISOString().split('T')[0];
const filename = `${date}-${session.id}.md`;
const filepath = path.join(this.claudeSessionsDir, filename);
const content = this.generateSessionMarkdown(session);
try {
fs.writeFileSync(filepath, content, 'utf-8');
} catch (error) {
console.error('Failed to save session to vault:', error);
}
}
/**
* Generate markdown representation of session
*/
generateSessionMarkdown(session) {
const lines = [];
lines.push('---');
lines.push(`type: claude-session`);
lines.push(`session_id: ${session.id}`);
lines.push(`status: ${session.status}`);
lines.push(`created_at: ${session.createdAt}`);
lines.push(`working_dir: ${session.workingDir}`);
if (session.metadata.project) {
lines.push(`project: ${session.metadata.project}`);
}
lines.push('---');
lines.push('');
lines.push(`# Claude Code Session: ${session.id}`);
lines.push('');
lines.push(`**Created**: ${session.createdAt}`);
lines.push(`**Status**: ${session.status}`);
lines.push(`**Working Directory**: \`${session.workingDir}\``);
if (session.pid) {
lines.push(`**PID**: ${session.pid}`);
}
lines.push('');
// Context summary
lines.push('## Context Usage');
lines.push('');
lines.push(`- **Total Tokens**: ${session.context.totalTokens}`);
lines.push(`- **Messages**: ${session.context.messages.length}`);
lines.push(`- **Token Limit**: ${session.context.maxTokens}`);
lines.push('');
// Output
lines.push('## Session Output');
lines.push('');
session.outputBuffer.forEach(entry => {
lines.push(`### ${entry.type} - ${entry.timestamp}`);
lines.push('');
lines.push('```');
lines.push(entry.content);
lines.push('```');
lines.push('');
});
return lines.join('\n');
}
/**
* Load historical sessions from vault
*/
loadHistoricalSessions() {
const sessions = [];
try {
const files = fs.readdirSync(this.claudeSessionsDir);
const sessionFiles = files.filter(f => f.endsWith('.md') && f.includes('session-'));
sessionFiles.forEach(file => {
const filepath = path.join(this.claudeSessionsDir, file);
const content = fs.readFileSync(filepath, 'utf-8');
// Parse frontmatter
const frontmatterMatch = content.match(/^---\n([\s\S]*?)\n---/);
if (frontmatterMatch) {
const frontmatter = {};
frontmatterMatch[1].split('\n').forEach(line => {
const [key, ...valueParts] = line.split(':');
if (key && valueParts.length > 0) {
frontmatter[key.trim()] = valueParts.join(':').trim();
}
});
// Parse output buffer from content
const outputBuffer = this.parseOutputFromMarkdown(content);
// Calculate metadata
const tempSession = { outputBuffer };
const metadata = this.calculateSessionMetadata(tempSession);
sessions.push({
id: frontmatter.session_id,
status: frontmatter.status,
createdAt: frontmatter.created_at,
workingDir: frontmatter.working_dir,
project: frontmatter.project,
file: filepath,
...metadata
});
}
});
} catch (error) {
console.error('Failed to load historical sessions:', error);
}
return sessions;
}
/**
* Get context statistics
*/
getContextStats(sessionId) {
const session = this.sessions.get(sessionId);
if (!session) {
throw new Error(`Session ${sessionId} not found`);
}
return {
totalTokens: session.context.totalTokens,
maxTokens: session.context.maxTokens,
usagePercentage: (session.context.totalTokens / session.context.maxTokens) * 100,
messageCount: session.context.messages.length,
messages: session.context.messages
};
}
/**
* Clean up terminated sessions
*/
cleanup() {
const now = Date.now();
const maxAge = 24 * 60 * 60 * 1000; // 24 hours
for (const [sessionId, session] of this.sessions.entries()) {
const sessionAge = now - new Date(session.createdAt).getTime();
if (session.status === 'terminated' && sessionAge > maxAge) {
this.sessions.delete(sessionId);
}
}
}
/**
* Execute operations after user approval
*/
async executeOperations(sessionId, response, onProgress) {
const session = this.sessions.get(sessionId);
if (!session) {
throw new Error(`Session ${sessionId} not found`);
}
console.log(`[ClaudeService] Executing operations for session ${sessionId}`);
try {
const results = await this.responseProcessor.processResponse(
sessionId,
response,
{
workingDir: session.workingDir,
autoApprove: true,
onProgress
}
);
eventBus.emit('operations-executed', {
sessionId,
results
});
return results;
} catch (error) {
console.error(`[ClaudeService] Error executing operations:`, error);
eventBus.emit('operations-error', {
sessionId,
error: error.message
});
throw error;
}
}
/**
* Preview operations without executing
*/
async previewOperations(sessionId, response) {
const session = this.sessions.get(sessionId);
if (!session) {
throw new Error(`Session ${sessionId} not found`);
}
return await this.responseProcessor.previewOperations(response, session.workingDir);
}
}
module.exports = ClaudeCodeService;