@yeaft/webchat-agent 1.0.196 → 1.0.198

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.
@@ -80,6 +80,7 @@ const LEGACY_SKILL_COMMAND_PREFIX = 'skill:';
80
80
  const YEAFT_SKILL_COMMAND_PREFIX = 'yeaft-skills:';
81
81
  const PROJECT_SKILL_TIERS = new Set(['project', 'project-claude', 'project-codex']);
82
82
  const BASE_RUNTIME_KEY = '__base__';
83
+ const SKILL_RELOAD_INTERVAL_MS = 2_000;
83
84
 
84
85
  /**
85
86
  * Live AskUser requests. They are Session-scoped runtime state rather than a
@@ -253,7 +254,7 @@ function scheduleYeaftLoadHistoryMetadataReplay(sessionId) {
253
254
  const replayConversationId = yeaftConversationId;
254
255
  setTimeout(async () => {
255
256
  try {
256
- if (!replaySession) return;
257
+ if (!replaySession || session !== replaySession) return;
257
258
  try {
258
259
  await refreshLiveSessionConfig();
259
260
  } catch (err) {
@@ -264,10 +265,19 @@ function scheduleYeaftLoadHistoryMetadataReplay(sessionId) {
264
265
  try {
265
266
  const metaRoot = ctx.CONFIG?.yeaftDir || DEFAULT_YEAFT_DIR;
266
267
  const meta = loadSessionMeta(join(sessionsRoot(metaRoot), sessionId));
267
- projectRuntime = await ensureProjectRuntimeForSessionMeta(meta);
268
+ const workDir = normalizeSessionWorkDir(meta?.workDir);
269
+ if (workDir) {
270
+ const scheduled = scheduleProjectRuntimeLoad(workDir);
271
+ projectRuntime = scheduled && typeof scheduled.then === 'function'
272
+ ? await scheduled
273
+ : scheduled;
274
+ } else {
275
+ activateBaseRuntime(captureRuntimeOwner(replaySession));
276
+ }
268
277
  } catch { /* best-effort project metadata */ }
269
278
  }
270
- const status = mergedStatusForProjectRuntime(projectRuntime);
279
+ if (session !== replaySession) return;
280
+ const status = mergedStatusForProjectRuntime(projectRuntime, replaySession);
271
281
  hydrateYeaftStatusFromSession({ ...replaySession, status }, { reason: 'history_load', emitEvent: true });
