UNPKG

@sirmrmarty/n8n-nodes-tmux-orchestrator

Version:

n8n nodes for orchestrating Claude AI agents through tmux sessions

880 lines (878 loc) 38.1 kB
"use strict"; var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) { if (k2 === undefined) k2 = k; var desc = Object.getOwnPropertyDescriptor(m, k); if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) { desc = { enumerable: true, get: function() { return m[k]; } }; } Object.defineProperty(o, k2, desc); }) : (function(o, m, k, k2) { if (k2 === undefined) k2 = k; o[k2] = m[k]; })); var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) { Object.defineProperty(o, "default", { enumerable: true, value: v }); }) : function(o, v) { o["default"] = v; }); var __importStar = (this && this.__importStar) || (function () { var ownKeys = function(o) { ownKeys = Object.getOwnPropertyNames || function (o) { var ar = []; for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k; return ar; }; return ownKeys(o); }; return function (mod) { if (mod && mod.__esModule) return mod; var result = {}; if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]); __setModuleDefault(result, mod); return result; }; })(); Object.defineProperty(exports, "__esModule", { value: true }); exports.TmuxProjectOrchestrator = void 0; const n8n_workflow_1 = require("n8n-workflow"); const tmuxBridge_1 = require("../../utils/tmuxBridge"); const modelManager_1 = require("../../utils/modelManager"); const fs_1 = require("fs"); const path = __importStar(require("path")); const child_process_1 = require("child_process"); class TmuxProjectOrchestrator { constructor() { this.scheduleFile = path.join(process.cwd(), '.n8n-project-schedule.json'); this.methods = { loadOptions: { async getAvailableModels() { try { return await modelManager_1.modelManager.getModelOptions(); } catch (error) { console.error('Failed to load models:', error); return [ { name: 'Claude Sonnet 4', value: 'claude-sonnet-4' }, { name: 'Claude Opus 4.1', value: 'claude-opus-4.1' }, { name: 'Claude Sonnet 3.5', value: 'claude-sonnet-3.5' }, { name: 'Claude Haiku 3.5', value: 'claude-haiku-3.5' }, ]; } }, }, }; this.description = { displayName: 'Tmux Project Orchestrator (Simplified)', name: 'tmuxProjectOrchestrator', group: ['transform'], version: 1, subtitle: '={{$parameter["operation"]}}', description: 'Simplified project management with Claude Code integration', defaults: { name: 'Tmux Project Orchestrator', }, inputs: ["main"], outputs: ["main"], credentials: [ { name: 'tmuxOrchestratorApi', required: false, }, ], properties: [ { displayName: 'Operation', name: 'operation', type: 'options', noDataExpression: true, options: [ { name: 'Create Project', value: 'createProject', description: 'Initialize project and generate plan with Claude Code', }, { name: 'Approve & Execute / Schedule', value: 'approveExecute', description: 'Approve plan and execute immediately or schedule for later', }, { name: 'Get Status', value: 'getStatus', description: 'Get project status and scheduling information', }, ], default: 'createProject', }, { displayName: 'Project Prompt', name: 'prompt', type: 'string', typeOptions: { rows: 4, }, default: '', placeholder: 'Describe your project idea in detail...', description: 'The project description that will be sent to Claude Code for planning', displayOptions: { show: { operation: ['createProject'], }, }, }, { displayName: 'Session Name', name: 'sessionName', type: 'string', default: '', placeholder: 'my-project-session', description: 'Unique name for the tmux session', displayOptions: { show: { operation: ['createProject'], }, }, }, { displayName: 'Reference', name: 'reference', type: 'string', default: '', placeholder: 'Optional project reference or ticket number', description: 'Optional reference information for the project', displayOptions: { show: { operation: ['createProject'], }, }, }, { displayName: 'Project Path', name: 'projectPath', type: 'string', default: '', placeholder: '/path/to/project', description: 'Path where the project will be created or executed', displayOptions: { show: { operation: ['createProject'], }, }, }, { displayName: 'Use GitHub Worktree', name: 'useGitWorktree', type: 'boolean', default: false, description: 'Whether to use Git worktree for the project', displayOptions: { show: { operation: ['createProject'], }, }, }, { displayName: 'Auto Commit & PR', name: 'autoCommit', type: 'boolean', default: true, description: 'Automatically commit changes and create PR after project execution', displayOptions: { show: { operation: ['createProject'], }, }, }, { displayName: 'Model Choice', name: 'model', type: 'options', typeOptions: { loadOptionsMethod: 'getAvailableModels', }, default: 'claude-sonnet-4', description: 'AI model to use for project planning. Latest Claude models are recommended.', displayOptions: { show: { operation: ['createProject'], }, }, }, { displayName: 'Project ID', name: 'projectId', type: 'string', default: '', placeholder: 'project-id-from-create-operation', description: 'Project ID from the Create Project operation (leave empty to use session name or reference)', displayOptions: { show: { operation: ['approveExecute', 'getStatus'], }, }, }, { displayName: 'Session Name', name: 'sessionName', type: 'string', default: '', placeholder: 'my-project-session', description: 'Session name for project lookup (alternative to Project ID)', displayOptions: { show: { operation: ['approveExecute', 'getStatus'], }, }, }, { displayName: 'Reference', name: 'reference', type: 'string', default: '', placeholder: 'ticket-123 or project-ref', description: 'Reference identifier for project lookup (alternative to Project ID)', displayOptions: { show: { operation: ['approveExecute', 'getStatus'], }, }, }, { displayName: 'Execution Mode', name: 'executionMode', type: 'options', options: [ { name: 'Execute Now', value: 'now', description: 'Execute the project immediately', }, { name: 'Schedule for Later', value: 'schedule', description: 'Schedule the project for later execution', }, ], default: 'now', description: 'Whether to execute immediately or schedule', displayOptions: { show: { operation: ['approveExecute'], }, }, }, { displayName: 'Schedule Type', name: 'scheduleType', type: 'options', options: [ { name: 'Custom Time', value: 'custom', description: 'Choose a specific date and time', }, { name: 'Day Hours (9:00 AM)', value: 'day', description: 'Schedule for 9:00 AM (any day including weekends)', }, { name: 'Tonight (2:00 AM off-hours)', value: 'night', description: 'Schedule for 2:00 AM tonight for off-hours processing', }, ], default: 'custom', description: 'Choose how to schedule the project execution', displayOptions: { show: { operation: ['approveExecute'], executionMode: ['schedule'], }, }, }, { displayName: 'Schedule Time', name: 'scheduleTime', type: 'dateTime', default: '', description: 'When to execute the scheduled project', displayOptions: { show: { operation: ['approveExecute'], executionMode: ['schedule'], scheduleType: ['custom'], }, }, }, { displayName: 'Priority', name: 'priority', type: 'options', options: [ { name: 'Low', value: 'low', }, { name: 'Normal', value: 'normal', }, { name: 'High', value: 'high', }, ], default: 'normal', description: 'Project execution priority', displayOptions: { show: { operation: ['approveExecute'], executionMode: ['schedule'], }, }, }, ], }; } async execute() { const items = this.getInputData(); const returnData = []; for (let i = 0; i < items.length; i++) { try { const operation = this.getNodeParameter('operation', i); let result; const nodeInstance = new TmuxProjectOrchestrator(); switch (operation) { case 'createProject': result = await nodeInstance.createProject(this, i); break; case 'approveExecute': result = await nodeInstance.approveExecute(this, i); break; case 'getStatus': result = await nodeInstance.getStatus(this, i); break; default: throw new n8n_workflow_1.NodeOperationError(this.getNode(), `Unknown operation: ${operation}`, { itemIndex: i, }); } returnData.push({ json: result, pairedItem: { item: i, }, }); } catch (error) { if (this.continueOnFail()) { returnData.push({ json: { error: error.message }, pairedItem: { item: i }, }); continue; } throw error; } } return [returnData]; } async createProject(context, itemIndex) { const prompt = context.getNodeParameter('prompt', itemIndex); const sessionName = context.getNodeParameter('sessionName', itemIndex); const reference = context.getNodeParameter('reference', itemIndex, ''); const projectPath = context.getNodeParameter('projectPath', itemIndex); const useGitWorktree = context.getNodeParameter('useGitWorktree', itemIndex); const autoCommit = context.getNodeParameter('autoCommit', itemIndex); const model = context.getNodeParameter('model', itemIndex); if (!prompt || !sessionName || !projectPath) { throw new n8n_workflow_1.NodeOperationError(context.getNode(), 'Prompt, session name, and project path are required'); } const isValidModel = await modelManager_1.modelManager.isValidModel(model); if (!isValidModel) { throw new n8n_workflow_1.NodeOperationError(context.getNode(), `Invalid model selection: ${model}. Please select a valid model.`); } const sanitizedPath = path.resolve(projectPath); const sanitizedSessionName = sessionName.replace(/[^a-zA-Z0-9_-]/g, '_'); const projectId = `proj_${Date.now()}_${Math.random().toString(36).substring(2, 11)}`; const projectConfig = { id: projectId, prompt, sessionName: sanitizedSessionName, reference, projectPath: sanitizedPath, useGitWorktree, autoCommit, model, status: 'created', createdAt: new Date(), }; const tmuxBridge = new tmuxBridge_1.TmuxBridge({ externalScriptsDir: sanitizedPath, projectBasePath: sanitizedPath, }); try { await tmuxBridge.createSession(sanitizedSessionName, sanitizedPath); projectConfig.tmuxSession = sanitizedSessionName; projectConfig.status = 'planning'; const plan = await this.generatePlan(projectConfig, tmuxBridge); projectConfig.plan = plan; projectConfig.status = 'planned'; await this.saveProject(projectConfig); return { success: true, projectId, sessionName: sanitizedSessionName, reference: reference || null, status: 'planned', plan, message: 'Project created and plan generated successfully', }; } catch (error) { projectConfig.status = 'failed'; await this.saveProject(projectConfig); throw new n8n_workflow_1.NodeOperationError(context.getNode(), `Failed to create project: ${error.message}`); } } calculateDaySchedule() { const now = new Date(); const nextSchedule = new Date(now); nextSchedule.setHours(9, 0, 0, 0); const currentHour = now.getHours(); if (currentHour >= 22 || currentHour < 6) { if (currentHour < 6) { return nextSchedule; } else { nextSchedule.setDate(nextSchedule.getDate() + 1); return nextSchedule; } } if (currentHour >= 9) { nextSchedule.setDate(nextSchedule.getDate() + 1); } return nextSchedule; } calculateNightSchedule() { const now = new Date(); const tonight = new Date(now); tonight.setHours(2, 0, 0, 0); if (now.getHours() >= 2) { tonight.setDate(tonight.getDate() + 1); } return tonight; } async approveExecute(context, itemIndex) { const projectId = context.getNodeParameter('projectId', itemIndex, ''); const sessionName = context.getNodeParameter('sessionName', itemIndex, ''); const reference = context.getNodeParameter('reference', itemIndex, ''); const executionMode = context.getNodeParameter('executionMode', itemIndex); let project = null; let lookupKey = ''; if (projectId) { project = await this.loadProject(projectId); lookupKey = `project ID: ${projectId}`; } else if (sessionName) { project = await this.loadProjectBySessionName(sessionName); lookupKey = `session name: ${sessionName}`; } else if (reference) { project = await this.loadProjectByReference(reference); lookupKey = `reference: ${reference}`; } else { throw new n8n_workflow_1.NodeOperationError(context.getNode(), 'Project ID, session name, or reference is required'); } if (!project) { throw new n8n_workflow_1.NodeOperationError(context.getNode(), `Project not found for ${lookupKey}`); } if (project.status !== 'planned') { throw new n8n_workflow_1.NodeOperationError(context.getNode(), `Project ${project.id} is not in planned status (current: ${project.status})`); } if (executionMode === 'now') { return await this.executeProject(context, project); } else { const scheduleType = context.getNodeParameter('scheduleType', itemIndex, 'custom'); const priority = context.getNodeParameter('priority', itemIndex, 'normal'); let scheduledDate; let scheduleDescription; if (scheduleType === 'custom') { const scheduleTime = context.getNodeParameter('scheduleTime', itemIndex); if (!scheduleTime) { throw new n8n_workflow_1.NodeOperationError(context.getNode(), 'Schedule time is required for custom scheduled execution'); } scheduledDate = new Date(scheduleTime); scheduleDescription = `custom time: ${scheduledDate.toLocaleString()}`; } else if (scheduleType === 'day') { scheduledDate = this.calculateDaySchedule(); scheduleDescription = `day hours at 9:00 AM: ${scheduledDate.toLocaleString()}`; } else if (scheduleType === 'night') { scheduledDate = this.calculateNightSchedule(); scheduleDescription = `tonight at 2:00 AM: ${scheduledDate.toLocaleString()}`; } else { throw new n8n_workflow_1.NodeOperationError(context.getNode(), `Invalid schedule type: ${scheduleType}`); } project.scheduledAt = scheduledDate; project.priority = priority; project.status = 'scheduled'; await this.saveProject(project); return { success: true, projectId: project.id, status: 'scheduled', scheduledAt: project.scheduledAt, scheduleType, priority, message: `Project scheduled successfully for ${scheduleDescription}`, }; } } async getStatus(context, itemIndex) { const projectId = context.getNodeParameter('projectId', itemIndex, ''); const sessionName = context.getNodeParameter('sessionName', itemIndex, ''); const reference = context.getNodeParameter('reference', itemIndex, ''); if (!projectId && !sessionName && !reference) { const schedule = await this.loadSchedule(); return { success: true, totalProjects: schedule.projects.length, activeProjects: schedule.activeProjects, projects: schedule.projects.map(p => ({ id: p.id, sessionName: p.sessionName, reference: p.reference, status: p.status, createdAt: p.createdAt, scheduledAt: p.scheduledAt, priority: p.priority, })), }; } let project = null; let lookupKey = ''; if (projectId) { project = await this.loadProject(projectId); lookupKey = `project ID: ${projectId}`; } else if (sessionName) { project = await this.loadProjectBySessionName(sessionName); lookupKey = `session name: ${sessionName}`; } else if (reference) { project = await this.loadProjectByReference(reference); lookupKey = `reference: ${reference}`; } if (!project) { throw new n8n_workflow_1.NodeOperationError(context.getNode(), `Project not found for ${lookupKey}`); } return { success: true, project: { id: project.id, sessionName: project.sessionName, reference: project.reference, status: project.status, createdAt: project.createdAt, scheduledAt: project.scheduledAt, priority: project.priority, plan: project.plan, tmuxSession: project.tmuxSession, executionLog: project.executionLog, }, }; } async generatePlan(project, tmuxBridge) { try { console.log('Generating plan using autonomous Claude Code execution...'); await tmuxBridge.sendKeysToWindow(project.sessionName, 0, 'C-c'); await new Promise(resolve => setTimeout(resolve, 1000)); const modelOptions = modelManager_1.modelManager.getClaudeCommandOptions(project.model); const autonomousCmd = `claude ${modelOptions} --dangerously-skip-permissions --max-turns 10 --print "Create a detailed implementation plan for: ${project.prompt}"`; const success = await tmuxBridge.sendCommandToWindow(project.sessionName, 0, autonomousCmd); if (!success) { throw new Error('Failed to start autonomous Claude Code in tmux session'); } await new Promise(resolve => setTimeout(resolve, 15000)); const content = await tmuxBridge.captureWindowContent(project.sessionName, 0, 100); if (typeof content === 'string' && content.length > 50) { const cleanContent = content .split('\n') .filter(line => !line.includes('claude ') && !line.includes('$') && line.trim().length > 0) .join('\n') .substring(0, 2000); return cleanContent || 'Autonomous plan generated successfully'; } throw new Error('Autonomous plan generation produced no output'); } catch (error) { console.error(`Autonomous plan generation failed: ${error.message}`); await this.cleanupFailedPlanGeneration(tmuxBridge, project.sessionName, 0); throw new Error(`Autonomous plan generation failed: ${error.message}`); } } async cleanupFailedPlanGeneration(tmuxBridge, sessionName, windowIndex) { try { await tmuxBridge.sendKeysToWindow(sessionName, windowIndex, 'C-c'); await new Promise(resolve => setTimeout(resolve, 1000)); await tmuxBridge.sendCommandToWindow(sessionName, windowIndex, 'exit'); await new Promise(resolve => setTimeout(resolve, 1000)); await tmuxBridge.sendKeysToWindow(sessionName, windowIndex, 'C-l'); } catch (error) { console.warn(`Cleanup after failed plan generation encountered error: ${error.message}`); } } async executeProject(context, project) { project.status = 'executing'; project.executionLog = []; await this.saveProject(project); try { const schedule = await this.loadSchedule(); const fourHoursAgo = new Date(Date.now() - 4 * 60 * 60 * 1000); const recentActiveProjects = schedule.activeProjects.filter(activeId => { const activeProject = schedule.projects.find(p => p.id === activeId); return activeProject && new Date(activeProject.createdAt) > fourHoursAgo; }); if (recentActiveProjects.length >= 4) { throw new Error('Resource limit exceeded: Maximum 4 projects allowed within 4 hours'); } schedule.activeProjects.push(project.id); await this.saveSchedule(schedule); const result = await this.runClaudeExecution(project); let gitResult = 'Git operations skipped (autoCommit disabled)'; if (project.autoCommit) { gitResult = await this.handleGitOperations(project); project.executionLog?.push(`Git operations: ${gitResult}`); } project.status = 'completed'; project.executionLog?.push(`Execution completed at ${new Date().toISOString()}`); schedule.activeProjects = schedule.activeProjects.filter(id => id !== project.id); await this.saveSchedule(schedule); await this.saveProject(project); const message = project.autoCommit ? 'Project executed successfully with Git operations completed' : 'Project executed successfully (Git operations skipped)'; return { success: true, projectId: project.id, status: 'completed', result, gitResult, message, }; } catch (error) { project.status = 'failed'; project.executionLog?.push(`Execution failed at ${new Date().toISOString()}: ${error.message}`); const schedule = await this.loadSchedule(); schedule.activeProjects = schedule.activeProjects.filter(id => id !== project.id); await this.saveSchedule(schedule); await this.saveProject(project); throw new n8n_workflow_1.NodeOperationError(context.getNode(), `Project execution failed: ${error.message}`); } } async runClaudeExecution(project) { return new Promise(async (resolve, reject) => { try { await this.ensureLatestVersion(project); } catch (pullError) { project.executionLog?.push(`Git pull warning: ${pullError.message}`); } const modelOptions = modelManager_1.modelManager.getClaudeCommandOptions(project.model); const autonomousExecutionCmd = `claude ${modelOptions} --dangerously-skip-permissions --max-turns 20 "${project.prompt}"`; project.executionLog?.push(`Starting autonomous Claude execution: ${autonomousExecutionCmd}`); const child = (0, child_process_1.spawn)('bash', ['-c', autonomousExecutionCmd], { cwd: project.projectPath, env: { ...process.env }, }); let stdout = ''; let stderr = ''; child.stdout.on('data', (data) => { const output = data.toString(); stdout += output; project.executionLog?.push(`STDOUT: ${output.trim()}`); }); child.stderr.on('data', (data) => { const output = data.toString(); stderr += output; project.executionLog?.push(`STDERR: ${output.trim()}`); }); child.on('close', (code) => { if (code === 0) { resolve(stdout || 'Execution completed successfully'); } else { reject(new Error(`Claude Code execution failed: ${stderr || 'Unknown error'}`)); } }); child.on('error', (error) => { reject(new Error(`Failed to execute Claude Code: ${error.message}`)); }); }); } async ensureLatestVersion(project) { const { execSync } = require('child_process'); const fs = require('fs'); try { const gitDir = `${project.projectPath}/.git`; if (!fs.existsSync(gitDir)) { return; } const remotes = execSync('git remote -v', { cwd: project.projectPath, encoding: 'utf8', timeout: 10000 }); if (!remotes || remotes.trim().length === 0) { return; } project.executionLog?.push(`Pulling latest changes from Git remote...`); execSync('git fetch origin', { cwd: project.projectPath, encoding: 'utf8', timeout: 30000 }); const currentBranch = execSync('git branch --show-current', { cwd: project.projectPath, encoding: 'utf8', timeout: 5000 }).trim(); try { execSync(`git pull origin ${currentBranch}`, { cwd: project.projectPath, encoding: 'utf8', timeout: 30000 }); project.executionLog?.push(`Successfully pulled latest changes from ${currentBranch}`); } catch (pullError) { try { execSync(`git pull --set-upstream origin ${currentBranch}`, { cwd: project.projectPath, encoding: 'utf8', timeout: 30000 }); project.executionLog?.push(`Successfully pulled and set upstream for ${currentBranch}`); } catch (upstreamError) { project.executionLog?.push(`Unable to pull latest changes: ${upstreamError.message}`); } } } catch (error) { throw new Error(`Git pull operation failed: ${error.message}`); } } async loadSchedule() { try { if (await fs_1.promises.access(this.scheduleFile).then(() => true).catch(() => false)) { const data = await fs_1.promises.readFile(this.scheduleFile, 'utf8'); return JSON.parse(data); } } catch (error) { } return { projects: [], activeProjects: [], lastCleanup: new Date(), }; } async saveSchedule(schedule) { await fs_1.promises.writeFile(this.scheduleFile, JSON.stringify(schedule, null, 2)); } async loadProject(projectId) { const schedule = await this.loadSchedule(); return schedule.projects.find(p => p.id === projectId) || null; } async loadProjectBySessionName(sessionName) { const schedule = await this.loadSchedule(); return schedule.projects.find(p => p.sessionName === sessionName) || null; } async loadProjectByReference(reference) { const schedule = await this.loadSchedule(); return schedule.projects.find(p => p.reference === reference) || null; } async saveProject(project) { const schedule = await this.loadSchedule(); const existingIndex = schedule.projects.findIndex(p => p.id === project.id); if (existingIndex >= 0) { schedule.projects[existingIndex] = project; } else { schedule.projects.push(project); } await this.saveSchedule(schedule); } async handleGitOperations(project) { return new Promise((resolve) => { const projectRef = project.reference ? ` - ${project.reference}` : ''; const commitMessage = `feat: ${project.prompt.slice(0, 50)}${project.prompt.length > 50 ? '...' : ''}${projectRef} Generated by Claude Code automation Project ID: ${project.id} Model: ${project.model} Co-authored-by: Claude Code <noreply@anthropic.com>`; let gitCommands; if (project.useGitWorktree) { const branchName = `feature/${project.sessionName}`; gitCommands = [ 'git add .', `git commit -m "${commitMessage.replace(/"/g, '\\"')}"`, `git push -u origin ${branchName}`, `gh pr create --title "feat: ${project.prompt.slice(0, 50)}${project.prompt.length > 50 ? '...' : ''}" --body "**Project:** ${project.sessionName}\\n\\n**Description:** ${project.prompt}\\n\\n**Reference:** ${project.reference || 'N/A'}\\n\\n**Model Used:** ${project.model}\\n\\n---\\n\\n🤖 This PR was generated automatically by Claude Code automation.\\n\\nProject ID: \`${project.id}\`"` ]; } else { gitCommands = [ 'git add .', `git commit -m "${commitMessage.replace(/"/g, '\\"')}"` ]; } const fullCommand = gitCommands.join(' && '); const child = (0, child_process_1.spawn)('bash', ['-c', fullCommand], { cwd: project.projectPath, env: { ...process.env } }); let stdout = ''; let stderr = ''; child.stdout.on('data', (data) => { const output = data.toString(); stdout += output; project.executionLog?.push(`GIT STDOUT: ${output.trim()}`); }); child.stderr.on('data', (data) => { const output = data.toString(); stderr += output; project.executionLog?.push(`GIT STDERR: ${output.trim()}`); }); child.on('close', (code) => { if (code === 0) { const result = project.useGitWorktree ? `Committed changes and created pull request for worktree branch` : `Committed changes to current branch`; resolve(`${result}: ${stdout || 'Success'}`); } else { const warning = `Git operations completed with warnings: ${stderr || 'Unknown issue'}`; project.executionLog?.push(`GIT WARNING: ${warning}`); resolve(warning); } }); child.on('error', (error) => { const warning = `Git operations failed: ${error.message}`; project.executionLog?.push(`GIT ERROR: ${warning}`); resolve(warning); }); }); } } exports.TmuxProjectOrchestrator = TmuxProjectOrchestrator; //# sourceMappingURL=TmuxProjectOrchestrator.node.js.map