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;

View File

@@ -0,0 +1,515 @@
const { spawn } = require('child_process');
const fs = require('fs');
const path = require('path');
const EventEmitter = require('events');
/**
* Enhanced Claude Service with Full Development Workflow
* Supports: Planning → Brainstorming → Coding → Live Preview
*/
class ClaudeWorkflowService extends EventEmitter {
constructor(vaultPath) {
super();
this.vaultPath = vaultPath;
this.sessions = new Map();
}
/**
* Create a new session with workflow tracking
*/
createSession(options = {}) {
const sessionId = `session-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`;
const workingDir = options.workingDir || this.vaultPath;
const session = {
id: sessionId,
workingDir,
status: 'running',
createdAt: new Date().toISOString(),
lastActivity: new Date().toISOString(),
outputBuffer: [],
// Workflow stages
workflow: {
currentPhase: 'planning', // planning, brainstorming, coding, preview
phases: {
planning: { status: 'pending', output: [] },
brainstorming: { status: 'pending', output: [] },
coding: { status: 'pending', output: [], filesCreated: [] },
preview: { status: 'pending', url: null, processId: null }
}
},
// Tool usage tracking
toolsUsed: [],
metadata: options.metadata || {}
};
this.sessions.set(sessionId, session);
console.log(`[WorkflowService] Session ${sessionId} created`);
return session;
}
/**
* Send command with automatic workflow detection
*/
async sendCommand(sessionId, command) {
const session = this.sessions.get(sessionId);
if (!session) {
throw new Error(`Session ${sessionId} not found`);
}
console.log(`[WorkflowService] Processing command:`, command);
// Detect phase based on command content
const phase = this.detectPhase(command);
session.workflow.currentPhase = phase;
// Update phase status
session.workflow.phases[phase].status = 'active';
// Execute command based on phase
let result;
switch (phase) {
case 'planning':
result = await this.executePlanningPhase(session, command);
break;
case 'brainstorming':
result = await this.executeBrainstormingPhase(session, command);
break;
case 'coding':
result = await this.executeCodingPhase(session, command);
break;
case 'preview':
result = await this.executePreviewPhase(session, command);
break;
default:
result = await this.executeDefaultPhase(session, command);
}
return result;
}
/**
* Detect which phase the command belongs to
*/
detectPhase(command) {
const lower = command.toLowerCase();
if (lower.includes('plan') || lower.includes('design') || lower.includes('architecture')) {
return 'planning';
}
if (lower.includes('brainstorm') || lower.includes('ideas') || lower.includes('approach')) {
return 'brainstorming';
}
if (lower.includes('create') || lower.includes('build') || lower.includes('implement') ||
lower.includes('write code') || lower.includes('make a')) {
return 'coding';
}
if (lower.includes('preview') || lower.includes('run') || lower.includes('start') ||
lower.includes('deploy') || lower.includes('test')) {
return 'preview';
}
return 'planning'; // default
}
/**
* Execute Planning Phase - Show structured plan
*/
async executePlanningPhase(session, command) {
console.log(`[WorkflowService] ===== PLANNING PHASE =====`);
const claude = spawn('claude', ['-p', command], {
cwd: session.workingDir,
stdio: ['ignore', 'pipe', 'pipe'],
env: { ...process.env, PATH: process.env.PATH },
shell: true
});
let output = '';
let structuredPlan = null;
claude.stdout.on('data', (data) => {
const text = data.toString();
output += text;
// Try to extract structured plan
const planMatch = text.match(/## Plan\n([\s\S]*?)(?=\n##|$)/);
if (planMatch) {
structuredPlan = this.parsePlan(planMatch[1]);
}
this.emitPhaseUpdate(session, 'planning', text);
});
await new Promise(resolve => claude.on('close', resolve));
session.workflow.phases.planning.status = 'completed';
session.workflow.phases.planning.output.push({ content: output, timestamp: new Date() });
return {
phase: 'planning',
output,
structuredPlan
};
}
/**
* Execute Brainstorming Phase - Explore alternatives
*/
async executeBrainstormingPhase(session, command) {
console.log(`[WorkflowService] ===== BRAINSTORMING PHASE =====`);
const brainstormingPrompt = `
Task: ${command}
Please brainstorm 2-3 different approaches to solve this. For each approach:
1. Describe the approach briefly
2. List pros and cons
3. Recommend the best option with reasoning
Format your response as:
## Approach 1: [Name]
**Description**: ...
**Pros**: ...
**Cons**: ...
## Recommendation
[Your recommended approach with reasoning]
`;
const claude = spawn('claude', ['-p', brainstormingPrompt], {
cwd: session.workingDir,
stdio: ['ignore', 'pipe', 'pipe'],
env: { ...process.env, PATH: process.env.PATH },
shell: true
});
let output = '';
let alternatives = [];
claude.stdout.on('data', (data) => {
const text = data.toString();
output += text;
// Extract alternatives
const approaches = text.match(/## Approach \d+: ([^\n]+)/g);
if (approaches) {
alternatives = approaches.map(a => a.replace('## Approach \\d+: ', ''));
}
this.emitPhaseUpdate(session, 'brainstorming', text);
});
await new Promise(resolve => claude.on('close', resolve));
session.workflow.phases.brainstorming.status = 'completed';
session.workflow.phases.brainstorming.output.push({ content: output, timestamp: new Date() });
return {
phase: 'brainstorming',
output,
alternatives
};
}
/**
* Execute Coding Phase - Actually create files
*/
async executeCodingPhase(session, command) {
console.log(`[WorkflowService] ===== CODING PHASE =====`);
// First, get the plan from Claude
const planningPrompt = `
Task: ${command}
Please provide a detailed implementation plan including:
1. File structure (what files to create)
2. For each file, specify the exact content in a code block
Format:
## File Structure
- file1.ext
- file2.ext
## Files to Create
### file1.ext
\`\`\`language
code here
\`\`\`
### file2.ext
\`\`\`language
code here
\`\`\`
`;
const claude = spawn('claude', ['-p', planningPrompt], {
cwd: session.workingDir,
stdio: ['ignore', 'pipe', 'pipe'],
env: { ...process.env, PATH: process.env.PATH },
shell: true
});
let output = '';
const filesToCreate = [];
claude.stdout.on('data', (data) => {
const text = data.toString();
output += text;
// Extract files to create
const fileMatches = text.match(/### ([^\n]+)\n```(\w+)?\n([\s\S]*?)```/g);
if (fileMatches) {
fileMatches.forEach(match => {
const [, filename, lang, content] = match.match(/### ([^\n]+)\n```(\w+)?\n([\s\S]*?)```/);
filesToCreate.push({ filename, content: content.trim() });
});
}
this.emitPhaseUpdate(session, 'coding', text);
});
await new Promise(resolve => claude.on('close', resolve));
// Actually create the files
const createdFiles = [];
for (const file of filesToCreate) {
try {
const filePath = path.join(session.workingDir, file.filename);
const dir = path.dirname(filePath);
// Create directory if needed
if (!fs.existsSync(dir)) {
fs.mkdirSync(dir, { recursive: true });
}
// Write file
fs.writeFileSync(filePath, file.content, 'utf-8');
createdFiles.push(file.filename);
console.log(`[WorkflowService] Created file: ${file.filename}`);
this.emit('file-created', {
sessionId: session.id,
filename: file.filename,
path: filePath
});
} catch (error) {
console.error(`[WorkflowService] Failed to create ${file.filename}:`, error);
}
}
session.workflow.phases.coding.status = 'completed';
session.workflow.phases.coding.output.push({ content: output, timestamp: new Date() });
session.workflow.phases.coding.filesCreated = createdFiles;
return {
phase: 'coding',
output,
filesCreated: createdFiles
};
}
/**
* Execute Preview Phase - Run and preview the app
*/
async executePreviewPhase(session, command) {
console.log(`[WorkflowService] ===== PREVIEW PHASE =====`);
// Detect package.json to determine how to run
const packageJsonPath = path.join(session.workingDir, 'package.json');
let startCommand = null;
let port = null;
if (fs.existsSync(packageJsonPath)) {
const packageJson = JSON.parse(fs.readFileSync(packageJsonPath, 'utf-8'));
// Check for common scripts
if (packageJson.scripts) {
if (packageJson.scripts.dev) startCommand = 'npm run dev';
else if (packageJson.scripts.start) startCommand = 'npm start';
else if (packageJson.scripts.preview) startCommand = 'npm run preview';
}
// Try to detect port
if (packageJson.scripts?.dev?.includes('--port')) {
const portMatch = packageJson.scripts.dev.match(/--port (\d+)/);
if (portMatch) port = parseInt(portMatch[1]);
}
}
if (!startCommand) {
// Check for common files
if (fs.existsSync(path.join(session.workingDir, 'index.html'))) {
// Static HTML - use simple HTTP server
startCommand = 'npx -y http-server -p 8080';
port = 8080;
} else if (fs.existsSync(path.join(session.workingDir, 'vite.config.js'))) {
startCommand = 'npm run dev -- --port 5173';
port = 5173;
}
}
if (!startCommand) {
return {
phase: 'preview',
error: 'Could not determine how to run this project. Please specify the start command.',
suggestion: 'Try running: npm install && npm run dev'
};
}
console.log(`[WorkflowService] Starting: ${startCommand}`);
// Start the dev server
const serverProcess = spawn('sh', ['-c', startCommand], {
cwd: session.workingDir,
stdio: ['ignore', 'pipe', 'pipe'],
env: { ...process.env, PATH: process.env.PATH },
detached: true
});
let serverOutput = '';
serverProcess.stdout.on('data', (data) => {
const text = data.toString();
serverOutput += text;
// Try to extract port from output
const portMatch = text.match(/(?:localhost|http:\/\/):(\d+)/);
if (portMatch && !port) {
port = parseInt(portMatch[1]);
}
this.emitPhaseUpdate(session, 'preview', `[Server] ${text}`);
});
serverProcess.stderr.on('data', (data) => {
const text = data.toString();
serverOutput += text;
this.emitPhaseUpdate(session, 'preview', `[Server Error] ${text}`);
});
// Wait a bit for server to start
await new Promise(resolve => setTimeout(resolve, 3000));
// Unref to keep it running in background
serverProcess.unref();
const previewUrl = port ? `http://localhost:${port}` : null;
session.workflow.phases.preview.status = 'running';
session.workflow.phases.preview.url = previewUrl;
session.workflow.phases.preview.processId = serverProcess.pid;
session.workflow.phases.preview.output.push({ content: serverOutput, timestamp: new Date() });
this.emit('preview-ready', {
sessionId: session.id,
url: previewUrl,
processId: serverProcess.pid
});
return {
phase: 'preview',
command: startCommand,
url: previewUrl,
output: serverOutput
};
}
/**
* Default phase execution
*/
async executeDefaultPhase(session, command) {
const claude = spawn('claude', ['-p', command], {
cwd: session.workingDir,
stdio: ['ignore', 'pipe', 'pipe'],
env: { ...process.env, PATH: process.env.PATH },
shell: true
});
let output = '';
claude.stdout.on('data', (data) => {
output += data.toString();
this.emit('session-output', { sessionId: session.id, content: data.toString() });
});
await new Promise(resolve => claude.on('close', resolve));
return { phase: 'default', output };
}
/**
* Parse structured plan from Claude output
*/
parsePlan(text) {
const steps = [];
const stepMatches = text.match(/\d+\.([^\n]+)/g);
if (stepMatches) {
stepMatches.forEach(match => {
steps.push(match.replace(/^\d+\.\s*/, ''));
});
}
return { steps };
}
/**
* Emit phase update for real-time UI updates
*/
emitPhaseUpdate(session, phase, content) {
this.emit('phase-update', {
sessionId: session.id,
phase,
content,
timestamp: new Date()
});
// Also add to output buffer
session.outputBuffer.push({
type: 'stdout',
timestamp: new Date().toISOString(),
content,
phase
});
session.lastActivity = new Date().toISOString();
}
/**
* Get session details
*/
getSession(sessionId) {
const session = this.sessions.get(sessionId);
if (!session) {
throw new Error(`Session ${sessionId} not found`);
}
return session;
}
/**
* Stop preview server
*/
stopPreview(sessionId) {
const session = this.sessions.get(sessionId);
if (!session || !session.workflow.phases.preview.processId) {
return { success: false, error: 'No preview server running' };
}
try {
process.kill(-session.workflow.phases.preview.processId, 'SIGTERM');
session.workflow.phases.preview.status = 'stopped';
session.workflow.phases.preview.processId = null;
return { success: true };
} catch (error) {
return { success: false, error: error.message };
}
}
}
module.exports = ClaudeWorkflowService;

View File

@@ -0,0 +1,298 @@
const fs = require('fs').promises;
const fsSync = require('fs');
const path = require('path');
const { spawn } = require('child_process');
const { extractAllTags } = require('./tag-parser');
/**
* Response Processor - Executes dyad-style tags
* Handles file operations, package installation, and commands
*/
class ResponseProcessor {
constructor(vaultPath) {
this.vaultPath = vaultPath;
}
/**
* Process all tags from a response
* Executes operations in order: delete → rename → write → dependencies → commands
*/
async processResponse(sessionId, response, options = {}) {
const {
workingDir = this.vaultPath,
autoApprove = false,
onProgress = null
} = options;
const tags = extractAllTags(response);
// Track operations
const results = {
sessionId,
workingDir,
operations: [],
errors: [],
timestamp: new Date().toISOString()
};
// Execute in order: deletes first, then renames, then writes
for (const tag of tags.deletes) {
try {
if (onProgress) onProgress({ type: 'delete', path: tag.path });
const result = await this.executeDelete(workingDir, tag);
results.operations.push(result);
} catch (error) {
results.errors.push({ type: 'delete', tag, error: error.message });
}
}
for (const tag of tags.renames) {
try {
if (onProgress) onProgress({ type: 'rename', from: tag.from, to: tag.to });
const result = await this.executeRename(workingDir, tag);
results.operations.push(result);
} catch (error) {
results.errors.push({ type: 'rename', tag, error: error.message });
}
}
for (const tag of tags.writes) {
try {
if (onProgress) onProgress({ type: 'write', path: tag.path });
const result = await this.executeWrite(workingDir, tag);
results.operations.push(result);
} catch (error) {
results.errors.push({ type: 'write', tag, error: error.message });
}
}
for (const tag of tags.dependencies) {
try {
if (onProgress) onProgress({ type: 'install', packages: tag.packages });
const result = await this.executeAddDependency(workingDir, tag);
results.operations.push(result);
} catch (error) {
results.errors.push({ type: 'install', tag, error: error.message });
}
}
for (const tag of tags.commands) {
try {
if (onProgress) onProgress({ type: 'command', command: tag.command });
const result = await this.executeCommand(workingDir, tag);
results.operations.push(result);
} catch (error) {
results.errors.push({ type: 'command', tag, error: error.message });
}
}
return results;
}
/**
* Execute dyad-write tag
*/
async executeWrite(workingDir, tag) {
const fullPath = path.join(workingDir, tag.path);
// Security check
if (!fullPath.startsWith(workingDir)) {
throw new Error('Access denied: path outside working directory');
}
// Create directory if needed
const dir = path.dirname(fullPath);
if (!fsSync.existsSync(dir)) {
await fs.mkdir(dir, { recursive: true });
}
// Write file
await fs.writeFile(fullPath, tag.content, 'utf-8');
return {
type: 'write',
path: tag.path,
fullPath,
success: true
};
}
/**
* Execute dyad-rename tag
*/
async executeRename(workingDir, tag) {
const fromPath = path.join(workingDir, tag.from);
const toPath = path.join(workingDir, tag.to);
// Security check
if (!fromPath.startsWith(workingDir) || !toPath.startsWith(workingDir)) {
throw new Error('Access denied: path outside working directory');
}
// Check if source exists
if (!fsSync.existsSync(fromPath)) {
throw new Error(`Source file not found: ${tag.from}`);
}
// Create target directory if needed
const toDir = path.dirname(toPath);
if (!fsSync.existsSync(toDir)) {
await fs.mkdir(toDir, { recursive: true });
}
// Rename/move file
await fs.rename(fromPath, toPath);
return {
type: 'rename',
from: tag.from,
to: tag.to,
fromPath,
toPath,
success: true
};
}
/**
* Execute dyad-delete tag
*/
async executeDelete(workingDir, tag) {
const fullPath = path.join(workingDir, tag.path);
// Security check
if (!fullPath.startsWith(workingDir)) {
throw new Error('Access denied: path outside working directory');
}
// Check if exists
if (!fsSync.existsSync(fullPath)) {
throw new Error(`File not found: ${tag.path}`);
}
// Delete file or directory
const stat = await fs.stat(fullPath);
if (stat.isDirectory()) {
await fs.rm(fullPath, { recursive: true, force: true });
} else {
await fs.unlink(fullPath);
}
return {
type: 'delete',
path: tag.path,
fullPath,
success: true
};
}
/**
* Execute dyad-add-dependency tag
*/
async executeAddDependency(workingDir, tag) {
const packageJsonPath = path.join(workingDir, 'package.json');
// Check if package.json exists
if (!fsSync.existsSync(packageJsonPath)) {
throw new Error('No package.json found in working directory');
}
// Install packages using npm
return new Promise((resolve, reject) => {
const packages = tag.packages.join(' ');
const npm = spawn('npm', ['install', '--save', ...tag.packages], {
cwd: workingDir,
stdio: ['ignore', 'pipe', 'pipe'],
shell: true
});
let output = '';
let errorOutput = '';
npm.stdout.on('data', (data) => {
output += data.toString();
});
npm.stderr.on('data', (data) => {
errorOutput += data.toString();
});
npm.on('close', (code) => {
if (code === 0) {
resolve({
type: 'install',
packages: tag.packages,
output,
success: true
});
} else {
reject(new Error(`npm install failed: ${errorOutput}`));
}
});
npm.on('error', (error) => {
reject(new Error(`Failed to spawn npm: ${error.message}`));
});
});
}
/**
* Execute dyad-command tag
* Currently supports: rebuild, restart, refresh
*/
async executeCommand(workingDir, tag) {
const commands = {
rebuild: async () => {
// Rebuild logic - could be project-specific
return {
type: 'command',
command: tag.command,
result: 'Rebuild triggered',
success: true
};
},
restart: async () => {
// Restart dev server logic
return {
type: 'command',
command: tag.command,
result: 'Restart triggered',
success: true
};
},
refresh: async () => {
// Refresh preview logic
return {
type: 'command',
command: tag.command,
result: 'Refresh triggered',
success: true
};
}
};
const handler = commands[tag.command];
if (!handler) {
throw new Error(`Unknown command: ${tag.command}`);
}
return await handler();
}
/**
* Preview operations without executing
*/
async previewOperations(response, workingDir = this.vaultPath) {
const tags = extractAllTags(response);
const { generateOperationSummary } = require('./tag-parser');
return {
workingDir,
operations: generateOperationSummary(tags),
hasChanges: tags.writes.length > 0 || tags.renames.length > 0 || tags.deletes.length > 0,
timestamp: new Date().toISOString()
};
}
}
module.exports = ResponseProcessor;

102
services/system-prompt.js Normal file
View File

@@ -0,0 +1,102 @@
/**
* Dyad-style System Prompt - Aggressive Version
* Forces Claude to use XML-like tags instead of markdown
*/
const SYSTEM_PROMPT = `<role>
You are an AI programming assistant that CREATES web applications AUTOMATICALLY.
# CRITICAL OUTPUT FORMAT - READ THIS FIRST
⚠️ **NEVER use markdown code blocks (\\\`\\\`\\\`)**
⚠️ **ALWAYS use <dyad-write> tags for ALL code**
⚠️ **When user asks to build/ create/ make something, IMMEDIATELY output the files**
## MANDATORY BEHAVIOR
When the user says:
- "build X" → IMMEDIATELY output <dyad-write> tags with the files
- "create X" → IMMEDIATELY output <dyad-write> tags with the files
- "make X" → IMMEDIATELY output <dyad-write> tags with the files
- "can you make X" → IMMEDIATELY output <dyad-write> tags with the files
DO NOT ask "Should I create it?" or "Do you want me to proceed?"
JUST CREATE THE FILES AUTOMATICALLY.
## Tag Reference
<dyad-write path="relative/path/to/file">
file content goes here
NO markdown wrapping, just the raw code
</dyad-write>
<dyad-add-dependency packages="package1 package2">
<dyad-command type="rebuild">
## Example - WRONG
User: "Create a calculator"
Assistant: "Sure! I'll create a calculator for you. Here's the code:
\\\`\\\`\\\`
// code
\\\`\\\`\\\`
## Example - CORRECT
User: "Create a calculator"
Assistant: "I'll create a calculator for you.
<dyad-write path="calculator.html">
<!DOCTYPE html>
<html>
<body>
<h1>Calculator</h1>
</body>
</html>
</dyad-write>
<dyad-write path="calculator.css">
body { font-family: Arial; }
</dyad-write>
<dyad-write path="calculator.js">
// Calculator logic here
</dyad-write>"
## Rules
1. **NEVER** wrap code in \\\`\\\`\\\` markdown blocks
2. **ALWAYS** use <dyad-write path="filename">content</dyad-write>
3. When user wants something built, **JUST BUILD IT** - don't ask for permission
4. Be brief in explanations, let the tags do the work
5. Use relative paths from current directory
## Quick Reference
Creating HTML page:
<dyad-write path="index.html">
<!DOCTYPE html>
<html>
<head><title>App</title></head>
<body><h1>Hello</h1></body>
</html>
</dyad-write>
Creating multiple files:
<dyad-write path="index.html">...</dyad-write>
<dyad-write path="style.css">...</dyad-write>
<dyad-write path="app.js">...</dyad-write>
Installing packages:
<dyad-add-dependency packages="react react-dom">
---
REMEMBER: User asks → You AUTOMATICALLY create files with <dyad-write> tags
NO markdown code blocks EVER
</role>
`;
module.exports = { SYSTEM_PROMPT };

236
services/tag-parser.js Normal file
View File

@@ -0,0 +1,236 @@
/**
* Tag Parser for Dyad-style XML-like tags
* Extracts operations from LLM responses
*/
/**
* Parse all dyad-write tags from response
* Format: <dyad-write path="file.ext">content</dyad-write>
*/
function getDyadWriteTags(response) {
const tags = [];
const regex = /<dyad-write\s+path="([^"]+)">([\s\S]*?)<\/dyad-write>/g;
let match;
while ((match = regex.exec(response)) !== null) {
tags.push({
type: 'write',
path: match[1],
content: match[2].trim()
});
}
return tags;
}
/**
* Parse all dyad-rename tags from response
* Format: <dyad-rename from="old.ext" to="new.ext">
*/
function getDyadRenameTags(response) {
const tags = [];
const regex = /<dyad-rename\s+from="([^"]+)"\s+to="([^"]+)">/g;
let match;
while ((match = regex.exec(response)) !== null) {
tags.push({
type: 'rename',
from: match[1],
to: match[2]
});
}
return tags;
}
/**
* Parse all dyad-delete tags from response
* Format: <dyad-delete path="file.ext">
*/
function getDyadDeleteTags(response) {
const tags = [];
const regex = /<dyad-delete\s+path="([^"]+)">/g;
let match;
while ((match = regex.exec(response)) !== null) {
tags.push({
type: 'delete',
path: match[1]
});
}
return tags;
}
/**
* Parse all dyad-add-dependency tags from response
* Format: <dyad-add-dependency packages="pkg1 pkg2">
*/
function getDyadAddDependencyTags(response) {
const tags = [];
const regex = /<dyad-add-dependency\s+packages="([^"]+)">/g;
let match;
while ((match = regex.exec(response)) !== null) {
tags.push({
type: 'add-dependency',
packages: match[1].split(' ').filter(p => p.trim())
});
}
return tags;
}
/**
* Parse all dyad-command tags from response
* Format: <dyad-command type="rebuild|restart|refresh">
*/
function getDyadCommandTags(response) {
const tags = [];
const regex = /<dyad-command\s+type="([^"]+)">/g;
let match;
while ((match = regex.exec(response)) !== null) {
tags.push({
type: 'command',
command: match[1]
});
}
return tags;
}
/**
* Parse all dyad-chat-summary tags from response
* Format: <dyad-chat-summary>summary text</dyad-chat-summary>
*/
function getDyadChatSummary(response) {
const match = response.match(/<dyad-chat-summary>([\s\S]*?)<\/dyad-chat-summary>/);
if (match) {
return {
type: 'chat-summary',
summary: match[1].trim()
};
}
return null;
}
/**
* Extract all tags from response in order
* Returns structured object with all tag types
*/
function extractAllTags(response) {
return {
writes: getDyadWriteTags(response),
renames: getDyadRenameTags(response),
deletes: getDyadDeleteTags(response),
dependencies: getDyadAddDependencyTags(response),
commands: getDyadCommandTags(response),
chatSummary: getDyadChatSummary(response)
};
}
/**
* Check if response contains any actionable tags
*/
function hasActionableTags(response) {
const tags = extractAllTags(response);
return (
tags.writes.length > 0 ||
tags.renames.length > 0 ||
tags.deletes.length > 0 ||
tags.dependencies.length > 0 ||
tags.commands.length > 0
);
}
/**
* Strip all tags from response for display purposes
*/
function stripTags(response) {
let stripped = response;
// Remove dyad-write tags
stripped = stripped.replace(/<dyad-write\s+path="[^"]+">[\s\S]*?<\/dyad-write>/g, '[File operation]');
// Remove dyad-rename tags
stripped = stripped.replace(/<dyad-rename\s+from="[^"]+"\s+to="[^"]+">/g, '[Rename operation]');
// Remove dyad-delete tags
stripped = stripped.replace(/<dyad-delete\s+path="[^"]+">/g, '[Delete operation]');
// Remove dyad-add-dependency tags
stripped = stripped.replace(/<dyad-add-dependency\s+packages="[^"]+">/g, '[Install packages]');
// Remove dyad-command tags
stripped = stripped.replace(/<dyad-command\s+type="[^"]+">/g, '[Command]');
// Remove dyad-chat-summary tags
stripped = stripped.replace(/<dyad-chat-summary>[\s\S]*?<\/dyad-chat-summary>/g, '');
return stripped;
}
/**
* Generate a summary of operations for approval UI
*/
function generateOperationSummary(tags) {
const operations = [];
tags.deletes.forEach(tag => {
operations.push({
type: 'delete',
description: `Delete ${tag.path}`,
tag
});
});
tags.renames.forEach(tag => {
operations.push({
type: 'rename',
description: `Rename ${tag.from}${tag.to}`,
tag
});
});
tags.writes.forEach(tag => {
operations.push({
type: 'write',
description: `Create/update ${tag.path}`,
tag
});
});
tags.dependencies.forEach(tag => {
operations.push({
type: 'install',
description: `Install packages: ${tag.packages.join(', ')}`,
tag
});
});
tags.commands.forEach(tag => {
operations.push({
type: 'command',
description: `Execute command: ${tag.command}`,
tag
});
});
return operations;
}
module.exports = {
getDyadWriteTags,
getDyadRenameTags,
getDyadDeleteTags,
getDyadAddDependencyTags,
getDyadCommandTags,
getDyadChatSummary,
extractAllTags,
hasActionableTags,
stripTags,
generateOperationSummary
};

