@yeaft/webchat-agent 1.0.197 → 1.0.199

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.
@@ -61,7 +61,7 @@ import {
61
61
  trimSnapshotForBudget,
62
62
  } from './history-compact.js';
63
63
  import { persistYeaftAttachments, attachmentsForPersistence, persistedAttachmentPreviewPayload } from './attachments.js';
64
- import { ConversationStore, parseSeqFromId } from './conversation/persist.js';
64
+ import { ConversationStore, parseSeqFromId, projectVisibleSessionMessages } from './conversation/persist.js';
65
65
  import { isHiddenConversationRow } from './conversation/internal-control.js';
66
66
  import { imageMetadataForPersistence } from './image-assets.js';
67
67
  import { sliceLastNTurns } from './turn-utils.js';
@@ -94,6 +94,7 @@ const SKILL_RELOAD_INTERVAL_MS = 2_000;
94
94
  * vpId:string,
95
95
  * threadId:string,
96
96
  * turnId:string,
97
+ * toolCallId:string,
97
98
  * question:string,
98
99
  * options:Array<string>,
99
100
  * createdAt:number,
@@ -254,7 +255,7 @@ function scheduleYeaftLoadHistoryMetadataReplay(sessionId) {
254
255
  const replayConversationId = yeaftConversationId;
255
256
  setTimeout(async () => {
256
257
  try {
257
- if (!replaySession) return;
258
+ if (!replaySession || session !== replaySession) return;
258
259
  try {
259
260
  await refreshLiveSessionConfig();
260
261
  } catch (err) {
@@ -265,10 +266,19 @@ function scheduleYeaftLoadHistoryMetadataReplay(sessionId) {
265
266
  try {
266
267
  const metaRoot = ctx.CONFIG?.yeaftDir || DEFAULT_YEAFT_DIR;
267
268
  const meta = loadSessionMeta(join(sessionsRoot(metaRoot), sessionId));
268
- projectRuntime = await ensureProjectRuntimeForSessionMeta(meta);
269
+ const workDir = normalizeSessionWorkDir(meta?.workDir);
270
+ if (workDir) {
271
+ const scheduled = scheduleProjectRuntimeLoad(workDir);
272
+ projectRuntime = scheduled && typeof scheduled.then === 'function'
273
+ ? await scheduled
274
+ : scheduled;
275
+ } else {
276
+ activateBaseRuntime(captureRuntimeOwner(replaySession));
277
+ }
269
278
  } catch { /* best-effort project metadata */ }
270
279
  }
271
- const status = mergedStatusForProjectRuntime(projectRuntime);
280
+ if (session !== replaySession) return;
281
+ const status = mergedStatusForProjectRuntime(projectRuntime, replaySession);
272
282
  hydrateYeaftStatusFromSession({ ...replaySession, status }, { reason: 'history_load', emitEvent: true });
273
283
  sendSessionEvent({
274
284
  type: 'session_ready',
@@ -591,16 +601,73 @@ const routePromisesByMsgId = new Map();
591
601
  const projectRuntimes = new Map();
592
602
  /** @type {Map<string, Promise<any>>} */
593
603
  const baseRuntimeLoadPromises = new Map();
604
+ let baseRuntime = null;
594
605
  let activeRuntimeKey = BASE_RUNTIME_KEY;
595
606
  let skillReloadTimer = null;
596
607
  let skillReloadRunning = false;
608
+ let skillReloadOwner = null;
609
+ let runtimeGeneration = 0;
610
+ /** @type {import('./session.js').Session | null} */
611
+ let runtimeOwnerSession = null;
612
+ const disconnectedRuntimeMcpManagers = new WeakSet();
613
+
614
+ let createRuntimeSkillManager = createSkillManager;
615
+ let createRuntimeMcpManager = () => new MCPManager();
616
+ let loadRuntimeMcpConfig = loadMCPConfig;
617
+ let loadRuntimeSession = loadSession;
618
+ const runtimeLoaderOwners = new WeakMap();
619
+
620
+ function loaderBelongsToOwner(promise, owner) {
621
+ const tracked = promise ? runtimeLoaderOwners.get(promise) : null;
622
+ return !!tracked
623
+ && tracked.generation === owner?.generation
624
+ && tracked.ownerSession === owner?.ownerSession;
625
+ }
626
+
627
+ function claimRuntimeOwnership(ownerSession) {
628
+ if (!ownerSession) return null;
629
+ runtimeOwnerSession = ownerSession;
630
+ return { generation: runtimeGeneration, ownerSession };
631
+ }
632
+
633
+ function captureRuntimeOwner(ownerSession = session) {
634
+ if (!ownerSession || ownerSession !== session || ownerSession !== runtimeOwnerSession) return null;
635
+ return { generation: runtimeGeneration, ownerSession };
636
+ }
637
+
638
+ function isCurrentRuntimeOwner(owner) {
639
+ return !!owner
640
+ && owner.generation === runtimeGeneration
641
+ && owner.ownerSession === runtimeOwnerSession
642
+ && owner.ownerSession === session;
643
+ }
644
+
645
+ function invalidateRuntimeOwnership() {
646
+ runtimeGeneration += 1;
647
+ runtimeOwnerSession = null;
648
+ }
649
+
650
+ function runtimeBelongsToOwner(runtime, owner) {
651
+ return isCurrentRuntimeOwner(owner)
652
+ && runtime?.generation === owner.generation
653
+ && runtime?.ownerSession === owner.ownerSession;
654
+ }
655
+
656
+ async function disconnectRuntimeMcpManager(mcpManager) {
657
+ if (!mcpManager || typeof mcpManager.disconnectAll !== 'function') return;
658
+ if (disconnectedRuntimeMcpManagers.has(mcpManager)) return;
659
+ disconnectedRuntimeMcpManagers.add(mcpManager);
660
+ try { await mcpManager.disconnectAll(); } catch { /* best-effort shutdown */ }
661
+ }
597
662
 
598
- function replaceSessionMcpTools(mcpManager) {
599
- if (!session?.toolRegistry || typeof session.toolRegistry.replaceMcpTools !== 'function') {
663
+ function replaceSessionMcpTools(owner, mcpManager) {
664
+ if (!isCurrentRuntimeOwner(owner)) return { removed: 0, added: 0, skipped: true };
665
+ const ownerSession = owner.ownerSession;
666
+ if (!ownerSession.toolRegistry || typeof ownerSession.toolRegistry.replaceMcpTools !== 'function') {
600
667
  return { removed: 0, added: 0, skipped: true };
601
668
  }
602
669
  try {
603
- const result = session.toolRegistry.replaceMcpTools(mcpManager, buildMcpFlattenedTools);
670
+ const result = ownerSession.toolRegistry.replaceMcpTools(mcpManager, buildMcpFlattenedTools);
604
671
  return { ...result, skipped: false };
605
672
  } catch (err) {
606
673
  console.warn('[Yeaft] hot-swap MCP tools failed:', err?.message || err);
@@ -608,9 +675,10 @@ function replaceSessionMcpTools(mcpManager) {
608
675
  }
609
676
  }
610
677
 
611
- function retargetVpEngines({ skillManager, mcpManager }) {
678
+ function retargetVpEngines(owner, { skillManager, mcpManager }) {
679
+ if (!isCurrentRuntimeOwner(owner)) return;
612
680
  try {
613
- session?.engine?.setRuntimeManagers?.({ skillManager, mcpManager });
681
+ owner.ownerSession.engine?.setRuntimeManagers?.({ skillManager, mcpManager });
614
682
  } catch { /* best-effort default-engine retarget */ }
615
683
  for (const eng of vpEngines.values()) {
616
684
  try {
@@ -619,49 +687,113 @@ function retargetVpEngines({ skillManager, mcpManager }) {
619
687
  }
620
688
  }
621
689
 
622
- function activateBaseRuntime() {
690
+ function reloadRuntimeSkillManager(owner, skillManager, status) {
691
+ if (!isCurrentRuntimeOwner(owner) || typeof skillManager?.load !== 'function') {
692
+ return { changed: false, loaded: 0, errors: [] };
693
+ }
694
+ let result;
695
+ try {
696
+ result = skillManager.load() || {};
697
+ } catch (err) {
698
+ result = { changed: false, loaded: 0, errors: [err?.message || String(err)] };
699
+ }
700
+ if (isCurrentRuntimeOwner(owner) && status) status.skills = skillManager.size || 0;
701
+ return {
702
+ changed: !!result.changed,
703
+ loaded: Number(result.loaded) || 0,
704
+ errors: result.errors || [],
705
+ };
706
+ }
707
+
708
+ function activateBaseRuntime(owner = captureRuntimeOwner(), { reloadSkills = true } = {}) {
709
+ if (!isCurrentRuntimeOwner(owner)) return { removed: 0, added: 0, skipped: true };
710
+ const ownerSession = owner.ownerSession;
711
+ const runtime = baseRuntime && runtimeBelongsToOwner(baseRuntime, owner) ? baseRuntime : null;
712
+ const skillManager = runtime?.skillManager || ownerSession.skillManager;
713
+ const mcpManager = runtime?.mcpManager || ownerSession.mcpManager;
714
+ const status = ownerSession.status || runtime?.status;
715
+ const switchingRuntime = activeRuntimeKey !== BASE_RUNTIME_KEY;
716
+ const reload = reloadSkills && switchingRuntime
717
+ ? reloadRuntimeSkillManager(owner, skillManager, status)
718
+ : { changed: false, loaded: 0, errors: [] };
719
+ if (!isCurrentRuntimeOwner(owner)) return { removed: 0, added: 0, skipped: true };
623
720
  activeRuntimeKey = BASE_RUNTIME_KEY;
624
- const swap = replaceSessionMcpTools(session?.mcpManager);
625
- retargetVpEngines({
626
- skillManager: session?.skillManager || null,
627
- mcpManager: session?.mcpManager || null,
628
- });
629
- const status = session?.status || null;
721
+ const swap = replaceSessionMcpTools(owner, mcpManager);
722
+ retargetVpEngines(owner, { skillManager: skillManager || null, mcpManager: mcpManager || null });
630
723
  if (status) {
631
- status.skills = session?.skillManager?.size || 0;
724
+ status.skills = skillManager?.size || 0;
632
725
  status.mcpServers = Array.isArray(status.mcpServers) ? status.mcpServers : [];
633
726
  status.mcpFailed = Array.isArray(status.mcpFailed) ? status.mcpFailed : [];
634
- status.tools = session?.toolRegistry?.size || status.tools || 0;
727
+ status.tools = ownerSession.toolRegistry?.size || status.tools || 0;
728
+ }
729
+ if (!isCurrentRuntimeOwner(owner)) return { removed: 0, added: 0, skipped: true };
730
+ broadcastSkillSlashCommands({ skillManager });
731
+ if (switchingRuntime || reload.changed) {
732
+ hydrateYeaftStatusFromSession({ ...ownerSession, status }, { reason: 'skills_runtime_activate', emitEvent: true });
635
733
  }
636
- broadcastSkillSlashCommands(session);
734
+ startSkillHotReload(owner);
637
735
  return swap;
638
736
  }
639
737
 
640
- function activateProjectRuntime(runtime) {
641
- if (!runtime) return activateBaseRuntime();
642
- activeRuntimeKey = projectRuntimeKey(runtime.workDir);
643
- const swap = replaceSessionMcpTools(runtime.mcpManager);
644
- retargetVpEngines({
738
+ function activateProjectRuntime(runtime, owner = captureRuntimeOwner(), { reloadSkills = true } = {}) {
739
+ if (!runtime) return activateBaseRuntime(owner, { reloadSkills });
740
+ if (!runtimeBelongsToOwner(runtime, owner)) return { removed: 0, added: 0, skipped: true };
741
+ const runtimeKey = projectRuntimeKey(runtime.workDir);
742
+ const switchingRuntime = activeRuntimeKey !== runtimeKey;
743
+ const reload = reloadSkills && switchingRuntime
744
+ ? reloadRuntimeSkillManager(owner, runtime.skillManager, runtime.status)
745
+ : { changed: false, loaded: 0, errors: [] };
746
+ if (!runtimeBelongsToOwner(runtime, owner)) return { removed: 0, added: 0, skipped: true };
747
+ activeRuntimeKey = runtimeKey;
748
+ const swap = replaceSessionMcpTools(owner, runtime.mcpManager);
749
+ retargetVpEngines(owner, {
645
750
  skillManager: runtime.skillManager,
646
751
  mcpManager: runtime.mcpManager,
647
752
  });
648
753
  runtime.status = {
649
754
  ...runtime.status,
650
- tools: session?.toolRegistry?.size || runtime.status?.tools || 0,
755
+ skills: runtime.skillManager?.size || 0,
756
+ tools: owner.ownerSession.toolRegistry?.size || runtime.status?.tools || 0,
651
757
  };
652
- broadcastSkillSlashCommands(session, [runtime.skillManager]);
758
+ if (!runtimeBelongsToOwner(runtime, owner)) return { removed: 0, added: 0, skipped: true };
759
+ // A project manager already contains bundled, user, and project tiers.
760
+ broadcastSkillSlashCommands({ skillManager: runtime.skillManager });
761
+ if (switchingRuntime || reload.changed) {
762
+ const status = mergedStatusForProjectRuntime(runtime, owner.ownerSession);
763
+ hydrateYeaftStatusFromSession({ ...owner.ownerSession, status }, { reason: 'skills_runtime_activate', emitEvent: true });
764
+ }
765
+ startSkillHotReload(owner);
653
766
  return swap;
654
767
  }
655
768
 
656
769
  async function shutdownProjectRuntimes() {
770
+ // Invalidate before the first await so every old continuation is cleanup-only.
771
+ invalidateRuntimeOwnership();
657
772
  stopSkillHotReload();
658
- const runtimes = Array.from(projectRuntimes.values());
773
+ const runtimes = [baseRuntime, ...projectRuntimes.values()].filter(Boolean);
774
+ const loaderPromises = [
775
+ ...baseRuntimeLoadPromises.values(),
776
+ ...projectRuntimeLoadPromises.values(),
777
+ ];
778
+ for (const runtime of runtimes) {
779
+ if (runtime?.previousSkillManager && runtime.ownerSession?.skillManager === runtime.skillManager) {
780
+ runtime.ownerSession.skillManager = runtime.previousSkillManager;
781
+ }
782
+ if (runtime?.previousMcpManager && runtime.ownerSession?.mcpManager === runtime.mcpManager) {
783
+ runtime.ownerSession.mcpManager = runtime.previousMcpManager;
784
+ }
785
+ }
786
+ baseRuntime = null;
659
787
  projectRuntimes.clear();
660
788
  projectRuntimeLoadPromises.clear();
661
789
  baseRuntimeLoadPromises.clear();
662
- await Promise.all(runtimes.map(async (runtime) => {
663
- try { await runtime?.mcpManager?.disconnectAll?.(); } catch { /* best-effort shutdown */ }
664
- }));
790
+ activeRuntimeKey = BASE_RUNTIME_KEY;
791
+ const disconnects = runtimes
792
+ // A loading manager may acquire its first connection after an early
793
+ // disconnect; the stale loader performs the reliable post-connect cleanup.
794
+ .filter(runtime => !runtime?.loading)
795
+ .map(runtime => disconnectRuntimeMcpManager(runtime?.mcpManager));
796
+ await Promise.allSettled([...disconnects, ...loaderPromises]);
665
797
  }
666
798
 
667
799
  function getVpThreadMap(sessionId, vpId) {
@@ -1057,6 +1189,9 @@ function projectPersistedToHistoryEntry(m) {
1057
1189
  if (m.clientMessageId) entry.clientMessageId = m.clientMessageId;
1058
1190
  if (m.speakerVpId) entry.speakerVpId = m.speakerVpId;
1059
1191
  if (m.toolCallId) entry.toolCallId = m.toolCallId;
1192
+ if (Array.isArray(m.askUserResults) && m.askUserResults.length > 0) {
1193
+ entry.askUserResults = m.askUserResults;
1194
+ }
1060
1195
  if (Array.isArray(m.toolCalls) && m.toolCalls.length > 0) {
1061
1196
  entry.toolCalls = m.toolCalls.map(tc => ({
1062
1197
  id: tc.id,
@@ -1072,7 +1207,7 @@ function projectPersistedToHistoryEntry(m) {
1072
1207
  else if (m.time) entry.ts = m.time;
1073
1208
  if (Array.isArray(m.images) && m.images.length > 0) entry.images = m.images;
1074
1209
  if (Array.isArray(m.attachments) && m.attachments.length > 0) entry.attachments = m.attachments;
1075
- if ((entry.role === 'user' || entry.role === 'assistant') && !entry.content && !entry.attachments && !entry.images && !entry.toolCalls && !entry.toolSummaryCount) return null;
1210
+ if ((entry.role === 'user' || entry.role === 'assistant') && !entry.content && !entry.attachments && !entry.images && !entry.toolCalls && !entry.toolSummaryCount && !entry.askUserResults) return null;
1076
1211
  return entry;
1077
1212
  }
1078
1213
 
@@ -1122,7 +1257,7 @@ function loadVisibleGroupHistoryPage(store, sessionId, limit, beforeSeq = null)
1122
1257
  return { messages: [], oldestSeq: null, hasMore: false };
1123
1258
  }
1124
1259
 
1125
- const visible = rows
1260
+ const visible = projectVisibleSessionMessages(rows)
1126
1261
  .map(projectPersistedToVisibleHistoryEntry)
1127
1262
  .filter(Boolean);
1128
1263
  const messages = sliceLastNTurns(visible, limit);
@@ -1149,7 +1284,7 @@ function ensureYeaftConversationId() {
1149
1284
  }
1150
1285
 
1151
1286
  function projectVisibleHistoryChunkMessages(messages = []) {
1152
- return (messages || [])
1287
+ return projectVisibleSessionMessages(messages)
1153
1288
  .map(projectPersistedToVisibleHistoryEntry)
1154
1289
  .filter(Boolean)
1155
1290
  .map(m => ({
@@ -1165,6 +1300,7 @@ function projectVisibleHistoryChunkMessages(messages = []) {
1165
1300
  ...(Array.isArray(m.attachments) && m.attachments.length > 0 ? { attachments: hydrateHistoryAttachmentPreviews(m.attachments) } : {}),
1166
1301
  ...(Array.isArray(m.images) && m.images.length > 0 ? { images: m.images } : {}),
1167
1302
  ...(m.speakerVpId ? { speakerVpId: m.speakerVpId } : {}),
1303
+ ...(Array.isArray(m.askUserResults) && m.askUserResults.length > 0 ? { askUserResults: m.askUserResults } : {}),
1168
1304
  ...(Number.isFinite(m.toolSummaryCount) && m.toolSummaryCount > 0
1169
1305
  ? { toolSummaryCount: m.toolSummaryCount }
1170
1306
  : (Array.isArray(m.toolCalls) && m.toolCalls.length > 0 ? { toolSummaryCount: m.toolCalls.length } : {})),
@@ -1173,9 +1309,9 @@ function projectVisibleHistoryChunkMessages(messages = []) {
1173
1309
 
1174
1310
  function emitHistoryChunk({ sessionId, messages, mode = 'older', oldestSeq = null, hasMore = false, latestSeq = null, afterSeq = null, turns = null, perfTraceId = null }) {
1175
1311
  const projectedMessages = projectVisibleHistoryChunkMessages(messages);
1176
- if (mode === 'delta' && projectedMessages.length === 0) {
1177
- return projectedMessages;
1178
- }
1312
+ // Empty deltas still carry the authoritative safe cursor and clear the
1313
+ // browser's syncingAfterSeq fence. Dropping this envelope leaves Session
1314
+ // switching stuck after hidden-only or pair-unsafe rows.
1179
1315
  sendToServer({
1180
1316
  type: 'yeaft_history_chunk',
1181
1317
  conversationId: yeaftConversationId,
@@ -1404,9 +1540,12 @@ export function __testResolveVpEffectiveConfig(sessionId) {
1404
1540
  * @param {{ conversationStore: object } | null} sessionLike
1405
1541
  */
1406
1542
  export function __testSetSession(sessionLike) {
1543
+ invalidateRuntimeOwnership();
1544
+ stopSkillHotReload();
1407
1545
  session = sessionLike;
1408
1546
  sessionLoadPromise = null;
1409
- if (!sessionLike) yeaftConversationId = null;
1547
+ if (sessionLike) claimRuntimeOwnership(sessionLike);
1548
+ else yeaftConversationId = null;
1410
1549
  }
1411
1550
 
1412
1551
  /**
@@ -2237,53 +2376,49 @@ function broadcastSkillSlashCommands(sessionLike, extraSkillManagers = []) {
2237
2376
  });
2238
2377
  }
2239
2378
 
2240
- function skillManagersForHotReload() {
2241
- const managers = new Set();
2242
- if (session?.skillManager) managers.add(session.skillManager);
2243
- for (const runtime of projectRuntimes.values()) {
2244
- if (runtime?.skillManager) managers.add(runtime.skillManager);
2379
+ function activeSkillRuntime(owner) {
2380
+ if (!isCurrentRuntimeOwner(owner)) return null;
2381
+ if (activeRuntimeKey === BASE_RUNTIME_KEY) {
2382
+ if (baseRuntime && runtimeBelongsToOwner(baseRuntime, owner)) return baseRuntime;
2383
+ return {
2384
+ generation: owner.generation,
2385
+ ownerSession: owner.ownerSession,
2386
+ skillManager: owner.ownerSession.skillManager,
2387
+ status: owner.ownerSession.status,
2388
+ };
2245
2389
  }
2246
- return [...managers];
2390
+ const runtime = projectRuntimes.get(activeRuntimeKey) || null;
2391
+ return runtimeBelongsToOwner(runtime, owner) ? runtime : null;
2247
2392
  }
2248
2393
 
2249
- function reloadActiveSkills() {
2394
+ function reloadActiveSkills(owner = skillReloadOwner || captureRuntimeOwner()) {
2395
+ if (!isCurrentRuntimeOwner(owner)) return { changed: false, loaded: 0, errors: [] };
2250
2396
  if (skillReloadRunning) return { changed: false, loaded: 0, errors: [] };
2397
+ const runtime = activeSkillRuntime(owner);
2398
+ const manager = runtime?.skillManager;
2399
+ if (typeof manager?.load !== 'function') return { changed: false, loaded: 0, errors: [] };
2400
+
2251
2401
  skillReloadRunning = true;
2252
2402
  try {
2253
- let changed = false;
2254
- let loaded = 0;
2255
- const errors = [];
2256
- const changedManagers = new Set();
2257
- const managers = skillManagersForHotReload();
2258
- for (const manager of managers) {
2259
- if (typeof manager?.load !== 'function') continue;
2260
- const result = manager.load();
2261
- if (result?.changed) {
2262
- changed = true;
2263
- changedManagers.add(manager);
2264
- }
2265
- loaded += Number(result?.loaded) || 0;
2266
- errors.push(...(result?.errors || []));
2403
+ const result = manager.load() || {};
2404
+ const currentRuntime = activeSkillRuntime(owner);
2405
+ if (!isCurrentRuntimeOwner(owner) || currentRuntime?.skillManager !== manager) {
2406
+ return { changed: false, loaded: 0, errors: [] };
2407
+ }
2408
+ const changed = !!result.changed;
2409
+ const loaded = Number(result.loaded) || 0;
2410
+ const errors = result.errors || [];
2411
+ if (runtime.status) runtime.status.skills = manager.size || 0;
2412
+ if (activeRuntimeKey === BASE_RUNTIME_KEY && owner.ownerSession.status) {
2413
+ owner.ownerSession.status.skills = manager.size || 0;
2267
2414
  }
2268
2415
  if (changed) {
2269
- if (session?.status && changedManagers.has(session.skillManager)) {
2270
- session.status.skills = session.skillManager?.size || 0;
2271
- }
2272
- for (const runtime of projectRuntimes.values()) {
2273
- if (runtime?.status && changedManagers.has(runtime.skillManager)) {
2274
- runtime.status.skills = runtime.skillManager?.size || 0;
2275
- }
2276
- }
2277
- if (activeRuntimeKey === BASE_RUNTIME_KEY) {
2278
- broadcastSkillSlashCommands(session);
2279
- } else {
2280
- const runtime = projectRuntimes.get(activeRuntimeKey);
2281
- broadcastSkillSlashCommands(session, runtime?.skillManager ? [runtime.skillManager] : []);
2282
- }
2283
- const activeRuntime = activeRuntimeKey === BASE_RUNTIME_KEY ? null : projectRuntimes.get(activeRuntimeKey);
2284
- const activeStatus = activeRuntime ? mergedStatusForProjectRuntime(activeRuntime) : session?.status;
2416
+ broadcastSkillSlashCommands({ skillManager: manager });
2417
+ const activeStatus = activeRuntimeKey === BASE_RUNTIME_KEY
2418
+ ? runtime.status
2419
+ : mergedStatusForProjectRuntime(runtime, owner.ownerSession);
2285
2420
  hydrateYeaftStatusFromSession(
2286
- activeStatus && session ? { ...session, status: activeStatus } : session,
2421
+ activeStatus ? { ...owner.ownerSession, status: activeStatus } : owner.ownerSession,
2287
2422
  { reason: 'skills_hot_reload', emitEvent: true },
2288
2423
  );
2289
2424
  }
@@ -2296,195 +2431,277 @@ function reloadActiveSkills() {
2296
2431
  }
2297
2432
  }
2298
2433
 
2299
- function startSkillHotReload() {
2300
- if (skillReloadTimer) return;
2301
- skillReloadTimer = setInterval(() => {
2302
- try { reloadActiveSkills(); }
2434
+ function startSkillHotReload(owner = captureRuntimeOwner()) {
2435
+ if (!isCurrentRuntimeOwner(owner)) return false;
2436
+ if (skillReloadTimer && skillReloadOwner
2437
+ && skillReloadOwner.generation === owner.generation
2438
+ && skillReloadOwner.ownerSession === owner.ownerSession) {
2439
+ return false;
2440
+ }
2441
+ stopSkillHotReload();
2442
+ skillReloadOwner = owner;
2443
+ const timer = setInterval(() => {
2444
+ if (!isCurrentRuntimeOwner(owner)) {
2445
+ if (skillReloadTimer === timer) stopSkillHotReload(owner);
2446
+ return;
2447
+ }
2448
+ try { reloadActiveSkills(owner); }
2303
2449
  catch (err) { console.warn('[Yeaft] skill hot reload failed:', err?.message || err); }
2304
2450
  }, SKILL_RELOAD_INTERVAL_MS);
2305
- skillReloadTimer.unref?.();
2451
+ skillReloadTimer = timer;
2452
+ timer.unref?.();
2453
+ return true;
2306
2454
  }
2307
2455
 
2308
- function stopSkillHotReload() {
2309
- if (!skillReloadTimer) return;
2310
- clearInterval(skillReloadTimer);
2456
+ function stopSkillHotReload(owner = null) {
2457
+ if (owner && skillReloadOwner
2458
+ && (skillReloadOwner.generation !== owner.generation
2459
+ || skillReloadOwner.ownerSession !== owner.ownerSession)) {
2460
+ return false;
2461
+ }
2462
+ if (skillReloadTimer) clearInterval(skillReloadTimer);
2311
2463
  skillReloadTimer = null;
2464
+ skillReloadOwner = null;
2312
2465
  skillReloadRunning = false;
2466
+ return true;
2313
2467
  }
2314
2468
 
2315
- async function loadBaseRuntime() {
2316
- if (!session) return null;
2317
- const yeaftDir = ctx.CONFIG?.yeaftDir || session.yeaftDir || DEFAULT_YEAFT_DIR;
2318
- const skillManager = createSkillManager(yeaftDir, process.cwd());
2319
- session.skillManager = skillManager;
2320
-
2321
- const mcpConfig = loadMCPConfig(yeaftDir, undefined, process.cwd());
2322
- const mcpManager = new MCPManager();
2323
- session.mcpManager = mcpManager;
2469
+ async function loadBaseRuntime(owner = captureRuntimeOwner()) {
2470
+ if (!isCurrentRuntimeOwner(owner)) return null;
2471
+ const ownerSession = owner.ownerSession;
2472
+ const yeaftDir = ctx.CONFIG?.yeaftDir || ownerSession.yeaftDir || DEFAULT_YEAFT_DIR;
2473
+ const previousSkillManager = ownerSession.skillManager;
2474
+ const previousMcpManager = ownerSession.mcpManager;
2475
+ const skillManager = createRuntimeSkillManager(yeaftDir, process.cwd());
2476
+ const mcpConfig = loadRuntimeMcpConfig(yeaftDir, undefined, process.cwd());
2477
+ const mcpManager = createRuntimeMcpManager();
2324
2478
  let mcpStatus = { connected: [], failed: [] };
2479
+ const runtime = {
2480
+ generation: owner.generation,
2481
+ ownerSession,
2482
+ workDir: '',
2483
+ previousSkillManager,
2484
+ previousMcpManager,
2485
+ skillManager,
2486
+ mcpManager,
2487
+ mcpStatus,
2488
+ mcpConfig,
2489
+ loading: mcpConfig.servers.length > 0,
2490
+ status: {
2491
+ skills: skillManager.size,
2492
+ mcpServers: [],
2493
+ mcpFailed: [],
2494
+ mcpSkipped: mcpConfig.skipped || [],
2495
+ tools: ownerSession.toolRegistry?.size || 0,
2496
+ },
2497
+ };
2325
2498
 
2326
- if (session.status) {
2327
- session.status.skills = skillManager.size;
2328
- session.status.mcpServers = [];
2329
- session.status.mcpFailed = [];
2330
- session.status.mcpSkipped = mcpConfig.skipped || [];
2331
- session.status.tools = session.toolRegistry?.size || session.status.tools || 0;
2499
+ if (!isCurrentRuntimeOwner(owner)) {
2500
+ await disconnectRuntimeMcpManager(mcpManager);
2501
+ return null;
2332
2502
  }
2503
+ baseRuntime = runtime;
2504
+ ownerSession.skillManager = skillManager;
2505
+ ownerSession.mcpManager = mcpManager;
2506
+ ownerSession.status = { ...ownerSession.status, ...runtime.status };
2333
2507
  if (activeRuntimeKey === BASE_RUNTIME_KEY) {
2334
- activateBaseRuntime();
2335
- } else {
2336
- broadcastSkillSlashCommands(session);
2508
+ activateBaseRuntime(owner, { reloadSkills: false });
2509
+ hydrateYeaftStatusFromSession(ownerSession, { reason: 'base_runtime_skills', emitEvent: true });
2337
2510
  }
2338
- startSkillHotReload();
2339
- hydrateYeaftStatusFromSession(session, { reason: 'base_runtime_skills', emitEvent: true });
2340
2511
 
2341
2512
  if (mcpConfig.servers.length > 0) {
2342
- mcpStatus = await mcpManager.connectAll(mcpConfig.servers);
2343
- session.mcpManager = mcpManager;
2344
- if (session.status) {
2345
- session.status.mcpServers = mcpStatus.connected;
2346
- session.status.mcpFailed = mcpStatus.failed;
2347
- session.status.mcpSkipped = mcpConfig.skipped || [];
2513
+ try {
2514
+ mcpStatus = await mcpManager.connectAll(mcpConfig.servers);
2515
+ } catch (err) {
2516
+ runtime.loading = false;
2517
+ if (ownerSession.skillManager === skillManager) ownerSession.skillManager = previousSkillManager;
2518
+ if (ownerSession.mcpManager === mcpManager) ownerSession.mcpManager = previousMcpManager;
2519
+ await disconnectRuntimeMcpManager(mcpManager);
2520
+ if (baseRuntime === runtime) baseRuntime = null;
2521
+ throw err;
2348
2522
  }
2523
+ runtime.loading = false;
2524
+ if (!isCurrentRuntimeOwner(owner) || baseRuntime !== runtime) {
2525
+ if (ownerSession.skillManager === skillManager) ownerSession.skillManager = previousSkillManager;
2526
+ if (ownerSession.mcpManager === mcpManager) ownerSession.mcpManager = previousMcpManager;
2527
+ await disconnectRuntimeMcpManager(mcpManager);
2528
+ return null;
2529
+ }
2530
+ runtime.mcpStatus = mcpStatus;
2531
+ runtime.status = {
2532
+ ...runtime.status,
2533
+ mcpServers: mcpStatus.connected,
2534
+ mcpFailed: mcpStatus.failed,
2535
+ mcpSkipped: mcpConfig.skipped || [],
2536
+ tools: ownerSession.toolRegistry?.size || 0,
2537
+ };
2538
+ ownerSession.mcpManager = mcpManager;
2539
+ ownerSession.status = { ...ownerSession.status, ...runtime.status };
2349
2540
  if (activeRuntimeKey === BASE_RUNTIME_KEY) {
2350
- activateBaseRuntime();
2541
+ activateBaseRuntime(owner, { reloadSkills: false });
2542
+ hydrateYeaftStatusFromSession(ownerSession, { reason: 'base_runtime_mcp', emitEvent: true });
2543
+ if (isCurrentRuntimeOwner(owner)) {
2544
+ try { broadcastMcpUpdated({ reason: 'base-runtime-load' }); } catch { /* best-effort */ }
2545
+ }
2351
2546
  }
2352
- hydrateYeaftStatusFromSession(session, { reason: 'base_runtime_mcp', emitEvent: true });
2353
- try { broadcastMcpUpdated({ reason: 'base-runtime-load' }); } catch { /* best-effort */ }
2354
2547
  }
2355
2548
 
2356
- return { skillManager, mcpManager, mcpStatus, mcpConfig };
2549
+ return isCurrentRuntimeOwner(owner) && baseRuntime === runtime ? runtime : null;
2357
2550
  }
2358
2551
 
2359
2552
  function scheduleBaseRuntimeLoad() {
2360
- if (!session) return null;
2361
- if (baseRuntimeLoadPromises.has(BASE_RUNTIME_KEY)) return baseRuntimeLoadPromises.get(BASE_RUNTIME_KEY);
2362
- const promise = new Promise(resolve => setTimeout(resolve, 0))
2363
- .then(() => loadBaseRuntime())
2553
+ const owner = captureRuntimeOwner();
2554
+ if (!owner) return null;
2555
+ const current = baseRuntimeLoadPromises.get(BASE_RUNTIME_KEY);
2556
+ if (current && loaderBelongsToOwner(current, owner)) return current;
2557
+ let promise;
2558
+ promise = new Promise(resolve => setTimeout(resolve, 0))
2559
+ .then(() => loadBaseRuntime(owner))
2364
2560
  .catch((err) => {
2365
2561
  console.warn('[Yeaft] async base runtime load failed:', err?.message || err);
2366
2562
  return null;
2367
2563
  })
2368
- .finally(() => { baseRuntimeLoadPromises.delete(BASE_RUNTIME_KEY); });
2564
+ .finally(() => {
2565
+ if (owner.generation === runtimeGeneration
2566
+ && baseRuntimeLoadPromises.get(BASE_RUNTIME_KEY) === promise) {
2567
+ baseRuntimeLoadPromises.delete(BASE_RUNTIME_KEY);
2568
+ }
2569
+ });
2570
+ runtimeLoaderOwners.set(promise, owner);
2369
2571
  baseRuntimeLoadPromises.set(BASE_RUNTIME_KEY, promise);
2370
2572
  return promise;
2371
2573
  }
2372
2574
 
2373
- async function loadProjectRuntime(workDir) {
2374
- if (!session) return null;
2575
+ async function loadProjectRuntime(workDir, owner = captureRuntimeOwner()) {
2576
+ if (!isCurrentRuntimeOwner(owner)) return null;
2577
+ const ownerSession = owner.ownerSession;
2375
2578
  const normalizedWorkDir = normalizeSessionWorkDir(workDir);
2376
2579
  if (!normalizedWorkDir) {
2377
- activateBaseRuntime();
2580
+ activateBaseRuntime(owner);
2378
2581
  return null;
2379
2582
  }
2380
2583
  const key = projectRuntimeKey(normalizedWorkDir);
2381
2584
  const cached = projectRuntimes.get(key);
2382
- if (cached) {
2383
- activateProjectRuntime(cached);
2585
+ if (runtimeBelongsToOwner(cached, owner)) {
2586
+ activateProjectRuntime(cached, owner);
2384
2587
  return cached;
2385
2588
  }
2386
2589
 
2387
- const yeaftDir = ctx.CONFIG?.yeaftDir || session.yeaftDir || DEFAULT_YEAFT_DIR;
2388
- const skillRoots = normalizedWorkDir && normalizedWorkDir !== process.cwd()
2590
+ const yeaftDir = ctx.CONFIG?.yeaftDir || ownerSession.yeaftDir || DEFAULT_YEAFT_DIR;
2591
+ const skillRoots = normalizedWorkDir !== process.cwd()
2389
2592
  ? `${process.cwd()}${delimiter}${normalizedWorkDir}`
2390
2593
  : normalizedWorkDir;
2391
- const skillManager = createSkillManager(yeaftDir, skillRoots);
2392
- const mcpConfig = loadMCPConfig(yeaftDir, undefined, normalizedWorkDir);
2393
- const mcpManager = new MCPManager();
2594
+ const skillManager = createRuntimeSkillManager(yeaftDir, skillRoots);
2595
+ const mcpConfig = loadRuntimeMcpConfig(yeaftDir, undefined, normalizedWorkDir);
2596
+ const mcpManager = createRuntimeMcpManager();
2394
2597
  let mcpStatus = { connected: [], failed: [] };
2395
2598
  const runtime = {
2599
+ generation: owner.generation,
2600
+ ownerSession,
2396
2601
  workDir: normalizedWorkDir,
2397
2602
  skillManager,
2398
2603
  mcpManager,
2399
2604
  mcpStatus,
2400
2605
  mcpConfig,
2606
+ loading: mcpConfig.servers.length > 0,
2401
2607
  status: {
2402
2608
  skills: skillManager.size,
2403
- mcpServers: mcpStatus.connected,
2404
- mcpFailed: mcpStatus.failed,
2609
+ mcpServers: [],
2610
+ mcpFailed: [],
2405
2611
  mcpSkipped: mcpConfig.skipped || [],
2406
- tools: session.toolRegistry?.size || 0,
2612
+ tools: ownerSession.toolRegistry?.size || 0,
2407
2613
  },
2408
2614
  };
2615
+ if (!isCurrentRuntimeOwner(owner)) {
2616
+ await disconnectRuntimeMcpManager(mcpManager);
2617
+ return null;
2618
+ }
2409
2619
  projectRuntimes.set(key, runtime);
2410
- // Skill metadata is available after the filesystem scan; publish it before
2411
- // any MCP server startup so autocomplete does not wait on external processes.
2412
- activateProjectRuntime(runtime);
2413
- startSkillHotReload();
2620
+ // Skill metadata is ready before external MCP startup. Activation is still
2621
+ // owner-gated so reset cannot publish this runtime into a replacement session.
2622
+ activateProjectRuntime(runtime, owner, { reloadSkills: false });
2414
2623
  if (mcpConfig.servers.length > 0) {
2415
- mcpStatus = await mcpManager.connectAll(mcpConfig.servers);
2624
+ try {
2625
+ mcpStatus = await mcpManager.connectAll(mcpConfig.servers);
2626
+ } catch (err) {
2627
+ runtime.loading = false;
2628
+ await disconnectRuntimeMcpManager(mcpManager);
2629
+ if (projectRuntimes.get(key) === runtime) projectRuntimes.delete(key);
2630
+ throw err;
2631
+ }
2632
+ runtime.loading = false;
2633
+ if (!runtimeBelongsToOwner(runtime, owner) || projectRuntimes.get(key) !== runtime) {
2634
+ await disconnectRuntimeMcpManager(mcpManager);
2635
+ return null;
2636
+ }
2416
2637
  runtime.mcpStatus = mcpStatus;
2417
2638
  runtime.status = {
2418
2639
  ...runtime.status,
2419
2640
  mcpServers: mcpStatus.connected,
2420
2641
  mcpFailed: mcpStatus.failed,
2421
2642
  mcpSkipped: mcpConfig.skipped || [],
2422
- tools: session.toolRegistry?.size || 0,
2643
+ tools: ownerSession.toolRegistry?.size || 0,
2423
2644
  };
2424
- activateProjectRuntime(runtime);
2645
+ if (activeRuntimeKey === key) activateProjectRuntime(runtime, owner, { reloadSkills: false });
2425
2646
  }
2426
- return runtime;
2647
+ return runtimeBelongsToOwner(runtime, owner) && projectRuntimes.get(key) === runtime ? runtime : null;
2427
2648
  }
2428
2649
 
2429
-
2430
2650
  function scheduleProjectRuntimeLoad(workDir) {
2651
+ const owner = captureRuntimeOwner();
2431
2652
  const normalizedWorkDir = normalizeSessionWorkDir(workDir);
2432
- if (!normalizedWorkDir || !session) return null;
2653
+ if (!normalizedWorkDir || !owner) return null;
2433
2654
  const key = projectRuntimeKey(normalizedWorkDir);
2434
- if (projectRuntimes.has(key)) return projectRuntimes.get(key);
2435
- if (projectRuntimeLoadPromises.has(key)) return projectRuntimeLoadPromises.get(key);
2436
- const promise = loadProjectRuntime(normalizedWorkDir)
2655
+ const cached = projectRuntimes.get(key);
2656
+ if (runtimeBelongsToOwner(cached, owner)) return cached;
2657
+ const current = projectRuntimeLoadPromises.get(key);
2658
+ if (current && loaderBelongsToOwner(current, owner)) return current;
2659
+ let promise;
2660
+ promise = loadProjectRuntime(normalizedWorkDir, owner)
2437
2661
  .catch((err) => {
2438
2662
  console.warn('[Yeaft] async project runtime load failed for %s: %s', normalizedWorkDir, err?.message || err);
2439
2663
  return null;
2440
2664
  })
2441
- .finally(() => { projectRuntimeLoadPromises.delete(key); });
2665
+ .finally(() => {
2666
+ if (owner.generation === runtimeGeneration
2667
+ && projectRuntimeLoadPromises.get(key) === promise) {
2668
+ projectRuntimeLoadPromises.delete(key);
2669
+ }
2670
+ });
2671
+ runtimeLoaderOwners.set(promise, owner);
2442
2672
  projectRuntimeLoadPromises.set(key, promise);
2443
2673
  return promise;
2444
2674
  }
2445
2675
 
2446
- async function ensureProjectRuntimeForSessionMeta(sessionMeta) {
2447
- const workDir = normalizeSessionWorkDir(sessionMeta?.workDir);
2448
- if (!workDir) {
2449
- activateBaseRuntime();
2450
- return null;
2451
- }
2452
- try {
2453
- return await loadProjectRuntime(workDir);
2454
- } catch (err) {
2455
- console.warn('[Yeaft] project runtime load failed for %s: %s', workDir, err?.message || err);
2456
- activateBaseRuntime();
2457
- return null;
2458
- }
2459
- }
2460
-
2461
2676
  function getProjectRuntimeForTurn(sessionMeta) {
2677
+ const owner = captureRuntimeOwner();
2678
+ if (!owner) return null;
2462
2679
  const workDir = normalizeSessionWorkDir(sessionMeta?.workDir);
2463
2680
  if (!workDir) {
2464
- activateBaseRuntime();
2681
+ activateBaseRuntime(owner);
2465
2682
  return null;
2466
2683
  }
2467
2684
  const cached = projectRuntimes.get(projectRuntimeKey(workDir)) || null;
2468
- if (cached) {
2469
- activateProjectRuntime(cached);
2685
+ if (runtimeBelongsToOwner(cached, owner)) {
2686
+ activateProjectRuntime(cached, owner);
2470
2687
  return cached;
2471
2688
  }
2472
2689
  scheduleProjectRuntimeLoad(workDir);
2473
2690
  // Do not let a previous workDir's MCP tools leak into this turn while the
2474
2691
  // requested project runtime is still loading in the background.
2475
- activateBaseRuntime();
2692
+ activateBaseRuntime(owner);
2476
2693
  return null;
2477
2694
  }
2478
2695
 
2479
- function mergedStatusForProjectRuntime(runtime) {
2480
- if (!session?.status || !runtime?.status) return session?.status || { skills: 0, mcpServers: [], tools: 0 };
2696
+ function mergedStatusForProjectRuntime(runtime, ownerSession = session) {
2697
+ if (!ownerSession?.status || !runtime?.status) return ownerSession?.status || { skills: 0, mcpServers: [], tools: 0 };
2481
2698
  return {
2482
- ...session.status,
2483
- skills: Math.max(Number(session.status.skills) || 0, Number(runtime.status.skills) || 0),
2484
- mcpServers: [...new Set([...(session.status.mcpServers || []), ...(runtime.status.mcpServers || [])])],
2485
- mcpFailed: [...(session.status.mcpFailed || []), ...(runtime.status.mcpFailed || [])],
2486
- mcpSkipped: [...(session.status.mcpSkipped || []), ...(runtime.status.mcpSkipped || [])],
2487
- tools: Math.max(Number(session.status.tools) || 0, Number(runtime.status.tools) || 0),
2699
+ ...ownerSession.status,
2700
+ skills: Math.max(Number(ownerSession.status.skills) || 0, Number(runtime.status.skills) || 0),
2701
+ mcpServers: [...new Set([...(ownerSession.status.mcpServers || []), ...(runtime.status.mcpServers || [])])],
2702
+ mcpFailed: [...(ownerSession.status.mcpFailed || []), ...(runtime.status.mcpFailed || [])],
2703
+ mcpSkipped: [...(ownerSession.status.mcpSkipped || []), ...(runtime.status.mcpSkipped || [])],
2704
+ tools: Math.max(Number(ownerSession.status.tools) || 0, Number(runtime.status.tools) || 0),
2488
2705
  };
2489
2706
  }
2490
2707
 
@@ -2507,6 +2724,7 @@ function pendingUserPromptEvent(requestId, pending, extra = {}) {
2507
2724
  return {
2508
2725
  type: 'ask_user_question',
2509
2726
  requestId,
2727
+ toolCallId: pending.toolCallId || null,
2510
2728
  questions: [{
2511
2729
  question: pending.question,
2512
2730
  options: pending.options.map(label => ({ label, description: '' })),
@@ -2546,6 +2764,7 @@ function settlePendingUserPrompt(requestId, pending, { answers = null, timedOut
2546
2764
  sendSessionEvent({
2547
2765
  type: timedOut ? 'ask_user_expired' : 'ask_user_answered',
2548
2766
  requestId,
2767
+ toolCallId: pending.toolCallId || null,
2549
2768
  ...(timedOut ? { expiredAt: Date.now() } : { answers: answers || {} }),
2550
2769
  }, {
2551
2770
  sessionId: pending.sessionId,
@@ -4331,13 +4550,14 @@ export async function ensureSessionLoaded(opts = {}) {
4331
4550
  const bootConfigRevision = sessionConfigRefreshRevision;
4332
4551
  const yeaftDir = ctx.CONFIG?.yeaftDir;
4333
4552
  const normalizedWorkDir = normalizeSessionWorkDir(opts?.workDir || opts?.sessionMeta?.workDir);
4334
- session = await loadSession({
4553
+ session = await loadRuntimeSession({
4335
4554
  ...(yeaftDir && { dir: yeaftDir }),
4336
4555
  ...(normalizedWorkDir && { workDir: normalizedWorkDir }),
4337
4556
  skipMCP: true,
4338
4557
  skipSkills: true,
4339
4558
  serverMode: true,
4340
4559
  });
4560
+ claimRuntimeOwnership(session);
4341
4561
 
4342
4562
  installYeaftRuntimeBridge(session);
4343
4563
  // A save may complete after loadSession read config.json but before this
@@ -4372,13 +4592,15 @@ export async function ensureSessionLoaded(opts = {}) {
4372
4592
 
4373
4593
  ensureYeaftConversationId();
4374
4594
  scheduleBaseRuntimeLoad();
4375
- const bootProjectRuntime = normalizedWorkDir ? projectRuntimes.get(projectRuntimeKey(normalizedWorkDir)) || null : null;
4595
+ let bootProjectRuntime = normalizedWorkDir ? projectRuntimes.get(projectRuntimeKey(normalizedWorkDir)) || null : null;
4376
4596
  if (normalizedWorkDir && !bootProjectRuntime) {
4377
4597
  scheduleProjectRuntimeLoad(normalizedWorkDir);
4598
+ bootProjectRuntime = projectRuntimes.get(projectRuntimeKey(normalizedWorkDir)) || null;
4378
4599
  }
4379
4600
  const bootStatus = mergedStatusForProjectRuntime(bootProjectRuntime);
4380
4601
  hydrateYeaftStatusFromSession({ ...session, status: bootStatus }, { reason: 'session_ready', emitEvent: true });
4381
- broadcastSkillSlashCommands(session, bootProjectRuntime ? [bootProjectRuntime.skillManager] : []);
4602
+ if (bootProjectRuntime) broadcastSkillSlashCommands({ skillManager: bootProjectRuntime.skillManager });
4603
+ else broadcastSkillSlashCommands(session);
4382
4604
 
4383
4605
  // Per-group history is hydrated lazily on first `getOrCreateSessionHistory`
4384
4606
  // — there's no global "all conversations" tape any more.
@@ -4415,6 +4637,7 @@ export async function ensureSessionLoaded(opts = {}) {
4415
4637
  try {
4416
4638
  return await sessionLoadPromise;
4417
4639
  } catch (err) {
4640
+ await shutdownProjectRuntimes();
4418
4641
  session = null;
4419
4642
  throw err;
4420
4643
  } finally {
@@ -4809,7 +5032,7 @@ async function runVpTurn({ prompt, promptParts = null, sessionId, vpId, threadId
4809
5032
  // each write a copy of the user message, and history replay
4810
5033
  // would render the user's prompt N times.
4811
5034
  userAlreadyPersisted: true,
4812
- askUser: ({ question, options }) => new Promise((resolve, reject) => {
5035
+ askUser: ({ question, options }, toolCall = null) => new Promise((resolve, reject) => {
4813
5036
  const requestId = `ask_${randomUUID()}`;
4814
5037
  const signal = vpAbort.signal;
4815
5038
  const createdAt = Date.now();
@@ -4827,6 +5050,7 @@ async function runVpTurn({ prompt, promptParts = null, sessionId, vpId, threadId
4827
5050
  vpId,
4828
5051
  threadId,
4829
5052
  turnId,
5053
+ toolCallId: typeof toolCall?.id === 'string' ? toolCall.id : '',
4830
5054
  question,
4831
5055
  options: Array.isArray(options) ? options.filter(label => typeof label === 'string') : [],
4832
5056
  createdAt,
@@ -5894,6 +6118,7 @@ export function handleYeaftAskUserAnswer(msg) {
5894
6118
  if (msg.vpId && msg.vpId !== pending.vpId) return false;
5895
6119
  if (msg.turnId && msg.turnId !== pending.turnId) return false;
5896
6120
  if (msg.threadId && msg.threadId !== pending.threadId) return false;
6121
+ if (msg.toolCallId && msg.toolCallId !== pending.toolCallId) return false;
5897
6122
 
5898
6123
  return settlePendingUserPrompt(requestId, pending, { answers: msg.answers || {} });
5899
6124
  }
@@ -6453,7 +6678,10 @@ export async function handleYeaftLoadMoreHistory(msg) {
6453
6678
  * session, then re-initialises so the frontend gets fresh config.
6454
6679
  */
6455
6680
  export async function resetYeaftSession() {
6456
- await shutdownProjectRuntimes();
6681
+ // Runtime ownership must be revoked before reset reaches its first await.
6682
+ const oldSession = session;
6683
+ const oldRuntimesShutdown = shutdownProjectRuntimes();
6684
+ await oldRuntimesShutdown;
6457
6685
  if (currentAbortCtrl && !currentAbortCtrl.signal.aborted) {
6458
6686
  try { currentAbortCtrl.abort(); } catch { /* ignore */ }
6459
6687
  }
@@ -6462,9 +6690,9 @@ export async function resetYeaftSession() {
6462
6690
  try { _vpUnsubscribe(); } catch { /* ignore */ }
6463
6691
  _vpUnsubscribe = null;
6464
6692
  }
6465
- if (session) {
6466
- await session.shutdown();
6467
- session = null;
6693
+ if (oldSession) {
6694
+ await oldSession.shutdown();
6695
+ if (session === oldSession) session = null;
6468
6696
  }
6469
6697
  yeaftConversationId = null;
6470
6698
  // Per-group histories live on sessionContexts entries — clearing the
@@ -6508,12 +6736,13 @@ export async function resetYeaftSession() {
6508
6736
 
6509
6737
  try {
6510
6738
  const yeaftDir = ctx.CONFIG?.yeaftDir;
6511
- session = await loadSession({
6739
+ session = await loadRuntimeSession({
6512
6740
  ...(yeaftDir && { dir: yeaftDir }),
6513
6741
  skipMCP: true,
6514
6742
  skipSkills: true,
6515
6743
  serverMode: true,
6516
6744
  });
6745
+ claimRuntimeOwnership(session);
6517
6746
  installYeaftRuntimeBridge(session);
6518
6747
 
6519
6748
  yeaftConversationId = `yeaft-${Date.now()}`;
@@ -6600,7 +6829,7 @@ function mcpRuntimeSnapshot() {
6600
6829
  * pick up the change.
6601
6830
  */
6602
6831
  function hotSwapMcpTools() {
6603
- return replaceSessionMcpTools(session?.mcpManager);
6832
+ return replaceSessionMcpTools(captureRuntimeOwner(), session?.mcpManager);
6604
6833
  }
6605
6834
 
6606
6835
  /**
@@ -6659,7 +6888,7 @@ export async function handleYeaftMcpAdd(msg = {}) {
6659
6888
  }
6660
6889
  }
6661
6890
 
6662
- const swap = replaceSessionMcpTools(session?.mcpManager);
6891
+ const swap = replaceSessionMcpTools(captureRuntimeOwner(), session?.mcpManager);
6663
6892
 
6664
6893
  sendToServer({
6665
6894
  type: 'yeaft_mcp_add_result',
@@ -6696,7 +6925,7 @@ export async function handleYeaftMcpRemove(msg = {}) {
6696
6925
  }
6697
6926
  }
6698
6927
 
6699
- const swap = replaceSessionMcpTools(session?.mcpManager);
6928
+ const swap = replaceSessionMcpTools(captureRuntimeOwner(), session?.mcpManager);
6700
6929
 
6701
6930
  sendToServer({
6702
6931
  type: 'yeaft_mcp_remove_result',
@@ -6754,7 +6983,7 @@ export async function handleYeaftMcpReload(msg = {}) {
6754
6983
  console.warn('[Yeaft] MCP reload failed:', err?.message || err);
6755
6984
  }
6756
6985
 
6757
- const swap = replaceSessionMcpTools(session?.mcpManager);
6986
+ const swap = replaceSessionMcpTools(captureRuntimeOwner(), session?.mcpManager);
6758
6987
 
6759
6988
  sendToServer({
6760
6989
  type: 'yeaft_mcp_reload_result',
@@ -6777,8 +7006,56 @@ export const __testHooks = {
6777
7006
  return runYeaftSessionSend(msg);
6778
7007
  },
6779
7008
  setSessionForTest(nextSession) {
6780
- session = nextSession || null;
6781
- sessionLoadPromise = null;
7009
+ __testSetSession(nextSession || null);
7010
+ },
7011
+ setRuntimeFactoriesForTest({
7012
+ createSkillManager: nextCreateSkillManager,
7013
+ createMcpManager: nextCreateMcpManager,
7014
+ loadMcpConfig: nextLoadMcpConfig,
7015
+ loadSession: nextLoadSession,
7016
+ } = {}) {
7017
+ if (typeof nextCreateSkillManager === 'function') createRuntimeSkillManager = nextCreateSkillManager;
7018
+ if (typeof nextCreateMcpManager === 'function') createRuntimeMcpManager = nextCreateMcpManager;
7019
+ if (typeof nextLoadMcpConfig === 'function') loadRuntimeMcpConfig = nextLoadMcpConfig;
7020
+ if (typeof nextLoadSession === 'function') loadRuntimeSession = nextLoadSession;
7021
+ },
7022
+ resetRuntimeFactoriesForTest() {
7023
+ createRuntimeSkillManager = createSkillManager;
7024
+ createRuntimeMcpManager = () => new MCPManager();
7025
+ loadRuntimeMcpConfig = loadMCPConfig;
7026
+ loadRuntimeSession = loadSession;
7027
+ },
7028
+ scheduleBaseRuntimeLoadForTest() {
7029
+ return scheduleBaseRuntimeLoad();
7030
+ },
7031
+ scheduleProjectRuntimeLoadForTest(workDir) {
7032
+ return scheduleProjectRuntimeLoad(workDir);
7033
+ },
7034
+ activateBaseRuntimeForTest() {
7035
+ return activateBaseRuntime();
7036
+ },
7037
+ activateProjectRuntimeForTest(workDir) {
7038
+ const runtime = projectRuntimes.get(projectRuntimeKey(workDir)) || null;
7039
+ return activateProjectRuntime(runtime);
7040
+ },
7041
+ startSkillHotReloadForTest() {
7042
+ return startSkillHotReload();
7043
+ },
7044
+ runtimeLifecycleSnapshotForTest(workDir = '') {
7045
+ const key = workDir ? projectRuntimeKey(workDir) : null;
7046
+ const owner = captureRuntimeOwner();
7047
+ return {
7048
+ generation: runtimeGeneration,
7049
+ ownerSession: runtimeOwnerSession,
7050
+ activeRuntimeKey,
7051
+ timerActive: !!skillReloadTimer,
7052
+ timerOwnerGeneration: skillReloadOwner?.generation ?? null,
7053
+ timerOwnerSession: skillReloadOwner?.ownerSession || null,
7054
+ basePromise: baseRuntimeLoadPromises.get(BASE_RUNTIME_KEY) || null,
7055
+ projectPromise: key ? projectRuntimeLoadPromises.get(key) || null : null,
7056
+ projectRuntime: key ? projectRuntimes.get(key) || null : null,
7057
+ activeSkillManager: activeSkillRuntime(owner)?.skillManager || null,
7058
+ };
6782
7059
  },
6783
7060
  ensureYeaftConversationIdForTest() {
6784
7061
  return ensureYeaftConversationId();
@@ -6798,10 +7075,10 @@ export const __testHooks = {
6798
7075
  vpAborts.clear();
6799
7076
  vpInboxes.clear();
6800
7077
  },
6801
- seedPendingUserPrompt({ requestId = 'ask-test', sessionId = 'session-test', vpId = 'vp-test', threadId = 'main', turnId = 'turn-test', question = 'Continue?', options = [], createdAt = Date.now(), expiresAt = Date.now() + ASK_USER_TIMEOUT_MS } = {}) {
7078
+ seedPendingUserPrompt({ requestId = 'ask-test', sessionId = 'session-test', vpId = 'vp-test', threadId = 'main', turnId = 'turn-test', toolCallId = 'call-test', question = 'Continue?', options = [], createdAt = Date.now(), expiresAt = Date.now() + ASK_USER_TIMEOUT_MS } = {}) {
6802
7079
  let resolved;
6803
7080
  const promise = new Promise(resolve => { resolved = resolve; });
6804
- pendingUserPrompts.set(requestId, { resolve: resolved, sessionId, vpId, threadId, turnId, question, options, createdAt, expiresAt, timer: null });
7081
+ pendingUserPrompts.set(requestId, { resolve: resolved, sessionId, vpId, threadId, turnId, toolCallId, question, options, createdAt, expiresAt, timer: null });
6805
7082
  return promise;
6806
7083
  },
6807
7084
  replayPendingUserPrompts,
@@ -6852,7 +7129,10 @@ export const __testHooks = {
6852
7129
  },
6853
7130
  seedProjectRuntime(workDir, runtime) {
6854
7131
  const normalizedWorkDir = normalizeSessionWorkDir(workDir);
7132
+ const owner = captureRuntimeOwner();
6855
7133
  const seeded = {
7134
+ generation: owner?.generation ?? runtimeGeneration,
7135
+ ownerSession: owner?.ownerSession || session,
6856
7136
  workDir: normalizedWorkDir,
6857
7137
  skillManager: runtime?.skillManager || { list: () => [] },
6858
7138
  mcpManager: runtime?.mcpManager || { listTools: () => [], disconnectAll: async () => {} },