Initial commit: Obsidian Web Interface for Claude Code
- Full IDE with terminal integration using xterm.js - Session management with local and web sessions - HTML preview functionality - Multi-terminal support with session picker Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
This commit is contained in:
678
services/claude-service.js
Normal file
678
services/claude-service.js
Normal file
@@ -0,0 +1,678 @@
|
||||
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;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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()
|
||||
});
|
||||
|
||||
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);
|
||||
return {
|
||||
id: session.id,
|
||||
pid: session.pid,
|
||||
workingDir: session.workingDir,
|
||||
status: session.status,
|
||||
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
|
||||
}
|
||||
);
|
||||
|
||||
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;
|
||||
Reference in New Issue
Block a user