@yeaft/webchat-agent 1.0.408 → 1.0.410

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.
@@ -23,8 +23,9 @@ import { DEFAULT_YEAFT_DIR } from './init.js';
23
23
  import { buildDreamOutputSnapshot } from './dream/output-snapshot.js';
24
24
  import { Engine } from './engine.js';
25
25
  import { loadSession } from './session.js';
26
- import { loadConfig, loadMCPConfig } from './config.js';
26
+ import { loadAgentMCPConfig, loadConfig, loadMCPConfig } from './config.js';
27
27
  import { createSkillManager } from './skills.js';
28
+ import { buildPluginCatalog, createPluginSkillManager, resolveMcpPluginPolicy } from './plugins.js';
28
29
  import { MCPManager } from './mcp.js';
29
30
  import { sendToServer } from '../connection/buffer.js';
30
31
  import ctx from '../context.js';
@@ -132,6 +133,19 @@ const pendingUserPrompts = new Map();
132
133
  /** @type {import('./session.js').Session | null} */
133
134
  let session = null;
134
135
 
136
+ // Agent-local MCP config and live runtime managers are mutable. WebSocket
137
+ // dispatch deliberately allows unrelated frames to overlap, so serialize MCP
138
+ // CRUD and runtime bootstrap from config read through connection, activation,
139
+ // tool hot-swap, and MCP publication. Keep the tail fulfilled after a failure:
140
+ // the caller still receives its error, but a later transition is not blocked.
141
+ let mcpTransitionTail = Promise.resolve();
142
+
143
+ function enqueueMcpTransition(operation) {
144
+ const queued = mcpTransitionTail.then(operation);
145
+ mcpTransitionTail = queued.catch(() => undefined);
146
+ return queued;
147
+ }
148
+
135
149
  /**
136
150
  * Single-flight runtime boot. History replay must not wait for this promise on
137
151
  * cold load; message send still awaits it through ensureSessionLoaded().
@@ -224,6 +238,8 @@ export async function refreshLiveSessionConfig(options = {}) {
224
238
 
225
239
  const liveSession = session;
226
240
  const currentConfig = liveSession.config || {};
241
+ const pluginsChanged = JSON.stringify(currentConfig.plugins || {})
242
+ !== JSON.stringify(freshConfig.plugins || {});
227
243
  const runtimeOnly = {};
228
244
  for (const key of ['serverMode', '_readOnly', 'modelEffort']) {
229
245
  if (Object.prototype.hasOwnProperty.call(currentConfig, key)) runtimeOnly[key] = currentConfig[key];
@@ -249,10 +265,19 @@ export async function refreshLiveSessionConfig(options = {}) {
249
265
  if (typeof liveSession.adapter?.refreshProviders === 'function') {
250
266
  liveSession.adapter.refreshProviders(freshConfig.providers || []);
251
267
  }
268
+ // Plugin selections affect connected MCP processes as well as model-visible
269
+ // schemas. Retire cached project runtimes before applying the new selection;
270
+ // the base runtime is rebuilt below so the default Session cannot retain old
271
+ // MCP tools or a stale manager after a save.
272
+ if (pluginsChanged) await shutdownProjectRuntimes();
252
273
  for (const key of Object.keys(currentConfig)) delete currentConfig[key];
253
274
  Object.assign(currentConfig, nextConfig);
254
275
  liveSession.engine?.refreshConfig?.(currentConfig);
255
276
  liveSession.trace?.refreshConfig?.(currentConfig.telemetry || {});
277
+ if (pluginsChanged && session === liveSession) {
278
+ claimRuntimeOwnership(liveSession);
279
+ await scheduleBaseRuntimeLoad();
280
+ }
256
281
  for (const { key, engine, config } of vpConfigSnapshots) {
257
282
  engine.refreshConfig?.(config);
258
283
  vpEngineConfigKeys.set(key, engineConfigKey(config));
@@ -689,10 +714,20 @@ function engineConfigKey(config) {
689
714
  fastModel: config?.fastModel || '',
690
715
  fastModelId: config?.fastModelId || '',
691
716
  fallbackModel: config?.fallbackModel || '',
717
+ plugins: config?.plugins || {},
692
718
  providers: Array.isArray(config?.providers) ? config.providers : [],
693
719
  });
694
720
  }
695
721
 
722
+ function effectiveRuntimeManagers(skillManager, mcpManager, _config = session?.config) {
723
+ // Engine owns the final skill-policy wrapper so refreshConfig() can safely
724
+ // reapply a changed Agent plugin selection without mutating shared managers.
725
+ return {
726
+ skillManager: skillManager || null,
727
+ mcpManager: mcpManager || null,
728
+ };
729
+ }
730
+
696
731
  function normalizeSessionWorkDir(workDir) {
697
732
  return typeof workDir === 'string' && workDir.trim() ? workDir.trim() : '';
698
733
  }
@@ -710,7 +745,7 @@ const RUNNING_THREAD_STATES = new Set(['queued', 'typing', 'thinking', 'retrying
710
745
  const vpThreads = new Map();
711
746
  /** @type {Map<string, Set<Promise<string|null>>>} */
712
747
  const routePromisesByMsgId = new Map();
713
- /** @type {Map<string, { workDir: string, skillManager: import('./skills.js').SkillManager, mcpManager: import('./mcp.js').MCPManager, mcpStatus: object, mcpConfig: object, status: { skills: number, mcpServers: string[], mcpFailed: object[], mcpSkipped: object[], tools: number } }>} */
748
+ /** @type {Map<string, { workDir: string, skillManager: import('./skills.js').SkillManager, mcpManager: import('./mcp.js').MCPManager, mcpStatus: object, configuredMcpConfig: object, effectiveMcpConfig: object, status: { skills: number, mcpServers: string[], mcpFailed: object[], mcpSkipped: object[], tools: number } }>} */
714
749
  const projectRuntimes = new Map();
