@yeaft/webchat-agent 1.0.57 → 1.0.59

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.59",
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';
@@ -79,6 +81,13 @@ const SKILL_COMMAND_PREFIX = 'skill:';
79
81
  /** @type {import('./session.js').Session | null} */
80
82
  let session = null;
81
83
 
84
+ /**
85
+ * Single-flight runtime boot. History replay must not wait for this promise on
86
+ * cold load; message send still awaits it through ensureSessionLoaded().
87
+ * @type {Promise<import('./session.js').Session> | null}
88
+ */
89
+ let sessionLoadPromise = null;
90
+
82
91
  let threadClassifier = defaultClassifyThread;
83
92
 
84
93
  function applyLiveLanguage(language) {
@@ -168,20 +177,29 @@ async function sendDreamSnapshotForSession(sessionId, extra = {}) {
168
177
  function scheduleYeaftLoadHistoryMetadataReplay(sessionId) {
169
178
  const replaySession = session;
170
179
  const replayConversationId = yeaftConversationId;
171
- setTimeout(() => {
180
+ setTimeout(async () => {
172
181
  try {
173
182
  if (!replaySession) return;
174
183
  refreshLiveSessionConfig();
175
- hydrateYeaftStatusFromSession(replaySession, { reason: 'history_load', emitEvent: true });
184
+ let projectRuntime = null;
185
+ if (sessionId) {
186
+ try {
187
+ const groupYeaftDir = resolveSessionYeaftDir(ctx.CONFIG?.yeaftDir || DEFAULT_YEAFT_DIR, sessionId);
188
+ const meta = loadSessionMeta(join(sessionsRoot(groupYeaftDir), sessionId));
189
+ projectRuntime = await ensureProjectRuntimeForSessionMeta(meta);
190
+ } catch { /* best-effort project metadata */ }
191
+ }
192
+ const status = mergedStatusForProjectRuntime(projectRuntime);
193
+ hydrateYeaftStatusFromSession({ ...replaySession, status }, { reason: 'history_load', emitEvent: true });
176
194
  sendSessionEvent({
177
195
  type: 'session_ready',
178
196
  conversationId: replayConversationId,
179
197
  model: replaySession.config.primaryModel || replaySession.config.model,
180
198
  modelEffort: replaySession.config.modelEffort || null,
181
199
  availableModels: replaySession.config.availableModels || [],
182
- skills: replaySession.status.skills,
183
- mcpServers: replaySession.status.mcpServers,
184
- tools: replaySession.status.tools,
200
+ skills: status.skills,
201
+ mcpServers: status.mcpServers,
202
+ tools: status.tools,
185
203
  yeaftDir: ctx.CONFIG?.yeaftDir || null,
186
204
  tasks: replaySession.taskManager ? replaySession.taskManager.listActiveTasks() : [],
187
205
  });
@@ -385,6 +403,14 @@ function threadKey(sessionId, vpId, threadId) {
385
403
  return `${sessionId}::${vpId}::${threadId || 'main'}`;
386
404
  }
387
405
 
406
+ function normalizeSessionWorkDir(workDir) {
407
+ return typeof workDir === 'string' && workDir.trim() ? workDir.trim() : '';
408
+ }
409
+
410
+ function projectRuntimeKey(workDir) {
411
+ return normalizeSessionWorkDir(workDir) || '__agent_cwd__';
412
+ }
413
+
388
414
  function createThreadId() {
389
415
  return `thr_${Date.now().toString(36)}_${randomUUID().slice(0, 8)}`;
390
416
  }
@@ -394,6 +420,62 @@ const RUNNING_THREAD_STATES = new Set(['queued', 'typing', 'thinking', 'streamin
394
420
  const vpThreads = new Map();
395
421
  /** @type {Map<string, Set<Promise<string|null>>>} */
396
422
  const routePromisesByMsgId = new Map();
423
+ /** @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 } }>} */
424
+ const projectRuntimes = new Map();
425
+
426
+ function replaceSessionMcpTools(mcpManager) {
427
+ if (!session?.toolRegistry || typeof session.toolRegistry.replaceMcpTools !== 'function') {
428
+ return { removed: 0, added: 0, skipped: true };
429
+ }
430
+ try {
431
+ const result = session.toolRegistry.replaceMcpTools(mcpManager, buildMcpFlattenedTools);
432
+ return { ...result, skipped: false };
433
+ } catch (err) {
434
+ console.warn('[Yeaft] hot-swap MCP tools failed:', err?.message || err);
435
+ return { removed: 0, added: 0, skipped: true, error: err?.message || String(err) };
436
+ }
437
+ }
438
+
439
+ function retargetVpEngines({ skillManager, mcpManager }) {
440
+ for (const eng of vpEngines.values()) {
441
+ try {
442
+ eng.setRuntimeManagers?.({ skillManager, mcpManager });
443
+ } catch { /* best-effort runtime retarget */ }
444
+ }
445
+ }
446
+
447
+ function activateBaseRuntime() {
448
+ const swap = replaceSessionMcpTools(session?.mcpManager);
449
+ retargetVpEngines({
450
+ skillManager: session?.skillManager || null,
451
+ mcpManager: session?.mcpManager || null,
452
+ });
453
+ broadcastSkillSlashCommands(session);
454
+ return swap;
455
+ }
456
+
457
+ function activateProjectRuntime(runtime) {
458
+ if (!runtime) return activateBaseRuntime();
459
+ const swap = replaceSessionMcpTools(runtime.mcpManager);
460
+ retargetVpEngines({
461
+ skillManager: runtime.skillManager,
462
+ mcpManager: runtime.mcpManager,
463
+ });
464
+ runtime.status = {
465
+ ...runtime.status,
466
+ tools: session?.toolRegistry?.size || runtime.status?.tools || 0,
467
+ };
468
+ broadcastSkillSlashCommands(session, [runtime.skillManager]);
469
+ return swap;
470
+ }
471
+
472
+ async function shutdownProjectRuntimes() {
473
+ const runtimes = Array.from(projectRuntimes.values());
474
+ projectRuntimes.clear();
475
+ await Promise.all(runtimes.map(async (runtime) => {
476
+ try { await runtime?.mcpManager?.disconnectAll?.(); } catch { /* best-effort shutdown */ }
477
+ }));
478
+ }
397
479
 
398
480
  function getVpThreadMap(sessionId, vpId) {
399
481
  const key = vpKey(sessionId, vpId);
@@ -575,7 +657,8 @@ function invalidateGroupContext(sessionId) {
575
657
  // Engines are NOT torn down here on purpose. They hold subordinate
576
658
  // state (AMS adjustments) that should survive a meta change and a
577
659
  // closed sessionHandle — they don't reach the on-disk group meta
578
- // directly. They *are* dropped on `resetYeaftSession`.
660
+ // directly. Project runtime managers are hot-swapped before each turn.
661
+ // They *are* dropped on `resetYeaftSession`.
579
662
  }
580
663
 
581
664
  /**
@@ -1084,6 +1167,7 @@ export function __testGroupHistory(sessionId) {
1084
1167
  */
1085
1168
  export function __testSetSession(sessionLike) {
1086
1169
  session = sessionLike;
1170
+ sessionLoadPromise = null;
1087
1171
  if (!sessionLike) yeaftConversationId = null;
1088
1172
  }
1089
1173
 
@@ -1703,6 +1787,7 @@ export async function __testDrainVpDrivers() {
1703
1787
  * a half-aborted controller writing to a now-cleared map.
1704
1788
  */
1705
1789
  export async function __testResetVpState() {
1790
+ await shutdownProjectRuntimes();
1706
1791
  for (const ctrl of vpAborts.values()) {
1707
1792
  try { if (!ctrl.signal.aborted) ctrl.abort(); } catch { /* */ }
1708
1793
  }
@@ -1777,23 +1862,110 @@ export function buildSkillSlashCommands(skillManager) {
1777
1862
  return { commands, descriptions };
1778
1863
  }
1779
1864
 
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
- };
1865
+ export function buildMergedSkillSlashCommands(skillManagers = []) {
1866
+ const commands = [];
1867
+ const descriptions = {};
1868
+ for (const manager of skillManagers) {
1869
+ const built = buildSkillSlashCommands(manager);
1870
+ for (const cmd of built.commands) commands.push(cmd);
1871
+ Object.assign(descriptions, built.descriptions);
1872
+ }
1873
+ return { commands: [...new Set(commands)].sort((a, b) => a.localeCompare(b)), descriptions };
1874
+ }
1875
+
1876
+ function broadcastSkillSlashCommands(sessionLike, extraSkillManagers = []) {
1877
+ const managers = [sessionLike?.skillManager, ...extraSkillManagers].filter(Boolean);
1878
+ const { commands, descriptions } = buildMergedSkillSlashCommands(managers);
1879
+ const nonSkillCommands = (ctx.slashCommands || [])
1880
+ .filter(cmd => !(typeof cmd === 'string' && cmd.startsWith(SKILL_COMMAND_PREFIX)));
1881
+ const slashCommands = [...new Set([...nonSkillCommands, ...commands])];
1882
+ const slashCommandDescriptions = Object.fromEntries(
1883
+ Object.entries(ctx.slashCommandDescriptions || {})
1884
+ .filter(([cmd]) => !(typeof cmd === 'string' && cmd.startsWith(SKILL_COMMAND_PREFIX)))
1885
+ );
1886
+ Object.assign(slashCommandDescriptions, descriptions);
1787
1887
  ctx.slashCommands = slashCommands;
1788
1888
  ctx.slashCommandDescriptions = slashCommandDescriptions;
1789
1889
  sendToServer({
1790
1890
  type: 'slash_commands_update',
1791
- conversationId: '__preload__',
1891
+ agentId: ctx.AGENT_ID || ctx.agentId || null,
1892
+ conversationId: yeaftConversationId || '__preload__',
1792
1893
  slashCommands,
1793
1894
  slashCommandDescriptions,
1794
1895
  });
1795
1896
  }
1796
1897
 
1898
+ async function loadProjectRuntime(workDir) {
1899
+ if (!session) return null;
1900
+ const normalizedWorkDir = normalizeSessionWorkDir(workDir);
1901
+ if (!normalizedWorkDir) {
1902
+ activateBaseRuntime();
1903
+ return null;
1904
+ }
1905
+ const key = projectRuntimeKey(normalizedWorkDir);
1906
+ const cached = projectRuntimes.get(key);
1907
+ if (cached) {
1908
+ activateProjectRuntime(cached);
1909
+ return cached;
1910
+ }
1911
+
1912
+ const yeaftDir = ctx.CONFIG?.yeaftDir || session.yeaftDir || DEFAULT_YEAFT_DIR;
1913
+ const skillRoots = normalizedWorkDir && normalizedWorkDir !== process.cwd()
1914
+ ? `${process.cwd()}${delimiter}${normalizedWorkDir}`
1915
+ : normalizedWorkDir;
1916
+ const skillManager = createSkillManager(yeaftDir, skillRoots);
1917
+ const mcpConfig = loadMCPConfig(yeaftDir, undefined, normalizedWorkDir);
1918
+ const mcpManager = new MCPManager();
1919
+ let mcpStatus = { connected: [], failed: [] };
1920
+ if (mcpConfig.servers.length > 0) {
1921
+ mcpStatus = await mcpManager.connectAll(mcpConfig.servers);
1922
+ }
1923
+ const runtime = {
1924
+ workDir: normalizedWorkDir,
1925
+ skillManager,
1926
+ mcpManager,
1927
+ mcpStatus,
1928
+ mcpConfig,
1929
+ status: {
1930
+ skills: skillManager.size,
1931
+ mcpServers: mcpStatus.connected,
1932
+ mcpFailed: mcpStatus.failed,
1933
+ mcpSkipped: mcpConfig.skipped || [],
1934
+ tools: session.toolRegistry?.size || 0,
1935
+ },
1936
+ };
1937
+ projectRuntimes.set(key, runtime);
1938
+ activateProjectRuntime(runtime);
1939
+ return runtime;
1940
+ }
1941
+
1942
+ async function ensureProjectRuntimeForSessionMeta(sessionMeta) {
1943
+ const workDir = normalizeSessionWorkDir(sessionMeta?.workDir);
1944
+ if (!workDir) {
1945
+ activateBaseRuntime();
1946
+ return null;
1947
+ }
1948
+ try {
1949
+ return await loadProjectRuntime(workDir);
1950
+ } catch (err) {
1951
+ console.warn('[Yeaft] project runtime load failed for %s: %s', workDir, err?.message || err);
1952
+ activateBaseRuntime();
1953
+ return null;
1954
+ }
1955
+ }
1956
+
1957
+ function mergedStatusForProjectRuntime(runtime) {
1958
+ if (!session?.status || !runtime?.status) return session?.status || { skills: 0, mcpServers: [], tools: 0 };
1959
+ return {
1960
+ ...session.status,
1961
+ skills: Math.max(Number(session.status.skills) || 0, Number(runtime.status.skills) || 0),
1962
+ mcpServers: [...new Set([...(session.status.mcpServers || []), ...(runtime.status.mcpServers || [])])],
1963
+ mcpFailed: [...(session.status.mcpFailed || []), ...(runtime.status.mcpFailed || [])],
1964
+ mcpSkipped: [...(session.status.mcpSkipped || []), ...(runtime.status.mcpSkipped || [])],
1965
+ tools: Math.max(Number(session.status.tools) || 0, Number(runtime.status.tools) || 0),
1966
+ };
1967
+ }
1968
+
1797
1969
  /** Send a Yeaft Session metadata event over the legacy-compatible envelope. */
1798
1970
  function sendSessionEvent(event, { sessionId, chatId, vpId, turnId, threadId, perfTraceId } = {}) {
1799
1971
  sendToServer({
@@ -3121,15 +3293,13 @@ export async function handleYeaftSessionSend(msg) {
3121
3293
  return;
3122
3294
  }
3123
3295
 
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.
3296
+ // Open the session metadata first so a workDir-backed Session can load its
3297
+ // project-tier skills and MCP before the first engine turn. The runtime boot
3298
+ // below uses the same workDir; if metadata is missing we still boot the agent
3299
+ // runtime for a useful error response, then fail on the session open check.
3131
3300
  let sessionHandle = null;
3132
3301
  let sessionRoot = null;
3302
+ let sessionMetaForRuntime = null;
3133
3303
  const openSessionStart = perfNowMs();
3134
3304
  try {
3135
3305
  const groupYeaftDir = resolveSessionYeaftDir(yeaftDir, sessionId);
@@ -3137,6 +3307,7 @@ export async function handleYeaftSessionSend(msg) {
3137
3307
  const dir = join(sessionRoot, sessionId);
3138
3308
  if (existsSync(dir) && loadSessionMeta(dir)) {
3139
3309
  sessionHandle = openSession(sessionRoot, sessionId);
3310
+ sessionMetaForRuntime = sessionHandle.getMeta();
3140
3311
  } else {
3141
3312
  // fix-yeaft-session-server-persistence: the `grp_default` on-the-
3142
3313
  // fly seed used to manufacture a missing session here. That
@@ -3149,9 +3320,14 @@ export async function handleYeaftSessionSend(msg) {
3149
3320
  } catch (err) {
3150
3321
  console.warn('[Yeaft] yeaft_session_chat: session open failed', err?.message || err);
3151
3322
  }
3152
-
3153
3323
  traceDuration('session_send.open_session', openSessionStart, { ok: !!sessionHandle });
3154
3324
 
3325
+ const ensureSessionStart = perfNowMs();
3326
+ await ensureSessionLoaded({ sessionMeta: sessionMetaForRuntime, perfTraceId });
3327
+ await ensureProjectRuntimeForSessionMeta(sessionMetaForRuntime);
3328
+ traceDuration('session_send.ensure_session_loaded', ensureSessionStart);
3329
+
3330
+
3155
3331
  if (!sessionHandle) {
3156
3332
  sendSessionOutputFrame({
3157
3333
  type: 'assistant',
@@ -3498,78 +3674,125 @@ export function buildVpQueryOpts({ vpId, sessionCoordinator, sessionId, envelope
3498
3674
  }
3499
3675
 
3500
3676
  /**
3501
- * Lazy session boot. Idempotent: subsequent calls are no-ops once `session`
3502
- * is set. Emits `session_ready` on first init so the frontend can finalize
3503
- * its handshake.
3677
+ * Lazy session boot. Idempotent: concurrent callers share one in-flight promise.
3678
+ * Emits `session_ready` on first init so the frontend can finalize its handshake.
3679
+ *
3680
+ * @param {{ workDir?: string, sessionId?: string|null, sessionMeta?: object, perfTraceId?: string|null, messageType?: string }} [opts]
3681
+ * @returns {Promise<import('./session.js').Session>}
3504
3682
  */
3505
- async function ensureSessionLoaded() {
3506
- if (session) return;
3683
+ async function ensureSessionLoaded(opts = {}) {
3684
+ if (session) return session;
3685
+ if (sessionLoadPromise) return sessionLoadPromise;
3507
3686
 
3508
- const yeaftDir = ctx.CONFIG?.yeaftDir;
3509
- session = await loadSession({
3510
- ...(yeaftDir && { dir: yeaftDir }),
3511
- skipMCP: false,
3512
- skipSkills: false,
3513
- serverMode: true,
3514
- });
3687
+ sessionLoadPromise = (async () => {
3688
+ const yeaftDir = ctx.CONFIG?.yeaftDir;
3689
+ const normalizedWorkDir = normalizeSessionWorkDir(opts?.workDir || opts?.sessionMeta?.workDir);
3690
+ session = await loadSession({
3691
+ ...(yeaftDir && { dir: yeaftDir }),
3692
+ ...(normalizedWorkDir && { workDir: normalizedWorkDir }),
3693
+ skipMCP: false,
3694
+ skipSkills: false,
3695
+ serverMode: true,
3696
+ });
3515
3697
 
3516
- installYeaftRuntimeBridge(session);
3698
+ installYeaftRuntimeBridge(session);
3517
3699
 
3518
- try {
3519
- if (session.engine && typeof session.engine.setSubAgentEventSink === 'function') {
3520
- session.engine.setSubAgentEventSink((agentId, evt) => {
3521
- try {
3522
- sendSessionEvent({ type: 'sub_agent_event', agentId, payload: evt });
3523
- } catch { /* ignore */ }
3524
- });
3700
+ try {
3701
+ if (session.engine && typeof session.engine.setSubAgentEventSink === 'function') {
3702
+ session.engine.setSubAgentEventSink((agentId, evt) => {
3703
+ try {
3704
+ sendSessionEvent({ type: 'sub_agent_event', agentId, payload: evt });
3705
+ } catch { /* ignore */ }
3706
+ });
3707
+ }
3708
+ } catch (err) {
3709
+ console.warn('[Yeaft] setSubAgentEventSink wiring failed:', err?.message || err);
3525
3710
  }
3526
- } catch (err) {
3527
- console.warn('[Yeaft] setSubAgentEventSink wiring failed:', err?.message || err);
3528
- }
3529
3711
 
3530
- // Bug 8: clean up legacy `.archived-*` group dirs at boot.
3531
- try {
3532
- if (yeaftDir) {
3533
- const removed = purgeArchivedSessions(yeaftDir);
3534
- if (removed && removed.length > 0) {
3535
- console.log(`[Yeaft] purged ${removed.length} legacy .archived group dir(s)`);
3712
+ // Bug 8: clean up legacy `.archived-*` group dirs at boot.
3713
+ try {
3714
+ if (yeaftDir) {
3715
+ const removed = purgeArchivedSessions(yeaftDir);
3716
+ if (removed && removed.length > 0) {
3717
+ console.log(`[Yeaft] purged ${removed.length} legacy .archived group dir(s)`);
3718
+ }
3536
3719
  }
3720
+ } catch (err) {
3721
+ console.warn('[Yeaft] purgeArchivedSessions failed:', err?.message || err);
3537
3722
  }
3538
- } catch (err) {
3539
- console.warn('[Yeaft] purgeArchivedSessions failed:', err?.message || err);
3540
- }
3541
3723
 
3542
- yeaftConversationId = `yeaft-${Date.now()}`;
3543
- hydrateYeaftStatusFromSession(session, { reason: 'session_ready', emitEvent: true });
3544
- broadcastSkillSlashCommands(session);
3724
+ ensureYeaftConversationId();
3725
+ const bootProjectRuntime = normalizedWorkDir ? await ensureProjectRuntimeForSessionMeta({ workDir: normalizedWorkDir }) : null;
3726
+ const bootStatus = mergedStatusForProjectRuntime(bootProjectRuntime);
3727
+ hydrateYeaftStatusFromSession({ ...session, status: bootStatus }, { reason: 'session_ready', emitEvent: true });
3728
+ broadcastSkillSlashCommands(session, bootProjectRuntime ? [bootProjectRuntime.skillManager] : []);
3545
3729
 
3546
- // Per-group history is hydrated lazily on first `getOrCreateSessionHistory`
3547
- // — there's no global "all conversations" tape any more.
3730
+ // Per-group history is hydrated lazily on first `getOrCreateSessionHistory`
3731
+ // — there's no global "all conversations" tape any more.
3732
+
3733
+ sendSessionEvent({
3734
+ type: 'session_ready',
3735
+ conversationId: yeaftConversationId,
3736
+ model: session.config.primaryModel || session.config.model,
3737
+ modelEffort: session.config.modelEffort || null,
3738
+ availableModels: session.config.availableModels || [],
3739
+ skills: bootStatus.skills,
3740
+ mcpServers: bootStatus.mcpServers,
3741
+ tools: bootStatus.tools,
3742
+ yeaftDir: ctx.CONFIG?.yeaftDir || null,
3743
+ tasks: session.taskManager ? session.taskManager.listActiveTasks() : [],
3744
+ }, opts?.perfTraceId ? { sessionId: opts?.sessionMeta?.id || opts?.sessionId || null, perfTraceId: opts.perfTraceId } : undefined);
3745
+ sendSessionSnapshotBroadcast();
3746
+ // vp-status: rebuild frontend status table from authoritative agent
3747
+ // memory. Sent unconditionally so reconnect/refresh paths get the same
3748
+ // bootstrap as first-load (the broker dedup logic makes a redundant
3749
+ // snapshot harmless).
3750
+ try {
3751
+ getVpStatusBroker().broadcastSnapshot();
3752
+ } catch (err) {
3753
+ console.warn('[Yeaft] vp-status snapshot broadcast failed:', err?.message || err);
3754
+ }
3755
+
3756
+ return session;
3757
+ })();
3548
3758
 
3549
- sendSessionEvent({
3550
- type: 'session_ready',
3551
- conversationId: yeaftConversationId,
3552
- model: session.config.primaryModel || session.config.model,
3553
- modelEffort: session.config.modelEffort || null,
3554
- availableModels: session.config.availableModels || [],
3555
- skills: session.status.skills,
3556
- mcpServers: session.status.mcpServers,
3557
- tools: session.status.tools,
3558
- yeaftDir: ctx.CONFIG?.yeaftDir || null,
3559
- tasks: session.taskManager ? session.taskManager.listActiveTasks() : [],
3560
- });
3561
- sendSessionSnapshotBroadcast();
3562
- // vp-status: rebuild frontend status table from authoritative agent
3563
- // memory. Sent unconditionally so reconnect/refresh paths get the same
3564
- // bootstrap as first-load (the broker dedup logic makes a redundant
3565
- // snapshot harmless).
3566
3759
  try {
3567
- getVpStatusBroker().broadcastSnapshot();
3760
+ return await sessionLoadPromise;
3568
3761
  } catch (err) {
3569
- console.warn('[Yeaft] vp-status snapshot broadcast failed:', err?.message || err);
3762
+ session = null;
3763
+ throw err;
3764
+ } finally {
3765
+ sessionLoadPromise = null;
3570
3766
  }
3571
3767
  }
3572
3768
 
3769
+ function startSessionLoadInBackground({ sessionId = null, sessionMeta = null, perfTraceId = null, traceDuration = null, tracePerf = null, phase = 'history.load_session_runtime' } = {}) {
3770
+ if (session) return null;
3771
+ const start = perfNowMs();
3772
+ const promise = ensureSessionLoaded({ sessionId, sessionMeta, perfTraceId })
3773
+ .then(async (loaded) => {
3774
+ if (typeof traceDuration === 'function') traceDuration(phase, start, { detail: { background: true } });
3775
+ if (sessionId && loaded?.conversationStore) {
3776
+ const hydrateStart = perfNowMs();
3777
+ setGroupHistory(sessionId, hydrateGroupHistory(sessionId));
3778
+ if (typeof traceDuration === 'function') traceDuration('history.hydrate_group_history', hydrateStart, { detail: { background: true } });
3779
+ sendDreamSnapshotForSession(sessionId, { trigger: 'load_history' }).catch(() => null);
3780
+ }
3781
+ return loaded;
3782
+ })
3783
+ .catch((err) => {
3784
+ if (typeof tracePerf === 'function') {
3785
+ tracePerf('history.load_session_runtime_error', {
3786
+ ok: false,
3787
+ detail: { background: true, errorName: err?.name || null, errorMessage: err?.message || String(err) },
3788
+ });
3789
+ }
3790
+ console.warn('[Yeaft] background session load failed:', err?.message || err);
3791
+ return null;
3792
+ });
3793
+ return promise;
3794
+ }
3795
+
3573
3796
  /**
3574
3797
  * Wrap {@link runVpTurn} with a hard escalation deadline.
3575
3798
  *
@@ -3795,8 +4018,20 @@ async function runVpTurn({ prompt, promptParts = null, sessionId, vpId, threadId
3795
4018
  envelope: inboundEnvelope,
3796
4019
  threadId,
3797
4020
  });
4021
+ const projectRuntime = await ensureProjectRuntimeForSessionMeta(sessionCoordinator?.group?.getMeta?.());
3798
4022
 
3799
4023
  vpEngine = getOrCreateVpEngine(sessionId, vpId, threadId);
4024
+ if (projectRuntime) {
4025
+ vpEngine.setRuntimeManagers?.({
4026
+ skillManager: projectRuntime.skillManager,
4027
+ mcpManager: projectRuntime.mcpManager,
4028
+ });
4029
+ } else {
4030
+ vpEngine.setRuntimeManagers?.({
4031
+ skillManager: session?.skillManager || null,
4032
+ mcpManager: session?.mcpManager || null,
4033
+ });
4034
+ }
3800
4035
  if (thread) thread.engine = vpEngine;
3801
4036
 
3802
4037
  const inboundInjectedBy = inboundEnvelope?.msg?.meta?.injectedBy;
@@ -5233,26 +5468,19 @@ export async function handleYeaftLoadHistory(msg) {
5233
5468
  }
5234
5469
  historyAlreadyReplayed = true;
5235
5470
 
5236
- const bootStart = perfNowMs();
5237
- session = await loadSession({
5238
- dir: yeaftDir,
5239
- skipMCP: false,
5240
- skipSkills: false,
5241
- serverMode: true,
5242
- });
5243
- traceDuration('history.load_session_runtime', bootStart);
5244
- installYeaftRuntimeBridge(session);
5245
-
5246
- // Per-group history hydrates lazily via getOrCreateSessionHistory.
5247
- // When the load-history call carries a sessionId, force-refresh THAT
5248
- // group's tape so the next user message sees on-disk state. When
5249
- // it doesn't (legacy callers), do nothing — the per-group lazy
5250
- // hydration handles it.
5471
+ let sessionMetaForRuntime = null;
5251
5472
  if (sessionId) {
5252
- const hydrateStart = perfNowMs();
5253
- setGroupHistory(sessionId, hydrateGroupHistory(sessionId));
5254
- traceDuration('history.hydrate_group_history', hydrateStart);
5473
+ try {
5474
+ const groupYeaftDir = resolveSessionYeaftDir(yeaftDir, sessionId);
5475
+ const metaDir = join(sessionsRoot(groupYeaftDir), sessionId);
5476
+ sessionMetaForRuntime = loadSessionMeta(metaDir);
5477
+ } catch { /* best-effort metadata hint */ }
5255
5478
  }
5479
+ // Full runtime boot can be expensive (memory FTS sync, skills, MCP, dream
5480
+ // boot checks). It is not needed to render persisted history, so keep this
5481
+ // request short and let message-send await the same single-flight boot when
5482
+ // the user actually submits a turn.
5483
+ startSessionLoadInBackground({ sessionId, sessionMeta: sessionMetaForRuntime, perfTraceId, traceDuration, tracePerf });
5256
5484
  } else {
5257
5485
  const replayStart = perfNowMs();
5258
5486
  replayHistoryFromStore();
@@ -5260,10 +5488,12 @@ export async function handleYeaftLoadHistory(msg) {
5260
5488
  historyAlreadyReplayed = true;
5261
5489
  }
5262
5490
 
5263
- if (sessionId) {
5491
+ if (session && sessionId) {
5264
5492
  // Re-entering an existing session with a (possibly new) group filter:
5265
5493
  // re-seed THIS group's history from disk so it doesn't carry stale
5266
- // in-memory state into the next turn's context.
5494
+ // in-memory state into the next turn's context. Do not mark it hydrated
5495
+ // before the runtime exists; that would cache an empty tape and starve the
5496
+ // next user turn of persisted context.
5267
5497
  const hydrateStart = perfNowMs();
5268
5498
  setGroupHistory(sessionId, hydrateGroupHistory(sessionId));
5269
5499
  traceDuration('history.hydrate_group_history_final', hydrateStart);
@@ -5273,7 +5503,7 @@ export async function handleYeaftLoadHistory(msg) {
5273
5503
  // never make the history response wait for bulky metadata snapshots. The
5274
5504
  // first visible chunk has already been sent above; defer metadata to the next
5275
5505
  // tick so the browser can paint messages before VP/session/dream snapshots.
5276
- scheduleYeaftLoadHistoryMetadataReplay(sessionId);
5506
+ if (session) scheduleYeaftLoadHistoryMetadataReplay(sessionId);
5277
5507
 
5278
5508
  if (historyAlreadyReplayed) {
5279
5509
  traceDuration('history.handler_total', perfStart, { ok: true });
@@ -5352,6 +5582,7 @@ export async function handleYeaftLoadMoreHistory(msg) {
5352
5582
  * session, then re-initialises so the frontend gets fresh config.
5353
5583
  */
5354
5584
  export async function resetYeaftSession() {
5585
+ await shutdownProjectRuntimes();
5355
5586
  if (currentAbortCtrl && !currentAbortCtrl.signal.aborted) {
5356
5587
  try { currentAbortCtrl.abort(); } catch { /* ignore */ }
5357
5588
  }
@@ -5415,6 +5646,7 @@ export async function resetYeaftSession() {
5415
5646
 
5416
5647
  yeaftConversationId = `yeaft-${Date.now()}`;
5417
5648
  hydrateYeaftStatusFromSession(session, { reason: 'reset', emitEvent: true });
5649
+ broadcastSkillSlashCommands(session);
5418
5650
 
5419
5651
  // Per-group history hydrates lazily via getOrCreateSessionHistory on
5420
5652
  // first read. Nothing to seed here.
@@ -5495,16 +5727,7 @@ function mcpRuntimeSnapshot() {
5495
5727
  * pick up the change.
5496
5728
  */
5497
5729
  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
- }
5730
+ return replaceSessionMcpTools(session?.mcpManager);
5508
5731
  }
5509
5732
 
5510
5733
  /**
@@ -5563,7 +5786,7 @@ export async function handleYeaftMcpAdd(msg = {}) {
5563
5786
  }
5564
5787
  }
5565
5788
 
5566
- const swap = hotSwapMcpTools();
5789
+ const swap = replaceSessionMcpTools(session?.mcpManager);
5567
5790
 
5568
5791
  sendToServer({
5569
5792
  type: 'yeaft_mcp_add_result',
@@ -5600,7 +5823,7 @@ export async function handleYeaftMcpRemove(msg = {}) {
5600
5823
  }
5601
5824
  }
5602
5825
 
5603
- const swap = hotSwapMcpTools();
5826
+ const swap = replaceSessionMcpTools(session?.mcpManager);
5604
5827
 
5605
5828
  sendToServer({
5606
5829
  type: 'yeaft_mcp_remove_result',
@@ -5658,7 +5881,7 @@ export async function handleYeaftMcpReload(msg = {}) {
5658
5881
  console.warn('[Yeaft] MCP reload failed:', err?.message || err);
5659
5882
  }
5660
5883
 
5661
- const swap = hotSwapMcpTools();
5884
+ const swap = replaceSessionMcpTools(session?.mcpManager);
5662
5885
 
5663
5886
  sendToServer({
5664
5887
  type: 'yeaft_mcp_reload_result',
@@ -5678,6 +5901,7 @@ export const __testHooks = {
5678
5901
  buildPendingRescueEnvelope,
5679
5902
  setSessionForTest(nextSession) {
5680
5903
  session = nextSession || null;
5904
+ sessionLoadPromise = null;
5681
5905
  },
5682
5906
  resetAbortState() {
5683
5907
  turnAbortCtrls.clear();
@@ -5692,6 +5916,28 @@ export const __testHooks = {
5692
5916
  return getVpStatusBroker().transition(status);
5693
5917
  },
5694
5918
  decorateSessionsWithRuntimeState,
5919
+ async loadProjectRuntime(workDir) {
5920
+ return loadProjectRuntime(workDir);
5921
+ },
5922
+ seedProjectRuntime(workDir, runtime) {
5923
+ const normalizedWorkDir = normalizeSessionWorkDir(workDir);
5924
+ const seeded = {
5925
+ workDir: normalizedWorkDir,
5926
+ skillManager: runtime?.skillManager || { list: () => [] },
5927
+ mcpManager: runtime?.mcpManager || { listTools: () => [], disconnectAll: async () => {} },
5928
+ mcpStatus: runtime?.mcpStatus || { connected: [], failed: [] },
5929
+ mcpConfig: runtime?.mcpConfig || { servers: [], skipped: [] },
5930
+ status: runtime?.status || { skills: 0, mcpServers: [], mcpFailed: [], mcpSkipped: [], tools: 0 },
5931
+ };
5932
+ projectRuntimes.set(projectRuntimeKey(normalizedWorkDir), seeded);
5933
+ return seeded;
5934
+ },
5935
+ async shutdownProjectRuntimes() {
5936
+ return shutdownProjectRuntimes();
5937
+ },
5938
+ projectRuntimeCount() {
5939
+ return projectRuntimes.size;
5940
+ },
5695
5941
  seedQueuedVpTurn({ sessionId = 'session-test', vpId = 'vp-test', threadId = 'main', turnId = 'turn-test' } = {}) {
5696
5942
  const key = threadKey(sessionId, vpId, threadId);
5697
5943
  const inbox = vpInboxes.get(key) || [];