Skip to main content
Glama
claude-print.ts13.5 kB
/** * Claude Print Executor - Execute one-off commands via claude --print * * This subsystem handles ephemeral command execution (slash commands, utilities) * as opposed to persistent streaming processes (Transport). * * Mirrors Transport pattern (local/remote abstraction) but implements * print-specific semantics: * - Command in CLI args (not stdin) * - Single response (not streaming) * - Process exits after completion */ import { spawn, ChildProcess } from "child_process"; import { getChildLogger } from "./logger.js"; import { ProcessError, TimeoutError } from "./errors.js"; import type { IrisConfig } from "../process-pool/types.js"; const logger = getChildLogger("utils:claude-print"); export interface ClaudePrintOptions { /** Command to execute (e.g., "ping", "/compact", "/help") */ command: string; /** Timeout in milliseconds (default: 30000) */ timeout?: number; /** Whether to use --resume (true) or --session-id (false) */ resume?: boolean; /** Number of retry attempts for transient failures (default: 0) */ retries?: number; /** Delay between retries in milliseconds (default: 1000, exponential backoff) */ retryDelay?: number; } export interface ClaudePrintResult { /** Exit code from claude process */ exitCode: number; /** stdout output */ stdout: string; /** stderr output */ stderr: string; /** Duration in milliseconds */ duration: number; /** Whether command completed successfully */ success: boolean; /** Debug log path (if available) */ debugLogPath?: string; /** Number of retry attempts made (0 if succeeded on first try) */ retryCount?: number; /** Total attempts made (including initial + retries) */ totalAttempts?: number; } /** * Metrics for tracking print command performance */ export interface ClaudePrintMetrics { command: string; duration: number; success: boolean; remote: boolean; timestamp: number; retryCount: number; exitCode: number; } /** * ClaudePrintExecutor - Executes one-off commands via claude --print * * Factory pattern: * ```typescript * const executor = ClaudePrintExecutor.create(teamConfig, sessionId); * const result = await executor.execute({ * command: '/compact', * resume: true, * retries: 2 // Retry up to 2 times on failure * }); * ``` */ export class ClaudePrintExecutor { private metrics: ClaudePrintMetrics[] = []; private constructor( private irisConfig: IrisConfig, private sessionId: string, ) {} /** * Factory method - creates executor for team config */ static create( irisConfig: IrisConfig, sessionId: string, ): ClaudePrintExecutor { return new ClaudePrintExecutor(irisConfig, sessionId); } /** * Execute command via claude --print with retry logic * Delegates to local or remote implementation based on config */ async execute(options: ClaudePrintOptions): Promise<ClaudePrintResult> { const { command, timeout = 30000, resume = true, retries = 0, retryDelay = 1000, } = options; const maxAttempts = retries + 1; // Initial attempt + retries let lastError: Error | null = null; let retryCount = 0; logger.info({ command, sessionId: this.sessionId, remote: !!this.irisConfig.remote, resume, timeout, maxAttempts, }, "PLACEHOLDER"); for (let attempt = 1; attempt <= maxAttempts; attempt++) { try { const result = await this.executeInternal(command, timeout, resume); // Record metrics this.recordMetrics({ command, duration: result.duration, success: result.success, remote: !!this.irisConfig.remote, timestamp: Date.now(), retryCount, exitCode: result.exitCode, }); // Add retry information to result return { ...result, retryCount, totalAttempts: attempt, }; } catch (error) { lastError = error as Error; retryCount++; logger.warn( { command, attempt, maxAttempts, error: lastError.message, }, "Command execution failed, will retry if attempts remain", ); // If we have retries left, wait before retrying if (attempt < maxAttempts) { // Exponential backoff: delay * attempt const delay = retryDelay * attempt; logger.info({ delay, attempt, maxAttempts }, "Waiting before retry"); await this.delay(delay); } } } // All attempts failed logger.error( { command, totalAttempts: maxAttempts, error: lastError?.message, }, "All retry attempts exhausted", ); // Record failure metrics this.recordMetrics({ command, duration: 0, success: false, remote: !!this.irisConfig.remote, timestamp: Date.now(), retryCount: retries, exitCode: -1, }); throw lastError; } /** * Internal execute method (without retry logic) */ private async executeInternal( command: string, timeout: number, resume: boolean, ): Promise<ClaudePrintResult> { if (this.irisConfig.remote) { return this.executeRemote(command, timeout, resume); } else { return this.executeLocal(command, timeout, resume); } } /** * Get recorded metrics */ getMetrics(): ClaudePrintMetrics[] { return [...this.metrics]; // Return copy to prevent mutation } /** * Clear recorded metrics */ clearMetrics(): void { this.metrics = []; } /** * Record execution metrics */ private recordMetrics(metric: ClaudePrintMetrics): void { this.metrics.push(metric); } /** * Delay helper for retry logic */ private delay(ms: number): Promise<void> { return new Promise((resolve) => setTimeout(resolve, ms)); } /** * Execute locally via child_process.spawn * Command: claude --resume <sessionId> --print <command> */ private async executeLocal( command: string, timeout: number, resume: boolean, ): Promise<ClaudePrintResult> { const startTime = Date.now(); // Use custom claudePath if provided, otherwise default to 'claude' const claudeExecutable = this.irisConfig.claudePath || "claude"; // Build command args const args = [ resume ? "--resume" : "--session-id", this.sessionId, "--print", command, ]; logger.debug({ command: `${claudeExecutable} ${args.join(" ")}`, cwd: this.irisConfig.path, }, "PLACEHOLDER"); // Spawn Claude const claudeProcess = spawn(claudeExecutable, args, { cwd: this.irisConfig.path, stdio: ["pipe", "pipe", "pipe"], env: process.env, }); // Close stdin immediately - we're not sending input claudeProcess.stdin!.end(); return this.waitForCompletion(claudeProcess, timeout, startTime); } /** * Execute remotely via SSH * Command: ssh <host> "cd <path> && claude --resume <sessionId> --print <command>" */ private async executeRemote( command: string, timeout: number, resume: boolean, ): Promise<ClaudePrintResult> { const startTime = Date.now(); // Use custom claudePath if provided, otherwise default to 'claude' const claudeExecutable = this.irisConfig.claudePath || "claude"; // Build remote command const claudeArgs = [ resume ? "--resume" : "--session-id", this.sessionId, "--print", command, ]; const remoteCommand = `cd ${this.escapeShellArg(this.irisConfig.path)} && ${claudeExecutable} ${claudeArgs.join(" ")}`; // Parse SSH connection string (e.g., "ssh inanna" → ["ssh", "inanna"]) const remoteParts = this.irisConfig.remote!.split(/\s+/); const sshExecutable = remoteParts[0]; // Should be "ssh" const sshArgs = remoteParts.slice(1); // Host and any SSH flags // Build SSH command: ssh <host> "cd <path> && claude ..." const fullArgs = [...sshArgs, remoteCommand]; logger.debug({ command: `${sshExecutable} ${fullArgs.join(" ")}`, remote: this.irisConfig.remote, }, "PLACEHOLDER"); // Spawn SSH process const sshProcess = spawn(sshExecutable, fullArgs, { stdio: ["pipe", "pipe", "pipe"], shell: false, }); // Close stdin immediately sshProcess.stdin!.end(); return this.waitForCompletion(sshProcess, timeout, startTime); } /** * Wait for process to complete and collect output * Used by both local and remote execution */ private async waitForCompletion( process: ChildProcess, timeout: number, startTime: number, ): Promise<ClaudePrintResult> { return new Promise((resolve, reject) => { let stdoutData = ""; let stderrData = ""; let debugLogPath: string | null = null; let spawnError: Error | null = null; let responseReceived = false; let timeoutHandle: NodeJS.Timeout | null = null; // Capture stdout process.stdout!.on("data", (data: Buffer) => { const output = data.toString(); stdoutData += output; logger.debug({ sessionId: this.sessionId, output: output.substring(0, 500), }, "PLACEHOLDER"); if (output.length > 0) { responseReceived = true; } }); // Capture stderr process.stderr!.on("data", (data: Buffer) => { const errorOutput = data.toString(); stderrData += errorOutput; // Extract debug log path if present const logPathMatch = errorOutput.match(/Logging to: (.+)/); if (logPathMatch && !debugLogPath) { debugLogPath = logPathMatch[1].trim(); logger.debug({ sessionId: this.sessionId, debugLogPath, }, "PLACEHOLDER"); } logger.debug({ sessionId: this.sessionId, stderr: errorOutput, }, "PLACEHOLDER"); }); // Handle spawn errors process.on("error", (err) => { logger.error({ err, sessionId: this.sessionId }, "Process spawn error"); spawnError = err; }); // Handle process exit process.on("exit", (code) => { // Clear timeout immediately if (timeoutHandle) { clearTimeout(timeoutHandle); timeoutHandle = null; } const duration = Date.now() - startTime; const exitCode = code ?? -1; if (spawnError) { logger.error( { err: spawnError, sessionId: this.sessionId, exitCode, stdoutLength: stdoutData.length, stderrLength: stderrData.length, }, "Process exited with spawn error", ); reject(spawnError); } else if (exitCode !== 0 && exitCode !== 143) { // 143 is SIGTERM (ok) logger.warn( { sessionId: this.sessionId, exitCode, stdout: stdoutData, stderr: stderrData, debugLogPath, }, "Command failed with non-zero exit code", ); resolve({ exitCode, stdout: stdoutData, stderr: stderrData, duration, success: false, debugLogPath: debugLogPath ?? undefined, }); } else if (!responseReceived) { logger.warn( { sessionId: this.sessionId, exitCode, stdout: stdoutData, stderr: stderrData, }, "Command completed but no response received", ); resolve({ exitCode, stdout: stdoutData, stderr: stderrData, duration, success: false, debugLogPath: debugLogPath ?? undefined, }); } else { // Success logger.info({ sessionId: this.sessionId, exitCode, stdoutLength: stdoutData.length, duration, }, "PLACEHOLDER"); resolve({ exitCode, stdout: stdoutData, stderr: stderrData, duration, success: true, debugLogPath: debugLogPath ?? undefined, }); } }); // Timeout handler timeoutHandle = setTimeout(() => { timeoutHandle = null; logger.error( { sessionId: this.sessionId, timeout, responseReceived, stdout: stdoutData, stderr: stderrData, }, "Command execution timed out", ); process.kill(); const errorMsg = [ `Command execution timed out after ${timeout}ms`, `Response received: ${responseReceived}`, debugLogPath ? `Debug logs: ${debugLogPath}` : null, ] .filter(Boolean) .join("\n"); reject(new TimeoutError(errorMsg, timeout)); }, timeout); }); } /** * Escape shell argument for safe command execution * Used for remote SSH commands */ private escapeShellArg(arg: string): string { // Single-quote the argument and escape any single quotes within return `'${arg.replace(/'/g, "'\\''")}'`; } }

Latest Blog Posts

MCP directory API

We provide all the information about MCP servers via our MCP API.

curl -X GET 'https://glama.ai/api/mcp/v1/servers/jenova-marie/iris-mcp'

If you have feedback or need assistance with the MCP directory API, please join our Discord server