272
282
  sendSessionEvent({
273
283
  type: 'session_ready',
@@ -590,14 +600,73 @@ const routePromisesByMsgId = new Map();
590
600
  const projectRuntimes = new Map();
591
601
  /** @type {Map<string, Promise<any>>} */
592
602
  const baseRuntimeLoadPromises = new Map();
603
+ let baseRuntime = null;
593
604
  let activeRuntimeKey = BASE_RUNTIME_KEY;
605
+ let skillReloadTimer = null;
606
+ let skillReloadRunning = false;
607
+ let skillReloadOwner = null;
608
+ let runtimeGeneration = 0;
609
+ /** @type {import('./session.js').Session | null} */
610
+ let runtimeOwnerSession = null;
611
+ const disconnectedRuntimeMcpManagers = new WeakSet();
612
+
613
+ let createRuntimeSkillManager = createSkillManager;
614
+ let createRuntimeMcpManager = () => new MCPManager();
615
+ let loadRuntimeMcpConfig = loadMCPConfig;
616
+ let loadRuntimeSession = loadSession;
617
+ const runtimeLoaderOwners = new WeakMap();
618
+
619
+ function loaderBelongsToOwner(promise, owner) {
620
+ const tracked = promise ? runtimeLoaderOwners.get(promise) : null;
621
+ return !!tracked
622
+ && tracked.generation === owner?.generation
623
+ && tracked.ownerSession === owner?.ownerSession;
624
+ }
625
+
626
+ function claimRuntimeOwnership(ownerSession) {
627
+ if (!ownerSession) return null;
628
+ runtimeOwnerSession = ownerSession;
629
+ return { generation: runtimeGeneration, ownerSession };
630
+ }
631
+
632
+ function captureRuntimeOwner(ownerSession = session) {
633
+ if (!ownerSession || ownerSession !== session || ownerSession !== runtimeOwnerSession) return null;
634
+ return { generation: runtimeGeneration, ownerSession };
635
+ }
636
+
637
+ function isCurrentRuntimeOwner(owner) {
638
+ return !!owner
639
+ && owner.generation === runtimeGeneration
640
+ && owner.ownerSession === runtimeOwnerSession
641
+ && owner.ownerSession === session;
642
+ }
643
+
644
+ function invalidateRuntimeOwnership() {
645
+ runtimeGeneration += 1;
646
+ runtimeOwnerSession = null;
647
+ }
648
+
649
+ function runtimeBelongsToOwner(runtime, owner) {
650
+ return isCurrentRuntimeOwner(owner)
651
+ && runtime?.generation === owner.generation
652
+ && runtime?.ownerSession === owner.ownerSession;
653
+ }
654
+
655
+ async function disconnectRuntimeMcpManager(mcpManager) {
656
+ if (!mcpManager || typeof mcpManager.disconnectAll !== 'function') return;
657
+ if (disconnectedRuntimeMcpManagers.has(mcpManager)) return;
658
+ disconnectedRuntimeMcpManagers.add(mcpManager);
659
+ try { await mcpManager.disconnectAll(); } catch { /* best-effort shutdown */ }
660
+ }
594
661
 
595
- function replaceSessionMcpTools(mcpManager) {
596
- if (!session?.toolRegistry || typeof session.toolRegistry.replaceMcpTools !== 'function') {
662
+ function replaceSessionMcpTools(owner, mcpManager) {
663
+ if (!isCurrentRuntimeOwner(owner)) return { removed: 0, added: 0, skipped: true };
664
+ const ownerSession = owner.ownerSession;
665
+ if (!ownerSession.toolRegistry || typeof ownerSession.toolRegistry.replaceMcpTools !== 'function') {
597
666
  return { removed: 0, added: 0, skipped: true };
598
667
  }
599
668
  try {
600
- const result = session.toolRegistry.replaceMcpTools(mcpManager, buildMcpFlattenedTools);
669
+ const result = ownerSession.toolRegistry.replaceMcpTools(mcpManager, buildMcpFlattenedTools);
601
670
  return { ...result, skipped: false };
602
671
  } catch (err) {
603
672
  console.warn('[Yeaft] hot-swap MCP tools failed:', err?.message || err);
@@ -605,9 +674,10 @@ function replaceSessionMcpTools(mcpManager) {
605
674
  }
606
675
  }
607
676
 
608
- function retargetVpEngines({ skillManager, mcpManager }) {
677
+ function retargetVpEngines(owner, { skillManager, mcpManager }) {
678
+ if (!isCurrentRuntimeOwner(owner)) return;
609
679
  try {
610
- session?.engine?.setRuntimeManagers?.({ skillManager, mcpManager });
680
+ owner.ownerSession.engine?.setRuntimeManagers?.({ skillManager, mcpManager });
611
681
  } catch { /* best-effort default-engine retarget */ }
612
682
  for (const eng of vpEngines.values()) {
613
683
  try {
@@ -616,48 +686,113 @@ function retargetVpEngines({ skillManager, mcpManager }) {
616
686
  }
617
687
  }
618
688
 
619
- function activateBaseRuntime() {
689
+ function reloadRuntimeSkillManager(owner, skillManager, status) {
690
+ if (!isCurrentRuntimeOwner(owner) || typeof skillManager?.load !== 'function') {
691
+ return { changed: false, loaded: 0, errors: [] };
692
+ }
693
+ let result;
694
+ try {
695
+ result = skillManager.load() || {};
696
+ } catch (err) {
697
+ result = { changed: false, loaded: 0, errors: [err?.message || String(err)] };
698
+ }
699
+ if (isCurrentRuntimeOwner(owner) && status) status.skills = skillManager.size || 0;
700
+ return {
701
+ changed: !!result.changed,
702
+ loaded: Number(result.loaded) || 0,
703
+ errors: result.errors || [],
704
+ };
705
+ }
706
+
707
+ function activateBaseRuntime(owner = captureRuntimeOwner(), { reloadSkills = true } = {}) {
708
+ if (!isCurrentRuntimeOwner(owner)) return { removed: 0, added: 0, skipped: true };
709
+ const ownerSession = owner.ownerSession;
710
+ const runtime = baseRuntime && runtimeBelongsToOwner(baseRuntime, owner) ? baseRuntime : null;
711
+ const skillManager = runtime?.skillManager || ownerSession.skillManager;
712
+ const mcpManager = runtime?.mcpManager || ownerSession.mcpManager;
713
+ const status = ownerSession.status || runtime?.status;
714
+ const switchingRuntime = activeRuntimeKey !== BASE_RUNTIME_KEY;
715
+ const reload = reloadSkills && switchingRuntime
716
+ ? reloadRuntimeSkillManager(owner, skillManager, status)
717
+ : { changed: false, loaded: 0, errors: [] };
718
+ if (!isCurrentRuntimeOwner(owner)) return { removed: 0, added: 0, skipped: true };
620
719
  activeRuntimeKey = BASE_RUNTIME_KEY;
621
- const swap = replaceSessionMcpTools(session?.mcpManager);
622
- retargetVpEngines({
623
- skillManager: session?.skillManager || null,
624
- mcpManager: session?.mcpManager || null,
625
- });
626
- const status = session?.status || null;
720
+ const swap = replaceSessionMcpTools(owner, mcpManager);
721
+ retargetVpEngines(owner, { skillManager: skillManager || null, mcpManager: mcpManager || null });
627
722
  if (status) {
628
- status.skills = session?.skillManager?.size || 0;
723
+ status.skills = skillManager?.size || 0;
629
724
  status.mcpServers = Array.isArray(status.mcpServers) ? status.mcpServers : [];
630
725
  status.mcpFailed = Array.isArray(status.mcpFailed) ? status.mcpFailed : [];
631
- status.tools = session?.toolRegistry?.size || status.tools || 0;
726
+ status.tools = ownerSession.toolRegistry?.size || status.tools || 0;
727
+ }
728
+ if (!isCurrentRuntimeOwner(owner)) return { removed: 0, added: 0, skipped: true };
729
+ broadcastSkillSlashCommands({ skillManager });
730
+ if (switchingRuntime || reload.changed) {
731
+ hydrateYeaftStatusFromSession({ ...ownerSession, status }, { reason: 'skills_runtime_activate', emitEvent: true });
632
732
  }
633
- broadcastSkillSlashCommands(session);
733
+ startSkillHotReload(owner);
634
734
  return swap;
635
735
  }
636
736
 
637
- function activateProjectRuntime(runtime) {
638
- if (!runtime) return activateBaseRuntime();
639
- activeRuntimeKey = projectRuntimeKey(runtime.workDir);
640
- const swap = replaceSessionMcpTools(runtime.mcpManager);
641
- retargetVpEngines({
737
+ function activateProjectRuntime(runtime, owner = captureRuntimeOwner(), { reloadSkills = true } = {}) {
738
+ if (!runtime) return activateBaseRuntime(owner, { reloadSkills });
739
+ if (!runtimeBelongsToOwner(runtime, owner)) return { removed: 0, added: 0, skipped: true };
740
+ const runtimeKey = projectRuntimeKey(runtime.workDir);
741
+ const switchingRuntime = activeRuntimeKey !== runtimeKey;
742
+ const reload = reloadSkills && switchingRuntime
743
+ ? reloadRuntimeSkillManager(owner, runtime.skillManager, runtime.status)
744
+ : { changed: false, loaded: 0, errors: [] };
745
+ if (!runtimeBelongsToOwner(runtime, owner)) return { removed: 0, added: 0, skipped: true };
746
+ activeRuntimeKey = runtimeKey;
747
+ const swap = replaceSessionMcpTools(owner, runtime.mcpManager);
748
+ retargetVpEngines(owner, {
642
749
  skillManager: runtime.skillManager,
643
750
  mcpManager: runtime.mcpManager,
644
751
  });
645
752
  runtime.status = {
646
753
  ...runtime.status,
647
- tools: session?.toolRegistry?.size || runtime.status?.tools || 0,
754
+ skills: runtime.skillManager?.size || 0,
755
+ tools: owner.ownerSession.toolRegistry?.size || runtime.status?.tools || 0,
648
756
  };
649
- broadcastSkillSlashCommands(session, [runtime.skillManager]);
757
+ if (!runtimeBelongsToOwner(runtime, owner)) return { removed: 0, added: 0, skipped: true };
758
+ // A project manager already contains bundled, user, and project tiers.
759
+ broadcastSkillSlashCommands({ skillManager: runtime.skillManager });
760
+ if (switchingRuntime || reload.changed) {
761
+ const status = mergedStatusForProjectRuntime(runtime, owner.ownerSession);
762
+ hydrateYeaftStatusFromSession({ ...owner.ownerSession, status }, { reason: 'skills_runtime_activate', emitEvent: true });
763
+ }
764
+ startSkillHotReload(owner);
650
765
  return swap;
651
766
  }
652
767
 
653
768
  async function shutdownProjectRuntimes() {
654
- const runtimes = Array.from(projectRuntimes.values());
769
+ // Invalidate before the first await so every old continuation is cleanup-only.
770
+ invalidateRuntimeOwnership();
771
+ stopSkillHotReload();
772
+ const runtimes = [baseRuntime, ...projectRuntimes.values()].filter(Boolean);
773
+ const loaderPromises = [
774
+ ...baseRuntimeLoadPromises.values(),
775
+ ...projectRuntimeLoadPromises.values(),
776
+ ];
777
+ for (const runtime of runtimes) {
778
+ if (runtime?.previousSkillManager && runtime.ownerSession?.skillManager === runtime.skillManager) {
779
+ runtime.ownerSession.skillManager = runtime.previousSkillManager;
780
+ }
781
+ if (runtime?.previousMcpManager && runtime.ownerSession?.mcpManager === runtime.mcpManager) {
782
+ runtime.ownerSession.mcpManager = runtime.previousMcpManager;
783
+ }
784
+ }
785
+ baseRuntime = null;
655
786
  projectRuntimes.clear();
656
787
  projectRuntimeLoadPromises.clear();
657
788
  baseRuntimeLoadPromises.clear();
658
- await Promise.all(runtimes.map(async (runtime) => {
659
- try { await runtime?.mcpManager?.disconnectAll?.(); } catch { /* best-effort shutdown */ }
660
- }));
789
+ activeRuntimeKey = BASE_RUNTIME_KEY;
790
+ const disconnects = runtimes
791
+ // A loading manager may acquire its first connection after an early
792
+ // disconnect; the stale loader performs the reliable post-connect cleanup.
793
+ .filter(runtime => !runtime?.loading)
794
+ .map(runtime => disconnectRuntimeMcpManager(runtime?.mcpManager));
795
+ await Promise.allSettled([...disconnects, ...loaderPromises]);
661
796
  }
662
797
 
663
798
  function getVpThreadMap(sessionId, vpId) {
@@ -936,7 +1071,6 @@ let yeaftConversationId = null;
936
1071
  * creates/replaces the virtual Yeaft conversation id so `/` autocomplete never
937
1072
  * falls back to built-ins while full Session metadata is still loading. */
938
1073
  let lastYeaftSlashCommandSnapshot = null;
939
- let lastYeaftGeneratedSlashCommands = new Set();
940
1074
  /** @type {Map<string, Promise<any>>} */
941
1075
  const projectRuntimeLoadPromises = new Map();
942
1076
 
@@ -1401,9 +1535,12 @@ export function __testResolveVpEffectiveConfig(sessionId) {
1401
1535
  * @param {{ conversationStore: object } | null} sessionLike
1402
1536
  */
1403
1537
  export function __testSetSession(sessionLike) {
1538
+ invalidateRuntimeOwnership();
1539
+ stopSkillHotReload();
1404
1540
  session = sessionLike;
1405
1541
  sessionLoadPromise = null;
1406
- if (!sessionLike) yeaftConversationId = null;
1542
+ if (sessionLike) claimRuntimeOwnership(sessionLike);
1543
+ else yeaftConversationId = null;
1407
1544
  }
1408
1545
 
1409
1546
  /**
@@ -2181,6 +2318,7 @@ export function buildMergedSkillSlashCommands(skillManagers = []) {
2181
2318
  function sendSkillSlashCommandsUpdate({ conversationId, slashCommands, slashCommandDescriptions }) {
2182
2319
  sendToServer({
2183
2320
  type: 'slash_commands_update',
2321
+ commandSet: 'yeaft',
2184
2322
  agentId: ctx.AGENT_ID || ctx.agentId || null,
2185
2323
  conversationId,
2186
2324
  slashCommands,
@@ -2221,21 +2359,10 @@ export function preloadYeaftSkillSlashCommands() {
2221
2359
 
2222
2360
  function broadcastSkillSlashCommands(sessionLike, extraSkillManagers = []) {
2223
2361
  const managers = [sessionLike?.skillManager, ...extraSkillManagers].filter(Boolean);
2224
- const { commands, descriptions } = buildMergedSkillSlashCommands(managers);
2225
- const isYeaftSkillCommand = (cmd) => typeof cmd === 'string'
2226
- && (lastYeaftGeneratedSlashCommands.has(cmd)
2227
- || cmd.startsWith(LEGACY_SKILL_COMMAND_PREFIX)
2228
- || cmd.startsWith(YEAFT_SKILL_COMMAND_PREFIX));
2229
- const nonSkillCommands = (ctx.slashCommands || []).filter(cmd => !isYeaftSkillCommand(cmd));
2230
- const slashCommands = [...new Set([...nonSkillCommands, ...commands])];
2231
- const slashCommandDescriptions = Object.fromEntries(
2232
- Object.entries(ctx.slashCommandDescriptions || {})
2233
- .filter(([cmd]) => !isYeaftSkillCommand(cmd))
2234
- );
2235
- Object.assign(slashCommandDescriptions, descriptions);
2236
- ctx.slashCommands = slashCommands;
2237
- ctx.slashCommandDescriptions = slashCommandDescriptions;
2238
- lastYeaftGeneratedSlashCommands = new Set(commands);
2362
+ const { commands: slashCommands, descriptions: slashCommandDescriptions } = buildMergedSkillSlashCommands(managers);
2363
+ // Yeaft owns an isolated command catalogue. Reusing ctx's Claude Chat
2364
+ // commands made unsupported entries such as /compact and /mcp appear in a
2365
+ // Session even though the Yeaft engine only parses effort and Skill prefixes.
2239
2366
  lastYeaftSlashCommandSnapshot = { slashCommands, slashCommandDescriptions };
2240
2367
  sendSkillSlashCommandsUpdate({
2241
2368
  conversationId: yeaftConversationId || '__preload__',
@@ -2244,177 +2371,332 @@ function broadcastSkillSlashCommands(sessionLike, extraSkillManagers = []) {
2244
2371
  });
2245
2372
  }
2246
2373
 
2247
- async function loadBaseRuntime() {
2248
- if (!session) return null;
2249
- const yeaftDir = ctx.CONFIG?.yeaftDir || session.yeaftDir || DEFAULT_YEAFT_DIR;
2250
- const skillManager = createSkillManager(yeaftDir, process.cwd());
2251
- session.skillManager = skillManager;
2374
+ function activeSkillRuntime(owner) {
2375
+ if (!isCurrentRuntimeOwner(owner)) return null;
2376
+ if (activeRuntimeKey === BASE_RUNTIME_KEY) {
2377
+ if (baseRuntime && runtimeBelongsToOwner(baseRuntime, owner)) return baseRuntime;
2378
+ return {
2379
+ generation: owner.generation,
2380
+ ownerSession: owner.ownerSession,
2381
+ skillManager: owner.ownerSession.skillManager,
2382
+ status: owner.ownerSession.status,
2383
+ };
2384
+ }
2385
+ const runtime = projectRuntimes.get(activeRuntimeKey) || null;
2386
+ return runtimeBelongsToOwner(runtime, owner) ? runtime : null;
2387
+ }
2252
2388
 
2253
- const mcpConfig = loadMCPConfig(yeaftDir, undefined, process.cwd());
2254
- const mcpManager = new MCPManager();
2255
- session.mcpManager = mcpManager;
2389
+ function reloadActiveSkills(owner = skillReloadOwner || captureRuntimeOwner()) {
2390
+ if (!isCurrentRuntimeOwner(owner)) return { changed: false, loaded: 0, errors: [] };
2391
+ if (skillReloadRunning) return { changed: false, loaded: 0, errors: [] };
2392
+ const runtime = activeSkillRuntime(owner);
2393
+ const manager = runtime?.skillManager;
2394
+ if (typeof manager?.load !== 'function') return { changed: false, loaded: 0, errors: [] };
2395
+
2396
+ skillReloadRunning = true;
2397
+ try {
2398
+ const result = manager.load() || {};
2399
+ const currentRuntime = activeSkillRuntime(owner);
2400
+ if (!isCurrentRuntimeOwner(owner) || currentRuntime?.skillManager !== manager) {
2401
+ return { changed: false, loaded: 0, errors: [] };
2402
+ }
2403
+ const changed = !!result.changed;
2404
+ const loaded = Number(result.loaded) || 0;
2405
+ const errors = result.errors || [];
2406
+ if (runtime.status) runtime.status.skills = manager.size || 0;
2407
+ if (activeRuntimeKey === BASE_RUNTIME_KEY && owner.ownerSession.status) {
2408
+ owner.ownerSession.status.skills = manager.size || 0;
2409
+ }
2410
+ if (changed) {
2411
+ broadcastSkillSlashCommands({ skillManager: manager });
2412
+ const activeStatus = activeRuntimeKey === BASE_RUNTIME_KEY
2413
+ ? runtime.status
2414
+ : mergedStatusForProjectRuntime(runtime, owner.ownerSession);
2415
+ hydrateYeaftStatusFromSession(
2416
+ activeStatus ? { ...owner.ownerSession, status: activeStatus } : owner.ownerSession,
2417
+ { reason: 'skills_hot_reload', emitEvent: true },
2418
+ );
2419
+ }
2420
+ if (errors.length > 0) {
2421
+ console.warn(`[Yeaft] skill hot reload completed with ${errors.length} error(s):`, errors.join('; '));
2422
+ }
2423
+ return { changed, loaded, errors };
2424
+ } finally {
2425
+ skillReloadRunning = false;
2426
+ }
2427
+ }
2428
+
2429
+ function startSkillHotReload(owner = captureRuntimeOwner()) {
2430
+ if (!isCurrentRuntimeOwner(owner)) return false;
2431
+ if (skillReloadTimer && skillReloadOwner
2432
+ && skillReloadOwner.generation === owner.generation
2433
+ && skillReloadOwner.ownerSession === owner.ownerSession) {
2434
+ return false;
2435
+ }
2436
+ stopSkillHotReload();
2437
+ skillReloadOwner = owner;
2438
+ const timer = setInterval(() => {
2439
+ if (!isCurrentRuntimeOwner(owner)) {
2440
+ if (skillReloadTimer === timer) stopSkillHotReload(owner);
2441
+ return;
2442
+ }
2443
+ try { reloadActiveSkills(owner); }
2444
+ catch (err) { console.warn('[Yeaft] skill hot reload failed:', err?.message || err); }
2445
+ }, SKILL_RELOAD_INTERVAL_MS);
2446
+ skillReloadTimer = timer;
2447
+ timer.unref?.();
2448
+ return true;
2449
+ }
2450
+
2451
+ function stopSkillHotReload(owner = null) {
2452
+ if (owner && skillReloadOwner
2453
+ && (skillReloadOwner.generation !== owner.generation
2454
+ || skillReloadOwner.ownerSession !== owner.ownerSession)) {
2455
+ return false;
2456
+ }
2457
+ if (skillReloadTimer) clearInterval(skillReloadTimer);
2458
+ skillReloadTimer = null;
2459
+ skillReloadOwner = null;
2460
+ skillReloadRunning = false;
2461
+ return true;
2462
+ }
2463
+
2464
+ async function loadBaseRuntime(owner = captureRuntimeOwner()) {
2465
+ if (!isCurrentRuntimeOwner(owner)) return null;
2466
+ const ownerSession = owner.ownerSession;
2467
+ const yeaftDir = ctx.CONFIG?.yeaftDir || ownerSession.yeaftDir || DEFAULT_YEAFT_DIR;
2468
+ const previousSkillManager = ownerSession.skillManager;
2469
+ const previousMcpManager = ownerSession.mcpManager;
2470
+ const skillManager = createRuntimeSkillManager(yeaftDir, process.cwd());
2471
+ const mcpConfig = loadRuntimeMcpConfig(yeaftDir, undefined, process.cwd());
2472
+ const mcpManager = createRuntimeMcpManager();
2256
2473
  let mcpStatus = { connected: [], failed: [] };
2474
+ const runtime = {
2475
+ generation: owner.generation,
2476
+ ownerSession,
2477
+ workDir: '',
2478
+ previousSkillManager,
2479
+ previousMcpManager,
2480
+ skillManager,
2481
+ mcpManager,
2482
+ mcpStatus,
2483
+ mcpConfig,
2484
+ loading: mcpConfig.servers.length > 0,
2485
+ status: {
2486
+ skills: skillManager.size,
2487
+ mcpServers: [],
2488
+ mcpFailed: [],
2489
+ mcpSkipped: mcpConfig.skipped || [],
2490
+ tools: ownerSession.toolRegistry?.size || 0,
2491
+ },
2492
+ };
2257
2493
 
2258
- if (session.status) {
2259
- session.status.skills = skillManager.size;
2260
- session.status.mcpServers = [];
2261
- session.status.mcpFailed = [];
2262
- session.status.mcpSkipped = mcpConfig.skipped || [];
2263
- session.status.tools = session.toolRegistry?.size || session.status.tools || 0;
2494
+ if (!isCurrentRuntimeOwner(owner)) {
2495
+ await disconnectRuntimeMcpManager(mcpManager);
2496
+ return null;
2264
2497
  }
2498
+ baseRuntime = runtime;
2499
+ ownerSession.skillManager = skillManager;
2500
+ ownerSession.mcpManager = mcpManager;
2501
+ ownerSession.status = { ...ownerSession.status, ...runtime.status };
2265
2502
  if (activeRuntimeKey === BASE_RUNTIME_KEY) {
2266
- activateBaseRuntime();
2267
- } else {
2268
- broadcastSkillSlashCommands(session);
2503
+ activateBaseRuntime(owner, { reloadSkills: false });
2504
+ hydrateYeaftStatusFromSession(ownerSession, { reason: 'base_runtime_skills', emitEvent: true });
2269
2505
  }
2270
- hydrateYeaftStatusFromSession(session, { reason: 'base_runtime_skills', emitEvent: true });
2271
2506
 
2272
2507
  if (mcpConfig.servers.length > 0) {
2273
- mcpStatus = await mcpManager.connectAll(mcpConfig.servers);
2274
- session.mcpManager = mcpManager;
2275
- if (session.status) {
2276
- session.status.mcpServers = mcpStatus.connected;
2277
- session.status.mcpFailed = mcpStatus.failed;
2278
- session.status.mcpSkipped = mcpConfig.skipped || [];
2508
+ try {
2509
+ mcpStatus = await mcpManager.connectAll(mcpConfig.servers);
2510
+ } catch (err) {
2511
+ runtime.loading = false;
2512
+ if (ownerSession.skillManager === skillManager) ownerSession.skillManager = previousSkillManager;
2513
+ if (ownerSession.mcpManager === mcpManager) ownerSession.mcpManager = previousMcpManager;
2514
+ await disconnectRuntimeMcpManager(mcpManager);
2515
+ if (baseRuntime === runtime) baseRuntime = null;
2516
+ throw err;
2517
+ }
2518
+ runtime.loading = false;
2519
+ if (!isCurrentRuntimeOwner(owner) || baseRuntime !== runtime) {
2520
+ if (ownerSession.skillManager === skillManager) ownerSession.skillManager = previousSkillManager;
2521
+ if (ownerSession.mcpManager === mcpManager) ownerSession.mcpManager = previousMcpManager;
2522
+ await disconnectRuntimeMcpManager(mcpManager);
2523
+ return null;
2279
2524
  }
2525
+ runtime.mcpStatus = mcpStatus;
2526
+ runtime.status = {
2527
+ ...runtime.status,
2528
+ mcpServers: mcpStatus.connected,
2529
+ mcpFailed: mcpStatus.failed,
2530
+ mcpSkipped: mcpConfig.skipped || [],
2531
+ tools: ownerSession.toolRegistry?.size || 0,
2532
+ };
2533
+ ownerSession.mcpManager = mcpManager;
2534
+ ownerSession.status = { ...ownerSession.status, ...runtime.status };
2280
2535
  if (activeRuntimeKey === BASE_RUNTIME_KEY) {
2281
- activateBaseRuntime();
2536
+ activateBaseRuntime(owner, { reloadSkills: false });
2537
+ hydrateYeaftStatusFromSession(ownerSession, { reason: 'base_runtime_mcp', emitEvent: true });
2538
+ if (isCurrentRuntimeOwner(owner)) {
2539
+ try { broadcastMcpUpdated({ reason: 'base-runtime-load' }); } catch { /* best-effort */ }
2540
+ }
2282
2541
  }
2283
- hydrateYeaftStatusFromSession(session, { reason: 'base_runtime_mcp', emitEvent: true });
2284
- try { broadcastMcpUpdated({ reason: 'base-runtime-load' }); } catch { /* best-effort */ }
2285
2542
  }
2286
2543
 
2287
- return { skillManager, mcpManager, mcpStatus, mcpConfig };
2544
+ return isCurrentRuntimeOwner(owner) && baseRuntime === runtime ? runtime : null;
2288
2545
  }
2289
2546
 
2290
2547
  function scheduleBaseRuntimeLoad() {
2291
- if (!session) return null;
2292
- if (baseRuntimeLoadPromises.has(BASE_RUNTIME_KEY)) return baseRuntimeLoadPromises.get(BASE_RUNTIME_KEY);
2293
- const promise = new Promise(resolve => setTimeout(resolve, 0))
2294
- .then(() => loadBaseRuntime())
2548
+ const owner = captureRuntimeOwner();
2549
+ if (!owner) return null;
2550
+ const current = baseRuntimeLoadPromises.get(BASE_RUNTIME_KEY);
2551
+ if (current && loaderBelongsToOwner(current, owner)) return current;
2552
+ let promise;
2553
+ promise = new Promise(resolve => setTimeout(resolve, 0))
2554
+ .then(() => loadBaseRuntime(owner))
2295
2555
  .catch((err) => {
2296
2556
  console.warn('[Yeaft] async base runtime load failed:', err?.message || err);
2297
2557
  return null;
2298
2558
  })
2299
- .finally(() => { baseRuntimeLoadPromises.delete(BASE_RUNTIME_KEY); });
2559
+ .finally(() => {
2560
+ if (owner.generation === runtimeGeneration
2561
+ && baseRuntimeLoadPromises.get(BASE_RUNTIME_KEY) === promise) {
2562
+ baseRuntimeLoadPromises.delete(BASE_RUNTIME_KEY);
2563
+ }
2564
+ });
2565
+ runtimeLoaderOwners.set(promise, owner);
2300
2566
  baseRuntimeLoadPromises.set(BASE_RUNTIME_KEY, promise);
2301
2567
  return promise;
2302
2568
  }
2303
2569
 
2304
- async function loadProjectRuntime(workDir) {
2305
- if (!session) return null;
2570
+ async function loadProjectRuntime(workDir, owner = captureRuntimeOwner()) {
2571
+ if (!isCurrentRuntimeOwner(owner)) return null;
2572
+ const ownerSession = owner.ownerSession;
2306
2573
  const normalizedWorkDir = normalizeSessionWorkDir(workDir);
2307
2574
  if (!normalizedWorkDir) {
2308
- activateBaseRuntime();
2575
+ activateBaseRuntime(owner);
2309
2576
  return null;
2310
2577
  }
2311
2578
  const key = projectRuntimeKey(normalizedWorkDir);
2312
2579
  const cached = projectRuntimes.get(key);
2313
- if (cached) {
2314
- activateProjectRuntime(cached);
2580
+ if (runtimeBelongsToOwner(cached, owner)) {
2581
+ activateProjectRuntime(cached, owner);
2315
2582
  return cached;
2316
2583
  }
2317
2584
 
2318
- const yeaftDir = ctx.CONFIG?.yeaftDir || session.yeaftDir || DEFAULT_YEAFT_DIR;
2319
- const skillRoots = normalizedWorkDir && normalizedWorkDir !== process.cwd()
2585
+ const yeaftDir = ctx.CONFIG?.yeaftDir || ownerSession.yeaftDir || DEFAULT_YEAFT_DIR;
2586
+ const skillRoots = normalizedWorkDir !== process.cwd()
2320
2587
  ? `${process.cwd()}${delimiter}${normalizedWorkDir}`
2321
2588
  : normalizedWorkDir;
2322
- const skillManager = createSkillManager(yeaftDir, skillRoots);
2323
- const mcpConfig = loadMCPConfig(yeaftDir, undefined, normalizedWorkDir);
2324
- const mcpManager = new MCPManager();
2589
+ const skillManager = createRuntimeSkillManager(yeaftDir, skillRoots);
2590
+ const mcpConfig = loadRuntimeMcpConfig(yeaftDir, undefined, normalizedWorkDir);
2591
+ const mcpManager = createRuntimeMcpManager();
2325
2592
  let mcpStatus = { connected: [], failed: [] };
2326
2593
  const runtime = {
2594
+ generation: owner.generation,
2595
+ ownerSession,
2327
2596
  workDir: normalizedWorkDir,
2328
2597
  skillManager,
2329
2598
  mcpManager,
2330
2599
  mcpStatus,
2331
2600
  mcpConfig,
2601
+ loading: mcpConfig.servers.length > 0,
2332
2602
  status: {
2333
2603
  skills: skillManager.size,
2334
- mcpServers: mcpStatus.connected,
2335
- mcpFailed: mcpStatus.failed,
2604
+ mcpServers: [],
2605
+ mcpFailed: [],
2336
2606
  mcpSkipped: mcpConfig.skipped || [],
2337
- tools: session.toolRegistry?.size || 0,
2607
+ tools: ownerSession.toolRegistry?.size || 0,
2338
2608
  },
2339
2609
  };
2610
+ if (!isCurrentRuntimeOwner(owner)) {
2611
+ await disconnectRuntimeMcpManager(mcpManager);
2612
+ return null;
2613
+ }
2340
2614
  projectRuntimes.set(key, runtime);
2341
- // Skill metadata is available after the filesystem scan; publish it before
2342
- // any MCP server startup so autocomplete does not wait on external processes.
2343
- activateProjectRuntime(runtime);
2615
+ // Skill metadata is ready before external MCP startup. Activation is still
2616
+ // owner-gated so reset cannot publish this runtime into a replacement session.
2617
+ activateProjectRuntime(runtime, owner, { reloadSkills: false });
2344
2618
  if (mcpConfig.servers.length > 0) {
2345
- mcpStatus = await mcpManager.connectAll(mcpConfig.servers);
2619
+ try {
2620
+ mcpStatus = await mcpManager.connectAll(mcpConfig.servers);
2621
+ } catch (err) {
2622
+ runtime.loading = false;
2623
+ await disconnectRuntimeMcpManager(mcpManager);
2624
+ if (projectRuntimes.get(key) === runtime) projectRuntimes.delete(key);
2625
+ throw err;
2626
+ }
2627
+ runtime.loading = false;
2628
+ if (!runtimeBelongsToOwner(runtime, owner) || projectRuntimes.get(key) !== runtime) {
2629
+ await disconnectRuntimeMcpManager(mcpManager);
2630
+ return null;
2631
+ }
2346
2632
  runtime.mcpStatus = mcpStatus;
2347
2633
  runtime.status = {
2348
2634
  ...runtime.status,
2349
2635
  mcpServers: mcpStatus.connected,
2350
2636
  mcpFailed: mcpStatus.failed,
2351
2637
  mcpSkipped: mcpConfig.skipped || [],
2352
- tools: session.toolRegistry?.size || 0,
2638
+ tools: ownerSession.toolRegistry?.size || 0,
2353
2639
  };
2354
- activateProjectRuntime(runtime);
2640
+ if (activeRuntimeKey === key) activateProjectRuntime(runtime, owner, { reloadSkills: false });
2355
2641
  }
2356
- return runtime;
2642
+ return runtimeBelongsToOwner(runtime, owner) && projectRuntimes.get(key) === runtime ? runtime : null;
2357
2643
  }
2358
2644
 
2359
-
2360
2645
  function scheduleProjectRuntimeLoad(workDir) {
2646
+ const owner = captureRuntimeOwner();
2361
2647
  const normalizedWorkDir = normalizeSessionWorkDir(workDir);
2362
- if (!normalizedWorkDir || !session) return null;
2648
+ if (!normalizedWorkDir || !owner) return null;
2363
2649
  const key = projectRuntimeKey(normalizedWorkDir);
2364
- if (projectRuntimes.has(key)) return projectRuntimes.get(key);
2365
- if (projectRuntimeLoadPromises.has(key)) return projectRuntimeLoadPromises.get(key);
2366
- const promise = loadProjectRuntime(normalizedWorkDir)
2650
+ const cached = projectRuntimes.get(key);
2651
+ if (runtimeBelongsToOwner(cached, owner)) return cached;
2652
+ const current = projectRuntimeLoadPromises.get(key);
2653
+ if (current && loaderBelongsToOwner(current, owner)) return current;
2654
+ let promise;
2655
+ promise = loadProjectRuntime(normalizedWorkDir, owner)
2367
2656
  .catch((err) => {
2368
2657
  console.warn('[Yeaft] async project runtime load failed for %s: %s', normalizedWorkDir, err?.message || err);
2369
2658
  return null;
2370
2659
  })
2371
- .finally(() => { projectRuntimeLoadPromises.delete(key); });
2660
+ .finally(() => {
2661
+ if (owner.generation === runtimeGeneration
2662
+ && projectRuntimeLoadPromises.get(key) === promise) {
2663
+ projectRuntimeLoadPromises.delete(key);
2664
+ }
2665
+ });
2666
+ runtimeLoaderOwners.set(promise, owner);
2372
2667
  projectRuntimeLoadPromises.set(key, promise);
2373
2668
  return promise;
2374
2669
  }
2375
2670
 
2376
- async function ensureProjectRuntimeForSessionMeta(sessionMeta) {
2377
- const workDir = normalizeSessionWorkDir(sessionMeta?.workDir);
2378
- if (!workDir) {
2379
- activateBaseRuntime();
2380
- return null;
2381
- }
2382
- try {
2383
- return await loadProjectRuntime(workDir);
2384
- } catch (err) {
2385
- console.warn('[Yeaft] project runtime load failed for %s: %s', workDir, err?.message || err);
2386
- activateBaseRuntime();
2387
- return null;
2388
- }
2389
- }
2390
-
2391
2671
  function getProjectRuntimeForTurn(sessionMeta) {
2672
+ const owner = captureRuntimeOwner();
2673
+ if (!owner) return null;
2392
2674
  const workDir = normalizeSessionWorkDir(sessionMeta?.workDir);
2393
2675
  if (!workDir) {
2394
- activateBaseRuntime();
2676
+ activateBaseRuntime(owner);
2395
2677
  return null;
2396
2678
  }
2397
2679
  const cached = projectRuntimes.get(projectRuntimeKey(workDir)) || null;
2398
- if (cached) {
2399
- activateProjectRuntime(cached);
2680
+ if (runtimeBelongsToOwner(cached, owner)) {
2681
+ activateProjectRuntime(cached, owner);
2400
2682
  return cached;
2401
2683
  }
2402
2684
  scheduleProjectRuntimeLoad(workDir);
2403
2685
  // Do not let a previous workDir's MCP tools leak into this turn while the
2404
2686
  // requested project runtime is still loading in the background.
2405
- activateBaseRuntime();
2687
+ activateBaseRuntime(owner);
2406
2688
  return null;
2407
2689
  }
2408
2690
 
2409
- function mergedStatusForProjectRuntime(runtime) {
2410
- if (!session?.status || !runtime?.status) return session?.status || { skills: 0, mcpServers: [], tools: 0 };
2691
+ function mergedStatusForProjectRuntime(runtime, ownerSession = session) {
2692
+ if (!ownerSession?.status || !runtime?.status) return ownerSession?.status || { skills: 0, mcpServers: [], tools: 0 };
2411
2693
  return {
2412
- ...session.status,
2413
- skills: Math.max(Number(session.status.skills) || 0, Number(runtime.status.skills) || 0),
2414
- mcpServers: [...new Set([...(session.status.mcpServers || []), ...(runtime.status.mcpServers || [])])],
2415
- mcpFailed: [...(session.status.mcpFailed || []), ...(runtime.status.mcpFailed || [])],
2416
- mcpSkipped: [...(session.status.mcpSkipped || []), ...(runtime.status.mcpSkipped || [])],
2417
- tools: Math.max(Number(session.status.tools) || 0, Number(runtime.status.tools) || 0),
2694
+ ...ownerSession.status,
2695
+ skills: Math.max(Number(ownerSession.status.skills) || 0, Number(runtime.status.skills) || 0),
2696
+ mcpServers: [...new Set([...(ownerSession.status.mcpServers || []), ...(runtime.status.mcpServers || [])])],
2697
+ mcpFailed: [...(ownerSession.status.mcpFailed || []), ...(runtime.status.mcpFailed || [])],
2698
+ mcpSkipped: [...(ownerSession.status.mcpSkipped || []), ...(runtime.status.mcpSkipped || [])],
2699
+ tools: Math.max(Number(ownerSession.status.tools) || 0, Number(runtime.status.tools) || 0),
2418
2700
  };
2419
2701
  }
2420
2702
 
@@ -4261,13 +4543,14 @@ export async function ensureSessionLoaded(opts = {}) {
4261
4543
  const bootConfigRevision = sessionConfigRefreshRevision;
4262
4544
  const yeaftDir = ctx.CONFIG?.yeaftDir;
4263
4545
  const normalizedWorkDir = normalizeSessionWorkDir(opts?.workDir || opts?.sessionMeta?.workDir);
4264
- session = await loadSession({
4546
+ session = await loadRuntimeSession({
4265
4547
  ...(yeaftDir && { dir: yeaftDir }),
4266
4548
  ...(normalizedWorkDir && { workDir: normalizedWorkDir }),
4267
4549
  skipMCP: true,
4268
4550
  skipSkills: true,
4269
4551
  serverMode: true,
4270
4552
  });
4553
+ claimRuntimeOwnership(session);
4271
4554
 
4272
4555
  installYeaftRuntimeBridge(session);
4273
4556
  // A save may complete after loadSession read config.json but before this
@@ -4302,13 +4585,15 @@ export async function ensureSessionLoaded(opts = {}) {
4302
4585
 
4303
4586
  ensureYeaftConversationId();
4304
4587
  scheduleBaseRuntimeLoad();
4305
- const bootProjectRuntime = normalizedWorkDir ? projectRuntimes.get(projectRuntimeKey(normalizedWorkDir)) || null : null;
4588
+ let bootProjectRuntime = normalizedWorkDir ? projectRuntimes.get(projectRuntimeKey(normalizedWorkDir)) || null : null;
4306
4589
  if (normalizedWorkDir && !bootProjectRuntime) {
4307
4590
  scheduleProjectRuntimeLoad(normalizedWorkDir);
4591
+ bootProjectRuntime = projectRuntimes.get(projectRuntimeKey(normalizedWorkDir)) || null;
4308
4592
  }
4309
4593
  const bootStatus = mergedStatusForProjectRuntime(bootProjectRuntime);
4310
4594
  hydrateYeaftStatusFromSession({ ...session, status: bootStatus }, { reason: 'session_ready', emitEvent: true });
4311
- broadcastSkillSlashCommands(session, bootProjectRuntime ? [bootProjectRuntime.skillManager] : []);
4595
+ if (bootProjectRuntime) broadcastSkillSlashCommands({ skillManager: bootProjectRuntime.skillManager });
4596
+ else broadcastSkillSlashCommands(session);
4312
4597
 
4313
4598
  // Per-group history is hydrated lazily on first `getOrCreateSessionHistory`
4314
4599
  // — there's no global "all conversations" tape any more.
@@ -4345,6 +4630,7 @@ export async function ensureSessionLoaded(opts = {}) {
4345
4630
  try {
4346
4631
  return await sessionLoadPromise;
4347
4632
  } catch (err) {
4633
+ await shutdownProjectRuntimes();
4348
4634
  session = null;
4349
4635
  throw err;
4350
4636
  } finally {
@@ -6383,7 +6669,10 @@ export async function handleYeaftLoadMoreHistory(msg) {
6383
6669
  * session, then re-initialises so the frontend gets fresh config.
6384
6670
  */
6385
6671
  export async function resetYeaftSession() {
6386
- await shutdownProjectRuntimes();
6672
+ // Runtime ownership must be revoked before reset reaches its first await.
6673
+ const oldSession = session;
6674
+ const oldRuntimesShutdown = shutdownProjectRuntimes();
6675
+ await oldRuntimesShutdown;
6387
6676
  if (currentAbortCtrl && !currentAbortCtrl.signal.aborted) {
6388
6677
  try { currentAbortCtrl.abort(); } catch { /* ignore */ }
6389
6678
  }
@@ -6392,9 +6681,9 @@ export async function resetYeaftSession() {
6392
6681
  try { _vpUnsubscribe(); } catch { /* ignore */ }
6393
6682
  _vpUnsubscribe = null;
6394
6683
  }
6395
- if (session) {
6396
- await session.shutdown();
6397
- session = null;
6684
+ if (oldSession) {
6685
+ await oldSession.shutdown();
6686
+ if (session === oldSession) session = null;
6398
6687
  }
6399
6688
  yeaftConversationId = null;
6400
6689
  // Per-group histories live on sessionContexts entries — clearing the
@@ -6438,12 +6727,13 @@ export async function resetYeaftSession() {
6438
6727
 
6439
6728
  try {
6440
6729
  const yeaftDir = ctx.CONFIG?.yeaftDir;
6441
- session = await loadSession({
6730
+ session = await loadRuntimeSession({
6442
6731
  ...(yeaftDir && { dir: yeaftDir }),
6443
6732
  skipMCP: true,
6444
6733
  skipSkills: true,
6445
6734
  serverMode: true,
6446
6735
  });
6736
+ claimRuntimeOwnership(session);
6447
6737
  installYeaftRuntimeBridge(session);
6448
6738
 
6449
6739
  yeaftConversationId = `yeaft-${Date.now()}`;
@@ -6530,7 +6820,7 @@ function mcpRuntimeSnapshot() {
6530
6820
  * pick up the change.
6531
6821
  */
6532
6822
  function hotSwapMcpTools() {
6533
- return replaceSessionMcpTools(session?.mcpManager);
6823
+ return replaceSessionMcpTools(captureRuntimeOwner(), session?.mcpManager);
6534
6824
  }
6535
6825
 
6536
6826
  /**
@@ -6589,7 +6879,7 @@ export async function handleYeaftMcpAdd(msg = {}) {
6589
6879
  }
6590
6880
  }
6591
6881
 
6592
- const swap = replaceSessionMcpTools(session?.mcpManager);
6882
+ const swap = replaceSessionMcpTools(captureRuntimeOwner(), session?.mcpManager);
6593
6883
 
6594
6884
  sendToServer({
6595
6885
  type: 'yeaft_mcp_add_result',
@@ -6626,7 +6916,7 @@ export async function handleYeaftMcpRemove(msg = {}) {
6626
6916
  }
6627
6917
  }
6628
6918
 
6629
- const swap = replaceSessionMcpTools(session?.mcpManager);
6919
+ const swap = replaceSessionMcpTools(captureRuntimeOwner(), session?.mcpManager);
6630
6920
 
6631
6921
  sendToServer({
6632
6922
  type: 'yeaft_mcp_remove_result',
@@ -6684,7 +6974,7 @@ export async function handleYeaftMcpReload(msg = {}) {
6684
6974
  console.warn('[Yeaft] MCP reload failed:', err?.message || err);
6685
6975
  }
6686
6976
 
6687
- const swap = replaceSessionMcpTools(session?.mcpManager);
6977
+ const swap = replaceSessionMcpTools(captureRuntimeOwner(), session?.mcpManager);
6688
6978
 
6689
6979
  sendToServer({
6690
6980
  type: 'yeaft_mcp_reload_result',
@@ -6707,8 +6997,56 @@ export const __testHooks = {
6707
6997
  return runYeaftSessionSend(msg);
6708
6998
  },
6709
6999
  setSessionForTest(nextSession) {
6710
- session = nextSession || null;
6711
- sessionLoadPromise = null;
7000
+ __testSetSession(nextSession || null);
7001
+ },
7002
+ setRuntimeFactoriesForTest({
7003
+ createSkillManager: nextCreateSkillManager,
7004
+ createMcpManager: nextCreateMcpManager,
7005
+ loadMcpConfig: nextLoadMcpConfig,
7006
+ loadSession: nextLoadSession,
7007
+ } = {}) {
7008
+ if (typeof nextCreateSkillManager === 'function') createRuntimeSkillManager = nextCreateSkillManager;
7009
+ if (typeof nextCreateMcpManager === 'function') createRuntimeMcpManager = nextCreateMcpManager;
7010
+ if (typeof nextLoadMcpConfig === 'function') loadRuntimeMcpConfig = nextLoadMcpConfig;
7011
+ if (typeof nextLoadSession === 'function') loadRuntimeSession = nextLoadSession;
7012
+ },
7013
+ resetRuntimeFactoriesForTest() {
7014
+ createRuntimeSkillManager = createSkillManager;
7015
+ createRuntimeMcpManager = () => new MCPManager();
7016
+ loadRuntimeMcpConfig = loadMCPConfig;
7017
+ loadRuntimeSession = loadSession;
7018
+ },
7019
+ scheduleBaseRuntimeLoadForTest() {
7020
+ return scheduleBaseRuntimeLoad();
7021
+ },
7022
+ scheduleProjectRuntimeLoadForTest(workDir) {
7023
+ return scheduleProjectRuntimeLoad(workDir);
7024
+ },
7025
+ activateBaseRuntimeForTest() {
7026
+ return activateBaseRuntime();
7027
+ },
7028
+ activateProjectRuntimeForTest(workDir) {
7029
+ const runtime = projectRuntimes.get(projectRuntimeKey(workDir)) || null;
7030
+ return activateProjectRuntime(runtime);
7031
+ },
7032
+ startSkillHotReloadForTest() {
7033
+ return startSkillHotReload();
7034
+ },
7035
+ runtimeLifecycleSnapshotForTest(workDir = '') {
7036
+ const key = workDir ? projectRuntimeKey(workDir) : null;
7037
+ const owner = captureRuntimeOwner();
7038
+ return {
7039
+ generation: runtimeGeneration,
7040
+ ownerSession: runtimeOwnerSession,
7041
+ activeRuntimeKey,
7042
+ timerActive: !!skillReloadTimer,
7043
+ timerOwnerGeneration: skillReloadOwner?.generation ?? null,
7044
+ timerOwnerSession: skillReloadOwner?.ownerSession || null,
7045
+ basePromise: baseRuntimeLoadPromises.get(BASE_RUNTIME_KEY) || null,
7046
+ projectPromise: key ? projectRuntimeLoadPromises.get(key) || null : null,
7047
+ projectRuntime: key ? projectRuntimes.get(key) || null : null,
7048
+ activeSkillManager: activeSkillRuntime(owner)?.skillManager || null,
7049
+ };
6712
7050
  },
6713
7051
  ensureYeaftConversationIdForTest() {
6714
7052
  return ensureYeaftConversationId();
@@ -6716,6 +7054,9 @@ export const __testHooks = {
6716
7054
  preloadYeaftSkillSlashCommandsForTest() {
6717
7055
  return broadcastSkillSlashCommands(session);
6718
7056
  },
7057
+ reloadActiveSkillsForTest() {
7058
+ return reloadActiveSkills();
7059
+ },
6719
7060
  loadAndBroadcastYeaftSkillSlashCommandsForTest() {
6720
7061
  return loadAndBroadcastYeaftSkillSlashCommands();
6721
7062
  },
@@ -6779,7 +7120,10 @@ export const __testHooks = {
6779
7120
  },
6780
7121
  seedProjectRuntime(workDir, runtime) {
6781
7122
  const normalizedWorkDir = normalizeSessionWorkDir(workDir);
7123
+ const owner = captureRuntimeOwner();
6782
7124
  const seeded = {
7125
+ generation: owner?.generation ?? runtimeGeneration,
7126
+ ownerSession: owner?.ownerSession || session,
6783
7127
  workDir: normalizedWorkDir,
6784
7128
  skillManager: runtime?.skillManager || { list: () => [] },
6785
7129
  mcpManager: runtime?.mcpManager || { listTools: () => [], disconnectAll: async () => {} },