715
750
  /** @type {Map<string, Promise<any>>} */
716
751
  const baseRuntimeLoadPromises = new Map();
@@ -832,7 +867,7 @@ function activateBaseRuntime(owner = captureRuntimeOwner(), { reloadSkills = tru
832
867
  if (!isCurrentRuntimeOwner(owner)) return { removed: 0, added: 0, skipped: true };
833
868
  activeRuntimeKey = BASE_RUNTIME_KEY;
834
869
  const swap = replaceSessionMcpTools(owner, mcpManager);
835
- retargetVpEngines(owner, { skillManager: skillManager || null, mcpManager: mcpManager || null });
870
+ retargetVpEngines(owner, effectiveRuntimeManagers(skillManager, mcpManager, ownerSession.config));
836
871
  if (status) {
837
872
  status.skills = skillManager?.size || 0;
838
873
  status.mcpServers = Array.isArray(status.mcpServers) ? status.mcpServers : [];
@@ -859,10 +894,11 @@ function activateProjectRuntime(runtime, owner = captureRuntimeOwner(), { reload
859
894
  if (!runtimeBelongsToOwner(runtime, owner)) return { removed: 0, added: 0, skipped: true };
860
895
  activeRuntimeKey = runtimeKey;
861
896
  const swap = replaceSessionMcpTools(owner, runtime.mcpManager);
862
- retargetVpEngines(owner, {
863
- skillManager: runtime.skillManager,
864
- mcpManager: runtime.mcpManager,
865
- });
897
+ retargetVpEngines(owner, effectiveRuntimeManagers(
898
+ runtime.skillManager,
899
+ runtime.mcpManager,
900
+ owner.ownerSession.config,
901
+ ));
866
902
  runtime.status = {
867
903
  ...runtime.status,
868
904
  skills: runtime.skillManager?.size || 0,
@@ -1798,6 +1834,9 @@ function getOrCreateVpEngine(sessionId, vpId, threadId = 'main') {
1798
1834
  // Per-session config overlay (v1: model only). Falls back to the
1799
1835
  // session's user-level config when no override is set. Session config is
1800
1836
  // always resolved from the agent-local root; project `.yeaft` is ignored.
1837
+ // Agent-level plugin config is already part of session.config; Session
1838
+ // config still only overlays model/effort. Keep the runtime policy Agent
1839
+ // scoped even though each VP Engine has its own effective config snapshot.
1801
1840
  const effectiveConfig = resolveLiveSessionConfig(session.config, sessionId);
1802
1841
  const configKey = engineConfigKey(effectiveConfig);
1803
1842
  let eng = vpEngines.get(key);
@@ -2353,6 +2392,8 @@ export async function __testDrainVpDrivers() {
2353
2392
  * a half-aborted controller writing to a now-cleared map.
2354
2393
  */
2355
2394
  export async function __testResetVpState() {
2395
+ await mcpTransitionTail.catch(() => undefined);
2396
+ mcpTransitionTail = Promise.resolve();
2356
2397
  await shutdownProjectRuntimes();
2357
2398
  for (const ctrl of vpAborts.values()) {
2358
2399
  try { if (!ctrl.signal.aborted) ctrl.abort(); } catch { /* */ }
@@ -2486,9 +2527,11 @@ function loadAndBroadcastYeaftSkillSlashCommands() {
2486
2527
  const configuredWorkDir = typeof ctx.CONFIG?.workDir === 'string' ? ctx.CONFIG.workDir.trim() : '';
2487
2528
  if (configuredWorkDir && configuredWorkDir !== process.cwd()) roots.push(configuredWorkDir);
2488
2529
  const skillManager = createSkillManager(yeaftDir, roots.join(delimiter));
2489
- broadcastSkillSlashCommands({ skillManager });
2530
+ const config = loadConfig({ dir: yeaftDir });
2531
+ const visibleSkillManager = createPluginSkillManager(skillManager, config.plugins);
2532
+ broadcastSkillSlashCommands({ skillManager: visibleSkillManager });
2490
2533
  return {
2491
- skills: skillManager.size,
2534
+ skills: visibleSkillManager?.size || 0,
2492
2535
  slashCommands: ctx.slashCommands,
2493
2536
  slashCommandDescriptions: ctx.slashCommandDescriptions,
2494
2537
  };
@@ -2503,7 +2546,10 @@ export function preloadYeaftSkillSlashCommands() {
2503
2546
  }
2504
2547
 
2505
2548
  function broadcastSkillSlashCommands(sessionLike, extraSkillManagers = []) {
2506
- const managers = [sessionLike?.skillManager, ...extraSkillManagers].filter(Boolean);
2549
+ const plugins = sessionLike?.config?.plugins || session?.config?.plugins || {};
2550
+ const managers = [sessionLike?.skillManager, ...extraSkillManagers]
2551
+ .filter(Boolean)
2552
+ .map(manager => createPluginSkillManager(manager, plugins));
2507
2553
  const { commands: slashCommands, descriptions: slashCommandDescriptions } = buildMergedSkillSlashCommands(managers);
2508
2554
  // Yeaft owns an isolated command catalogue. Reusing ctx's Claude Chat
2509
2555
  // commands made unsupported entries such as /compact and /mcp appear in a
@@ -2606,14 +2652,18 @@ function stopSkillHotReload(owner = null) {
2606
2652
  return true;
2607
2653
  }
2608
2654
 
2609
- async function loadBaseRuntime(owner = captureRuntimeOwner()) {
2655
+ async function runBaseRuntimeTransition(owner = captureRuntimeOwner()) {
2610
2656
  if (!isCurrentRuntimeOwner(owner)) return null;
2611
2657
  const ownerSession = owner.ownerSession;
2612
2658
  const yeaftDir = ctx.CONFIG?.yeaftDir || ownerSession.yeaftDir || DEFAULT_YEAFT_DIR;
2613
2659
  const previousSkillManager = ownerSession.skillManager;
2614
2660
  const previousMcpManager = ownerSession.mcpManager;
2615
2661
  const skillManager = createRuntimeSkillManager(yeaftDir, process.cwd());
2616
- const mcpConfig = loadRuntimeMcpConfig(yeaftDir, undefined, process.cwd());
2662
+ const rawMcpConfig = loadRuntimeMcpConfig(yeaftDir, undefined, process.cwd());
2663
+ const { configured: configuredMcpConfig, effective: effectiveMcpConfig } = resolveMcpPluginPolicy(
2664
+ rawMcpConfig,
2665
+ ownerSession.config?.plugins,
2666
+ );
2617
2667
  const mcpManager = createRuntimeMcpManager();
2618
2668
  let mcpStatus = { connected: [], failed: [] };
2619
2669
  const runtime = {
@@ -2625,13 +2675,14 @@ async function loadBaseRuntime(owner = captureRuntimeOwner()) {
2625
2675
  skillManager,
2626
2676
  mcpManager,
2627
2677
  mcpStatus,
2628
- mcpConfig,
2629
- loading: mcpConfig.servers.length > 0,
2678
+ configuredMcpConfig,
2679
+ effectiveMcpConfig,
2680
+ loading: effectiveMcpConfig.servers.length > 0,
2630
2681
  status: {
2631
2682
  skills: skillManager.size,
2632
2683
  mcpServers: [],
2633
2684
  mcpFailed: [],
2634
- mcpSkipped: mcpConfig.skipped || [],
2685
+ mcpSkipped: effectiveMcpConfig.skipped || [],
2635
2686
  tools: ownerSession.toolRegistry?.size || 0,
2636
2687
  },
2637
2688
  };
@@ -2649,9 +2700,9 @@ async function loadBaseRuntime(owner = captureRuntimeOwner()) {
2649
2700
  hydrateYeaftStatusFromSession(ownerSession, { reason: 'base_runtime_skills', emitEvent: true });
2650
2701
  }
2651
2702
 
2652
- if (mcpConfig.servers.length > 0) {
2703
+ if (effectiveMcpConfig.servers.length > 0) {
2653
2704
  try {
2654
- mcpStatus = await mcpManager.connectAll(mcpConfig.servers);
2705
+ mcpStatus = await mcpManager.connectAll(effectiveMcpConfig.servers);
2655
2706
  } catch (err) {
2656
2707
  runtime.loading = false;
2657
2708
  if (ownerSession.skillManager === skillManager) ownerSession.skillManager = previousSkillManager;
@@ -2672,7 +2723,7 @@ async function loadBaseRuntime(owner = captureRuntimeOwner()) {
2672
2723
  ...runtime.status,
2673
2724
  mcpServers: mcpStatus.connected,
2674
2725
  mcpFailed: mcpStatus.failed,
2675
- mcpSkipped: mcpConfig.skipped || [],
2726
+ mcpSkipped: effectiveMcpConfig.skipped || [],
2676
2727
  tools: ownerSession.toolRegistry?.size || 0,
2677
2728
  };
2678
2729
  ownerSession.mcpManager = mcpManager;
@@ -2681,7 +2732,7 @@ async function loadBaseRuntime(owner = captureRuntimeOwner()) {
2681
2732
  activateBaseRuntime(owner, { reloadSkills: false });
2682
2733
  hydrateYeaftStatusFromSession(ownerSession, { reason: 'base_runtime_mcp', emitEvent: true });
2683
2734
  if (isCurrentRuntimeOwner(owner)) {
2684
- try { broadcastMcpUpdated({ reason: 'base-runtime-load' }); } catch { /* best-effort */ }
2735
+ try { await broadcastMcpUpdated({ reason: 'base-runtime-load' }); } catch { /* best-effort */ }
2685
2736
  }
2686
2737
  }
2687
2738
  }
@@ -2689,6 +2740,10 @@ async function loadBaseRuntime(owner = captureRuntimeOwner()) {
2689
2740
  return isCurrentRuntimeOwner(owner) && baseRuntime === runtime ? runtime : null;
2690
2741
  }
2691
2742
 
2743
+ function loadBaseRuntime(owner = captureRuntimeOwner()) {
2744
+ return enqueueMcpTransition(() => runBaseRuntimeTransition(owner));
2745
+ }
2746
+
2692
2747
  function scheduleBaseRuntimeLoad() {
2693
2748
  const owner = captureRuntimeOwner();
2694
2749
  if (!owner) return null;
@@ -2712,7 +2767,7 @@ function scheduleBaseRuntimeLoad() {
2712
2767
  return promise;
2713
2768
  }
2714
2769
 
2715
- async function loadProjectRuntime(workDir, owner = captureRuntimeOwner()) {
2770
+ async function runProjectRuntimeTransition(workDir, owner = captureRuntimeOwner()) {
2716
2771
  if (!isCurrentRuntimeOwner(owner)) return null;
2717
2772
  const ownerSession = owner.ownerSession;
2718
2773
  const normalizedWorkDir = normalizeSessionWorkDir(workDir);
@@ -2732,7 +2787,11 @@ async function loadProjectRuntime(workDir, owner = captureRuntimeOwner()) {
2732
2787
  ? `${process.cwd()}${delimiter}${normalizedWorkDir}`
2733
2788
  : normalizedWorkDir;
2734
2789
  const skillManager = createRuntimeSkillManager(yeaftDir, skillRoots);
2735
- const mcpConfig = loadRuntimeMcpConfig(yeaftDir, undefined, normalizedWorkDir);
2790
+ const rawMcpConfig = loadRuntimeMcpConfig(yeaftDir, undefined, normalizedWorkDir);
2791
+ const { configured: configuredMcpConfig, effective: effectiveMcpConfig } = resolveMcpPluginPolicy(
2792
+ rawMcpConfig,
2793
+ ownerSession.config?.plugins,
2794
+ );
2736
2795
  const mcpManager = createRuntimeMcpManager();
2737
2796
  let mcpStatus = { connected: [], failed: [] };
2738
2797
  const runtime = {
@@ -2742,13 +2801,14 @@ async function loadProjectRuntime(workDir, owner = captureRuntimeOwner()) {
2742
2801
  skillManager,
2743
2802
  mcpManager,
2744
2803
  mcpStatus,
2745
- mcpConfig,
2746
- loading: mcpConfig.servers.length > 0,
2804
+ configuredMcpConfig,
2805
+ effectiveMcpConfig,
2806
+ loading: effectiveMcpConfig.servers.length > 0,
2747
2807
  status: {
2748
2808
  skills: skillManager.size,
2749
2809
  mcpServers: [],
2750
2810
  mcpFailed: [],
2751
- mcpSkipped: mcpConfig.skipped || [],
2811
+ mcpSkipped: effectiveMcpConfig.skipped || [],
2752
2812
  tools: ownerSession.toolRegistry?.size || 0,
2753
2813
  },
2754
2814
  };
@@ -2760,9 +2820,9 @@ async function loadProjectRuntime(workDir, owner = captureRuntimeOwner()) {
2760
2820
  // Skill metadata is ready before external MCP startup. Activation is still
2761
2821
  // owner-gated so reset cannot publish this runtime into a replacement session.
2762
2822
  activateProjectRuntime(runtime, owner, { reloadSkills: false });
2763
- if (mcpConfig.servers.length > 0) {
2823
+ if (effectiveMcpConfig.servers.length > 0) {
2764
2824
  try {
2765
- mcpStatus = await mcpManager.connectAll(mcpConfig.servers);
2825
+ mcpStatus = await mcpManager.connectAll(effectiveMcpConfig.servers);
2766
2826
  } catch (err) {
2767
2827
  runtime.loading = false;
2768
2828
  await disconnectRuntimeMcpManager(mcpManager);
@@ -2779,7 +2839,7 @@ async function loadProjectRuntime(workDir, owner = captureRuntimeOwner()) {
2779
2839
  ...runtime.status,
2780
2840
  mcpServers: mcpStatus.connected,
2781
2841
  mcpFailed: mcpStatus.failed,
2782
- mcpSkipped: mcpConfig.skipped || [],
2842
+ mcpSkipped: effectiveMcpConfig.skipped || [],
2783
2843
  tools: ownerSession.toolRegistry?.size || 0,
2784
2844
  };
2785
2845
  if (activeRuntimeKey === key) activateProjectRuntime(runtime, owner, { reloadSkills: false });
@@ -2787,6 +2847,10 @@ async function loadProjectRuntime(workDir, owner = captureRuntimeOwner()) {
2787
2847
  return runtimeBelongsToOwner(runtime, owner) && projectRuntimes.get(key) === runtime ? runtime : null;
2788
2848
  }
2789
2849
 
2850
+ function loadProjectRuntime(workDir, owner = captureRuntimeOwner()) {
2851
+ return enqueueMcpTransition(() => runProjectRuntimeTransition(workDir, owner));
2852
+ }
2853
+
2790
2854
  function scheduleProjectRuntimeLoad(workDir) {
2791
2855
  const owner = captureRuntimeOwner();
2792
2856
  const normalizedWorkDir = normalizeSessionWorkDir(workDir);
@@ -2818,6 +2882,7 @@ function getProjectRuntimeForTurn(sessionMeta) {
2818
2882
  if (!owner) return null;
2819
2883
  const workDir = normalizeSessionWorkDir(sessionMeta?.workDir);
2820
2884
  if (!workDir) {
2885
+ if (!runtimeBelongsToOwner(baseRuntime, owner)) scheduleBaseRuntimeLoad();
2821
2886
  activateBaseRuntime(owner);
2822
2887
  return null;
2823
2888
  }
@@ -3169,6 +3234,64 @@ function sendSessionCrudResult(payload) {
3169
3234
  sendSessionEvent({ type: 'session_crud_result', ...next });
3170
3235
  }
3171
3236
 
3237
+ function isMcpServerEnabled(name, plugins = session?.config?.plugins) {
3238
+ return !Array.isArray(plugins?.mcpServers) || plugins.mcpServers.includes(name);
3239
+ }
3240
+
3241
+ function resolvePluginCatalogRuntime(workDir = '') {
3242
+ const owner = captureRuntimeOwner();
3243
+ const normalizedWorkDir = normalizeSessionWorkDir(workDir);
3244
+ const runtime = normalizedWorkDir
3245
+ ? projectRuntimes.get(projectRuntimeKey(normalizedWorkDir))
3246
+ : null;
3247
+ const active = runtimeBelongsToOwner(runtime, owner)
3248
+ ? runtime
3249
+ : (baseRuntime && runtimeBelongsToOwner(baseRuntime, owner) ? baseRuntime : null);
3250
+ return {
3251
+ toolRegistry: session?.toolRegistry || null,
3252
+ skillManager: active?.skillManager || session?.skillManager || null,
3253
+ mcpManager: active?.mcpManager || session?.mcpManager || null,
3254
+ };
3255
+ }
3256
+
3257
+ function loadPluginCatalogMcpConfig(yeaftDir) {
3258
+ // The Plugins catalog is Agent-owned. Re-read its raw configuration on every
3259
+ // request so MCP CRUD is visible immediately, while keeping the
3260
+ // config.json -> mcp.json compatibility fallback.
3261
+ return loadAgentMCPConfig(yeaftDir);
3262
+ }
3263
+
3264
+ /** Test-only: read the MCP catalog source without sending a bridge response. */
3265
+ export function __testLoadPluginCatalogMcpConfig(yeaftDir) {
3266
+ return loadPluginCatalogMcpConfig(yeaftDir);
3267
+ }
3268
+
3269
+ export function handleYeaftPluginCatalog(msg = {}) {
3270
+ const requestId = msg.requestId || null;
3271
+ const requestedWorkDir = normalizeSessionWorkDir(msg.workDir);
3272
+ try {
3273
+ const runtime = resolvePluginCatalogRuntime(requestedWorkDir);
3274
+ const yeaftDir = ctx.CONFIG?.yeaftDir || session?.yeaftDir || DEFAULT_YEAFT_DIR;
3275
+ runtime.mcpConfig = loadPluginCatalogMcpConfig(yeaftDir);
3276
+ const catalog = buildPluginCatalog(runtime);
3277
+ sendToServer({
3278
+ type: 'yeaft_plugin_catalog_result',
3279
+ requestId,
3280
+ ...(msg._requestClientId ? { _requestClientId: msg._requestClientId } : {}),
3281
+ catalog,
3282
+ error: null,
3283
+ });
3284
+ } catch (err) {
3285
+ sendToServer({
3286
+ type: 'yeaft_plugin_catalog_result',
3287
+ requestId,
3288
+ ...(msg._requestClientId ? { _requestClientId: msg._requestClientId } : {}),
3289
+ catalog: { tools: [], skills: [], mcpServers: [] },
3290
+ error: err?.message || String(err),
3291
+ });
3292
+ }
3293
+ }
3294
+
3172
3295
  function sendSessionSnapshotBroadcast() {
3173
3296
  try {
3174
3297
  const yeaftDir = ctx.CONFIG?.yeaftDir;
@@ -5343,15 +5466,17 @@ async function runVpTurn({ prompt, promptParts = null, sessionId, vpId, threadId
5343
5466
  escalationState.engineKey = threadKey(sessionId, vpId, threadId);
5344
5467
  }
5345
5468
  if (projectRuntime) {
5346
- vpEngine.setRuntimeManagers?.({
5347
- skillManager: projectRuntime.skillManager,
5348
- mcpManager: projectRuntime.mcpManager,
5349
- });
5469
+ vpEngine.setRuntimeManagers?.(effectiveRuntimeManagers(
5470
+ projectRuntime.skillManager,
5471
+ projectRuntime.mcpManager,
5472
+ session?.config?.plugins,
5473
+ ));
5350
5474
  } else {
5351
- vpEngine.setRuntimeManagers?.({
5352
- skillManager: session?.skillManager || null,
5353
- mcpManager: session?.mcpManager || null,
5354
- });
5475
+ vpEngine.setRuntimeManagers?.(effectiveRuntimeManagers(
5476
+ session?.skillManager || null,
5477
+ session?.mcpManager || null,
5478
+ session?.config?.plugins,
5479
+ ));
5355
5480
  }
5356
5481
  if (thread) thread.engine = vpEngine;
5357
5482
 
@@ -7528,16 +7653,54 @@ export async function resetYeaftSession() {
7528
7653
  * connection state next to the configured servers. Safe to call when
7529
7654
  * the session hasn't been initialised yet — returns an empty runtime.
7530
7655
  */
7656
+ function activeMcpManager(owner = captureRuntimeOwner()) {
7657
+ if (!isCurrentRuntimeOwner(owner)) return session?.mcpManager || null;
7658
+ if (activeRuntimeKey !== BASE_RUNTIME_KEY) {
7659
+ const runtime = projectRuntimes.get(activeRuntimeKey) || null;
7660
+ if (runtimeBelongsToOwner(runtime, owner)) return runtime.mcpManager || null;
7661
+ }
7662
+ return owner.ownerSession.mcpManager || null;
7663
+ }
7664
+
7665
+ async function retireInactiveMcpRuntimes(owner, activeManager) {
7666
+ if (!isCurrentRuntimeOwner(owner)) return;
7667
+ const retire = [];
7668
+
7669
+ // The base manager stays cached while a project runtime is active. It was
7670
+ // built from the old Agent config, so remove it rather than allowing a later
7671
+ // base activation to revive a deleted MCP. The next base turn schedules a
7672
+ // fresh loader; until then there is deliberately no base MCP fallback.
7673
+ if (runtimeBelongsToOwner(baseRuntime, owner) && baseRuntime.mcpManager !== activeManager) {
7674
+ const staleBase = baseRuntime;
7675
+ baseRuntime = null;
7676
+ baseRuntimeLoadPromises.delete(BASE_RUNTIME_KEY);
7677
+ if (owner.ownerSession.mcpManager === staleBase.mcpManager) owner.ownerSession.mcpManager = null;
7678
+ // A loading manager may acquire its connection after this point. Its
7679
+ // post-connect ownership check performs the reliable cleanup in that case.
7680
+ if (!staleBase.loading) retire.push(disconnectRuntimeMcpManager(staleBase.mcpManager));
7681
+ }
7682
+
7683
+ for (const [key, runtime] of projectRuntimes) {
7684
+ if (!runtimeBelongsToOwner(runtime, owner) || runtime.mcpManager === activeManager) continue;
7685
+ projectRuntimes.delete(key);
7686
+ projectRuntimeLoadPromises.delete(key);
7687
+ if (!runtime.loading) retire.push(disconnectRuntimeMcpManager(runtime.mcpManager));
7688
+ }
7689
+
7690
+ await Promise.allSettled(retire);
7691
+ }
7692
+
7531
7693
  function mcpRuntimeSnapshot() {
7532
- if (!session?.mcpManager) {
7694
+ const mcpManager = activeMcpManager();
7695
+ if (!mcpManager) {
7533
7696
  return { connected: false, toolCount: 0, perServer: [] };
7534
7697
  }
7535
- const status = session.mcpManager.status() || [];
7536
- const toolCount = typeof session.mcpManager.toolCount === 'number'
7537
- ? session.mcpManager.toolCount
7698
+ const status = mcpManager.status() || [];
7699
+ const toolCount = typeof mcpManager.toolCount === 'number'
7700
+ ? mcpManager.toolCount
7538
7701
  : status.reduce((sum, s) => sum + (s.toolCount || 0), 0);
7539
7702
  return {
7540
- connected: !!session.mcpManager.hasServers,
7703
+ connected: !!mcpManager.hasServers,
7541
7704
  toolCount,
7542
7705
  perServer: status.map(s => ({
7543
7706
  name: s.name,
@@ -7553,23 +7716,34 @@ function mcpRuntimeSnapshot() {
7553
7716
  * pick up the change.
7554
7717
  */
7555
7718
  function hotSwapMcpTools() {
7556
- return replaceSessionMcpTools(captureRuntimeOwner(), session?.mcpManager);
7719
+ const owner = captureRuntimeOwner();
7720
+ return replaceSessionMcpTools(owner, activeMcpManager(owner));
7557
7721
  }
7558
7722
 
7559
7723
  /**
7560
7724
  * Broadcast a `yeaft_mcp_updated` event so any client subscribed to the
7561
7725
  * Yeaft view (Settings panel, status badge) refreshes without needing
7562
- * to re-open the panel. The current list+runtime are included so the UI
7563
- * is single-source (no separate fetch round-trip needed).
7726
+ * to re-open the panel. A CRUD/reload caller should pass its confirmed
7727
+ * `configuredServers` snapshot rather than rereading config after async
7728
+ * runtime work. Fallback reads preserve an existing UI cache on failure by
7729
+ * omitting `servers` and reporting the strict-read error.
7564
7730
  */
7565
- function broadcastMcpUpdated(extra = {}) {
7566
- const yeaftDir = ctx.CONFIG?.yeaftDir;
7567
- const listed = listMcpServers(yeaftDir);
7568
- sendToServer({
7731
+ function broadcastMcpUpdated({ configuredServers, ...extra } = {}) {
7732
+ let servers = configuredServers;
7733
+ let error = null;
7734
+ if (!Array.isArray(servers)) {
7735
+ const yeaftDir = ctx.CONFIG?.yeaftDir;
7736
+ const listed = listMcpServers(yeaftDir);
7737
+ if (listed.error) error = listed.error;
7738
+ else servers = listed.servers;
7739
+ }
7740
+
7741
+ return sendToServer({
7569
7742
  type: 'yeaft_mcp_updated',
7570
- servers: listed.servers || [],
7571
7743
  runtime: mcpRuntimeSnapshot(),
7572
7744
  ...extra,
7745
+ ...(Array.isArray(servers) ? { servers } : {}),
7746
+ error,
7573
7747
  });
7574
7748
  }
7575
7749
 
@@ -7585,11 +7759,11 @@ export function handleYeaftMcpList(msg = {}) {
7585
7759
  });
7586
7760
  }
7587
7761
 
7588
- export async function handleYeaftMcpAdd(msg = {}) {
7762
+ async function runYeaftMcpAddMutation(msg = {}) {
7589
7763
  const yeaftDir = ctx.CONFIG?.yeaftDir;
7590
7764
  const result = upsertMcpServer(msg.server || {}, yeaftDir);
7591
7765
  if (result.error) {
7592
- sendToServer({
7766
+ await sendToServer({
7593
7767
  type: 'yeaft_mcp_add_result',
7594
7768
  requestId: msg.requestId || null,
7595
7769
  servers: [],
@@ -7599,22 +7773,28 @@ export async function handleYeaftMcpAdd(msg = {}) {
7599
7773
  return;
7600
7774
  }
7601
7775
 
7602
- // Apply at runtime when the session is live. The MCPManager's
7603
- // `connect(serverConfig)` already disconnects-and-reconnects if a
7604
- // server with the same name was already registered.
7776
+ // A queued runtime bootstrap may have made a project manager active before
7777
+ // this mutation runs. Capture the manager after the config write, then
7778
+ // retire inactive caches and update the manager that owns live tools.
7779
+ const owner = captureRuntimeOwner();
7780
+ const mcpManager = activeMcpManager(owner);
7781
+ await retireInactiveMcpRuntimes(owner, mcpManager);
7605
7782
  let connectError = null;
7606
- if (session?.mcpManager) {
7783
+ if (mcpManager && !isMcpServerEnabled(result.server.name)) {
7784
+ try {
7785
+ await mcpManager.disconnect(result.server.name);
7786
+ } catch { /* no active connection to retire */ }
7787
+ } else if (mcpManager) {
7607
7788
  try {
7608
- await session.mcpManager.connect(result.server);
7789
+ await mcpManager.connect(result.server);
7609
7790
  } catch (err) {
7610
7791
  connectError = err?.message || String(err);
7611
7792
  console.warn(`[Yeaft] MCP connect "${result.server.name}" failed:`, connectError);
7612
7793
  }
7613
7794
  }
7614
7795
 
7615
- const swap = replaceSessionMcpTools(captureRuntimeOwner(), session?.mcpManager);
7616
-
7617
- sendToServer({
7796
+ const swap = replaceSessionMcpTools(owner, mcpManager);
7797
+ await sendToServer({
7618
7798
  type: 'yeaft_mcp_add_result',
7619
7799
  requestId: msg.requestId || null,
7620
7800
  servers: result.servers,
@@ -7623,15 +7803,24 @@ export async function handleYeaftMcpAdd(msg = {}) {
7623
7803
  connectError,
7624
7804
  error: null,
7625
7805
  });
7626
- broadcastMcpUpdated({ reason: 'add', name: result.server.name, connectError });
7806
+ await broadcastMcpUpdated({
7807
+ reason: 'add',
7808
+ name: result.server.name,
7809
+ connectError,
7810
+ configuredServers: result.servers,
7811
+ });
7627
7812
  }
7628
7813
 
7629
- export async function handleYeaftMcpRemove(msg = {}) {
7814
+ export function handleYeaftMcpAdd(msg = {}) {
7815
+ return enqueueMcpTransition(() => runYeaftMcpAddMutation(msg));
7816
+ }
7817
+
7818
+ async function runYeaftMcpRemoveMutation(msg = {}) {
7630
7819
  const yeaftDir = ctx.CONFIG?.yeaftDir;
7631
7820
  const name = typeof msg.name === 'string' ? msg.name : '';
7632
7821
  const result = removeMcpServer(name, yeaftDir);
7633
7822
  if (result.error) {
7634
- sendToServer({
7823
+ await sendToServer({
7635
7824
  type: 'yeaft_mcp_remove_result',
7636
7825
  requestId: msg.requestId || null,
7637
7826
  servers: [],
@@ -7641,17 +7830,19 @@ export async function handleYeaftMcpRemove(msg = {}) {
7641
7830
  return;
7642
7831
  }
7643
7832
 
7644
- if (session?.mcpManager) {
7833
+ const owner = captureRuntimeOwner();
7834
+ const mcpManager = activeMcpManager(owner);
7835
+ await retireInactiveMcpRuntimes(owner, mcpManager);
7836
+ if (mcpManager) {
7645
7837
  try {
7646
- await session.mcpManager.disconnect(name);
7838
+ await mcpManager.disconnect(name);
7647
7839
  } catch (err) {
7648
7840
  console.warn(`[Yeaft] MCP disconnect "${name}" failed:`, err?.message || err);
7649
7841
  }
7650
7842
  }
7651
7843
 
7652
- const swap = replaceSessionMcpTools(captureRuntimeOwner(), session?.mcpManager);
7653
-
7654
- sendToServer({
7844
+ const swap = replaceSessionMcpTools(owner, mcpManager);
7845
+ await sendToServer({
7655
7846
  type: 'yeaft_mcp_remove_result',
7656
7847
  requestId: msg.requestId || null,
7657
7848
  servers: result.servers,
@@ -7660,46 +7851,71 @@ export async function handleYeaftMcpRemove(msg = {}) {
7660
7851
  swap,
7661
7852
  error: null,
7662
7853
  });
7663
- broadcastMcpUpdated({ reason: 'remove', name });
7854
+ await broadcastMcpUpdated({
7855
+ reason: 'remove',
7856
+ name,
7857
+ configuredServers: result.servers,
7858
+ });
7859
+ }
7860
+
7861
+ export function handleYeaftMcpRemove(msg = {}) {
7862
+ return enqueueMcpTransition(() => runYeaftMcpRemoveMutation(msg));
7664
7863
  }
7665
7864
 
7666
- export async function handleYeaftMcpReload(msg = {}) {
7865
+ async function runYeaftMcpReloadMutation(msg = {}) {
7667
7866
  const yeaftDir = ctx.CONFIG?.yeaftDir;
7668
7867
  const targetName = typeof msg.name === 'string' && msg.name ? msg.name : null;
7868
+ const listed = listMcpServers(yeaftDir);
7869
+
7870
+ // Do not turn a failed strict config read into an empty successful reload.
7871
+ // In particular, leave an existing MCP runtime and its flattened tools alone
7872
+ // until the user repairs config.json.
7873
+ if (listed.error) {
7874
+ await sendToServer({
7875
+ type: 'yeaft_mcp_reload_result',
7876
+ requestId: msg.requestId || null,
7877
+ servers: [],
7878
+ runtime: mcpRuntimeSnapshot(),
7879
+ error: listed.error,
7880
+ });
7881
+ return;
7882
+ }
7669
7883
 
7670
- if (!session?.mcpManager) {
7671
- // Session not yet alive — just echo the current config + an empty
7884
+ const owner = captureRuntimeOwner();
7885
+ const mcpManager = activeMcpManager(owner);
7886
+ if (!mcpManager) {
7887
+ // Session not yet alive; just echo the current config + an empty
7672
7888
  // runtime so the UI knows to wait for session boot.
7673
- const listed = listMcpServers(yeaftDir);
7674
- sendToServer({
7889
+ await sendToServer({
7675
7890
  type: 'yeaft_mcp_reload_result',
7676
7891
  requestId: msg.requestId || null,
7677
- servers: listed.servers || [],
7892
+ servers: listed.servers,
7678
7893
  runtime: mcpRuntimeSnapshot(),
7679
7894
  error: null,
7680
7895
  });
7681
7896
  return;
7682
7897
  }
7683
7898
 
7684
- const listed = listMcpServers(yeaftDir);
7685
- const configured = listed.servers || [];
7899
+ // Full reload applies an externally changed Agent MCP config, so stale
7900
+ // runtime caches are just as unsafe as after add/remove. A named reload is
7901
+ // only a local reconnect and must keep inactive base/project caches warm.
7902
+ if (!targetName) await retireInactiveMcpRuntimes(owner, mcpManager);
7686
7903
 
7687
- // Per-server reload: disconnect + reconnect the named server only.
7688
- // Whole-set reload: disconnect everything, then reconnect from current
7689
- // config.json. The latter is what the user clicks "Reload all" for.
7904
+ const configured = listed.servers;
7905
+ const enabled = configured.filter(server => isMcpServerEnabled(server.name));
7690
7906
  const failures = [];
7691
7907
  try {
7692
7908
  if (targetName) {
7693
- const cfg = configured.find(s => s.name === targetName);
7694
- try { await session.mcpManager.disconnect(targetName); } catch { /* ignore */ }
7909
+ const cfg = enabled.find(server => server.name === targetName);
7910
+ try { await mcpManager.disconnect(targetName); } catch { /* ignore */ }
7695
7911
  if (cfg) {
7696
- try { await session.mcpManager.connect(cfg); }
7912
+ try { await mcpManager.connect(cfg); }
7697
7913
  catch (err) { failures.push({ name: targetName, error: err?.message || String(err) }); }
7698
7914
  }
7699
7915
  } else {
7700
- try { await session.mcpManager.disconnectAll(); } catch { /* ignore */ }
7701
- for (const cfg of configured) {
7702
- try { await session.mcpManager.connect(cfg); }
7916
+ try { await mcpManager.disconnectAll(); } catch { /* ignore */ }
7917
+ for (const cfg of enabled) {
7918
+ try { await mcpManager.connect(cfg); }
7703
7919
  catch (err) { failures.push({ name: cfg.name, error: err?.message || String(err) }); }
7704
7920
  }
7705
7921
  }
@@ -7707,9 +7923,8 @@ export async function handleYeaftMcpReload(msg = {}) {
7707
7923
  console.warn('[Yeaft] MCP reload failed:', err?.message || err);
7708
7924
  }
7709
7925
 
7710
- const swap = replaceSessionMcpTools(captureRuntimeOwner(), session?.mcpManager);
7711
-
7712
- sendToServer({
7926
+ const swap = replaceSessionMcpTools(owner, mcpManager);
7927
+ await sendToServer({
7713
7928
  type: 'yeaft_mcp_reload_result',
7714
7929
  requestId: msg.requestId || null,
7715
7930
  servers: configured,
@@ -7718,7 +7933,16 @@ export async function handleYeaftMcpReload(msg = {}) {
7718
7933
  swap,
7719
7934
  error: null,
7720
7935
  });
7721
- broadcastMcpUpdated({ reason: 'reload', name: targetName, failures });
7936
+ await broadcastMcpUpdated({
7937
+ reason: 'reload',
7938
+ name: targetName,
7939
+ failures,
7940
+ configuredServers: configured,
7941
+ });
7942
+ }
7943
+
7944
+ export function handleYeaftMcpReload(msg = {}) {
7945
+ return enqueueMcpTransition(() => runYeaftMcpReloadMutation(msg));
7722
7946
  }
7723
7947
 
7724
7948
  export const __testHooks = {
@@ -7742,6 +7966,9 @@ export const __testHooks = {
7742
7966
  setSessionForTest(nextSession) {
7743
7967
  __testSetSession(nextSession || null);
7744
7968
  },
7969
+ broadcastMcpUpdatedForTest(extra) {
7970
+ return broadcastMcpUpdated(extra);
7971
+ },
7745
7972
  setRuntimeFactoriesForTest({
7746
7973
  createSkillManager: nextCreateSkillManager,
7747
7974
  createMcpManager: nextCreateMcpManager,
@@ -7888,7 +8115,8 @@ export const __testHooks = {
7888
8115
  skillManager: runtime?.skillManager || { list: () => [] },
7889
8116
  mcpManager: runtime?.mcpManager || { listTools: () => [], disconnectAll: async () => {} },
7890
8117
  mcpStatus: runtime?.mcpStatus || { connected: [], failed: [] },
7891
- mcpConfig: runtime?.mcpConfig || { servers: [], skipped: [] },
8118
+ configuredMcpConfig: runtime?.configuredMcpConfig || runtime?.mcpConfig || { servers: [], skipped: [] },
8119
+ effectiveMcpConfig: runtime?.effectiveMcpConfig || runtime?.mcpConfig || { servers: [], skipped: [] },
7892
8120
  status: runtime?.status || { skills: 0, mcpServers: [], mcpFailed: [], mcpSkipped: [], tools: 0 },
7893
8121
  };
7894
8122
  projectRuntimes.set(projectRuntimeKey(normalizedWorkDir), seeded);