View File

@@ -0,0 +1,384 @@
/**
* Terminal Service - Manages PTY processes and WebSocket connections
*/
const { spawn } = require('node-pty');
const { Server } = require('ws');
const fs = require('fs').promises;
const path = require('path');
class TerminalService {
constructor() {
this.terminals = new Map(); // terminalId -> { pty, ws, sessionId, workingDir, mode, createdAt }
this.wsServer = null;
this.logFile = path.join(process.env.HOME, 'obsidian-vault', '.claude-ide', 'terminal-logs.jsonl');
}
/**
* Setup WebSocket server for terminal I/O
*/
createServer(httpServer) {
this.wsServer = new Server({ noServer: true });
// Handle WebSocket upgrade requests
httpServer.on('upgrade', (request, socket, head) => {
const pathname = new URL(request.url, `http://${request.headers.host}`).pathname;
// Match /claude/api/terminals/:id/ws
const terminalMatch = pathname.match(/^\/claude\/api\/terminals\/([^/]+)\/ws$/);
if (terminalMatch) {
const terminalId = terminalMatch[1];
this.wsServer.handleUpgrade(request, socket, head, (ws) => {
this.wsServer.emit('connection', ws, request, terminalId);
});
}
});
// Handle WebSocket connections
this.wsServer.on('connection', (ws, request, terminalId) => {
this.handleConnection(terminalId, ws);
});
console.log('[TerminalService] WebSocket server initialized');
}
/**
* Create a new terminal PTY
*/
createTerminal(options) {
const {
workingDir = process.env.HOME,
sessionId = null,
mode = 'mixed',
shell = process.env.SHELL || '/bin/bash'
} = options;
// Generate unique terminal ID
const terminalId = `term-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`;
try {
// Spawn PTY process
const pty = spawn(shell, [], {
name: 'xterm-color',
cols: 80,
rows: 24,
cwd: workingDir,
env: process.env
});
// Store terminal info
const terminal = {
id: terminalId,
pty,
ws: null,
sessionId,
workingDir,
mode, // 'session', 'shell', or 'mixed'
createdAt: new Date().toISOString(),
lastActivity: new Date().toISOString()
};
this.terminals.set(terminalId, terminal);
// Log terminal creation
this.logCommand(terminalId, null, `Terminal created in ${workingDir} (mode: ${mode})`);
console.log(`[TerminalService] Created terminal ${terminalId} in ${workingDir}`);
return { success: true, terminalId, terminal };
} catch (error) {
console.error(`[TerminalService] Failed to create terminal:`, error);
return { success: false, error: error.message };
}
}
/**
* Handle WebSocket connection for terminal I/O
*/
handleConnection(terminalId, ws) {
const terminal = this.terminals.get(terminalId);
if (!terminal) {
ws.close(1008, 'Terminal not found');
return;
}
terminal.ws = ws;
terminal.lastActivity = new Date().toISOString();
console.log(`[TerminalService] WebSocket connected for terminal ${terminalId}`);
// Handle incoming messages from client (user input)
ws.on('message', (data) => {
try {
const message = JSON.parse(data);
if (message.type === 'input') {
// User typed something - send to PTY
terminal.pty.write(message.data);
terminal.lastActivity = new Date().toISOString();
// Log commands (basic detection of Enter key)
if (message.data === '\r') {
// Could extract and log command here
}
} else if (message.type === 'resize') {
// Handle terminal resize
terminal.pty.resize(message.cols, message.rows);
}
} catch (error) {
console.error('[TerminalService] Error handling message:', error);
}
});
// Handle PTY output - send to client
terminal.pty.onData((data) => {
if (ws.readyState === ws.OPEN) {
ws.send(JSON.stringify({
type: 'data',
data: data
}));
}
});
// Handle PTY exit
terminal.pty.onExit(({ exitCode, signal }) => {
console.log(`[TerminalService] Terminal ${terminalId} exited: ${exitCode || signal}`);
this.logCommand(terminalId, null, `Terminal exited: ${exitCode || signal}`);
if (ws.readyState === ws.OPEN) {
ws.send(JSON.stringify({
type: 'exit',
exitCode,
signal
}));
ws.close();
}
this.terminals.delete(terminalId);
});
// Handle WebSocket close
ws.on('close', () => {
console.log(`[TerminalService] WebSocket closed for terminal ${terminalId}`);
// Don't kill PTY immediately - allow reconnection
// PTY will be killed after timeout or explicit close
});
// Handle WebSocket error
ws.on('error', (error) => {
console.error(`[TerminalService] WebSocket error for terminal ${terminalId}:`, error);
});
// Send initial welcome message
ws.send(JSON.stringify({
type: 'ready',
terminalId,
workingDir: terminal.workingDir,
mode: terminal.mode
}));
}
/**
* Attach terminal to a Claude Code session
* Pipes session stdout/stderr to PTY
*/
attachToSession(terminalId, session) {
const terminal = this.terminals.get(terminalId);
if (!terminal) {
return { success: false, error: 'Terminal not found' };
}
if (!session || !session.process) {
return { success: false, error: 'Invalid session' };
}
terminal.sessionId = session.id;
terminal.mode = 'session';
// Pipe session output to PTY
if (session.process.stdout) {
session.process.stdout.on('data', (data) => {
if (terminal.pty) {
terminal.pty.write(data.toString());
}
});
}
if (session.process.stderr) {
session.process.stderr.on('data', (data) => {
if (terminal.pty) {
// Write stderr in red
terminal.pty.write(`\x1b[31m${data.toString()}\x1b[0m`);
}
});
}
this.logCommand(terminalId, null, `Attached to session ${session.id}`);
console.log(`[TerminalService] Terminal ${terminalId} attached to session ${session.id}`);
return { success: true };
}
/**
* Set terminal mode (session/shell/mixed)
*/
setTerminalMode(terminalId, mode) {
const terminal = this.terminals.get(terminalId);
if (!terminal) {
return { success: false, error: 'Terminal not found' };
}
terminal.mode = mode;
terminal.lastActivity = new Date().toISOString();
// Notify client of mode change
if (terminal.ws && terminal.ws.readyState === terminal.ws.OPEN) {
terminal.ws.send(JSON.stringify({
type: 'modeChanged',
mode
}));
}
return { success: true, mode };
}
/**
* Close terminal and kill PTY process
*/
closeTerminal(terminalId) {
const terminal = this.terminals.get(terminalId);
if (!terminal) {
return { success: false, error: 'Terminal not found' };
}
try {
// Kill PTY process
if (terminal.pty) {
terminal.pty.kill();
}
// Close WebSocket
if (terminal.ws && terminal.ws.readyState === terminal.ws.OPEN) {
terminal.ws.close(1000, 'Terminal closed');
}
this.terminals.delete(terminalId);
this.logCommand(terminalId, null, 'Terminal closed');
console.log(`[TerminalService] Terminal ${terminalId} closed`);
return { success: true };
} catch (error) {
console.error(`[TerminalService] Error closing terminal ${terminalId}:`, error);
return { success: false, error: error.message };
}
}
/**
* Get list of active terminals
*/
listTerminals() {
const terminals = [];
for (const [id, terminal] of this.terminals.entries()) {
terminals.push({
id,
workingDir: terminal.workingDir,
sessionId: terminal.sessionId,
mode: terminal.mode,
createdAt: terminal.createdAt,
lastActivity: terminal.lastActivity
});
}
return terminals;
}
/**
* Get terminal by ID
*/
getTerminal(terminalId) {
const terminal = this.terminals.get(terminalId);
if (!terminal) {
return { success: false, error: 'Terminal not found' };
}
return {
success: true,
terminal: {
id: terminal.id,
workingDir: terminal.workingDir,
sessionId: terminal.sessionId,
mode: terminal.mode,
createdAt: terminal.createdAt,
lastActivity: terminal.lastActivity
}
};
}
/**
* Log command to file
*/
async logCommand(terminalId, command, action) {
try {
const logEntry = {
timestamp: new Date().toISOString(),
terminalId,
command: command || action,
user: process.env.USER || 'unknown'
};
// Ensure directory exists
const logDir = path.dirname(this.logFile);
await fs.mkdir(logDir, { recursive: true });
// Append to log file
await fs.appendFile(this.logFile, JSON.stringify(logEntry) + '\n');
} catch (error) {
console.error('[TerminalService] Failed to log command:', error);
}
}
/**
* Cleanup all terminals (called on server shutdown)
*/
async cleanup() {
console.log('[TerminalService] Cleaning up all terminals...');
for (const [id, terminal] of this.terminals.entries()) {
try {
if (terminal.pty) {
terminal.pty.kill();
}
if (terminal.ws && terminal.ws.readyState === terminal.ws.OPEN) {
terminal.ws.close(1000, 'Server shutting down');
}
} catch (error) {
console.error(`[TerminalService] Error cleaning up terminal ${id}:`, error);
}
}
this.terminals.clear();
// Close WebSocket server
if (this.wsServer) {
this.wsServer.close();
}
console.log('[TerminalService] Cleanup complete');
}
}
// Export singleton instance
const terminalService = new TerminalService();
module.exports = terminalService;

