@yeaft/webchat-agent 0.1.935 → 0.1.937

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.
@@ -11,6 +11,27 @@
11
11
 
12
12
  import { formatSize } from '../archive/tool-results.js';
13
13
 
14
+ /**
15
+ * Collaboration tools are mutually exclusive per Yeaft group shape:
16
+ * single-VP groups use sub-agents; multi-VP groups use VP-to-VP forwarding.
17
+ * Keep the policy names close to the tool registry so LLM exposure and
18
+ * execution gating use the same source of truth.
19
+ */
20
+ export const COLLAB_TOOL_POLICY = Object.freeze({
21
+ SINGLE_VP: 'single-vp',
22
+ MULTI_VP: 'multi-vp',
23
+ });
24
+
25
+ export const SUB_AGENT_TOOL_NAMES = Object.freeze([
26
+ 'SpawnAgent',
27
+ 'PromptAgent',
28
+ 'WaitAgent',
29
+ 'CloseAgent',
30
+ 'ListAgents',
31
+ ]);
32
+
33
+ export const FORWARD_TOOL_NAMES = Object.freeze(['RouteForward']);
34
+
14
35
  /**
15
36
  * Per-tool-result hard cap.
16
37
  *
@@ -208,6 +229,20 @@ function runWithTimeout(promise, timeoutMs, toolName) {
208
229
  });
209
230
  }
210
231
 
232
+ export function normalizeCollabToolPolicy(policy) {
233
+ if (policy === COLLAB_TOOL_POLICY.SINGLE_VP || policy === COLLAB_TOOL_POLICY.MULTI_VP) {
234
+ return policy;
235
+ }
236
+ return null;
237
+ }
238
+
239
+ export function isToolHiddenByCollabPolicy(toolName, policy) {
240
+ const normalized = normalizeCollabToolPolicy(policy);
241
+ if (!normalized) return false;
242
+ if (normalized === COLLAB_TOOL_POLICY.SINGLE_VP) return FORWARD_TOOL_NAMES.includes(toolName);
243
+ return SUB_AGENT_TOOL_NAMES.includes(toolName);
244
+ }
245
+
211
246
  export class ToolRegistry {
212
247
  /** @type {Map<string, import('./types.js').ToolDef>} */
213
248
  #tools = new Map();
