@yeaft/webchat-agent 1.0.57 → 1.0.58

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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@yeaft/webchat-agent",
3
- "version": "1.0.57",
3
+ "version": "1.0.58",
4
4
  "description": "Remote worker agent for Yeaft Web Code Agent — connects the native Yeaft engine, CLI providers, and workbench tools",
5
5
  "main": "index.js",
6
6
  "type": "module",
package/yeaft/engine.js CHANGED
@@ -703,6 +703,22 @@ export class Engine {
703
703
  this.#config.language = lang;
704
704
  }
705
705
 
706
+ /**
707
+ * Hot-swap runtime managers for a project-bound Session. The ToolRegistry is
708
+ * shared at the bridge/session layer; these references only decide which
709
+ * skills are injected and which MCP manager flattened tools call through.
710
+ *
711
+ * @param {{ skillManager?: import('./skills.js').SkillManager, mcpManager?: import('./mcp.js').MCPManager }} managers
712
+ */
713
+ setRuntimeManagers(managers = {}) {
714
+ if (Object.prototype.hasOwnProperty.call(managers, 'skillManager')) {
715
+ this.#skillManager = managers.skillManager || null;
716
+ }
717
+ if (Object.prototype.hasOwnProperty.call(managers, 'mcpManager')) {
718
+ this.#mcpManager = managers.mcpManager || null;
719
+ }
720
+ }
721
+
706
722
  /**
707
723
  * Unregister a tool.
708
724
  *
package/yeaft/session.js CHANGED
@@ -363,12 +363,9 @@ export async function loadSession(options = {}) {
363
363
  }
364
364
 
365
365
  // ─── 6. Load skills ────────────────────────────────────
366
- // Project tier root for skills + MCP project assets. Per-session/per-group
367
- // workdirs override at tool-execution time via ToolContext.cwd; for the
368
- // SYSTEM-PROMPT skill set and the project MCP servers we use the agent
369
- // process cwd, the common case when an agent is launched inside a project
370
- // the user wants project-tier assets for. Shared so skills (.claude/skills,
371
- // .yeaft/skills) and MCP (.mcp.json) resolve from the same root.
366
+ // Project tier root for skills + MCP project assets. Per-session workDir
367
+ // overlays are loaded by web-bridge once it knows the selected Session meta;
368
+ // the base runtime still uses the agent process cwd for global/default status.
372
369
  const projectTierRoot = process.cwd();
373
370
 
374
371
  let skillManager;
package/yeaft/skills.js CHANGED
@@ -53,7 +53,7 @@
53
53
  */
54
54
 
55
55
  import { existsSync, readFileSync, readdirSync, writeFileSync, unlinkSync, mkdirSync, statSync } from 'fs';
56
- import { join, basename, sep, dirname, resolve } from 'path';
56
+ import { join, basename, sep, dirname, resolve, delimiter } from 'path';
57
57
  import { platform, homedir } from 'os';
58
58
  import { fileURLToPath } from 'url';
59
59
 
