@sirmrmarty/n8n-nodes-tmux-orchestrator
Version:
n8n nodes for orchestrating Claude AI agents through tmux sessions
453 lines (408 loc) • 12.2 kB
text/typescript
/**
* Condition-based Waiting Utility
* Replaces hardcoded delays with intelligent condition-based waiting
*/
export interface WaitCondition {
check: () => Promise<boolean> | boolean;
description?: string;
}
export interface WaitOptions {
timeout?: number; // Maximum time to wait in milliseconds
interval?: number; // Polling interval in milliseconds
retries?: number; // Maximum number of retries
exponentialBackoff?: boolean; // Use exponential backoff for intervals
minInterval?: number; // Minimum interval for exponential backoff
maxInterval?: number; // Maximum interval for exponential backoff
}
export interface WaitResult {
success: boolean;
duration: number;
attempts: number;
reason?: string;
}
export class ConditionWaiter {
private static readonly DEFAULT_TIMEOUT = 120000; // 2 minutes for Claude operations
private static readonly DEFAULT_INTERVAL = 500; // 500ms
private static readonly DEFAULT_MIN_INTERVAL = 100; // 100ms
private static readonly DEFAULT_MAX_INTERVAL = 5000; // 5 seconds
private static readonly DEFAULT_RETRIES = 60;
/**
* Wait for a condition to be met
*/
public static async waitForCondition(
condition: WaitCondition,
options: WaitOptions = {}
): Promise<WaitResult> {
const startTime = Date.now();
const timeout = options.timeout || this.DEFAULT_TIMEOUT;
const baseInterval = options.interval || this.DEFAULT_INTERVAL;
const maxRetries = options.retries || this.DEFAULT_RETRIES;
const useExponentialBackoff = options.exponentialBackoff || false;
const minInterval = options.minInterval || this.DEFAULT_MIN_INTERVAL;
const maxInterval = options.maxInterval || this.DEFAULT_MAX_INTERVAL;
let attempts = 0;
let currentInterval = baseInterval;
while (attempts < maxRetries && (Date.now() - startTime) < timeout) {
attempts++;
try {
const result = await condition.check();
if (result) {
return {
success: true,
duration: Date.now() - startTime,
attempts,
reason: 'Condition met'
};
}
} catch (error) {
console.warn(`Condition check failed on attempt ${attempts}: ${error.message}`);
}
// Calculate next interval
if (useExponentialBackoff) {
currentInterval = Math.min(
Math.max(currentInterval * 1.5, minInterval),
maxInterval
);
} else {
currentInterval = baseInterval;
}
// Wait before next attempt
await new Promise(resolve => setTimeout(resolve, currentInterval));
}
return {
success: false,
duration: Date.now() - startTime,
attempts,
reason: `Timeout after ${timeout}ms or max retries (${maxRetries}) reached`
};
}
/**
* Wait for tmux window to be ready (output contains expected content)
*/
public static async waitForTmuxWindow(
bridge: any,
sessionName: string,
windowIndex: number,
expectedContent: string | RegExp,
options: WaitOptions = {}
): Promise<WaitResult> {
const condition: WaitCondition = {
check: async () => {
try {
const content = await bridge.captureWindowContent(sessionName, windowIndex, 20);
if (typeof expectedContent === 'string') {
return content.includes(expectedContent);
} else {
return expectedContent.test(content);
}
} catch (error) {
return false;
}
},
description: `Waiting for tmux window ${sessionName}:${windowIndex} to contain: ${expectedContent}`
};
return this.waitForCondition(condition, {
timeout: 30000,
interval: 1000,
exponentialBackoff: true,
...options
});
}
/**
* Wait for command completion (prompt appears)
*/
public static async waitForPrompt(
bridge: any,
sessionName: string,
windowIndex: number,
promptPattern: RegExp = /[\$#%>]\s*$/m,
options: WaitOptions = {}
): Promise<WaitResult> {
const condition: WaitCondition = {
check: async () => {
try {
const content = await bridge.captureWindowContent(sessionName, windowIndex, 5);
return promptPattern.test(content);
} catch (error) {
return false;
}
},
description: `Waiting for command prompt in ${sessionName}:${windowIndex}`
};
return this.waitForCondition(condition, {
timeout: 10000,
interval: 500,
exponentialBackoff: true,
...options
});
}
/**
* Wait for Claude agent to be ready (specific output patterns)
*/
public static async waitForClaudeReady(
bridge: any,
sessionName: string,
windowIndex: number,
options: WaitOptions = {}
): Promise<WaitResult> {
const readyPatterns = [
/Claude\s+Code/i,
/Model:\s+Claude/i,
/Ready\s+to\s+help/i,
/How\s+can\s+I\s+help/i,
/What\s+would\s+you\s+like/i
];
const condition: WaitCondition = {
check: async () => {
try {
const content = await bridge.captureWindowContent(sessionName, windowIndex, 10);
return readyPatterns.some(pattern => pattern.test(content));
} catch (error) {
return false;
}
},
description: `Waiting for Claude agent to be ready in ${sessionName}:${windowIndex}`
};
return this.waitForCondition(condition, {
timeout: 60000, // Increased to 60 seconds for Claude startup
interval: 2000, // Check every 2 seconds
exponentialBackoff: true,
...options
});
}
/**
* Wait for git operation completion
*/
public static async waitForGitOperation(
bridge: any,
sessionName: string,
windowIndex: number,
operation: 'push' | 'pull' | 'commit' | 'branch',
options: WaitOptions = {}
): Promise<WaitResult> {
const operationPatterns = {
push: [/branch.*up.to.date/i, /everything.*up.to.date/i, /\d+\s+objects/i, /Total\s+\d+/i],
pull: [/Already.*up.to.date/i, /Fast.forward/i, /files?\s+changed/i],
commit: [/\d+\s+files?\s+changed/i, /create\s+mode/i, /\[\w+.*\]/i],
branch: [/Switched\s+to/i, /already\s+on/i, /branch.*set\s+up/i]
};
const errorPatterns = [
/error:/i,
/fatal:/i,
/failed/i,
/rejected/i,
/permission\s+denied/i,
/authentication\s+failed/i
];
const condition: WaitCondition = {
check: async () => {
try {
const content = await bridge.captureWindowContent(sessionName, windowIndex, 10);
// Check for errors first
if (errorPatterns.some(pattern => pattern.test(content))) {
throw new Error(`Git operation failed: ${content.substring(0, 200)}`);
}
// Check for success patterns
return operationPatterns[operation].some(pattern => pattern.test(content));
} catch (error) {
if (error.message.includes('Git operation failed')) {
throw error;
}
return false;
}
},
description: `Waiting for git ${operation} operation to complete in ${sessionName}:${windowIndex}`
};
return this.waitForCondition(condition, {
timeout: 30000,
interval: 1000,
exponentialBackoff: true,
...options
});
}
/**
* Wait for file or directory to exist
*/
public static async waitForPath(
pathToCheck: string,
options: WaitOptions = {}
): Promise<WaitResult> {
const fs = require('fs').promises;
const condition: WaitCondition = {
check: async () => {
try {
await fs.access(pathToCheck);
return true;
} catch {
return false;
}
},
description: `Waiting for path to exist: ${pathToCheck}`
};
return this.waitForCondition(condition, {
timeout: 10000,
interval: 200,
...options
});
}
/**
* Wait for Claude to complete a potentially long-running operation
*/
public static async waitForClaudeOperation(
bridge: any,
sessionName: string,
windowIndex: number,
operationDescription: string,
options: WaitOptions = {}
): Promise<WaitResult> {
const completionPatterns = [
/✅/, // Success emoji
/completed/i, // "completed" text
/finished/i, // "finished" text
/done/i, // "done" text
/ready/i, // "ready" text
/[>$#%]\s*$/m, // Command prompt returned
];
const condition: WaitCondition = {
check: async () => {
try {
const content = await bridge.captureWindowContent(sessionName, windowIndex, 20);
return completionPatterns.some(pattern => pattern.test(content));
} catch (error) {
return false;
}
},
description: `Waiting for Claude to complete: ${operationDescription} in ${sessionName}:${windowIndex}`
};
return this.waitForCondition(condition, {
timeout: 600000, // 10 minutes for long operations
interval: 10000, // Check every 10 seconds
exponentialBackoff: false,
...options
});
}
/**
* Wait for agent to be responsive (can execute simple commands)
*/
public static async waitForAgentResponsive(
bridge: any,
sessionName: string,
windowIndex: number,
options: WaitOptions = {}
): Promise<WaitResult> {
const condition: WaitCondition = {
check: async () => {
try {
// Send a simple test command
await bridge.sendCommandToWindow(sessionName, windowIndex, 'echo "agent_test_ready"');
// Wait a bit for response
await new Promise(resolve => setTimeout(resolve, 500));
// Check if we got the expected response
const content = await bridge.captureWindowContent(sessionName, windowIndex, 5);
return content.includes('agent_test_ready');
} catch (error) {
return false;
}
},
description: `Waiting for agent to be responsive in ${sessionName}:${windowIndex}`
};
return this.waitForCondition(condition, {
timeout: 10000,
interval: 2000,
exponentialBackoff: false,
...options
});
}
/**
* Wait for process to complete (no active processes in tmux pane)
*/
public static async waitForProcessCompletion(
bridge: any,
sessionName: string,
windowIndex: number,
options: WaitOptions = {}
): Promise<WaitResult> {
const condition: WaitCondition = {
check: async () => {
try {
// Check if there are any running processes
await bridge.sendCommandToWindow(sessionName, windowIndex, 'ps aux | grep -v grep | grep -c "[^]]$" || echo "0"');
await new Promise(resolve => setTimeout(resolve, 300));
const content = await bridge.captureWindowContent(sessionName, windowIndex, 3);
// Look for process count of 0 or 1 (just the shell)
const lines = content.trim().split('\n');
const lastLine = lines[lines.length - 1];
const processCount = parseInt(lastLine.trim());
return processCount <= 1;
} catch (error) {
return false;
}
},
description: `Waiting for processes to complete in ${sessionName}:${windowIndex}`
};
return this.waitForCondition(condition, {
timeout: 60000,
interval: 2000,
exponentialBackoff: true,
...options
});
}
/**
* Wait for network connectivity
*/
public static async waitForNetwork(
host: string = 'google.com',
options: WaitOptions = {}
): Promise<WaitResult> {
const { exec } = require('child_process');
const { promisify } = require('util');
const execAsync = promisify(exec);
const condition: WaitCondition = {
check: async () => {
try {
await execAsync(`ping -c 1 -W 2 ${host}`, { timeout: 3000 });
return true;
} catch {
return false;
}
},
description: `Waiting for network connectivity to ${host}`
};
return this.waitForCondition(condition, {
timeout: 30000,
interval: 2000,
exponentialBackoff: true,
...options
});
}
/**
* Create a combined condition (AND logic)
*/
public static combineConditions(...conditions: WaitCondition[]): WaitCondition {
return {
check: async () => {
for (const condition of conditions) {
const result = await condition.check();
if (!result) return false;
}
return true;
},
description: `Combined conditions: ${conditions.map(c => c.description).join(' AND ')}`
};
}
/**
* Create an any condition (OR logic)
*/
public static anyCondition(...conditions: WaitCondition[]): WaitCondition {
return {
check: async () => {
for (const condition of conditions) {
const result = await condition.check();
if (result) return true;
}
return false;
},
description: `Any condition: ${conditions.map(c => c.description).join(' OR ')}`
};
}
}