@@ -293,17 +328,36 @@ export class ToolRegistry {
293
328
 
294
329
  /**
295
330
  * Get tool definitions for the LLM adapter.
296
- * Returns all registered tools mode filtering was removed in task-297.
331
+ * Returns all registered tools unless a collaboration policy hides one of
332
+ * the mutually-exclusive orchestration tool families.
297
333
  * @param {string} [language='en']
334
+ * @param {{ collabToolPolicy?: string }} [opts]
298
335
  * @returns {{ name: string, description: string, parameters: object }[]}
299
336
  */
300
- getToolDefs(language = 'en') {
337
+ getToolDefs(language = 'en', opts = {}) {
301
338
  const lang = normalizeLanguage(language);
302
- return this.getAllTools().map(t => ({
303
- name: t.name,
304
- description: localizeVisibleText(t.description, lang, t.name),
305
- parameters: localizeParameters(t.parameters, lang, t.name),
306
- }));
339
+ const collabToolPolicy = normalizeCollabToolPolicy(opts?.collabToolPolicy);
340
+ return this.getAllTools()
341
+ .filter(t => !isToolHiddenByCollabPolicy(t.name, collabToolPolicy))
342
+ .map(t => ({
343
+ name: t.name,
344
+ description: localizeVisibleText(t.description, lang, t.name),
345
+ parameters: localizeParameters(t.parameters, lang, t.name),
346
+ }));
347
+ }
348
+
349
+ /**
350
+ * Check whether a tool may be called under the current collaboration policy.
351
+ * Unknown / absent policy keeps the historical behavior: all registered
352
+ * tools remain callable.
353
+ * @param {string} name
354
+ * @param {{ collabToolPolicy?: string }} [opts]
355
+ * @returns {boolean}
356
+ */
357
+ isAllowed(name, opts = {}) {
358
+ const tool = this.#tools.get(name);
359
+ if (!tool) return false;
360
+ return !isToolHiddenByCollabPolicy(tool.name, opts?.collabToolPolicy);
307
361
  }
308
362
 
309
363
  /**
@@ -359,6 +413,48 @@ export class ToolRegistry {
359
413
  get names() {
360
414
  return this.getAllTools().map(t => t.name);
361
415
  }
416
+
417
+ /**
418
+ * Hot-swap the flattened MCP tool set.
419
+ *
420
+ * Removes every currently registered tool whose canonical name starts
421
+ * with `mcp__` (the flattened MCP tool naming convention used by
422
+ * `buildMcpFlattenedTools()`), then re-registers a fresh set built from
423
+ * the live MCPManager. Called by the MCP web-bridge after a successful
424
+ * connect/disconnect/reload so the running engine's next turn sees the
425
+ * new tool catalogue without needing a full session restart.
426
+ *
427
+ * Why "starts with `mcp__`": that prefix is the canonical Claude Code
428
+ * naming for flattened MCP tools. It cleanly distinguishes them from
429
+ * built-in tools (Bash, FileRead, etc.) and from the legacy meta-tools
430
+ * (`mcp_list_tools`, `mcp_call_tool` — single underscore, NOT removed
431
+ * here) which a caller may still opt into.
432
+ *
433
+ * @param {import('../mcp.js').MCPManager} mcpManager
434
+ * @param {(mgr: import('../mcp.js').MCPManager) => import('./types.js').ToolDef[]} buildFlattened
435
+ * — the builder from `./mcp-tools.js`. Injected so this registry file
436
+ * stays free of circular `import` to mcp-tools.js (mcp-tools imports
437
+ * `defineTool` from types.js, which lives alongside this file).
438
+ * @returns {{ removed: number, added: number }}
439
+ */
440
+ replaceMcpTools(mcpManager, buildFlattened) {
441
+ let removed = 0;
442
+ for (const name of [...this.#tools.keys()]) {
443
+ if (name.startsWith('mcp__')) {
444
+ this.#tools.delete(name);
445
+ removed += 1;
446
+ }
447
+ }
448
+ let added = 0;
449
+ if (typeof buildFlattened === 'function' && mcpManager) {
450
+ const fresh = buildFlattened(mcpManager) || [];
451
+ for (const tool of fresh) {
452
+ this.register(tool);
453
+ added += 1;
454
+ }
455
+ }
456
+ return { removed, added };
457
+ }
362
458
  }
363
459
 
364
460
  /**
@@ -19,6 +19,7 @@
19
19
  */
20
20
 
21
21
  import { join } from 'node:path';
22
+ import { COLLAB_TOOL_POLICY } from './tools/registry.js';
22
23
  import { existsSync } from 'node:fs';
23
24
  import { randomUUID } from 'node:crypto';
24
25
  import { buildDreamOutputSnapshot } from './dream/output-snapshot.js';
@@ -65,6 +66,8 @@ import { pairSanitize } from './pair-sanitize.js';
65
66
  import { filterSnapshotForVp } from './snapshot-filter.js';
66
67
  import { createVpStatusBroker } from './vp-status-broker.js';
67
68
  import { classifyThread as defaultClassifyThread, fallbackTitle } from './vp/thread-classifier.js';
69
+ import { listMcpServers, upsertMcpServer, removeMcpServer } from './config-api.js';
70
+ import { buildMcpFlattenedTools } from './tools/mcp-tools.js';
68
71
 
69
72
  /** @type {import('./session.js').Session | null} */
70
73
  let session = null;
@@ -2614,6 +2617,14 @@ async function waitForVpDrivers(_groupId, driverKeys = []) {
2614
2617
  * `route_forward` can extend `causedBy` chains correctly. Optional only
2615
2618
  * for pre-707 callers that no longer exist in production.
2616
2619
  */
2620
+ function resolveCollabToolPolicy(sessionMeta) {
2621
+ if (!sessionMeta || typeof sessionMeta !== 'object' || !Array.isArray(sessionMeta.roster)) {
2622
+ return null;
2623
+ }
2624
+ const vpCount = new Set(sessionMeta.roster.filter(v => typeof v === 'string' && v.trim())).size;
2625
+ return vpCount > 1 ? COLLAB_TOOL_POLICY.MULTI_VP : COLLAB_TOOL_POLICY.SINGLE_VP;
2626
+ }
2627
+
2617
2628
  export function buildVpQueryOpts({ vpId, sessionCoordinator, sessionId, envelope, threadId = 'main' }) {
2618
2629
  // Read the group meta once and reuse for both defaultVpId fallback and
2619
2630
  // announcement injection. Each .getMeta() reload reads + parses the
@@ -2654,6 +2665,8 @@ export function buildVpQueryOpts({ vpId, sessionCoordinator, sessionId, envelope
2654
2665
  if (typeof sessionId === 'string' && sessionId.trim()) {
2655
2666
  out.sessionId = sessionId.trim();
2656
2667
  }
2668
+ const collabToolPolicy = resolveCollabToolPolicy(sessionMeta);
2669
+ if (collabToolPolicy) out.collabToolPolicy = collabToolPolicy;
2657
2670
  // task-334-group-editor: surface the group announcement to the engine so
2658
2671
  // buildWorkerPrompt can inject it as a CLAUDE.md-style shared prefix.
2659
2672
  // Empty/missing reads as '' and prompts.js skips the section.
@@ -4186,3 +4199,233 @@ export async function resetYeaftSession() {
4186
4199
  console.error('[Yeaft] Failed to re-initialize session after reset:', err.message);
4187
4200
  }
4188
4201
  }
4202
+
4203
+ // ────────────────────────────────────────────────────────────
4204
+ // MCP CRUD wire handlers (Claude-Code-style Settings → MCP tab)
4205
+ //
4206
+ // Wire types: `yeaft_mcp_list` / `yeaft_mcp_add` / `yeaft_mcp_remove` /
4207
+ // `yeaft_mcp_reload`. Each:
4208
+ // 1. Reads / writes ~/.yeaft/config.json `mcpServers` via config-api.
4209
+ // 2. Calls `session.mcpManager.connect|disconnect` to apply at runtime.
4210
+ // 3. Hot-swaps the live `toolRegistry` via `replaceMcpTools(...)` so the
4211
+ // next LLM turn sees the new tool catalogue WITHOUT a session restart.
4212
+ // 4. Broadcasts `yeaft_mcp_updated` so any subscribed web client (the
4213
+ // Settings panel + any open Yeaft view) refreshes its badge without
4214
+ // a manual reload.
4215
+ //
4216
+ // The handlers do NOT block on `ensureSessionLoaded()` — the session may
4217
+ // not yet be initialised when the user opens Settings before sending the
4218
+ // first message. In that case `session` is null and we operate ONLY on
4219
+ // the on-disk config; the live runtime takes effect on the next session
4220
+ // boot. When `session` IS available, we apply the runtime change too.
4221
+ //
4222
+ // Wire shape per response: always `{ type: 'yeaft_mcp_*', servers, runtime?, error? }`.
4223
+ // Frontend reducer should treat `error` as a non-empty string failure.
4224
+ // ────────────────────────────────────────────────────────────
4225
+
4226
+ /**
4227
+ * Snapshot the live MCP runtime so the UI can render per-server
4228
+ * connection state next to the configured servers. Safe to call when
4229
+ * the session hasn't been initialised yet — returns an empty runtime.
4230
+ */
4231
+ function mcpRuntimeSnapshot() {
4232
+ if (!session?.mcpManager) {
4233
+ return { connected: false, toolCount: 0, perServer: [] };
4234
+ }
4235
+ const status = session.mcpManager.status() || [];
4236
+ const toolCount = typeof session.mcpManager.toolCount === 'number'
4237
+ ? session.mcpManager.toolCount
4238
+ : status.reduce((sum, s) => sum + (s.toolCount || 0), 0);
4239
+ return {
4240
+ connected: !!session.mcpManager.hasServers,
4241
+ toolCount,
4242
+ perServer: status.map(s => ({
4243
+ name: s.name,
4244
+ ready: !!s.ready,
4245
+ toolCount: s.toolCount || 0,
4246
+ })),
4247
+ };
4248
+ }
4249
+
4250
+ /**
4251
+ * Re-flatten MCP tools into the live ToolRegistry. No-op when the session
4252
+ * (or its registry) hasn't been created yet — the next session boot will
4253
+ * pick up the change.
4254
+ */
4255
+ function hotSwapMcpTools() {
4256
+ if (!session?.toolRegistry || typeof session.toolRegistry.replaceMcpTools !== 'function') {
4257
+ return { removed: 0, added: 0, skipped: true };
4258
+ }
4259
+ try {
4260
+ const result = session.toolRegistry.replaceMcpTools(session.mcpManager, buildMcpFlattenedTools);
4261
+ return { ...result, skipped: false };
4262
+ } catch (err) {
4263
+ console.warn('[Yeaft] hot-swap MCP tools failed:', err?.message || err);
4264
+ return { removed: 0, added: 0, skipped: true, error: err?.message || String(err) };
4265
+ }
4266
+ }
4267
+
4268
+ /**
4269
+ * Broadcast a `yeaft_mcp_updated` event so any client subscribed to the
4270
+ * Yeaft view (Settings panel, status badge) refreshes without needing
4271
+ * to re-open the panel. The current list+runtime are included so the UI
4272
+ * is single-source (no separate fetch round-trip needed).
4273
+ */
4274
+ function broadcastMcpUpdated(extra = {}) {
4275
+ const yeaftDir = ctx.CONFIG?.yeaftDir;
4276
+ const listed = listMcpServers(yeaftDir);
4277
+ sendToServer({
4278
+ type: 'yeaft_mcp_updated',
4279
+ servers: listed.servers || [],
4280
+ runtime: mcpRuntimeSnapshot(),
4281
+ ...extra,
4282
+ });
4283
+ }
4284
+
4285
+ export function handleYeaftMcpList(msg = {}) {
4286
+ const yeaftDir = ctx.CONFIG?.yeaftDir;
4287
+ const listed = listMcpServers(yeaftDir);
4288
+ sendToServer({
4289
+ type: 'yeaft_mcp_list_result',
4290
+ requestId: msg.requestId || null,
4291
+ servers: listed.servers || [],
4292
+ runtime: mcpRuntimeSnapshot(),
4293
+ error: listed.error || null,
4294
+ });
4295
+ }
4296
+
4297
+ export async function handleYeaftMcpAdd(msg = {}) {
4298
+ const yeaftDir = ctx.CONFIG?.yeaftDir;
4299
+ const result = upsertMcpServer(msg.server || {}, yeaftDir);
4300
+ if (result.error) {
4301
+ sendToServer({
4302
+ type: 'yeaft_mcp_add_result',
4303
+ requestId: msg.requestId || null,
4304
+ servers: [],
4305
+ runtime: mcpRuntimeSnapshot(),
4306
+ error: result.error,
4307
+ });
4308
+ return;
4309
+ }
4310
+
4311
+ // Apply at runtime when the session is live. The MCPManager's
4312
+ // `connect(serverConfig)` already disconnects-and-reconnects if a
4313
+ // server with the same name was already registered.
4314
+ let connectError = null;
4315
+ if (session?.mcpManager) {
4316
+ try {
4317
+ await session.mcpManager.connect(result.server);
4318
+ } catch (err) {
4319
+ connectError = err?.message || String(err);
4320
+ console.warn(`[Yeaft] MCP connect "${result.server.name}" failed:`, connectError);
4321
+ }
4322
+ }
4323
+
4324
+ const swap = hotSwapMcpTools();
4325
+
4326
+ sendToServer({
4327
+ type: 'yeaft_mcp_add_result',
4328
+ requestId: msg.requestId || null,
4329
+ servers: result.servers,
4330
+ runtime: mcpRuntimeSnapshot(),
4331
+ swap,
4332
+ connectError,
4333
+ error: null,
4334
+ });
4335
+ broadcastMcpUpdated({ reason: 'add', name: result.server.name, connectError });
4336
+ }
4337
+
4338
+ export async function handleYeaftMcpRemove(msg = {}) {
4339
+ const yeaftDir = ctx.CONFIG?.yeaftDir;
4340
+ const name = typeof msg.name === 'string' ? msg.name : '';
4341
+ const result = removeMcpServer(name, yeaftDir);
4342
+ if (result.error) {
4343
+ sendToServer({
4344
+ type: 'yeaft_mcp_remove_result',
4345
+ requestId: msg.requestId || null,
4346
+ servers: [],
4347
+ runtime: mcpRuntimeSnapshot(),
4348
+ error: result.error,
4349
+ });
4350
+ return;
4351
+ }
4352
+
4353
+ if (session?.mcpManager) {
4354
+ try {
4355
+ await session.mcpManager.disconnect(name);
4356
+ } catch (err) {
4357
+ console.warn(`[Yeaft] MCP disconnect "${name}" failed:`, err?.message || err);
4358
+ }
4359
+ }
4360
+
4361
+ const swap = hotSwapMcpTools();
4362
+
4363
+ sendToServer({
4364
+ type: 'yeaft_mcp_remove_result',
4365
+ requestId: msg.requestId || null,
4366
+ servers: result.servers,
4367
+ runtime: mcpRuntimeSnapshot(),
4368
+ removed: !!result.removed,
4369
+ swap,
4370
+ error: null,
4371
+ });
4372
+ broadcastMcpUpdated({ reason: 'remove', name });
4373
+ }
4374
+
4375
+ export async function handleYeaftMcpReload(msg = {}) {
4376
+ const yeaftDir = ctx.CONFIG?.yeaftDir;
4377
+ const targetName = typeof msg.name === 'string' && msg.name ? msg.name : null;
4378
+
4379
+ if (!session?.mcpManager) {
4380
+ // Session not yet alive — just echo the current config + an empty
4381
+ // runtime so the UI knows to wait for session boot.
4382
+ const listed = listMcpServers(yeaftDir);
4383
+ sendToServer({
4384
+ type: 'yeaft_mcp_reload_result',
4385
+ requestId: msg.requestId || null,
4386
+ servers: listed.servers || [],
4387
+ runtime: mcpRuntimeSnapshot(),
4388
+ error: null,
4389
+ });
4390
+ return;
4391
+ }
4392
+
4393
+ const listed = listMcpServers(yeaftDir);
4394
+ const configured = listed.servers || [];
4395
+
4396
+ // Per-server reload: disconnect + reconnect the named server only.
4397
+ // Whole-set reload: disconnect everything, then reconnect from current
4398
+ // config.json. The latter is what the user clicks "Reload all" for.
4399
+ const failures = [];
4400
+ try {
4401
+ if (targetName) {
4402
+ const cfg = configured.find(s => s.name === targetName);
4403
+ try { await session.mcpManager.disconnect(targetName); } catch { /* ignore */ }
4404
+ if (cfg) {
4405
+ try { await session.mcpManager.connect(cfg); }
4406
+ catch (err) { failures.push({ name: targetName, error: err?.message || String(err) }); }
4407
+ }
4408
+ } else {
4409
+ try { await session.mcpManager.disconnectAll(); } catch { /* ignore */ }
4410
+ for (const cfg of configured) {
4411
+ try { await session.mcpManager.connect(cfg); }
4412
+ catch (err) { failures.push({ name: cfg.name, error: err?.message || String(err) }); }
4413
+ }
4414
+ }
4415
+ } catch (err) {
4416
+ console.warn('[Yeaft] MCP reload failed:', err?.message || err);
4417
+ }
4418
+
4419
+ const swap = hotSwapMcpTools();
4420
+
4421
+ sendToServer({
4422
+ type: 'yeaft_mcp_reload_result',
4423
+ requestId: msg.requestId || null,
4424
+ servers: configured,
4425
+ runtime: mcpRuntimeSnapshot(),
4426
+ failures,
4427
+ swap,
4428
+ error: null,
4429
+ });
4430
+ broadcastMcpUpdated({ reason: 'reload', name: targetName, failures });
4431
+ }