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'); 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 this.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 }); this.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); // 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(); this.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 const claude = spawn('claude', ['-p', fullCommand], { cwd: session.workingDir, stdio: ['ignore', 'pipe', 'pipe'], // Explicitly set stdio to get stdout/stderr env: { ...process.env, TERM: 'xterm-256color' } }); let output = ''; let stderrOutput = ''; claude.stdout.on('data', (data) => { const text = data.toString(); console.log(`[ClaudeService] [${sessionId}] stdout:`, text.substring(0, 100)); output += text; // Add to output buffer session.outputBuffer.push({ type: 'stdout', timestamp: new Date().toISOString(), content: text }); // Emit for real-time updates this.emit('session-output', { sessionId, type: 'stdout', content: text }); }); 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 }); this.emit('session-output', { sessionId, type: 'stderr', content: text }); }); claude.on('close', (code) => { console.log(`[ClaudeService] [${sessionId}] Command completed with exit code ${code}`); // 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); this.emit('operations-detected', { sessionId, response: output, tags, operations }); console.log(`[ClaudeService] Detected ${operations.length} operations requiring approval`); } this.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); this.emit('session-error', { sessionId, error: error.message }); }); return { success: 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); return { id: historical.id, pid: null, workingDir: historical.workingDir, status: historical.status, createdAt: historical.created_at, lastActivity: historical.created_at, terminatedAt: historical.created_at, 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 */ listSessions() { return Array.from(this.sessions.values()).map(session => { const metadata = this.calculateSessionMetadata(session); // FIX: Only mark as running if process is actually alive const isRunning = session.status === 'running' && session.process && !session.process.killed; 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(/]*>[\s\S]*?<\/dyad-write>/g, '[File]') .replace(/]*)?>/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 } ); this.emit('operations-executed', { sessionId, results }); return results; } catch (error) { console.error(`[ClaudeService] Error executing operations:`, error); this.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;