UNPKG

overmind-mcp

Version:

Orchestrateur universel agents IA multi-modeles via MCP. Inclut le protocole 'Custom-Nickname' pour identifier vos agents avec des surnoms originaux (The Chaos Prophet, Shadow Sniper, etc.), l'isolation mΓ©moire (Private Memory Context) et le support pour

507 lines (504 loc) β€’ 26.6 kB
import fs from 'fs'; import path from 'path'; import { spawn } from 'child_process'; import { CONFIG, resolveConfigPath } from '../lib/config.js'; import { getLastSessionId, saveSessionId } from '../lib/sessions.js'; import { interpolateEnvVars } from '../lib/envUtils.js'; import { PromptManager } from './PromptManager.js'; import { resolveKiloModel } from '../lib/modelMapping.js'; import { withSpan } from '../lib/telemetry.js'; import { loadEnvQuietly } from '../lib/loadEnv.js'; import { registerProcess, linkSessionToPid, appendOutput, updateProcessStatus, killProcessTree, } from '../lib/processRegistry.js'; export class KiloRunner { config; timeoutMs; /* eslint-disable no-useless-escape */ static INSTALL_INSTRUCTIONS = ` πŸ’‘ **Comment installer/mettre Γ  jour Kilo Code v7.2.14 :** **Option A β€” VS Code (RecommandΓ©)** 1. Dans VS Code, Extensions (Ctrl+Shift+X) 2. Recherchez "Kilo Code" par "kilocode" 3. Ou via terminal : \`code --install-extension kilocode.Kilo-Code\` **Option B β€” CLI Standalone (Binaire)** 1. TΓ©lΓ©chargez \`kilo-windows-x64.zip\` depuis : https://github.com/Kilo-Org/kilocode/releases 2. Extrayez \`kilo.exe\` et placez-le dans un dossier de votre PATH (ex: \`%USERPROFILE%\AppData\Roaming\npm\`) **Option C β€” Scoop** \`scoop bucket add kilo https://github.com/Kilo-Org/scoop-kilo\` \`scoop install kilo\` `; constructor() { this.config = CONFIG.KILO; this.timeoutMs = CONFIG.TIMEOUT_MS || 900000; // 15 minutes par dΓ©faut } async verifyInstallation() { const { verifyInstallation: check } = await import('../lib/InstallHelper.js'); return check('kilo'); } async runAgent(options) { if (options.agentName) { // Inline validation β€” prevents path traversal on settings_${agentName}.json if (!/^[a-zA-Z0-9_-]+$/.test(options.agentName)) { return { result: '', error: `INVALID_AGENT_NAME: '${options.agentName}' contains invalid characters. Only [a-zA-Z0-9_-] allowed.`, }; } } // Load .env files first (before anything else) β€” same as ClaudeRunner/Hermes const cwd = options.cwd || process.cwd(); loadEnvQuietly(path.join(cwd, '.env')); loadEnvQuietly(path.join(cwd, '../Workflow/.env')); const { prompt, agentName, autoResume, mode } = options; const startTime = Date.now(); let { sessionId } = options; const { model } = options; const { PATHS, DEFAULT_MODEL } = this.config; const agentCustomEnv = {}; // --- Auto Resume --- if (autoResume && agentName && !sessionId) { const lastId = await getLastSessionId(agentName, options.configPath, 'kilo'); if (lastId) { sessionId = lastId; if (!options.silent) { process.stderr.write(`\x1b[33m[Kilo] [SESSION] Auto-resume session: ${sessionId}\x1b[0m\n`); } } } let customTimeoutMs = this.timeoutMs; let selectedModel = model || DEFAULT_MODEL; const originalModel = selectedModel; // --- Isolation --- if (agentName) { try { const agentSettingsPath = resolveConfigPath(path.join(path.dirname(PATHS.SETTINGS), `settings_${agentName}.json`), options.configPath); if (fs.existsSync(agentSettingsPath)) { let settings = JSON.parse(fs.readFileSync(agentSettingsPath, 'utf8')); // --- New interpolation logic --- settings = interpolateEnvVars(settings); if (settings.env) { Object.assign(agentCustomEnv, settings.env); // Kilo uses OPENAI_API_BASE, not the Anthropic SDK. // Remove ANTHROPIC_BASE_URL so polyglot agents (Claude + Kilo) // don't accidentally redirect Kilo to a Claude-provider endpoint. delete agentCustomEnv['ANTHROPIC_BASE_URL']; // Allow custom base URL for Anthropic-compatible providers like Minimax if (settings.env.AGENT_TIMEOUT_MS || settings.env.API_TIMEOUT_MS) { customTimeoutMs = parseInt(settings.env.AGENT_TIMEOUT_MS || settings.env.API_TIMEOUT_MS, 10) || customTimeoutMs; } if (!model && settings.model) { selectedModel = settings.model; if (!options.silent) { process.stderr.write(`\x1b[35m[Kilo:Debug] model from settings: ${selectedModel}\x1b[0m\n`); } } else if (!model && settings.env.MODEL) { selectedModel = settings.env.MODEL; if (!options.silent) { process.stderr.write(`\x1b[35m[Kilo:Debug] MODEL from env: ${selectedModel}\x1b[0m\n`); } } else if (!model && settings.env.KILO_MODEL) { selectedModel = settings.env.KILO_MODEL; if (!options.silent) { process.stderr.write(`\x1b[35m[Kilo:Debug] KILO_MODEL from env: ${selectedModel}\x1b[0m\n`); } } else if (!model && settings.env.ANTHROPIC_MODEL) { selectedModel = settings.env.ANTHROPIC_MODEL; if (!options.silent) { process.stderr.write(`\x1b[35m[Kilo:Debug] ANTHROPIC_MODEL from env: ${selectedModel}\x1b[0m\n`); } } if (!options.silent) { process.stderr.write(`\x1b[35m[Kilo:Debug] Final selectedModel (pre-mapping): ${selectedModel} (model arg was: ${model})\x1b[0m\n`); } } } } catch (_e) { if (!options.silent) { process.stderr.write(`\x1b[31m[Kilo:Debug] Error loading agent settings: ${_e}\x1b[0m\n`); } } } // Apply mapping using shared resolver selectedModel = resolveKiloModel(selectedModel); // Build kilo args: kilo run [prompt] [options] const argsSpawn = ['run']; let effectivePrompt = prompt; if (agentName) { try { const promptManager = new PromptManager(options.configPath); const persona = await promptManager.getPromptContent(agentName); if (persona) { effectivePrompt = `${persona}\n\n---\n\n${prompt}`; } } catch (_e) { // Persona injection is best-effort } } argsSpawn.push(effectivePrompt); if (mode) { argsSpawn.push('--agent', mode); } if (sessionId && sessionId.length > 0) { argsSpawn.push('--session', sessionId); } if (selectedModel) argsSpawn.push('--model', selectedModel); // Required for non-interactive execution argsSpawn.push('--auto'); argsSpawn.push('--format', 'json'); if (cwd) { argsSpawn.push('--dir', cwd); } // ─────────────────────────────────────────────────────────────────────────── // πŸ”„ FALLBACK TOKEN RETRY LOGIC // // Overmind lit les tokens fallback depuis agentCustomEnv (rΓ©solus depuis $VAR). // Si une erreur 401 (auth) survient, on tente chaque fallback sΓ©quentiellement : // AUTH_FALLBACK_1 β†’ AUTH_FALLBACK_2 β†’ AUTH_FALLBACK_3 // // Settings exemple : // { "env": { "OPENAI_API_KEY": "$ANTHROPIC_AUTH_FALLBACK_1" } } // ─────────────────────────────────────────────────────────────────────────── const FALLBACK_KEYS = ['AUTH_FALLBACK_1', 'AUTH_FALLBACK_2', 'AUTH_FALLBACK_3']; /** * VΓ©rifie si le stderr contient une erreur d'authentification (401). * Kilo CLI affiche des messages explicites en cas d'auth failure. */ const isAuthError = (stderr) => { const lower = stderr.toLowerCase(); return (lower.includes('401') || lower.includes('unauthorized') || lower.includes('invalid api key') || lower.includes('api key invalid') || lower.includes('authentication failed') || lower.includes('auth error') || lower.includes('invalid authentication') || lower.includes('openai authenticationerror') || lower.includes('authenticationerror')); }; /** * Extrait les tokens fallback disponibles depuis agentCustomEnv. */ const getAvailableFallbacks = () => { const fallbacks = []; for (const key of FALLBACK_KEYS) { const val = agentCustomEnv[key]; if (val && typeof val === 'string' && val.length > 0) { // Resolve $VAR like ClaudeRunner does let resolvedValue = val; if (val.startsWith('$')) { const envKey = val.slice(1); resolvedValue = process.env[envKey] || val; } fallbacks.push({ key, value: resolvedValue }); } } return fallbacks; }; /** * DΓ©termine quel token API utiliser (pour Kilo = OPENAI_API_KEY). * Retourne le token du fallback Γ  l'index donnΓ©. */ const getTokenForIndex = (index) => { if (index === 0) { // Tentative initiale : utiliser le primary token const primaryKeys = ['OPENAI_API_KEY', 'ANTHROPIC_AUTH_TOKEN', 'ANTHROPIC_AUTH_TOKEN_E']; for (const tk of primaryKeys) { const val = agentCustomEnv[tk]; if (val && typeof val === 'string' && val.length > 0) { // Resolve $VAR if present let resolvedValue = val; if (val.startsWith('$')) { const envKey = val.slice(1); resolvedValue = process.env[envKey] || val; } return { tokenEnvKey: tk, tokenValue: resolvedValue }; } } return null; } // Retry (index >= 1) : use fallbacks directly const fallbacks = getAvailableFallbacks(); const fallbackIndex = index - 1; if (fallbackIndex < fallbacks.length) { return { tokenEnvKey: fallbacks[fallbackIndex].key, tokenValue: fallbacks[fallbackIndex].value, }; } return null; }; const runImpl = async (span) => { span.setAttribute('agentName', agentName || ''); span.setAttribute('model', selectedModel || ''); span.setAttribute('runner', 'kilo'); return new Promise((resolve) => { let resolved = false; let retryCount = 0; const maxRetries = getAvailableFallbacks().length + 1; let currentChild = null; let currentStdout = ''; let currentStderr = ''; const MAX_BUF = 10 * 1024 * 1024; let finalResult = ''; let lastSessionId = sessionId; const safeResolve = (value) => { if (!resolved) { resolved = true; resolve(value); } }; let killTimer = null; let hardTimeoutTimer = null; /** * Fonction centrale qui spawn Kilo avec le bon token. */ const spawnWithToken = (tokenInfo) => { if (hardTimeoutTimer) { clearTimeout(hardTimeoutTimer); hardTimeoutTimer = null; } if (killTimer) { clearTimeout(killTimer); killTimer = null; } // Construire l'env avec le bon token API // NOTE: Overmind gΓ¨re la substitution des variables $VAR dans les settings. const spawnEnv = { ...process.env, ...agentCustomEnv, }; if (tokenInfo) { // Remplacer le token API actif par le fallback const apiKeys = ['OPENAI_API_KEY', 'ANTHROPIC_AUTH_TOKEN', 'ANTHROPIC_AUTH_TOKEN_E']; for (const tk of apiKeys) { delete spawnEnv[tk]; } spawnEnv[tokenInfo.tokenEnvKey] = tokenInfo.tokenValue; } currentStdout = ''; currentStderr = ''; finalResult = ''; const command = 'kilo'; if (!options.silent) { const tokenLabel = tokenInfo ? ` (token: ${tokenInfo.tokenEnvKey})` : ''; process.stderr.write(`\n\x1b[33m[Kilo]${tokenLabel} ⚑ Initialisation de l'agent: ${agentName || 'Anonyme'}\x1b[0m\n`); process.stderr.write(`\x1b[33m[Kilo] πŸ€– ModΓ¨le: ${selectedModel}\x1b[0m\n`); if (mode) process.stderr.write(`\x1b[33m[Kilo] πŸ› οΈ Mode/Agent: ${mode}\x1b[0m\n`); if (sessionId && sessionId.length > 0) process.stderr.write(`\x1b[33m[Kilo] πŸ“œ Session: ${sessionId}\x1b[0m\n`); // Sanitize command args before display to prevent injection const sanitizedArgs = argsSpawn .map((arg) => { let result = ''; for (const char of arg) { const code = char.charCodeAt(0); // Skip control chars (0x00-0x1F) and DEL (0x7F) if (code >= 0x20 && code !== 0x7f) { result += char; } } return result; }) .join(' '); process.stderr.write(`\x1b[33m[Kilo] πŸš€ Commande: ${command} ${sanitizedArgs}\x1b[0m\n`); } currentChild = spawn(command, argsSpawn, { cwd: options.cwd || process.cwd(), shell: false, windowsHide: true, env: { ...spawnEnv, ...(agentName ? { OVERMIND_AGENT_NAME: agentName } : {}), }, }); // Register process immediately after spawn if (currentChild.pid) { void registerProcess(currentChild.pid, { agentName: agentName || '', runner: 'kilo', configPath: options.configPath, }); } if (currentChild.stdout) { currentChild.stdout.on('data', (d) => { const chunk = d.toString(); // Append to live output buffer if (currentChild && currentChild.pid && chunk) { void appendOutput(currentChild.pid, chunk, options.configPath); } if (currentStdout.length + chunk.length > MAX_BUF) currentStdout = currentStdout.slice(-MAX_BUF); else currentStdout += chunk; const lines = chunk.split('\n'); for (const line of lines) { const trimmedLine = line.trim(); if (!trimmedLine) continue; try { const event = JSON.parse(trimmedLine); if (event.sessionID && !lastSessionId) { lastSessionId = event.sessionID; if (currentChild) { void linkSessionToPid(event.sessionID, currentChild.pid, options.configPath); } } if (event.type === 'text' && event.part && event.part.text) { finalResult += event.part.text; } else if (event.type === 'message' || event.type === 'reply') { finalResult += event.content || event.text || ''; } else if (event.type === 'session') { if (!lastSessionId) { lastSessionId = event.id || event.sessionID; if (currentChild && lastSessionId) { void linkSessionToPid(lastSessionId, currentChild.pid, options.configPath); } } else { lastSessionId = event.id || event.sessionID || lastSessionId; } } else if (event.type === 'error') { currentStderr += (event.message || JSON.stringify(event)) + '\n'; } } catch (_e) { if (!trimmedLine.startsWith('{') && !options.silent) { process.stderr.write(`\x1b[36m[Kilo]\x1b[0m ${trimmedLine}\n`); } } } }); } if (currentChild.stderr) { currentChild.stderr.on('data', (d) => { const chunk = d.toString(); if (currentStderr.length + chunk.length > MAX_BUF) currentStderr = currentStderr.slice(-MAX_BUF); else currentStderr += chunk; const lowerChunk = chunk.toLowerCase(); if (lowerChunk.includes('quota') || lowerChunk.includes('exhausted') || lowerChunk.includes('rate limit') || chunk.includes('429')) { process.stderr.write(`\n\x1b[41m\x1b[37m[Kilo ALERT] QUOTA ATTEINT / MODÈLE Γ‰PUISΓ‰\x1b[0m\n`); process.stderr.write(`\x1b[31m[DΓ©tail] ${chunk.trim()}\x1b[0m\n`); } else if (!options.silent) { process.stderr.write(`\x1b[31m[Kilo STDERR]\x1b[0m ${chunk}`); } }); } const timeout = setTimeout(() => { if (currentChild && currentChild.stdin && !currentChild.stdin.destroyed) { try { currentChild.stdin.write('\n'); if (!options.silent) { process.stderr.write(`\n\x1b[33m[Kilo] [WARN] Agent stagnant (${customTimeoutMs}ms). Envoi d'un keep-alive (\\n)...\x1b[0m\n`); } } catch (_e) { // ignore } } const hardTimeoutDelay = CONFIG.HARD_TIMEOUT_MS || 60000; hardTimeoutTimer = setTimeout(async () => { if (currentChild && currentChild.pid) await killProcessTree(currentChild.pid); else if (currentChild) currentChild.kill(); killTimer = setTimeout(() => { if (currentChild && !currentChild.killed) currentChild.kill('SIGKILL'); }, 5000); safeResolve({ result: finalResult || currentStdout, error: 'HARD_TIMEOUT', rawOutput: currentStdout, }); }, hardTimeoutDelay); }, customTimeoutMs); currentChild.on('error', (err) => { clearTimeout(timeout); if (hardTimeoutTimer) clearTimeout(hardTimeoutTimer); safeResolve({ result: '', error: err.message, rawOutput: '' }); }); currentChild.on('close', async (code) => { clearTimeout(timeout); if (hardTimeoutTimer) clearTimeout(hardTimeoutTimer); // ─── VΓ©rification 401 / Auth Error β†’ Retry avec fallback ─── if (code !== 0 && isAuthError(currentStderr)) { const tokenInfo = getTokenForIndex(retryCount); if (tokenInfo && retryCount < maxRetries) { retryCount++; if (!options.silent) { process.stderr.write(`\n\x1b[41m\x1b[37m[Kilo] πŸ”„ Auth error (401). Retry ${retryCount}/${maxRetries} avec ${tokenInfo.tokenEnvKey}...\x1b[0m\n`); } spawnWithToken(tokenInfo); return; } else { if (!options.silent) { process.stderr.write(`\n\x1b[41m\x1b[37m[Kilo] ❌ Tous les tokens fallback Γ©puisΓ©s. Auth error finale.\x1b[0m\n`); } safeResolve({ result: '', error: 'AUTH_ERROR_ALL_FALLBACKS_EXHAUSTED', rawOutput: currentStdout + currentStderr, }); return; } } if (code !== 0 && !finalResult && !currentStdout) { if (!options.silent) { process.stderr.write(`\x1b[31m[Kilo] ❌ Γ‰chec avec Code: ${code}\x1b[0m\n`); } return safeResolve({ result: '', error: `EXIT_CODE_${code}`, rawOutput: currentStderr || currentStdout, }); } if (!options.silent) { process.stderr.write(`\x1b[32m[Kilo] βœ… Mission terminΓ©e (${((Date.now() - startTime) / 1000).toFixed(1)}s)\x1b[0m\n`); } if (currentChild && currentChild.pid) { void updateProcessStatus(currentChild.pid, code === 0 ? 'done' : 'failed', code, options.configPath); } if (agentName && lastSessionId) { await saveSessionId(agentName, lastSessionId, options.configPath, 'kilo'); } safeResolve({ result: finalResult || currentStdout.trim(), sessionId: lastSessionId, rawOutput: currentStdout, model: selectedModel, nickname: originalModel !== selectedModel ? originalModel : undefined, fallbackUsed: retryCount > 0 ? FALLBACK_KEYS[retryCount - 1] : undefined, }); }); }; // ─── DΓ©marrage initial ─── spawnWithToken(getTokenForIndex(0)); }); }; return withSpan('kilo.runAgent', runImpl, { agentName: agentName || '', model: selectedModel || '', runner: 'kilo', }); } } //# sourceMappingURL=KiloRunner.js.map