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
332 lines • 17.4 kB
JavaScript
/**
* HermesRunner — Thin wrapper around the native Hermes Agent CLI.
*
* ╔════════════════════════════════════════════════════════════════════════╗
* ║ ARCHITECTURE (v3.0 — Refactored) ║
* ║ ║
* ║ This runner replaces the 1391-line NousHermesRunner. It does NOT: ║
* ║ - Resolve tokens (Hermes .env per-profile does that) ║
* ║ - Detect providers (Hermes config.yaml does that) ║
* ║ - Manage HERMES_HOME custom paths (Hermes ~/.hermes native) ║
* ║ - Filter config.yaml (1 config per profile now) ║
* ║ - Find the hermes binary (it's in PATH) ║
* ║ ║
* ║ It DOES: ║
* ║ - Spawn: hermes -p <profile> chat -q "<prompt>" -Q --yolo ║
* ║ - Capture stdout (response) + stderr (session_id + diagnostics) ║
* ║ - Handle timeout + abort signal ║
* ║ - Register with agent_lifecycle + processRegistry (UI parity) ║
* ║ ║
* ║ Profile management is handled by AgentManager via: ║
* ║ hermes profile create / delete / list / show ║
* ║ ║
* ║ Session resume is handled by: ║
* ║ hermes -p <name> chat -q "<prompt>" --resume <sessionId> ║
* ╚════════════════════════════════════════════════════════════════════════╝
*/
import { spawn } from 'child_process';
import { CONFIG, getWorkspaceDir, getSharedHermesHome, getAgentHermesHome } from '../lib/config.js';
import { getLastSessionId, saveSessionId } from '../lib/sessions.js';
import { linkSessionToPid } from '../lib/processRegistry.js';
import { withSpan } from '../lib/telemetry.js';
import { rootLogger } from '../lib/logger.js';
import { registerProcess, updateProcessStatus } from '../lib/processRegistry.js';
import { registerLiveAgent, appendLiveOutput, setLiveStatus, unregisterLiveAgent, } from '../lib/agent_lifecycle.js';
import { killProcessTree } from './hermes/processUtils.js';
const logger = rootLogger.child({ module: 'HermesRunner' });
// ─── Helpers ──────────────────────────────────────────────────────────────────
/** Kill a child process tree, cross-platform. */
function killChildTree(child) {
return killProcessTree(child);
}
/** Extract session_id from Hermes stderr output. */
function extractSessionId(stderr) {
// Hermes outputs: "session_id: 20260628_114351_3fa469"
const match = stderr.match(/session_id:\s*(\S+)/i);
return match?.[1];
}
/** Check if an error output indicates a retryable failure. */
function isRetryableError(stderr, stdout) {
const combined = (stderr + ' ' + stdout).toLowerCase();
return (combined.includes('401') ||
combined.includes('unauthorized') ||
combined.includes('invalid api key') ||
combined.includes('authentication failed') ||
combined.includes('429') ||
combined.includes('rate limit') ||
combined.includes('quota') ||
combined.includes('503') ||
combined.includes('service unavailable'));
}
// ─── Main Class ───────────────────────────────────────────────────────────────
export class HermesRunner {
timeoutMs;
MAX_BUF = 10 * 1024 * 1024; // 10MB buffer limit
constructor() {
this.timeoutMs = CONFIG.TIMEOUT_MS || 900000; // 15 min default
}
async runAgent(options) {
if (options.agentName) {
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.`,
};
}
}
logger.info({ agentName: options.agentName, model: options.model, sessionId: options.sessionId }, '[RUN_AGENT] Starting Hermes thin-wrapper run.');
try {
const result = await withSpan('hermes.runAgent', async (span) => {
span.setAttribute('agentName', options.agentName || '');
span.setAttribute('model', options.model || '');
span.setAttribute('runner', 'hermes');
return await this.runAgentInternal(options);
}, {
agentName: options.agentName || '',
model: options.model || '',
runner: 'hermes',
});
if (options.agentName && result.sessionId) {
await saveSessionId(options.agentName, result.sessionId, options.configPath, 'hermes');
}
return result;
}
catch (error) {
logger.error({
error: error instanceof Error ? error.message : String(error),
agentName: options.agentName,
}, '[RUN_AGENT] Hermes runner threw.');
throw error;
}
}
async runAgentInternal(options) {
const { prompt, agentName, silent } = options;
let { sessionId } = options;
// When an agentName is provided, spawn Hermes inside that agent's profile
// home (e.g. ~/.overmind/hermes/profiles/<name>/) so the process CWD, the
// terminal tool's cwd, and HERMES_HOME all agree. An explicit options.cwd
// still wins; without an agent we fall back to the current process cwd.
const cwd = options.cwd || (agentName ? getAgentHermesHome(agentName) : process.cwd());
const configPath = options.configPath || getWorkspaceDir();
// ─── Auto Resume ─────────────────────────────────────────────────────────
if (options.autoResume && agentName && !sessionId) {
const lastId = await getLastSessionId(agentName, configPath, 'hermes');
if (lastId) {
sessionId = lastId;
logger.info({ sessionId }, '[RUN_AGENT] Auto-resume session.');
}
}
// ─── Build CLI args ──────────────────────────────────────────────────────
// Global flags come BEFORE the subcommand; subcommand flags come AFTER.
//
// hermes -p <profile> chat -q "<prompt>" -Q --yolo --pass-session-id
// ^^^^^^^^^^^ ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
// profile quiet query
//
const cleanArgs = [];
// Profile selection — agentName maps to a Hermes profile
if (agentName) {
cleanArgs.push('-p', agentName);
}
// Subcommand
cleanArgs.push('chat');
// Query (non-interactive mode)
cleanArgs.push('-q', prompt);
// Quiet mode: suppress banner, spinner, tool previews
cleanArgs.push('-Q');
// Bypass all approval prompts (yolo mode — no human-in-the-loop)
cleanArgs.push('--yolo');
// Include session ID in output for extraction
cleanArgs.push('--pass-session-id');
// Tag as third-party integration (not shown in user session lists)
cleanArgs.push('--source', 'tool');
// Model override (optional — profile config.yaml is the default)
if (options.model) {
cleanArgs.push('--model', options.model);
}
// Provider override (optional — profile config.yaml is the default)
if (options.provider) {
cleanArgs.push('--provider', options.provider);
}
// Resume a previous session
if (sessionId) {
cleanArgs.push('--resume', sessionId);
}
// ─── Spawn ───────────────────────────────────────────────────────────────
const timeoutMs = this.timeoutMs;
const HARD_TIMEOUT_MS = 60000;
let currentChildRef = null;
return new Promise((resolve) => {
let resolved = false;
let currentSessionId = sessionId;
const abortListener = () => {
if (currentChildRef) {
killChildTree(currentChildRef).then(() => {
safeResolve({ result: '', error: 'ABORTED', rawOutput: '' });
});
}
else {
safeResolve({ result: '', error: 'ABORTED', rawOutput: '' });
}
};
const safeResolve = (v) => {
if (!resolved) {
resolved = true;
if (options.signal) {
options.signal.removeEventListener('abort', abortListener);
}
resolve(v);
}
};
// ─── Spawn the Hermes process ──────────────────────────────────────────
const child = spawn('hermes', cleanArgs, {
cwd,
shell: false,
windowsHide: true,
env: {
...process.env,
// Force Hermes à lire depuis ~/.overmind/hermes/ (canonique Overmind)
HERMES_HOME: getSharedHermesHome(),
// Tell the child which agent it is — used by memory_search/store for DB isolation
OVERMIND_AGENT_NAME: agentName || process.env.OVERMIND_AGENT_NAME || '',
// Ensure Python uses UTF-8 (avoids Windows encoding crashes)
PYTHONIOENCODING: 'utf-8',
PYTHONUNBUFFERED: '1',
TERM: 'emacs',
// Disable interactive prompt_toolkit
PROMPT_TOOLKIT_NO_INTERACTIVE: '1',
},
});
currentChildRef = child;
if (child.pid) {
void registerProcess(child.pid, {
agentName: agentName || '',
runner: 'hermes',
configPath,
});
void registerLiveAgent({
pid: child.pid,
runner: 'hermes',
agentName: agentName || '',
sessionId: currentSessionId || '',
cleanupFn: async () => {
await killChildTree(child);
},
childRef: child,
});
child.once('exit', (code) => {
setLiveStatus(child.pid, code === 0 ? 'done' : 'failed', code ?? null);
void unregisterLiveAgent(child.pid);
});
}
// ─── Capture output ────────────────────────────────────────────────────
let stdout = '';
let stderr = '';
child.stdout?.on('data', (d) => {
const chunk = d.toString();
if (child.pid) {
void appendLiveOutput(child.pid, chunk);
}
if (stdout.length + chunk.length > this.MAX_BUF) {
stdout = stdout.slice(-this.MAX_BUF);
}
else {
stdout += chunk;
}
if (!silent && agentName) {
process.stderr.write(`[Hermes:${agentName}] ${chunk}`);
}
});
child.stderr?.on('data', (d) => {
const chunk = d.toString();
if (stderr.length + chunk.length > this.MAX_BUF) {
stderr = stderr.slice(-this.MAX_BUF);
}
else {
stderr += chunk;
}
if (!silent && agentName) {
process.stderr.write(`[Hermes:${agentName}:ERR] ${chunk}`);
}
});
// ─── Timeout ───────────────────────────────────────────────────────────
const timer = setTimeout(() => {
if (child.stdin && !child.stdin.destroyed) {
try {
child.stdin.write('\n');
}
catch {
/* ignore */
}
}
setTimeout(async () => {
await killChildTree(child);
safeResolve({ result: '', error: 'HARD_TIMEOUT', rawOutput: stdout + stderr });
}, HARD_TIMEOUT_MS);
}, timeoutMs);
// ─── Process exit ──────────────────────────────────────────────────────
child.on('close', async (code) => {
clearTimeout(timer);
if (child.pid) {
void updateProcessStatus(child.pid, code === 0 ? 'done' : 'failed', code, configPath);
}
// Extract session_id from stderr
const extractedSid = extractSessionId(stderr);
if (extractedSid) {
currentSessionId = extractedSid;
}
// Save session linkage
if (currentSessionId && agentName) {
await saveSessionId(agentName, currentSessionId, configPath, 'hermes');
if (child.pid) {
void linkSessionToPid(currentSessionId, child.pid, configPath);
}
}
// ─── Error handling ──────────────────────────────────────────────────
if (code !== 0) {
const retryable = isRetryableError(stderr, stdout);
if (retryable) {
// Surface the error — the caller (run_hermes.ts) handles session cleanup + retry
logger.warn({ agentName, code, retryable, sessionId: currentSessionId }, '[RUN_AGENT] Hermes exited with retryable error.');
}
if (!stdout.trim()) {
safeResolve({
result: '',
error: `EXIT_CODE_${code}`,
rawOutput: stderr || stdout,
sessionId: currentSessionId,
});
return;
}
}
// ─── Success ─────────────────────────────────────────────────────────
safeResolve({
result: stdout.trim(),
sessionId: currentSessionId,
rawOutput: stdout,
});
});
child.on('error', (err) => {
clearTimeout(timer);
killChildTree(child).then(() => {
safeResolve({
result: '',
error: `SPAWN_ERROR: ${err.message}`,
rawOutput: '',
});
});
});
// ─── Abort signal ──────────────────────────────────────────────────────
if (options.signal) {
if (options.signal.aborted) {
killChildTree(child).then(() => {
safeResolve({ result: '', error: 'ABORTED', rawOutput: '' });
});
return;
}
options.signal.addEventListener('abort', abortListener);
}
});
}
}
//# sourceMappingURL=HermesRunner.js.map