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:
uroma
2026-01-19 16:29:44 +00:00
Unverified
commit 0dd2083556
44 changed files with 18955 additions and 0 deletions

678
services/claude-service.js Normal file
View 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;