@@ -758,19 +758,23 @@ export function createSkillManager(yeaftDir, workDir) {
758
758
  const claudeUserDir = home ? join(home, '.claude', 'skills') : null;
759
759
  const codexUserDir = home ? join(home, '.codex', 'skills') : null;
760
760
  const userDir = join(yeaftDir, 'skills');
761
- const claudeProjectDir = workDir ? join(workDir, '.claude', 'skills') : null;
762
- const codexProjectDir = workDir ? join(workDir, '.agents', 'skills') : null;
763
- const projectDir = workDir ? join(workDir, '.yeaft', 'skills') : null;
764
-
765
- const dirs = [bundled, claudeUserDir, codexUserDir, userDir, claudeProjectDir, codexProjectDir, projectDir].filter(Boolean);
761
+ const projectRoots = [...new Set(String(workDir || '')
762
+ .split(delimiter)
763
+ .map(p => p.trim())
764
+ .filter(Boolean))];
765
+ const claudeProjectDirs = projectRoots.map(root => join(root, '.claude', 'skills'));
766
+ const codexProjectDirs = projectRoots.map(root => join(root, '.agents', 'skills'));
767
+ const projectDirs = projectRoots.map(root => join(root, '.yeaft', 'skills'));
768
+
769
+ const dirs = [bundled, claudeUserDir, codexUserDir, userDir, ...claudeProjectDirs, ...codexProjectDirs, ...projectDirs].filter(Boolean);
766
770
  const tierByDir = {};
767
771
  if (bundled) tierByDir[bundled] = 'bundled';
768
772
  if (claudeUserDir) tierByDir[claudeUserDir] = 'user-claude';
769
773
  if (codexUserDir) tierByDir[codexUserDir] = 'user-codex';
770
774
  tierByDir[userDir] = 'user';
771
- if (claudeProjectDir) tierByDir[claudeProjectDir] = 'project-claude';
772
- if (codexProjectDir) tierByDir[codexProjectDir] = 'project-codex';
773
- if (projectDir) tierByDir[projectDir] = 'project';
775
+ for (const dir of claudeProjectDirs) tierByDir[dir] = 'project-claude';
776
+ for (const dir of codexProjectDirs) tierByDir[dir] = 'project-codex';
777
+ for (const dir of projectDirs) tierByDir[dir] = 'project';
774
778
 
775
779
  const ignorePathsByDir = {};
776
780
  if (claudeUserDir && bundled && pathIsInside(bundled, claudeUserDir)) {
@@ -18,7 +18,7 @@
18
18
  * ranges removed.
19
19
  */
20
20
 
21
- import { join } from 'node:path';
21
+ import { delimiter, join } from 'node:path';
22
22
  import { COLLAB_TOOL_POLICY } from './tools/registry.js';
23
23
  import { existsSync } from 'node:fs';
24
24
  import { randomUUID } from 'node:crypto';
@@ -26,7 +26,9 @@ import { DEFAULT_YEAFT_DIR } from './init.js';
26
26
  import { buildDreamOutputSnapshot } from './dream/output-snapshot.js';
27
27
  import { Engine } from './engine.js';
28
28
  import { loadSession } from './session.js';
29
- import { loadConfig } from './config.js';
29
+ import { loadConfig, loadMCPConfig } from './config.js';
30
+ import { createSkillManager } from './skills.js';
31
+ import { MCPManager } from './mcp.js';
30
32
  import { sendToServer } from '../connection/buffer.js';
31
33
  import ctx from '../context.js';
32
34
  import { hydrateYeaftStatusFromSession } from './status-cache.js';
@@ -168,20 +170,29 @@ async function sendDreamSnapshotForSession(sessionId, extra = {}) {
168
170
  function scheduleYeaftLoadHistoryMetadataReplay(sessionId) {
169
171
  const replaySession = session;
170
172
  const replayConversationId = yeaftConversationId;
171
- setTimeout(() => {
173
+ setTimeout(async () => {
172
174
  try {
173
175
  if (!replaySession) return;
174
176
  refreshLiveSessionConfig();
175
- hydrateYeaftStatusFromSession(replaySession, { reason: 'history_load', emitEvent: true });
177
+ let projectRuntime = null;
178
+ if (sessionId) {
179
+ try {
180
+ const groupYeaftDir = resolveSessionYeaftDir(ctx.CONFIG?.yeaftDir || DEFAULT_YEAFT_DIR, sessionId);
181
+ const meta = loadSessionMeta(join(sessionsRoot(groupYeaftDir), sessionId));
182
+ projectRuntime = await ensureProjectRuntimeForSessionMeta(meta);
183
+ } catch { /* best-effort project metadata */ }
184
+ }
185
+ const status = mergedStatusForProjectRuntime(projectRuntime);
186
+ hydrateYeaftStatusFromSession({ ...replaySession, status }, { reason: 'history_load', emitEvent: true });
176
187
  sendSessionEvent({
177
188
  type: 'session_ready',
178
189
  conversationId: replayConversationId,
179
190
  model: replaySession.config.primaryModel || replaySession.config.model,
180
191
  modelEffort: replaySession.config.modelEffort || null,
181
192
  availableModels: replaySession.config.availableModels || [],
182
- skills: replaySession.status.skills,
183
- mcpServers: replaySession.status.mcpServers,
184
- tools: replaySession.status.tools,
193
+ skills: status.skills,
194
+ mcpServers: status.mcpServers,
195
+ tools: status.tools,
185
196
  yeaftDir: ctx.CONFIG?.yeaftDir || null,
186
197
  tasks: replaySession.taskManager ? replaySession.taskManager.listActiveTasks() : [],
187
198
  });
@@ -385,6 +396,14 @@ function threadKey(sessionId, vpId, threadId) {
385
396
  return `${sessionId}::${vpId}::${threadId || 'main'}`;
386
397
  }
387
398
 
399
+ function normalizeSessionWorkDir(workDir) {
400
+ return typeof workDir === 'string' && workDir.trim() ? workDir.trim() : '';
401
+ }
402
+
403
+ function projectRuntimeKey(workDir) {
404
+ return normalizeSessionWorkDir(workDir) || '__agent_cwd__';
405
+ }
406
+
388
407
  function createThreadId() {
389
408
  return `thr_${Date.now().toString(36)}_${randomUUID().slice(0, 8)}`;
390
409
  }
@@ -394,6 +413,62 @@ const RUNNING_THREAD_STATES = new Set(['queued', 'typing', 'thinking', 'streamin
394
413
  const vpThreads = new Map();
395
414
  /** @type {Map<string, Set<Promise<string|null>>>} */
396
415
  const routePromisesByMsgId = new Map();
416
+ /** @type {Map<string, { workDir: string, skillManager: import('./skills.js').SkillManager, mcpManager: import('./mcp.js').MCPManager, mcpStatus: object, mcpConfig: object, status: { skills: number, mcpServers: string[], mcpFailed: object[], mcpSkipped: object[], tools: number } }>} */
417
+ const projectRuntimes = new Map();
418
+
419
+ function replaceSessionMcpTools(mcpManager) {
420
+ if (!session?.toolRegistry || typeof session.toolRegistry.replaceMcpTools !== 'function') {
421
+ return { removed: 0, added: 0, skipped: true };
422
+ }
423
+ try {
424
+ const result = session.toolRegistry.replaceMcpTools(mcpManager, buildMcpFlattenedTools);
425
+ return { ...result, skipped: false };
426
+ } catch (err) {
427
+ console.warn('[Yeaft] hot-swap MCP tools failed:', err?.message || err);
428
+ return { removed: 0, added: 0, skipped: true, error: err?.message || String(err) };
429
+ }
430
+ }
431
+
432
+ function retargetVpEngines({ skillManager, mcpManager }) {
433
+ for (const eng of vpEngines.values()) {
434
+ try {
435
+ eng.setRuntimeManagers?.({ skillManager, mcpManager });
436
+ } catch { /* best-effort runtime retarget */ }
437
+ }
438
+ }
439
+
440
+ function activateBaseRuntime() {
441
+ const swap = replaceSessionMcpTools(session?.mcpManager);
442
+ retargetVpEngines({
443
+ skillManager: session?.skillManager || null,
444
+ mcpManager: session?.mcpManager || null,
445
+ });
446
+ broadcastSkillSlashCommands(session);
447
+ return swap;
448
+ }
449
+
450
+ function activateProjectRuntime(runtime) {
451
+ if (!runtime) return activateBaseRuntime();
452
+ const swap = replaceSessionMcpTools(runtime.mcpManager);
453
+ retargetVpEngines({
454
+ skillManager: runtime.skillManager,
455
+ mcpManager: runtime.mcpManager,
456
+ });
457
+ runtime.status = {
458
+ ...runtime.status,
459
+ tools: session?.toolRegistry?.size || runtime.status?.tools || 0,
460
+ };
461
+ broadcastSkillSlashCommands(session, [runtime.skillManager]);
462
+ return swap;
463
+ }
464
+
465
+ async function shutdownProjectRuntimes() {
466
+ const runtimes = Array.from(projectRuntimes.values());
467
+ projectRuntimes.clear();
468
+ await Promise.all(runtimes.map(async (runtime) => {
469
+ try { await runtime?.mcpManager?.disconnectAll?.(); } catch { /* best-effort shutdown */ }
470
+ }));
471
+ }
397
472
 
398
473
  function getVpThreadMap(sessionId, vpId) {
399
474
  const key = vpKey(sessionId, vpId);
@@ -575,7 +650,8 @@ function invalidateGroupContext(sessionId) {
575
650
  // Engines are NOT torn down here on purpose. They hold subordinate
576
651
  // state (AMS adjustments) that should survive a meta change and a
577
652
  // closed sessionHandle — they don't reach the on-disk group meta
578
- // directly. They *are* dropped on `resetYeaftSession`.
653
+ // directly. Project runtime managers are hot-swapped before each turn.
654
+ // They *are* dropped on `resetYeaftSession`.
579
655
  }
580
656
 
581
657
  /**
@@ -1703,6 +1779,7 @@ export async function __testDrainVpDrivers() {
1703
1779
  * a half-aborted controller writing to a now-cleared map.
1704
1780
  */
1705
1781
  export async function __testResetVpState() {
1782
+ await shutdownProjectRuntimes();
1706
1783
  for (const ctrl of vpAborts.values()) {
1707
1784
  try { if (!ctrl.signal.aborted) ctrl.abort(); } catch { /* */ }
1708
1785
  }
@@ -1777,23 +1854,110 @@ export function buildSkillSlashCommands(skillManager) {
1777
1854
  return { commands, descriptions };
1778
1855
  }
1779
1856
 
1780
- function broadcastSkillSlashCommands(sessionLike) {
1781
- const { commands, descriptions } = buildSkillSlashCommands(sessionLike?.skillManager);
1782
- const slashCommands = [...new Set([...(ctx.slashCommands || []), ...commands])];
1783
- const slashCommandDescriptions = {
1784
- ...(ctx.slashCommandDescriptions || {}),
1785
- ...descriptions,
1786
- };
1857
+ export function buildMergedSkillSlashCommands(skillManagers = []) {
1858
+ const commands = [];
1859
+ const descriptions = {};
1860
+ for (const manager of skillManagers) {
1861
+ const built = buildSkillSlashCommands(manager);
1862
+ for (const cmd of built.commands) commands.push(cmd);
1863
+ Object.assign(descriptions, built.descriptions);
1864
+ }
1865
+ return { commands: [...new Set(commands)].sort((a, b) => a.localeCompare(b)), descriptions };
1866
+ }
1867
+
1868
+ function broadcastSkillSlashCommands(sessionLike, extraSkillManagers = []) {
1869
+ const managers = [sessionLike?.skillManager, ...extraSkillManagers].filter(Boolean);
1870
+ const { commands, descriptions } = buildMergedSkillSlashCommands(managers);
1871
+ const nonSkillCommands = (ctx.slashCommands || [])
1872
+ .filter(cmd => !(typeof cmd === 'string' && cmd.startsWith(SKILL_COMMAND_PREFIX)));
1873
+ const slashCommands = [...new Set([...nonSkillCommands, ...commands])];
1874
+ const slashCommandDescriptions = Object.fromEntries(
1875
+ Object.entries(ctx.slashCommandDescriptions || {})
1876
+ .filter(([cmd]) => !(typeof cmd === 'string' && cmd.startsWith(SKILL_COMMAND_PREFIX)))
1877
+ );
1878
+ Object.assign(slashCommandDescriptions, descriptions);
1787
1879
  ctx.slashCommands = slashCommands;
1788
1880
  ctx.slashCommandDescriptions = slashCommandDescriptions;
1789
1881
  sendToServer({
1790
1882
  type: 'slash_commands_update',
1791
- conversationId: '__preload__',
1883
+ agentId: ctx.AGENT_ID || ctx.agentId || null,
1884
+ conversationId: yeaftConversationId || '__preload__',
1792
1885
  slashCommands,
1793
1886
  slashCommandDescriptions,
1794
1887
  });
1795
1888
  }
1796
1889
 
1890
+ async function loadProjectRuntime(workDir) {
1891
+ if (!session) return null;
1892
+ const normalizedWorkDir = normalizeSessionWorkDir(workDir);
1893
+ if (!normalizedWorkDir) {
1894
+ activateBaseRuntime();
1895
+ return null;
1896
+ }
1897
+ const key = projectRuntimeKey(normalizedWorkDir);
1898
+ const cached = projectRuntimes.get(key);
1899
+ if (cached) {
1900
+ activateProjectRuntime(cached);
1901
+ return cached;
1902
+ }
1903
+
1904
+ const yeaftDir = ctx.CONFIG?.yeaftDir || session.yeaftDir || DEFAULT_YEAFT_DIR;
1905
+ const skillRoots = normalizedWorkDir && normalizedWorkDir !== process.cwd()
1906
+ ? `${process.cwd()}${delimiter}${normalizedWorkDir}`
1907
+ : normalizedWorkDir;
1908
+ const skillManager = createSkillManager(yeaftDir, skillRoots);
1909
+ const mcpConfig = loadMCPConfig(yeaftDir, undefined, normalizedWorkDir);
1910
+ const mcpManager = new MCPManager();
1911
+ let mcpStatus = { connected: [], failed: [] };
1912
+ if (mcpConfig.servers.length > 0) {
1913
+ mcpStatus = await mcpManager.connectAll(mcpConfig.servers);
1914
+ }
1915
+ const runtime = {
1916
+ workDir: normalizedWorkDir,
1917
+ skillManager,
1918
+ mcpManager,
1919
+ mcpStatus,
1920
+ mcpConfig,
1921
+ status: {
1922
+ skills: skillManager.size,
1923
+ mcpServers: mcpStatus.connected,
1924
+ mcpFailed: mcpStatus.failed,
1925
+ mcpSkipped: mcpConfig.skipped || [],
1926
+ tools: session.toolRegistry?.size || 0,
1927
+ },
1928
+ };
1929
+ projectRuntimes.set(key, runtime);
1930
+ activateProjectRuntime(runtime);
1931
+ return runtime;
1932
+ }
1933
+
1934
+ async function ensureProjectRuntimeForSessionMeta(sessionMeta) {
1935
+ const workDir = normalizeSessionWorkDir(sessionMeta?.workDir);
1936
+ if (!workDir) {
1937
+ activateBaseRuntime();
1938
+ return null;
1939
+ }
1940
+ try {
1941
+ return await loadProjectRuntime(workDir);
1942
+ } catch (err) {
1943
+ console.warn('[Yeaft] project runtime load failed for %s: %s', workDir, err?.message || err);
1944
+ activateBaseRuntime();
1945
+ return null;
1946
+ }
1947
+ }
1948
+
1949
+ function mergedStatusForProjectRuntime(runtime) {
1950
+ if (!session?.status || !runtime?.status) return session?.status || { skills: 0, mcpServers: [], tools: 0 };
1951
+ return {
1952
+ ...session.status,
1953
+ skills: Math.max(Number(session.status.skills) || 0, Number(runtime.status.skills) || 0),
1954
+ mcpServers: [...new Set([...(session.status.mcpServers || []), ...(runtime.status.mcpServers || [])])],
1955
+ mcpFailed: [...(session.status.mcpFailed || []), ...(runtime.status.mcpFailed || [])],
1956
+ mcpSkipped: [...(session.status.mcpSkipped || []), ...(runtime.status.mcpSkipped || [])],
1957
+ tools: Math.max(Number(session.status.tools) || 0, Number(runtime.status.tools) || 0),
1958
+ };
1959
+ }
1960
+
1797
1961
  /** Send a Yeaft Session metadata event over the legacy-compatible envelope. */
1798
1962
  function sendSessionEvent(event, { sessionId, chatId, vpId, turnId, threadId, perfTraceId } = {}) {
1799
1963
  sendToServer({
@@ -3121,15 +3285,13 @@ export async function handleYeaftSessionSend(msg) {
3121
3285
  return;
3122
3286
  }
3123
3287
 
3124
- const ensureSessionStart = perfNowMs();
3125
- await ensureSessionLoaded();
3126
- traceDuration('session_send.ensure_session_loaded', ensureSessionStart);
3127
-
3128
- // Open the session. The default `grp_default` no longer self-seeds
3129
- // here — session creation happens up-front via `handleYeaftCreateSession`,
3130
- // so a missing dir surfaces a clear error rather than masking it.
3288
+ // Open the session metadata first so a workDir-backed Session can load its
3289
+ // project-tier skills and MCP before the first engine turn. The runtime boot
3290
+ // below uses the same workDir; if metadata is missing we still boot the agent
3291
+ // runtime for a useful error response, then fail on the session open check.
3131
3292
  let sessionHandle = null;
3132
3293
  let sessionRoot = null;
3294
+ let sessionMetaForRuntime = null;
3133
3295
  const openSessionStart = perfNowMs();
3134
3296
  try {
3135
3297
  const groupYeaftDir = resolveSessionYeaftDir(yeaftDir, sessionId);
@@ -3137,6 +3299,7 @@ export async function handleYeaftSessionSend(msg) {
3137
3299
  const dir = join(sessionRoot, sessionId);
3138
3300
  if (existsSync(dir) && loadSessionMeta(dir)) {
3139
3301
  sessionHandle = openSession(sessionRoot, sessionId);
3302
+ sessionMetaForRuntime = sessionHandle.getMeta();
3140
3303
  } else {
3141
3304
  // fix-yeaft-session-server-persistence: the `grp_default` on-the-
3142
3305
  // fly seed used to manufacture a missing session here. That
@@ -3149,9 +3312,14 @@ export async function handleYeaftSessionSend(msg) {
3149
3312
  } catch (err) {
3150
3313
  console.warn('[Yeaft] yeaft_session_chat: session open failed', err?.message || err);
3151
3314
  }
3152
-
3153
3315
  traceDuration('session_send.open_session', openSessionStart, { ok: !!sessionHandle });
3154
3316
 
3317
+ const ensureSessionStart = perfNowMs();
3318
+ await ensureSessionLoaded();
3319
+ await ensureProjectRuntimeForSessionMeta(sessionMetaForRuntime);
3320
+ traceDuration('session_send.ensure_session_loaded', ensureSessionStart);
3321
+
3322
+
3155
3323
  if (!sessionHandle) {
3156
3324
  sendSessionOutputFrame({
3157
3325
  type: 'assistant',
@@ -3540,8 +3708,10 @@ async function ensureSessionLoaded() {
3540
3708
  }
3541
3709
 
3542
3710
  yeaftConversationId = `yeaft-${Date.now()}`;
3543
- hydrateYeaftStatusFromSession(session, { reason: 'session_ready', emitEvent: true });
3544
- broadcastSkillSlashCommands(session);
3711
+ const bootProjectRuntime = workDir ? await ensureProjectRuntimeForSessionMeta({ workDir }) : null;
3712
+ const bootStatus = mergedStatusForProjectRuntime(bootProjectRuntime);
3713
+ hydrateYeaftStatusFromSession({ ...session, status: bootStatus }, { reason: 'session_ready', emitEvent: true });
3714
+ broadcastSkillSlashCommands(session, bootProjectRuntime ? [bootProjectRuntime.skillManager] : []);
3545
3715
 
3546
3716
  // Per-group history is hydrated lazily on first `getOrCreateSessionHistory`
3547
3717
  // — there's no global "all conversations" tape any more.
@@ -3552,9 +3722,9 @@ async function ensureSessionLoaded() {
3552
3722
  model: session.config.primaryModel || session.config.model,
3553
3723
  modelEffort: session.config.modelEffort || null,
3554
3724
  availableModels: session.config.availableModels || [],
3555
- skills: session.status.skills,
3556
- mcpServers: session.status.mcpServers,
3557
- tools: session.status.tools,
3725
+ skills: bootStatus.skills,
3726
+ mcpServers: bootStatus.mcpServers,
3727
+ tools: bootStatus.tools,
3558
3728
  yeaftDir: ctx.CONFIG?.yeaftDir || null,
3559
3729
  tasks: session.taskManager ? session.taskManager.listActiveTasks() : [],
3560
3730
  });
@@ -3795,8 +3965,20 @@ async function runVpTurn({ prompt, promptParts = null, sessionId, vpId, threadId
3795
3965
  envelope: inboundEnvelope,
3796
3966
  threadId,
3797
3967
  });
3968
+ const projectRuntime = await ensureProjectRuntimeForSessionMeta(sessionCoordinator?.group?.getMeta?.());
3798
3969
 
3799
3970
  vpEngine = getOrCreateVpEngine(sessionId, vpId, threadId);
3971
+ if (projectRuntime) {
3972
+ vpEngine.setRuntimeManagers?.({
3973
+ skillManager: projectRuntime.skillManager,
3974
+ mcpManager: projectRuntime.mcpManager,
3975
+ });
3976
+ } else {
3977
+ vpEngine.setRuntimeManagers?.({
3978
+ skillManager: session?.skillManager || null,
3979
+ mcpManager: session?.mcpManager || null,
3980
+ });
3981
+ }
3800
3982
  if (thread) thread.engine = vpEngine;
3801
3983
 
3802
3984
  const inboundInjectedBy = inboundEnvelope?.msg?.meta?.injectedBy;
@@ -5233,15 +5415,25 @@ export async function handleYeaftLoadHistory(msg) {
5233
5415
  }
5234
5416
  historyAlreadyReplayed = true;
5235
5417
 
5418
+ let sessionMetaForRuntime = null;
5419
+ if (sessionId) {
5420
+ try {
5421
+ const groupYeaftDir = resolveSessionYeaftDir(yeaftDir, sessionId);
5422
+ const metaDir = join(sessionsRoot(groupYeaftDir), sessionId);
5423
+ sessionMetaForRuntime = loadSessionMeta(metaDir);
5424
+ } catch { /* best-effort metadata hint */ }
5425
+ }
5236
5426
  const bootStart = perfNowMs();
5237
5427
  session = await loadSession({
5238
5428
  dir: yeaftDir,
5429
+ ...(normalizeSessionWorkDir(sessionMetaForRuntime?.workDir) && { workDir: normalizeSessionWorkDir(sessionMetaForRuntime.workDir) }),
5239
5430
  skipMCP: false,
5240
5431
  skipSkills: false,
5241
5432
  serverMode: true,
5242
5433
  });
5243
5434
  traceDuration('history.load_session_runtime', bootStart);
5244
5435
  installYeaftRuntimeBridge(session);
5436
+ await ensureProjectRuntimeForSessionMeta(sessionMetaForRuntime);
5245
5437
 
5246
5438
  // Per-group history hydrates lazily via getOrCreateSessionHistory.
5247
5439
  // When the load-history call carries a sessionId, force-refresh THAT
@@ -5352,6 +5544,7 @@ export async function handleYeaftLoadMoreHistory(msg) {
5352
5544
  * session, then re-initialises so the frontend gets fresh config.
5353
5545
  */
5354
5546
  export async function resetYeaftSession() {
5547
+ await shutdownProjectRuntimes();
5355
5548
  if (currentAbortCtrl && !currentAbortCtrl.signal.aborted) {
5356
5549
  try { currentAbortCtrl.abort(); } catch { /* ignore */ }
5357
5550
  }
@@ -5415,6 +5608,7 @@ export async function resetYeaftSession() {
5415
5608
 
5416
5609
  yeaftConversationId = `yeaft-${Date.now()}`;
5417
5610
  hydrateYeaftStatusFromSession(session, { reason: 'reset', emitEvent: true });
5611
+ broadcastSkillSlashCommands(session);
5418
5612
 
5419
5613
  // Per-group history hydrates lazily via getOrCreateSessionHistory on
5420
5614
  // first read. Nothing to seed here.
@@ -5495,16 +5689,7 @@ function mcpRuntimeSnapshot() {
5495
5689
  * pick up the change.
5496
5690
  */
5497
5691
  function hotSwapMcpTools() {
5498
- if (!session?.toolRegistry || typeof session.toolRegistry.replaceMcpTools !== 'function') {
5499
- return { removed: 0, added: 0, skipped: true };
5500
- }
5501
- try {
5502
- const result = session.toolRegistry.replaceMcpTools(session.mcpManager, buildMcpFlattenedTools);
5503
- return { ...result, skipped: false };
5504
- } catch (err) {
5505
- console.warn('[Yeaft] hot-swap MCP tools failed:', err?.message || err);
5506
- return { removed: 0, added: 0, skipped: true, error: err?.message || String(err) };
5507
- }
5692
+ return replaceSessionMcpTools(session?.mcpManager);
5508
5693
  }
5509
5694
 
5510
5695
  /**
@@ -5563,7 +5748,7 @@ export async function handleYeaftMcpAdd(msg = {}) {
5563
5748
  }
5564
5749
  }
5565
5750
 
5566
- const swap = hotSwapMcpTools();
5751
+ const swap = replaceSessionMcpTools(session?.mcpManager);
5567
5752
 
5568
5753
  sendToServer({
5569
5754
  type: 'yeaft_mcp_add_result',
@@ -5600,7 +5785,7 @@ export async function handleYeaftMcpRemove(msg = {}) {
5600
5785
  }
5601
5786
  }
5602
5787
 
5603
- const swap = hotSwapMcpTools();
5788
+ const swap = replaceSessionMcpTools(session?.mcpManager);
5604
5789
 
5605
5790
  sendToServer({
5606
5791
  type: 'yeaft_mcp_remove_result',
@@ -5658,7 +5843,7 @@ export async function handleYeaftMcpReload(msg = {}) {
5658
5843
  console.warn('[Yeaft] MCP reload failed:', err?.message || err);
5659
5844
  }
5660
5845
 
5661
- const swap = hotSwapMcpTools();
5846
+ const swap = replaceSessionMcpTools(session?.mcpManager);
5662
5847
 
5663
5848
  sendToServer({
5664
5849
  type: 'yeaft_mcp_reload_result',
@@ -5692,6 +5877,28 @@ export const __testHooks = {
5692
5877
  return getVpStatusBroker().transition(status);
5693
5878
  },
5694
5879
  decorateSessionsWithRuntimeState,
5880
+ async loadProjectRuntime(workDir) {
5881
+ return loadProjectRuntime(workDir);
5882
+ },
5883
+ seedProjectRuntime(workDir, runtime) {
5884
+ const normalizedWorkDir = normalizeSessionWorkDir(workDir);
5885
+ const seeded = {
5886
+ workDir: normalizedWorkDir,
5887
+ skillManager: runtime?.skillManager || { list: () => [] },
5888
+ mcpManager: runtime?.mcpManager || { listTools: () => [], disconnectAll: async () => {} },
5889
+ mcpStatus: runtime?.mcpStatus || { connected: [], failed: [] },
5890
+ mcpConfig: runtime?.mcpConfig || { servers: [], skipped: [] },
5891
+ status: runtime?.status || { skills: 0, mcpServers: [], mcpFailed: [], mcpSkipped: [], tools: 0 },
5892
+ };
5893
+ projectRuntimes.set(projectRuntimeKey(normalizedWorkDir), seeded);
5894
+ return seeded;
5895
+ },
5896
+ async shutdownProjectRuntimes() {
5897
+ return shutdownProjectRuntimes();
5898
+ },
5899
+ projectRuntimeCount() {
5900
+ return projectRuntimes.size;
5901
+ },
5695
5902
  seedQueuedVpTurn({ sessionId = 'session-test', vpId = 'vp-test', threadId = 'main', turnId = 'turn-test' } = {}) {
5696
5903
  const key = threadKey(sessionId, vpId, threadId);
5697
5904
  const inbox = vpInboxes.get(key) || [];