@yeaft/webchat-agent 1.0.81 → 1.0.83

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.81",
3
+ "version": "1.0.83",
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/mcp.js CHANGED
@@ -34,6 +34,71 @@ const DEFAULT_TIMEOUT_MS = 30000;
34
34
  /** Server startup timeout (10 seconds). */
35
35
  const STARTUP_TIMEOUT_MS = 10000;
36
36
 
37
+ // ─── Process-level connection pool ────────────────────────────
38
+
39
+ /** @type {Map<string, { connection: MCPServerConnection, refs: number, startPromise: Promise<void> }>} */
40
+ const mcpConnectionPool = new Map();
41
+
42
+ function stableEnv(env = {}) {
43
+ return Object.fromEntries(
44
+ Object.entries(env || {})
45
+ .filter(([, value]) => value !== undefined)
46
+ .sort(([a], [b]) => a.localeCompare(b))
47
+ );
48
+ }
49
+
50
+ function mcpConnectionKey(config) {
51
+ return JSON.stringify({
52
+ name: config.name,
53
+ command: config.command,
54
+ args: Array.isArray(config.args) ? config.args : [],
55
+ env: stableEnv(config.env),
56
+ });
57
+ }
58
+
59
+ async function acquireMcpConnection(serverConfig) {
60
+ const key = mcpConnectionKey(serverConfig);
61
+ let entry = mcpConnectionPool.get(key);
62
+ if (!entry) {
63
+ const connection = new MCPServerConnection(serverConfig.name, serverConfig);
64
+ entry = {
65
+ connection,
66
+ refs: 0,
67
+ startPromise: connection.start(),
68
+ };
69
+ connection.once('close', () => {
70
+ if (mcpConnectionPool.get(key) === entry) {
71
+ mcpConnectionPool.delete(key);
72
+ }
73
+ });
74
+ mcpConnectionPool.set(key, entry);
75
+ }
76
+
77
+ entry.refs += 1;
78
+ try {
79
+ await entry.startPromise;
80
+ return { key, connection: entry.connection };
81
+ } catch (err) {
82
+ entry.refs -= 1;
83
+ if (entry.refs <= 0 && mcpConnectionPool.get(key) === entry) {
84
+ mcpConnectionPool.delete(key);
85
+ }
86
+ throw err;
87
+ }
88
+ }
89
+
90
+ async function releaseMcpConnection(key, connection) {
91
+ const entry = mcpConnectionPool.get(key);
92
+ if (!entry || entry.connection !== connection) {
93
+ await connection.stop();
94
+ return;
95
+ }
96
+ entry.refs -= 1;
97
+ if (entry.refs > 0) return;
98
+ mcpConnectionPool.delete(key);
99
+ await connection.stop();
100
+ }
101
+
37
102
  // ─── MCP Server Connection ─────────────────────────────────
38
103
 
39
104
  /**
@@ -46,6 +111,7 @@ class MCPServerConnection extends EventEmitter {
46
111
  #pendingRequests;
47
112
  #tools;
48
113
  #ready;
114
+ #closed;
49
115
  #buffer;
50
116
 
51
117
  /**
@@ -59,6 +125,7 @@ class MCPServerConnection extends EventEmitter {
59
125
  this.#pendingRequests = new Map();
60
126
  this.#tools = [];
61
127
  this.#ready = false;
128
+ this.#closed = false;
62
129
  this.#buffer = '';
63
130
  this.config = config;
64
131
  }
@@ -102,6 +169,7 @@ class MCPServerConnection extends EventEmitter {
102
169
 
103
170
  this.#process.on('close', (code) => {
104
171
  this.#ready = false;
172
+ this.#closed = true;
105
173
  this.emit('close', code);
106
174
  // Reject all pending requests
107
175
  for (const [, { reject: rej }] of this.#pendingRequests) {
@@ -252,9 +320,16 @@ class MCPServerConnection extends EventEmitter {
252
320
  if (this.#process) {
253
321
  this.#ready = false;
254
322
  this.#process.kill('SIGTERM');
255
- // Wait a bit then force kill
256
- await new Promise(resolve => setTimeout(resolve, 2000));
257
- if (this.#process && !this.#process.killed) {
323
+ // Wait a bit then force kill. Test fakes do not emit close, so cap the
324
+ // wait at 2s and do not require a close event for cleanup to finish.
325
+ await Promise.race([
326
+ new Promise(resolve => {
327
+ if (this.#closed) return resolve();
328
+ this.#process.once('close', resolve);
329
+ }),
330
+ new Promise(resolve => setTimeout(resolve, 2000)),
331
+ ]);
332
+ if (this.#process && !this.#process.killed && !this.#closed) {
258
333
  this.#process.kill('SIGKILL');
259
334
  }
260
335
  this.#process = null;
@@ -268,7 +343,7 @@ class MCPServerConnection extends EventEmitter {
268
343
  * MCPManager — manages multiple MCP server connections.
269
344
  */