441
services/xml-parser.js Normal file
View File

@@ -0,0 +1,441 @@
const { XMLParser } = require('fast-xml-parser');
const fs = require('fs');
const path = require('path');
/**
* Custom XML Tag Parser for Claude Code responses
* Handles: <claude-write>, <claude-edit>, <claude-command>, <claude-dependency>
*/
class ClaudeXMLParser {
constructor(options = {}) {
this.options = {
ignoreAttributes: false,
attributeNamePrefix: '',
textNodeName: '#text',
...options
};
this.parser = new XMLParser(this.options);
}
/**
* Parse Claude's response and extract all custom tags
*/
parseResponse(response) {
const result = {
writes: [],
edits: [],
commands: [],
dependencies: [],
previews: [],
text: response
};
// Extract <claude-write> tags
result.writes = this.extractClaudeWriteTags(response);
// Extract <claude-edit> tags
result.edits = this.extractClaudeEditTags(response);
// Extract <claude-command> tags
result.commands = this.extractClaudeCommandTags(response);
// Extract <claude-dependency> tags
result.dependencies = this.extractClaudeDependencyTags(response);
// Extract <claude-preview> tags
result.previews = this.extractClaudePreviewTags(response);
// Clean text (remove tags for display)
result.text = this.cleanText(response);
return result;
}
/**
* Extract <claude-write> tags
* Format: <claude-write path="src/file.ts">content</claude-write>
*/
extractClaudeWriteTags(response) {
const tags = [];
const regex = /<claude-write\s+path="([^"]+)"\s*>([\s\S]*?)<\/claude-write>/g;
let match;
while ((match = regex.exec(response)) !== null) {
tags.push({
type: 'write',
path: match[1],
content: match[2].trim()
});
}
return tags;
}
/**
* Extract <claude-edit> tags
* Format: <claude-edit path="src/file.ts" mode="replace">
* <search>pattern</search>
* <replace>replacement</replace>
* </claude-edit>
*/
extractClaudeEditTags(response) {
const tags = [];
const regex = /<claude-edit\s+path="([^"]+)"\s*(?:mode="([^"]+)")?\s*>([\s\S]*?)<\/claude-edit>/g;
let match;
while ((match = regex.exec(response)) !== null) {
const content = match[3];
const searchMatch = content.match(/<search>\s*([\s\S]*?)\s*<\/search>/);
const replaceMatch = content.match(/<replace>\s*([\s\S]*?)\s*<\/replace>/);
tags.push({
type: 'edit',
path: match[1],
mode: match[2] || 'replace',
search: searchMatch ? searchMatch[1].trim() : '',
replace: replaceMatch ? replaceMatch[1].trim() : ''
});
}
return tags;
}
/**
* Extract <claude-command> tags
* Format: <claude-command working-dir="/path">command</claude-command>
*/
extractClaudeCommandTags(response) {
const tags = [];
const regex = /<claude-command\s+(?:working-dir="([^"]+)")?\s*>([\s\S]*?)<\/claude-command>/g;
let match;
while ((match = regex.exec(response)) !== null) {
tags.push({
type: 'command',
workingDir: match[1] || process.cwd(),
command: match[2].trim()
});
}
return tags;
}
/**
* Extract <claude-dependency> tags
* Format: <claude-dependency package="package-name">install-command</claude-dependency>
*/
extractClaudeDependencyTags(response) {
const tags = [];
const regex = /<claude-dependency\s+package="([^"]+)"\s*>([\s\S]*?)<\/claude-dependency>/g;
let match;
while ((match = regex.exec(response)) !== null) {
tags.push({
type: 'dependency',
package: match[1],
command: match[2].trim()
});
}
return tags;
}
/**
* Extract <claude-preview> tags
* Format: <claude-preview url="http://localhost:3000" />
*/
extractClaudePreviewTags(response) {
const tags = [];
const regex = /<claude-preview\s+(?:url="([^"]+)"|port="(\d+)")\s*\/?>/g;
let match;
while ((match = regex.exec(response)) !== null) {
tags.push({
type: 'preview',
url: match[1] || `http://localhost:${match[2]}`,
port: match[2]
});
}
return tags;
}
/**
* Remove XML tags from response for clean text display
*/
cleanText(response) {
return response
.replace(/<claude-write[^>]*>[\s\S]*?<\/claude-write>/g, '[Wrote file]')
.replace(/<claude-edit[^>]*>[\s\S]*?<\/claude-edit>/g, '[Edited file]')
.replace(/<claude-command[^>]*>[\s\S]*?<\/claude-command>/g, '[Executed command]')
.replace(/<claude-dependency[^>]*>[\s\S]*?<\/claude-dependency>/g, '[Added dependency]')
.replace(/<claude-preview[^>]*>/g, '[Updated preview]')
.trim();
}
/**
* Validate tag structure
*/
validateTag(tag) {
const errors = [];
if (tag.type === 'write') {
if (!tag.path) errors.push('Missing path attribute');
if (tag.content === undefined) errors.push('Missing content');
}
if (tag.type === 'edit') {
if (!tag.path) errors.push('Missing path attribute');
if (!tag.search) errors.push('Missing search pattern');
if (!tag.replace) errors.push('Missing replacement');
}
if (tag.type === 'command') {
if (!tag.command) errors.push('Missing command');
}
return {
valid: errors.length === 0,
errors
};
}
}
/**
* Response Processor - Executes parsed XML tags
*/
class ClaudeResponseProcessor {
constructor(vaultPath) {
this.vaultPath = vaultPath;
this.parser = new ClaudeXMLParser();
this.executedOperations = [];
}
/**
* Process Claude's response and execute all operations
*/
async process(response, options = {}) {
const { dryRun = false, basePath = this.vaultPath } = options;
const parsed = this.parser.parseResponse(response);
const results = {
writes: [],
edits: [],
commands: [],
dependencies: [],
previews: [],
errors: [],
summary: {}
};
// Execute write operations
for (const writeOp of parsed.writes) {
try {
const validation = this.parser.validateTag(writeOp);
if (!validation.valid) {
results.errors.push({
operation: 'write',
errors: validation.errors
});
continue;
}
if (!dryRun) {
await this.executeWrite(writeOp, basePath);
}
results.writes.push({
path: writeOp.path,
size: writeOp.content.length,
success: true
});
} catch (error) {
results.errors.push({
operation: 'write',
path: writeOp.path,
error: error.message
});
}
}
// Execute edit operations
for (const editOp of parsed.edits) {
try {
const validation = this.parser.validateTag(editOp);
if (!validation.valid) {
results.errors.push({
operation: 'edit',
errors: validation.errors
});
continue;
}
if (!dryRun) {
await this.executeEdit(editOp, basePath);
}
results.edits.push({
path: editOp.path,
mode: editOp.mode,
success: true
});
} catch (error) {
results.errors.push({
operation: 'edit',
path: editOp.path,
error: error.message
});
}
}
// Execute command operations
for (const cmdOp of parsed.commands) {
try {
const validation = this.parser.validateTag(cmdOp);
if (!validation.valid) {
results.errors.push({
operation: 'command',
errors: validation.errors
});
continue;
}
results.commands.push({
command: cmdOp.command,
workingDir: cmdOp.workingDir,
success: true,
output: null // Will be filled when executed
});
if (!dryRun) {
const output = await this.executeCommand(cmdOp);
results.commands[results.commands.length - 1].output = output;
}
} catch (error) {
results.errors.push({
operation: 'command',
command: cmdOp.command,
error: error.message
});
}
}
// Collect dependency operations
for (const depOp of parsed.dependencies) {
results.dependencies.push({
package: depOp.package,
command: depOp.command
});
}
// Collect preview operations
for (const previewOp of parsed.previews) {
results.previews.push({
url: previewOp.url,
port: previewOp.port
});
}
// Generate summary
results.summary = {
totalOperations: results.writes.length + results.edits.length +
results.commands.length + results.dependencies.length,
successful: results.writes.length + results.edits.length +
results.commands.filter(c => c.success).length,
failed: results.errors.length,
filesWritten: results.writes.length,
filesEdited: results.edits.length,
commandsExecuted: results.commands.length
};
return {
parsed,
results,
cleanText: parsed.text
};
}
/**
* Execute write operation
*/
async executeWrite(writeOp, basePath) {
const fullPath = path.resolve(basePath, writeOp.path);
// Create directory if it doesn't exist
const dir = path.dirname(fullPath);
if (!fs.existsSync(dir)) {
fs.mkdirSync(dir, { recursive: true });
}
// Write file
fs.writeFileSync(fullPath, writeOp.content, 'utf-8');
this.executedOperations.push({
type: 'write',
path: fullPath,
timestamp: new Date().toISOString()
});
}
/**
* Execute edit operation
*/
async executeEdit(editOp, basePath) {
const fullPath = path.resolve(basePath, editOp.path);
if (!fs.existsSync(fullPath)) {
throw new Error(`File not found: ${editOp.path}`);
}
let content = fs.readFileSync(fullPath, 'utf-8');
if (editOp.mode === 'replace') {
// Simple string replacement
content = content.replace(editOp.search, editOp.replace);
} else if (editOp.mode === 'regex') {
// Regex replacement
const regex = new RegExp(editOp.search, 'g');
content = content.replace(regex, editOp.replace);
}
fs.writeFileSync(fullPath, content, 'utf-8');
this.executedOperations.push({
type: 'edit',
path: fullPath,
mode: editOp.mode,
timestamp: new Date().toISOString()
});
}
/**
* Execute command (returns command object for session to execute)
*/
async executeCommand(cmdOp) {
// Commands are executed by the Claude Code session
// This just returns the command for the session to handle
return {
command: cmdOp.command,
workingDir: cmdOp.workingDir
};
}
/**
* Get executed operations history
*/
getExecutedOperations() {
return this.executedOperations;
}
/**
* Clear operations history
*/
clearHistory() {
this.executedOperations = [];
}
}
module.exports = {
ClaudeXMLParser,
ClaudeResponseProcessor
};