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
JavaScript
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