overmind-mcp 2.8.28 → 2.8.34

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -3,7 +3,7 @@ import path from 'path';
3
3
  import { spawn } from 'child_process';
4
4
  import { exec } from 'child_process';
5
5
  import { promisify } from 'util';
6
- import { CONFIG, resolveConfigPath, getWorkspaceDir, getAgentHermesHome, getAgentOvermindHome } from '../lib/config.js';
6
+ import { CONFIG, resolveConfigPath, getWorkspaceDir, getAgentHermesHome, getAgentOvermindHome, getSharedHermesHome } from '../lib/config.js';
7
7
  import { getLastSessionId, saveSessionId } from '../lib/sessions.js';
8
8
  import { linkSessionToPid } from '../lib/processRegistry.js';
9
9
  import { interpolateEnvVars } from '../lib/envUtils.js';
@@ -297,7 +297,14 @@ export class NousHermesRunner {
297
297
  'MISTRAL_API_KEY_6', 'MISTRAL_API_KEY_7', 'MISTRAL_API_KEY_E', 'MISTRAL_API_KEY_Y',
298
298
  ];
299
299
  if (agentName) {
300
- const agentPromptPath = path.join(overmindHermesSubPath, 'SOUL.md');
300
+ // Locate the per-agent SOUL.md (system prompt). We support the canonical
301
+ // Hermes layout (HERMES_HOME/agents/<name>/SOUL.md) and a one-shot legacy
302
+ // path (HERMES_HOME/agent_<name>/.hermes/SOUL.md) for existing installs.
303
+ // The canonical path wins; the legacy is fallback so we don't break
304
+ // agents that haven't been migrated yet.
305
+ const canonicalSoul = path.join(overmindHermesSubPath, 'SOUL.md');
306
+ const legacySoul = path.join(getSharedHermesHome(), `agent_${agentName}`, '.hermes', 'SOUL.md');
307
+ const agentPromptPath = fs.existsSync(canonicalSoul) ? canonicalSoul : legacySoul;
301
308
  if (fs.existsSync(agentPromptPath)) {
302
309
  systemPrompt = fs.readFileSync(agentPromptPath, 'utf8');
303
310
  }
@@ -378,7 +385,21 @@ export class NousHermesRunner {
378
385
  catch (e) {
379
386
  logger.warn({ error: e }, `Failed to process settings/mcp configurations for Hermes agent ${agentName}`);
380
387
  }
381
- // Load environment from isolated .env file (to allow overrides)
388
+ // Load environment from the agent's isolated .hermes/.env file, BUT only as a
389
+ // fallback for keys that the explicit settings_<agent>.json did NOT set.
390
+ //
391
+ // CRITICAL (2.8.29): The .hermes/.env file is a STALE WRITE of the previous
392
+ // spawn — it gets re-written by the runner itself at line ~1013, but if a
393
+ // user's first run was for Z.AI and they later switch the agent to MiniMax
394
+ // CN, the stale .hermes/.env from the previous run gets re-loaded into
395
+ // agentCustomEnv HERE, OVERWRITING the MiniMax settings that were just merged
396
+ // from settings_<agent>.json in the block above (line ~412). Symptom: the
397
+ // agent silently reverts to the old provider (e.g. Z.AI glm-5.1) on every
398
+ // spawn, causing persistent 401s.
399
+ //
400
+ // Fix: only load keys from .hermes/.env that are NOT already in agentCustomEnv
401
+ // (i.e. settings_<agent>.json wins; .hermes/.env is a fallback for unrelated
402
+ // custom vars the user might have set manually).
382
403
  const envPath = path.join(overmindHermesSubPath, '.env');
383
404
  if (fs.existsSync(envPath)) {
384
405
  try {
@@ -396,7 +417,10 @@ export class NousHermesRunner {
396
417
  value = value.slice(1, -1);
397
418
  else if (value.startsWith("'") && value.endsWith("'"))
398
419
  value = value.slice(1, -1);
399
- if (key) {
420
+ if (key && agentCustomEnv[key] === undefined) {
421
+ // Only fill in keys that settings_<agent>.json did NOT set.
422
+ // settings_<agent>.json is the user's source of truth; .hermes/.env
423
+ // is a stale write from a previous spawn and must not override it.
400
424
  agentCustomEnv[key] = value;
401
425
  }
402
426
  });
@@ -425,18 +449,25 @@ export class NousHermesRunner {
425
449
  // Build CLI args: chat -q (persistent session, NOT -z oneshot)
426
450
  // -z + --resume doesn't work — resume is ignored in oneshot mode
427
451
  //
428
- // DO NOT pass --provider explicitly. We learned empirically (Hermes-MiniMax-2.bat
429
- // works while `hermes chat -q --provider minimax-cn` 401s) that letting Hermes
430
- // auto-detect the provider from MINIMAX_CN_API_KEY / ZAI_ANTHROPIC_FALLBACK_KEY
431
- // / etc. in the env gives correct results, while the explicit --provider flag
432
- // activates a buggy code path that sends an auth header the upstream rejects.
433
- // The ANTHROPIC_MODEL + ANTHROPIC_BASE_URL + provider-specific env var are
434
- // enough for Hermes to pick the right plugin on its own.
452
+ // 2.8.33: RE-ADD --provider for MiniMax/Z.AI cases. The empirical 2.8.28
453
+ // observation ("`hermes chat -q --provider minimax-cn` 401s while `--yolo`
454
+ // alone works") was based on a specific `Hermes-MiniMax-2.bat` test
455
+ // where the env was set perfectly. In our sniperbot_analyst scenario
456
+ // (a real production setup with stale state, multiple providers in the
457
+ // auth.json pool, and Hermes upstream's auto-router that picks
458
+ // openrouter for `MiniMax-M3` as an OpenRouter alias), NOT passing
459
+ // --provider makes Hermes upstream fall back to openrouter, which
460
+ // then 401s because the OPENROUTER_API_KEY is purged.
461
+ //
462
+ // So: pass --provider when we have a resolved provider that matches
463
+ // a registered plugin. This forces Hermes upstream to use the right
464
+ // plugin profile (and the right credential pool bucket).
435
465
  const cleanArgs = ['chat', '-q', cliPrompt, '-Q'];
436
466
  cleanArgs.push('--model', finalModel);
437
- // resolvedProvider is logged for debugging but NOT passed as --provider.
438
467
  if (options.provider || resolvedProvider) {
439
- logger.info({ agentName, resolvedProvider: options.provider || resolvedProvider, hint: 'omitted --provider; letting Hermes auto-detect from env' }, '[HERMES_ARGS] Not passing --provider (auto-detect from MINIMAX_CN_API_KEY et al. is more reliable).');
468
+ const provider = (options.provider || resolvedProvider);
469
+ cleanArgs.push('--provider', provider);
470
+ logger.info({ agentName, provider, model: finalModel }, '[HERMES_ARGS] Passing --provider (2.8.33: needed to bypass upstream auto-router that picked openrouter for MiniMax-M3).');
440
471
  }
441
472
  if (sessionId)
442
473
  cleanArgs.push('--resume', sessionId);
@@ -618,10 +649,12 @@ export class NousHermesRunner {
618
649
  lower.includes('service unavailable') || lower.includes('500') ||
619
650
  lower.includes('internal server error');
620
651
  };
621
- // HERMES_HOME setup
652
+ // HERMES_HOME setup — the SHARED root, not the per-agent home.
653
+ // Hermes upstream resolves `agents/<name>/`, `config.yaml`, `auth.json`, etc.
654
+ // relative to this single root. We do NOT seed `agentCustomEnv.HERMES_HOME`
655
+ // here anymore because spawnHermes() sets it explicitly from getSharedHermesHome().
622
656
  if (!fs.existsSync(overmindHermesSubPath))
623
657
  fs.mkdirSync(overmindHermesSubPath, { recursive: true });
624
- agentCustomEnv.HERMES_HOME = overmindHermesSubPath;
625
658
  // HOME / USERPROFILE override: point Hermes at the parent .overmind dir,
626
659
  // NOT the cwd. This makes relative .hermes lookups inside Hermes
627
660
  // (e.g. `~/.hermes/.env` resolution) resolve to the same canonical
@@ -630,86 +663,129 @@ export class NousHermesRunner {
630
663
  agentCustomEnv.USERPROFILE = overmindHermesPath;
631
664
  else
632
665
  agentCustomEnv.HOME = overmindHermesPath;
633
- // Write .env to HERMES_HOME (credential auto-discovery) - Cleaned to prevent duplicates
634
- // EXCLUDE all OpenRouter keys — OpenRouter is managed internally by Overmind, Hermes must never see it
635
- const credRegex = /(?:api_key|auth_token|base_url|endpoint|url)$/i;
636
- const openRouterPrefixes = ['OPENROUTER', 'OVERMIND_EMBEDDING'];
637
- const envMap = new Map();
638
- const dotPath = path.join(overmindHermesSubPath, '.env');
639
- if (fs.existsSync(dotPath)) {
640
- try {
641
- const existing = fs.readFileSync(dotPath, 'utf8');
642
- existing.split('\n').forEach((line) => {
643
- const trimmed = line.trim();
644
- if (!trimmed || trimmed.startsWith('#'))
645
- return;
646
- const eqIdx = trimmed.indexOf('=');
647
- if (eqIdx === -1)
648
- return;
649
- const k = trimmed.slice(0, eqIdx).trim();
650
- let v = trimmed.slice(eqIdx + 1).trim();
651
- if (v.startsWith('"') && v.endsWith('"'))
652
- v = v.slice(1, -1);
653
- else if (v.startsWith("'") && v.endsWith("'"))
654
- v = v.slice(1, -1);
655
- if (k) {
656
- if (openRouterPrefixes.some(p => k.toUpperCase().startsWith(p)))
657
- return;
658
- envMap.set(k, v);
666
+ // AbortSignal
667
+ if (options.signal?.aborted)
668
+ return Promise.reject(new Error('ABORTED'));
669
+ // =============================================================
670
+ // 2.8.30 WRITE CANONICAL Hermes SETTINGS
671
+ // =============================================================
672
+ // Hermes upstream uses the standard appdirs-style layout:
673
+ // <HERMES_HOME>/agents/<name>/settings.json ← per-agent env, mcp, persona
674
+ // <HERMES_HOME>/agents/<name>/SOUL.md ← per-agent system prompt
675
+ // <HERMES_HOME>/config.yaml ← global, Hermes manages
676
+ // <HERMES_HOME>/auth.json ← global, Hermes manages
677
+ //
678
+ // Overmind's only job here is to:
679
+ // 1. Convert Workflow/.claude/settings_<name>.json (Overmind runner format)
680
+ // into the canonical Hermes format and write it to <HERMES_HOME>/agents/<name>/settings.json
681
+ // 2. Make sure <HERMES_HOME>/agents/<name>/SOUL.md exists (canonical path)
682
+ // 3. Let Hermes upstream manage config.yaml, auth.json, sessions/, etc.
683
+ //
684
+ // This replaces the previous "polylgot" hack where Overmind wrote
685
+ // `<HERMES_HOME>/agent_<name>/.hermes/.env`, `.hermes/config.yaml`, and
686
+ // `.hermes/auth.json` — files that don't match Hermes's expected layout
687
+ // and caused credential drift + silent 401s.
688
+ if (agentName) {
689
+ const agentHome = overmindHermesSubPath; // = <HERMES_HOME>/agents/<name>/
690
+ if (!fs.existsSync(agentHome))
691
+ fs.mkdirSync(agentHome, { recursive: true });
692
+ // Build the canonical Hermes settings.json from the agent's settings_<name>.json.
693
+ // We preserve: env, enableAllProjectMcpServers, enabledMcpjsonServers, agent, runner.
694
+ // We do NOT touch: config.yaml, auth.json, .env — Hermes upstream owns those.
695
+ const tmpAgentSettings = path.join(agentHome, 'settings.json');
696
+ const settingsJson = {};
697
+ // Read the interpolated settings the runner just merged (line ~412 above)
698
+ // and copy the Hermes-relevant fields. We don't re-interpolate here because
699
+ // the caller already did it on the raw `settings` object.
700
+ if (tmpSettingsPath && fs.existsSync(tmpSettingsPath)) {
701
+ try {
702
+ const raw = JSON.parse(fs.readFileSync(tmpSettingsPath, 'utf8'));
703
+ if (raw.env)
704
+ settingsJson.env = { ...raw.env };
705
+ if (raw.enableAllProjectMcpServers !== undefined) {
706
+ settingsJson.enableAllProjectMcpServers = raw.enableAllProjectMcpServers;
707
+ }
708
+ if (Array.isArray(raw.enabledMcpjsonServers)) {
709
+ settingsJson.enabledMcpjsonServers = raw.enabledMcpjsonServers;
659
710
  }
660
- });
661
- }
662
- catch (e) {
663
- logger.warn({ envPath: dotPath, error: e }, 'Failed to read existing agent env file for deduplication');
664
- }
665
- }
666
- for (const [k, v] of Object.entries(agentCustomEnv)) {
667
- if (typeof v === 'string' && v.length > 0 && credRegex.test(k)) {
668
- if (openRouterPrefixes.some(p => k.toUpperCase().startsWith(p)))
669
- continue;
670
- envMap.set(k, v);
671
- }
672
- }
673
- const finalDotEntries = [];
674
- for (const [k, v] of envMap.entries()) {
675
- finalDotEntries.push(`${k}=${v}`);
676
- }
677
- fs.writeFileSync(dotPath, finalDotEntries.join('\n') + '\n', 'utf8');
678
- // Generate config.yaml in HERMES_HOME (MCP servers)
679
- if (tmpMcpPath && fs.existsSync(tmpMcpPath)) {
680
- try {
681
- const mc = JSON.parse(fs.readFileSync(tmpMcpPath, 'utf8'));
682
- const yamlPath = path.join(overmindHermesSubPath, 'config.yaml');
683
- // Preserve existing config.yaml (tts, llm, etc.) — merge mcp_servers only
684
- let existingYaml = '';
685
- if (fs.existsSync(yamlPath)) {
686
- existingYaml = fs.readFileSync(yamlPath, 'utf8');
687
711
  }
688
- // Build new mcp_servers section
689
- let newMcpSection = 'mcp_servers:\n';
690
- for (const [name, srv] of Object.entries(mc.mcpServers || {})) {
691
- const s = srv;
692
- newMcpSection += ` ${name}:\n`;
693
- if (s.url)
694
- newMcpSection += ` url: "${s.url}"\n`;
695
- if (s.command)
696
- newMcpSection += ` command: "${s.command}"\n`;
712
+ catch (e) {
713
+ logger.warn({ tmpSettingsPath, error: e }, 'Failed to read tmp settings for canonical write');
697
714
  }
698
- // Merge: replace mcp_servers block in existing yaml or append
699
- let finalYaml;
700
- if (existingYaml.includes('mcp_servers:')) {
701
- finalYaml = existingYaml.replace(/mcp_servers:\n([\s\S]*?)(?=\n\w|\n$|$)/, newMcpSection.trimEnd() + '\n');
715
+ }
716
+ // ============================================================
717
+ // 2.8.31 — INJECT PROVIDER-SPECIFIC ENV VARS INTO settings.json
718
+ // ============================================================
719
+ // The Hermes plugins (e.g. `minimax`, `zai`, `openai`) read PROVIDER-
720
+ // SPECIFIC env vars from the per-agent settings.json — NOT the generic
721
+ // `ANTHROPIC_AUTH_TOKEN`. For example the `minimax-cn` plugin reads
722
+ // `MINIMAX_CN_API_KEY`, and the `minimax` (GLOBAL) plugin reads
723
+ // `MINIMAX_API_KEY`. If those vars aren't in the agent's settings.json,
724
+ // the plugin can't find the credential and the upstream falls back to
725
+ // the wrong provider (we saw it pick `openrouter` or `nvidia` instead
726
+ // of `minimax-cn`).
727
+ //
728
+ // We inject by:
729
+ // 1. Detecting the provider from the user's ANTHROPIC_BASE_URL hint
730
+ // (api.minimaxi.com → CN, api.minimax.io → GLOBAL, etc.).
731
+ // 2. For MiniMax: ONLY seed the matching env var (CN vs GLOBAL) so
732
+ // the upstream plugin's first-match resolver picks the right one.
733
+ // Do NOT seed both — that was the bug in 2.8.30 (caused it to pick
734
+ // GLOBAL even when the URL was CN).
735
+ // 3. For Z.AI: seed ZAI_ANTHROPIC_FALLBACK_KEY + GLM_API_KEY.
736
+ // 4. Leave ANTHROPIC_AUTH_TOKEN in place as a fallback (some Hermes
737
+ // code paths still read it generically).
738
+ const envObj = settingsJson.env;
739
+ if (envObj) {
740
+ const baseUrl = (envObj['ANTHROPIC_BASE_URL'] || '').toLowerCase();
741
+ const anthropicToken = envObj['ANTHROPIC_AUTH_TOKEN'] || envObj['ANTHROPIC_API_KEY'] || '';
742
+ if (anthropicToken && (anthropicToken.startsWith('sk-cp-') || anthropicToken.startsWith('sk-mm-'))) {
743
+ // MiniMax token — pick CN vs GLOBAL based on URL
744
+ if (baseUrl.includes('minimaxi')) {
745
+ // CN: api.minimaxi.com
746
+ envObj['MINIMAX_CN_API_KEY'] = anthropicToken;
747
+ // The Hermes `providers.py` resolver reads `MINIMAX_CN_BASE_URL`
748
+ // (NOT just `ANTHROPIC_BASE_URL`) to dispatch to the CN plugin
749
+ // profile. Seed it explicitly so the provider resolver picks
750
+ // `minimax-cn` (not `minimax` GLOBAL on first-match).
751
+ envObj['MINIMAX_CN_BASE_URL'] = 'https://api.minimaxi.com/anthropic';
752
+ logger.info({ agentName, cnApiKeySet: true, cnBaseUrlSet: true, globalApiKeySet: false, detectedFrom: 'api.minimaxi.com (CN)' }, '[SETTINGS_JSON] Seeded MINIMAX_CN_API_KEY + MINIMAX_CN_BASE_URL (CN plugin resolver).');
753
+ }
754
+ else if (baseUrl.includes('minimax') || baseUrl === '') {
755
+ // GLOBAL: api.minimax.io, OR no URL hint (default to CN per OVERMIND_MINIMAX_DEFAULT=cn)
756
+ const defaultCn = (process.env.OVERMIND_MINIMAX_DEFAULT || 'cn').toLowerCase() === 'cn';
757
+ if (defaultCn) {
758
+ envObj['MINIMAX_CN_API_KEY'] = anthropicToken;
759
+ envObj['MINIMAX_CN_BASE_URL'] = 'https://api.minimaxi.com/anthropic';
760
+ logger.info({ agentName, detectedFrom: 'no URL + OVERMIND_MINIMAX_DEFAULT=cn' }, '[SETTINGS_JSON] Seeded MINIMAX_CN_API_KEY + MINIMAX_CN_BASE_URL (default CN per OVERMIND_MINIMAX_DEFAULT).');
761
+ }
762
+ else {
763
+ envObj['MINIMAX_API_KEY'] = anthropicToken;
764
+ envObj['MINIMAX_BASE_URL'] = 'https://api.minimax.io/anthropic';
765
+ logger.info({ agentName, detectedFrom: 'api.minimax.io (GLOBAL)' }, '[SETTINGS_JSON] Seeded MINIMAX_API_KEY + MINIMAX_BASE_URL (GLOBAL plugin resolver).');
766
+ }
767
+ }
702
768
  }
703
- else {
704
- finalYaml = existingYaml.trimEnd() + '\n' + newMcpSection;
769
+ else if (anthropicToken && /^[0-9a-f]{32}(\.[0-9a-zA-Z]+)?$/i.test(anthropicToken)) {
770
+ // Z.AI token (32hex or 32hex.32hex)
771
+ envObj['ZAI_ANTHROPIC_FALLBACK_KEY'] = anthropicToken;
772
+ envObj['GLM_API_KEY'] = anthropicToken;
773
+ logger.info({ agentName }, '[SETTINGS_JSON] Seeded ZAI_ANTHROPIC_FALLBACK_KEY + GLM_API_KEY (Z.AI token).');
705
774
  }
706
- fs.writeFileSync(yamlPath, finalYaml, 'utf8');
707
- if (!silent)
708
- console.error(`[NousHermesRunner] MCP config.yaml written to ${yamlPath}`);
709
- }
710
- catch (e) {
711
- console.error(`[NousHermesRunner] config.yaml error: ${e}`);
712
775
  }
776
+ // Always declare the agent name + runner so Hermes can route to the right MCP servers.
777
+ settingsJson.agent = agentName;
778
+ settingsJson.runner = 'hermes';
779
+ fs.writeFileSync(tmpAgentSettings, JSON.stringify(settingsJson, null, 2) + '\n', 'utf8');
780
+ // 2.8.32: DO NOT push tmpAgentSettings to this.tempFiles — the
781
+ // canonical settings.json is the AGENT'S PERMANENT Hermes config,
782
+ // not a temp file. cleanupTempFiles() (called in finally + after
783
+ // spawn) would otherwise unlink it on every spawn, forcing the
784
+ // Hermes upstream plugin resolver to re-derive provider routing
785
+ // from the (now-empty) .env block on the next run. This was the
786
+ // root cause of the 13:51 "Erreur inconnue" — manual settings.json
787
+ // edits were being silently deleted after each spawn.
788
+ logger.info({ agentName, settingsPath: tmpAgentSettings, envKeys: Object.keys(settingsJson.env || {}).length }, '[HERMES] Wrote canonical agents/<name>/settings.json (env block from settings_<name>.json + provider-specific seeds).');
713
789
  }
714
790
  // AbortSignal
715
791
  if (options.signal?.aborted)
@@ -734,187 +810,63 @@ export class NousHermesRunner {
734
810
  }
735
811
  }
736
812
  };
737
- const writeAuthJson = (tokenInfo) => {
738
- if (!tokenInfo || !overmindHermesSubPath)
739
- return;
740
- try {
741
- const authPath = path.join(overmindHermesSubPath, 'auth.json');
742
- // Read existing auth.json to preserve non-credential_pool state
743
- // (e.g. oauth tokens, settings, version). But we PRUNE credential_pool
744
- // entries for OTHER providers — those are stale from previous provider
745
- // configs and Hermes may pick them up by mistake, causing silent 401s
746
- // on the wrong endpoint. This is the source of the "auth.json drift"
747
- // bug where the runner would seed `minimax-cn` credentials while a stale
748
- // `zai` entry with last_status="exhausted" still existed in the pool.
749
- let preservedAuth = { version: 1, providers: {} };
750
- if (fs.existsSync(authPath)) {
751
- try {
752
- const parsed = JSON.parse(fs.readFileSync(authPath, 'utf8'));
753
- // Keep the version + any oauth providers; drop credential_pool entirely
754
- // (it will be re-seeded below with only the effectiveProvider's entries).
755
- preservedAuth = {
756
- version: parsed.version ?? 1,
757
- providers: parsed.providers ?? {},
758
- };
759
- }
760
- catch (e) {
761
- logger.warn({ authPath, error: e }, 'auth.json was malformed; re-creating from scratch');
762
- }
763
- }
764
- const auth = {
765
- ...preservedAuth,
766
- credential_pool: {},
767
- };
768
- const cleanCp = auth.credential_pool;
769
- // Determine effective provider from MULTIPLE signals
770
- // Priority: TOKEN PREFIX (most reliable) > BASE_URL (very reliable) > settings.ANTHROPIC_PROVIDER (hint only)
771
- // The user can put anything in settings.ANTHROPIC_PROVIDER — we don't blindly trust it.
772
- const baseUrlHint = agentCustomEnv['ANTHROPIC_BASE_URL'] || agentCustomEnv['GLM_BASE_URL'] || '';
773
- // First, detect from token prefix
774
- const detectedFromToken = detectTokenProvider(tokenInfo.tokenValue);
775
- // Then, detect from base URL
776
- let detectedFromUrl = null;
777
- if (baseUrlHint) {
778
- const url = baseUrlHint.toLowerCase();
779
- if (url.includes('minimaxi')) {
780
- // The "i" suffix in api.minimaxi.com is the CN-specific endpoint
781
- detectedFromUrl = 'minimax-cn';
782
- }
783
- else if (url.includes('minimax')) {
784
- // api.minimax.com (no i) is the GLOBAL endpoint
785
- detectedFromUrl = 'minimax';
786
- }
787
- else if (url.includes('z.ai') || url.includes('bigmodel') || url.includes('zhipu')) {
788
- detectedFromUrl = 'zai';
789
- }
790
- else if (url.includes('anthropic.com')) {
791
- detectedFromUrl = 'anthropic';
792
- }
793
- else if (url.includes('openai.com')) {
794
- detectedFromUrl = 'openai';
795
- }
796
- }
797
- // Then, the hint from settings
798
- const settingsHint = resolvedProvider || '';
799
- // Voting: token > URL > settings
800
- // SPECIAL CASE: if token says "minimax" and URL says "minimax-cn" (or vice versa),
801
- // the URL wins because the token prefix sk-cp- is shared between both endpoints.
802
- // The URL is the only signal that can disambiguate CN vs GLOBAL.
803
- //
804
- // DEFAULT FOR MiniMax WHEN AMBIGUOUS:
805
- // The sk-cp- prefix is shared between MiniMax GLOBAL and MiniMax CN. The
806
- // URL is the only signal that disambiguates. For users whose setup
807
- // exclusively uses CN tokens (the most common case for non-China
808
- // operators), an absent/ambiguous URL should default to CN rather than
809
- // silently picking GLOBAL and getting a 401. Override via env var:
810
- // OVERMIND_MINIMAX_DEFAULT=cn (default: CN when ambiguous)
811
- // OVERMIND_MINIMAX_DEFAULT=global (treat sk-cp-* as GLOBAL)
812
- // OVERMIND_MINIMAX_DEFAULT=auto (never infer, require URL to disambiguate)
813
- const minimaxDefault = (process.env.OVERMIND_MINIMAX_DEFAULT || 'cn').toLowerCase();
814
- const minimaxDefaults = { cn: 'minimax-cn', global: 'minimax', auto: 'minimax' };
815
- const minimaxFallback = minimaxDefaults[minimaxDefault] || minimaxDefaults.cn;
816
- let effectiveProvider;
817
- if (detectedFromToken.provider === 'minimax' && detectedFromUrl === 'minimax-cn') {
818
- // URL has more specific info than the token prefix
819
- effectiveProvider = 'minimax-cn';
820
- logger.info({ agentName, tokenSays: 'minimax', urlSays: 'minimax-cn', settingsHint }, '[SUBTILISATION] URL is more specific than token prefix (minimax vs minimax-cn) — using URL.');
821
- }
822
- else if (detectedFromToken.provider === 'minimax-cn' && detectedFromUrl === 'minimax') {
823
- effectiveProvider = 'minimax';
824
- logger.info({ agentName, tokenSays: 'minimax-cn', urlSays: 'minimax', settingsHint }, '[SUBTILISATION] URL is more specific than token prefix (minimax vs minimax-cn) — using URL.');
825
- }
826
- else if (detectedFromToken.provider === 'minimax' && !detectedFromUrl) {
827
- // Token says MiniMax, no URL hint — use OVERMIND_MINIMAX_DEFAULT
828
- effectiveProvider = minimaxFallback;
829
- logger.info({ agentName, tokenSays: 'minimax', urlSays: '(none)', minimaxDefault, effectiveProvider }, '[SUBTILISATION] MiniMax token without explicit URL — applying OVERMIND_MINIMAX_DEFAULT.');
830
- }
831
- else if (detectedFromToken.provider !== 'unknown') {
832
- effectiveProvider = detectedFromToken.provider;
833
- if (settingsHint && settingsHint !== effectiveProvider) {
834
- logger.warn({ agentName, settingsHint, tokenSays: effectiveProvider, urlSays: detectedFromUrl }, '[SUBTILISATION] settings.ANTHROPIC_PROVIDER contradicts token prefix — using token.');
835
- }
836
- }
837
- else if (detectedFromUrl) {
838
- effectiveProvider = detectedFromUrl;
839
- if (settingsHint && settingsHint !== effectiveProvider) {
840
- logger.warn({ agentName, settingsHint, urlSays: effectiveProvider, tokenSays: detectedFromToken.provider }, '[SUBTILISATION] settings.ANTHROPIC_PROVIDER contradicts BASE_URL — using URL.');
841
- }
842
- }
843
- else if (settingsHint) {
844
- effectiveProvider = settingsHint;
845
- }
846
- else {
847
- effectiveProvider = 'zai';
848
- }
849
- cleanCp[effectiveProvider] = [{
850
- id: `${effectiveProvider}-default`, label: tokenInfo.tokenEnvKey, auth_type: 'api_key',
851
- priority: 0, source: `env:${tokenInfo.tokenEnvKey}`, access_token: tokenInfo.tokenValue,
852
- last_status: null, last_error_code: null,
853
- base_url: baseUrlHint || defaultBaseUrlFor(effectiveProvider),
854
- request_count: 0,
855
- }];
856
- fs.writeFileSync(authPath, JSON.stringify(auth, null, 2), 'utf8');
857
- // ============================================================
858
- // Write .env for HERMES_HOME — emit the 4 canonical fields
859
- // Hermes needs: ANTHROPIC_MODEL, ANTHROPIC_AUTH_TOKEN,
860
- // ANTHROPIC_PROVIDER, ANTHROPIC_BASE_URL
861
- // ============================================================
862
- const dotEnvPath = path.join(overmindHermesSubPath, '.env');
863
- const dotLines = [];
864
- // 1. ANTHROPIC_MODEL (always — Hermes needs it)
865
- if (finalModel) {
866
- dotLines.push(`ANTHROPIC_MODEL=${finalModel}`);
867
- }
868
- // 2. ANTHROPIC_PROVIDER (the kebab-case provider name)
869
- dotLines.push(`ANTHROPIC_PROVIDER=${effectiveProvider}`);
870
- // 3. ANTHROPIC_BASE_URL (from settings, or fallback)
871
- const resolvedBaseUrl = baseUrlHint || defaultBaseUrlFor(effectiveProvider);
872
- dotLines.push(`ANTHROPIC_BASE_URL=${resolvedBaseUrl}`);
873
- // 4. ANTHROPIC_AUTH_TOKEN = literal token value (for backward compat with older Hermes versions)
874
- // (Hermes reads this env var directly — no more provider-specific mapping)
875
- dotLines.push(`ANTHROPIC_AUTH_TOKEN=${tokenInfo.tokenValue}`);
876
- // 5. ALSO seed the provider-specific env var for plugins that need it
877
- // For MiniMax/Z.AI plugins, the provider-specific var is the PRIMARY key
878
- // the plugin reads. The .bat launchers in C:\Users\Deamon\Desktop\launcher\
879
- // set MINIMAX_CN_API_KEY directly (not ANTHROPIC_AUTH_TOKEN), confirming
880
- // that this is what the upstream plugin actually consumes.
881
- if (effectiveProvider === 'minimax' || effectiveProvider === 'minimax-cn') {
882
- // Both: the plugin reads whichever is set
883
- if (effectiveProvider === 'minimax-cn') {
884
- dotLines.push(`MINIMAX_CN_API_KEY=${tokenInfo.tokenValue}`);
885
- }
886
- else {
887
- dotLines.push(`MINIMAX_API_KEY=${tokenInfo.tokenValue}`);
888
- }
889
- }
890
- else if (effectiveProvider === 'zai' || effectiveProvider === 'z-ai') {
891
- dotLines.push(`GLM_API_KEY=${tokenInfo.tokenValue}`);
892
- dotLines.push(`ZAI_ANTHROPIC_FALLBACK_KEY=${tokenInfo.tokenValue}`);
893
- }
894
- else if (effectiveProvider === 'openai') {
895
- dotLines.push(`OPENAI_API_KEY=${tokenInfo.tokenValue}`);
896
- }
897
- else if (effectiveProvider === 'anthropic') {
898
- // ANTHROPIC_AUTH_TOKEN already set above
899
- }
900
- fs.writeFileSync(dotEnvPath, dotLines.join('\n') + '\n', 'utf8');
901
- logger.info({ agentName, effectiveProvider, baseUrl: resolvedBaseUrl, model: finalModel, sourceKey: tokenInfo.tokenEnvKey, detectedProvider: detectedFromToken.provider, envKeysWritten: dotLines.length }, '[AUTH] Wrote agent .env with 4 canonical Hermes fields + provider-specific seeds.');
902
- }
903
- catch (e) {
904
- logger.warn({ error: e, agentName }, '[AUTH] Failed to write auth.json or agent .env');
905
- }
906
- };
907
813
  const spawnHermes = async (tokenInfo) => {
908
814
  const spawnEnv = { ...process.env, ...agentCustomEnv };
909
815
  if (tokenInfo) {
816
+ // Purge ALL known LLM provider env vars from the spawn env. We
817
+ // re-seed ONLY the ones this agent actually uses, derived from the
818
+ // token prefix. Without this purge, a stale `MINIMAX_CN_API_KEY` or
819
+ // `ZAI_ANTHROPIC_FALLBACK_KEY` left over from a previous provider
820
+ // (e.g. in `Workflow/.env`) can shadow the correct credential.
910
821
  for (const tk of TOKEN_KEYS)
911
822
  delete spawnEnv[tk];
823
+ // Also purge provider-specific env vars that the user might have
824
+ // left in the workflow .env (e.g. Z.AI legacy keys) but that don't
825
+ // match the agent's current provider.
826
+ for (const stale of [
827
+ 'MINIMAX_CN_API_KEY', 'MINIMAX_API_KEY',
828
+ 'ZAI_ANTHROPIC_FALLBACK_KEY', 'GLM_API_KEY',
829
+ 'Z_AI_API_KEY', 'Z_AI_BASE_URL', 'GLM_BASE_URL',
830
+ 'NVIDIA_API_KEY', 'NVIDIA_API_BASE',
831
+ ]) {
832
+ delete spawnEnv[stale];
833
+ }
912
834
  let resolvedToken = tokenInfo.tokenValue;
913
835
  if (resolvedToken.startsWith('$'))
914
836
  resolvedToken = process.env[resolvedToken.slice(1)] || resolvedToken;
915
837
  spawnEnv[tokenInfo.tokenEnvKey] = resolvedToken;
838
+ // ============================================================
839
+ // 2.8.30 — Seed provider-specific env vars for Hermes plugins.
840
+ // ============================================================
841
+ // The Hermes plugins read provider-specific env vars, not the
842
+ // generic `ANTHROPIC_AUTH_TOKEN`. For example the `minimax` plugin
843
+ // reads `MINIMAX_CN_API_KEY` (CN) or `MINIMAX_API_KEY` (GLOBAL),
844
+ // and the `zai` plugin reads `ZAI_ANTHROPIC_FALLBACK_KEY`.
845
+ //
846
+ // Without this seed, Hermes falls back to the WRONG plugin (we saw
847
+ // it pick `nvidia` because `ANTHROPIC_BASE_URL` wasn't set yet and
848
+ // it could match an NVIDIA-style model name pattern). The fix is
849
+ // tiny: when we know the token prefix (e.g. `sk-cp-` for MiniMax),
850
+ // ALSO seed the provider-specific env var that the upstream plugin
851
+ // actually reads. Reference: the .bat launchers do the same.
852
+ //
853
+ // We do NOT write to the agent's `.hermes/.env` file anymore —
854
+ // this is a process-env-only seed, scoped to this single spawn.
855
+ if (resolvedToken.startsWith('sk-cp-') || resolvedToken.startsWith('sk-mm-')) {
856
+ // MiniMax token — seed BOTH env vars so either CN or GLOBAL plugin
857
+ // can pick it up. The plugin's own URL/host detection will pick
858
+ // the right one.
859
+ spawnEnv['MINIMAX_CN_API_KEY'] = resolvedToken;
860
+ spawnEnv['MINIMAX_API_KEY'] = resolvedToken;
861
+ logger.info({ agentName, envKey: tokenInfo.tokenEnvKey, alsoSeeded: ['MINIMAX_CN_API_KEY', 'MINIMAX_API_KEY'] }, '[SPAWN_ENV] Seeded MiniMax provider-specific env vars from sk-cp-* token (plugin compat).');
862
+ }
863
+ else if (/^[0-9a-f]{32}(\.[0-9a-zA-Z]+)?$/i.test(resolvedToken)) {
864
+ // Z.AI token (32hex or 32hex.32hex) — seed Z.AI env vars.
865
+ spawnEnv['ZAI_ANTHROPIC_FALLBACK_KEY'] = resolvedToken;
866
+ spawnEnv['GLM_API_KEY'] = resolvedToken;
867
+ logger.info({ agentName, envKey: tokenInfo.tokenEnvKey, alsoSeeded: ['ZAI_ANTHROPIC_FALLBACK_KEY', 'GLM_API_KEY'] }, '[SPAWN_ENV] Seeded Z.AI provider-specific env vars from 32hex token (plugin compat).');
868
+ }
916
869
  }
917
- writeAuthJson(tokenInfo);
918
870
  // BLOCK: OpenRouter is for embeddings only — never pass to Hermes for LLM inference
919
871
  delete spawnEnv['OPENROUTER_API_KEY'];
920
872
  delete spawnEnv['OPENROUTER_BASE_URL'];
@@ -931,11 +883,17 @@ export class NousHermesRunner {
931
883
  const venvBin = isWin ? path.join(venvRoot, 'Scripts') : path.join(venvRoot, 'bin');
932
884
  const isVenvInstall = hermesBin.startsWith(venvBin + path.sep) || hermesBin === venvBin;
933
885
  const pathSep = isWin ? ';' : ':';
886
+ // 2.8.30: HERMES_HOME is the SHARED root, not the per-agent home.
887
+ // Hermes upstream resolves `agents/<name>/`, `config.yaml`, `auth.json`,
888
+ // etc. relative to HERMES_HOME. Setting it to the per-agent home would
889
+ // tell Hermes "this IS the Hermes root" and make it look for config.yaml
890
+ // IN the agent dir — wrong layout.
891
+ const sharedHermesHome = getSharedHermesHome();
934
892
  const child = spawn(hermesBin, cleanArgs, {
935
893
  cwd, shell: false, windowsHide: true,
936
894
  env: {
937
895
  ...spawnEnv,
938
- HERMES_HOME: overmindHermesSubPath,
896
+ HERMES_HOME: sharedHermesHome,
939
897
  ...(isVenvInstall
940
898
  ? {
941
899
  VIRTUAL_ENV: venvRoot,