270
345
  export class MCPManager {
271
- /** @type {Map<string, MCPServerConnection>} */
346
+ /** @type {Map<string, { key: string, connection: MCPServerConnection }>} */
272
347
  #servers = new Map();
273
348
 
274
349
  /** @type {Map<string, object>} */
@@ -288,10 +363,10 @@ export class MCPManager {
288
363
  await this.disconnect(name);
289
364
  }
290
365
 
291
- const connection = new MCPServerConnection(name, serverConfig);
292
- await connection.start();
366
+ const pooled = await acquireMcpConnection(serverConfig);
367
+ const connection = pooled.connection;
293
368
 
294
- this.#servers.set(name, connection);
369
+ this.#servers.set(name, pooled);
295
370
 
296
371
  // Index tools
297
372
  for (const tool of connection.tools) {
@@ -329,14 +404,15 @@ export class MCPManager {
329
404
  * @param {string} name — server name
330
405
  */
331
406
  async disconnect(name) {
332
- const connection = this.#servers.get(name);
333
- if (connection) {
407
+ const entry = this.#servers.get(name);
408
+ if (entry) {
409
+ const connection = entry.connection;
334
410
  // Remove tools from index
335
411
  for (const tool of connection.tools) {
336
412
  this.#toolIndex.delete(tool.name);
337
413
  }
338
- await connection.stop();
339
414
  this.#servers.delete(name);
415
+ await releaseMcpConnection(entry.key, connection);
340
416
  }
341
417
  }
342
418
 
@@ -400,7 +476,7 @@ export class MCPManager {
400
476
  * @returns {{ name: string, ready: boolean, toolCount: number }[]}
401
477
  */
402
478
  status() {
403
- return [...this.#servers.entries()].map(([name, conn]) => ({
479
+ return [...this.#servers.entries()].map(([name, { connection: conn }]) => ({
404
480
  name,
405
481
  ready: conn.ready,
406
482
  toolCount: conn.tools.length,
@@ -439,3 +515,14 @@ export async function createMCPManager(config) {
439
515
 
440
516
  return manager;
441
517
  }
518
+
519
+ export async function __resetMcpConnectionPoolForTests({ stop = true } = {}) {
520
+ const entries = [...mcpConnectionPool.values()];
521
+ mcpConnectionPool.clear();
522
+ if (!stop) return;
523
+ await Promise.all(entries.map(entry => entry.connection.stop().catch(() => {})));
524
+ }
525
+
526
+ export function __mcpConnectionPoolSizeForTests() {
527
+ return mcpConnectionPool.size;
528
+ }
@@ -1190,6 +1190,12 @@ export function __testGroupHistory(sessionId) {
1190
1190
  return getOrCreateSessionHistory(sessionId);
1191
1191
  }
1192
1192
 
1193
+ export function __testResolveVpEffectiveConfig(sessionId) {
1194
+ if (!session) return null;
1195
+ const sessionConfigRoot = ctx.CONFIG?.yeaftDir || session.yeaftDir;
1196
+ return resolveSessionConfig(session.config, loadSessionConfig(sessionConfigRoot, sessionId));
1197
+ }
1198
+
1193
1199
  /**
1194
1200
  * Test-only: install a minimal `session` so `hydrateGroupHistory` can
1195
1201
  * read from a real `ConversationStore`. Pass `null` to clear.
@@ -1293,11 +1299,13 @@ function getOrCreateVpEngine(sessionId, vpId, threadId = 'main') {
1293
1299
  let eng = vpEngines.get(key);
1294
1300
  if (eng) return eng;
1295
1301
  if (!session) throw new Error('getOrCreateVpEngine: session not loaded');
1296
- // Per-group config overlay (v1: model only). Falls back to the
1297
- // session's user-level config when no override is set. The resolver
1298
- // never mutates session.config it returns a new object.
1299
- const yeaftDir = ctx.CONFIG?.yeaftDir || session.yeaftDir;
1300
- const groupCfg = loadSessionConfig(yeaftDir, sessionId);
1302
+ // Per-session config overlay (v1: model only). Falls back to the
1303
+ // session's user-level config when no override is set. Prefer the agent-local
1304
+ // config root: sessionConfigPath() resolves registered workDir-backed
1305
+ // sessions from there, while still allowing a later agent-local session in
1306
+ // the same bridge runtime to read its own override after a workDir-first boot.
1307
+ const sessionConfigRoot = ctx.CONFIG?.yeaftDir || session.yeaftDir;
1308
+ const groupCfg = loadSessionConfig(sessionConfigRoot, sessionId);
1301
1309
  const effectiveConfig = resolveSessionConfig(session.config, groupCfg);
1302
1310
  eng = new Engine({
1303
1311
  adapter: session.adapter,