@yeaft/webchat-agent 1.0.408 → 1.0.410

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/yeaft/config.js CHANGED
@@ -26,6 +26,7 @@ import { DEFAULT_YEAFT_DIR } from './init.js';
26
26
  import { getModelEffortOptions, getThinkingCapability, modelSupportsEffort, resolveModel, parseModelRef, normalizeProviderModels, resolveContextWindow, resolveMaxOutputTokens } from './models.js';
27
27
  import { inferProtocolFromModelId } from './llm/router.js';
28
28
  import { normalizeKnownProviderForRuntime } from './llm/known-providers.js';
29
+ import { createDenyAllPluginConfig, normalizePluginConfig } from './plugins.js';
29
30
  import { readWorkspaceFile } from './workspace-file.js';
30
31
 
31
32
  /** Default configuration values. */
@@ -367,6 +368,7 @@ function loadLegacyConfig(dir, overrides) {
367
368
  // task-318: legacy path never had the `yeaft` section — defaults.
368
369
  yeaft: normaliseYeaftSection(null),
369
370
  telemetry: normaliseTelemetrySection(null),
371
+ plugins: {},
370
372
  providers: null,
371
373
  primaryModel: null,
372
374
  fastModel: null,
@@ -413,12 +415,20 @@ export function loadConfig(overrides = {}) {
413
415
  // Determine data directory
414
416
  const dir = overrides.dir || process.env.YEAFT_DIR || DEFAULTS.dir;
415
417
 
416
- // Try config.json first
418
+ // Try config.json first. A missing file preserves legacy behavior, but an
419
+ // existing file that cannot be parsed (or is not an object) must not reopen
420
+ // a persisted Plugin restriction through the legacy fallback.
421
+ const configPath = join(dir, 'config.json');
422
+ const hasConfigFile = existsSync(configPath);
417
423
  const jsonConfig = readConfigJson(dir);
418
424
 
419
- if (!jsonConfig) {
420
- // No config.json legacy path
421
- return loadLegacyConfig(dir, overrides);
425
+ if (!jsonConfig || typeof jsonConfig !== 'object' || Array.isArray(jsonConfig)) {
426
+ const config = loadLegacyConfig(dir, overrides);
427
+ if (hasConfigFile) {
428
+ config.plugins = createDenyAllPluginConfig();
429
+ config.pluginConfigError = 'config.json is invalid or must contain an object';
430
+ }
431
+ return config;
422
432
  }
423
433
 
424
434
  // ─── Build config from config.json ────────────────────────
@@ -461,6 +471,18 @@ export function loadConfig(overrides = {}) {
461
471
  const resolvedMaxOutput = overrides.maxOutputTokens ?? jsonConfig.maxOutputTokens
462
472
  ?? resolveMaxOutputTokens(modelIdForInfo, { modelInfo });
463
473
 
474
+ // Missing plugins keeps the historical all-enabled behavior. A present but
475
+ // invalid schema must not collapse to `{}` because that is also all-enabled;
476
+ // use explicit empty allowlists until the user repairs config.json instead.
477
+ let plugins;
478
+ let pluginConfigError = null;
479
+ try {
480
+ plugins = normalizePluginConfig(jsonConfig.plugins);
481
+ } catch (err) {
482
+ plugins = createDenyAllPluginConfig();
483
+ pluginConfigError = err?.message || String(err);
484
+ }
485
+
464
486
  const config = {
465
487
  // Model
466
488
  model: overrides.model || model,
@@ -503,6 +525,13 @@ export function loadConfig(overrides = {}) {
503
525
  yeaft: normaliseYeaftSection(jsonConfig.yeaft),
504
526
  telemetry: normaliseTelemetrySection(jsonConfig.telemetry),
505
527
 
528
+ // Agent-level tools / skills / MCP server allowlists. Missing fields mean
529
+ // all currently discovered capabilities remain enabled. A persisted schema
530
+ // error is represented by explicit empty allowlists so runtime policy fails
531
+ // closed instead of reopening every capability.
532
+ plugins,
533
+ pluginConfigError,
534
+
506
535
  // Legacy fields (null when using config.json)
507
536
  apiKey: overrides.apiKey || null,
508
537
  openaiApiKey: null,
@@ -600,7 +629,7 @@ export function loadConfig(overrides = {}) {
600
629
  * @returns {{ servers: object[], skipped: { name: string, reason: string, source: string }[] }}
601
630
  */
602
631
  export function loadMCPConfig(yeaftDir, jsonConfig, workDir, options = {}) {
603
- const yeaftGlobal = loadGlobalMCPServers(yeaftDir, jsonConfig);
632
+ const yeaftGlobal = loadAgentMCPConfig(yeaftDir, jsonConfig);
604
633
  const externalUser = loadExternalUserMCPServers();
605
634
  const project = workDir
606
635
  ? loadProjectMCPServers(workDir, options)
@@ -608,7 +637,7 @@ export function loadMCPConfig(yeaftDir, jsonConfig, workDir, options = {}) {
608
637
 
609
638
  const servers = [];
610
639
  const seen = new Set();
611
- for (const tier of [yeaftGlobal, externalUser.servers, project.servers]) {
640
+ for (const tier of [yeaftGlobal.servers, externalUser.servers, project.servers]) {
612
641
  for (const s of tier) {
613
642
  if (!s?.name || seen.has(s.name)) continue;
614
643
  seen.add(s.name);
@@ -619,6 +648,24 @@ export function loadMCPConfig(yeaftDir, jsonConfig, workDir, options = {}) {
619
648
  return { servers, skipped: [...externalUser.skipped, ...project.skipped] };
620
649
  }
621
650
 
651
+ /**
652
+ * Load only the Agent-owned MCP configuration tier. Plugin Center uses this
653
+ * catalog source so it reflects current Agent configuration rather than
654
+ * borrowed user/project MCP sources or a live runtime snapshot.
655
+ *
656
+ * An explicitly present `config.json.mcpServers` array is authoritative,
657
+ * including an empty array. The legacy `mcp.json` fallback applies only when
658
+ * that field is absent.
659
+ *
660
+ * @param {string} yeaftDir
661
+ * @param {object} [jsonConfig] — Already-parsed config.json (optional, avoids re-read)
662
+ * @returns {{ servers: object[], skipped: object[] }}
663
+ */
664
+ export function loadAgentMCPConfig(yeaftDir, jsonConfig) {
665
+ const configured = jsonConfig === undefined ? readConfigJson(yeaftDir) : jsonConfig;
666
+ return { servers: loadGlobalMCPServers(yeaftDir, configured), skipped: [] };
667
+ }
668
+
622
669
  /**
623
670
  * Load the global (~/.yeaft) MCP server list.
624
671
  *
@@ -631,10 +678,10 @@ export function loadMCPConfig(yeaftDir, jsonConfig, workDir, options = {}) {
631
678
  * @returns {object[]}
632
679
  */
633
680
  function loadGlobalMCPServers(yeaftDir, jsonConfig) {
634
- // Check config.json mcpServers field
681
+ // An explicitly present config.json array is authoritative, even when it
682
+ // is empty. Falling back in that case would resurrect removed MCP servers.
635
683
  if (jsonConfig && Array.isArray(jsonConfig.mcpServers)) {
636
- const valid = jsonConfig.mcpServers.filter(s => s.name && s.command);
637
- if (valid.length > 0) return valid;
684
+ return jsonConfig.mcpServers.filter(s => s.name && s.command);
638
685
  }
639
686
 
640
687
  // Fallback: standalone mcp.json
package/yeaft/engine.js CHANGED
@@ -56,6 +56,7 @@ import { COLLAB_TOOL_POLICY, isToolErrorOutput, localizeVisibleText, normalizeTo
56
56
  import { CONDITIONAL_BUILTIN_TOOL_NAMES, resolveActiveToolNames } from './tools/activation.js';
57
57
  import { discoverToolCapabilities } from './tools/discover-tools.js';
58
58
  import { agentBelongsToScope, getAgentRegistry } from './tools/agent.js';
59
+ import { createPluginSkillManager } from './plugins.js';
59
60
  import { extractDisplayImages, stripDisplayImageData } from './image-assets.js';
60
61
  import { acknowledgePendingNotifications, formatNotificationsForPrompt, peekPendingNotifications } from './sub-agent/notifications.js';
61
62
  import {
@@ -203,6 +204,39 @@ function stripLeadingSkillCommandFromPromptParts(promptParts, skillManager) {
203
204
  });
204
205
  }
205
206
 
207
+ function resolveSkillPromptState({ skillManager, prompt, explicitSkillName }) {
208
+ let resolvedSkillContent = '';
209
+ let resolvedSkills = [];
210
+ let skillResolutionError = null;
211
+ if (!skillManager) {
212
+ return { resolvedSkillContent, resolvedSkills, skillResolutionError };
213
+ }
214
+
215
+ if (explicitSkillName) {
216
+ resolvedSkillContent = skillManager.getPromptContent(explicitSkillName);
217
+ const skill = skillManager.list?.().find(item => item.name === explicitSkillName)
218
+ || (resolvedSkillContent ? { name: explicitSkillName } : null);
219
+ if (resolvedSkillContent && skill) {
220
+ resolvedSkills = [{ ...skill, explicit: true }];
221
+ } else {
222
+ skillResolutionError = `Requested skill "${explicitSkillName}" was not found.`;
223
+ resolvedSkillContent = `## Skill command error\n\n${skillResolutionError} Continue without that skill and tell the user it is unavailable.`;
224
+ }
225
+ } else if (prompt && typeof skillManager.findRelevant === 'function') {
226
+ resolvedSkills = skillManager.findRelevant(prompt).map(skill => ({
227
+ name: skill.name,
228
+ description: skill.description || '',
229
+ trigger: skill.trigger || '',
230
+ category: skill.category,
231
+ tier: skill._tier,
232
+ explicit: false,
233
+ }));
234
+ resolvedSkillContent = resolvedSkills.map(skill => skillManager.getPromptContent(skill.name)).join('\n\n');
235
+ }
236
+
237
+ return { resolvedSkillContent, resolvedSkills, skillResolutionError };
238
+ }
239
+
206
240
  function resolveRetryPolicy(config) {
207
241
  const raw = config?.llmRetry || {};
208
242
  const num = (v, d) => (Number.isFinite(v) && v >= 0 ? v : d);
@@ -617,6 +651,9 @@ export class Engine {
617
651
  /** @type {import('./skills.js').SkillManager|null} */
618
652
  #skillManager;
619
653
 
654
+ /** @type {import('./skills.js').SkillManager|null} */
655
+ #baseSkillManager;
656
+
620
657
  /** @type {import('./mcp.js').MCPManager|null} */
621
658
  #mcpManager;
622
659
 
@@ -838,7 +875,10 @@ export class Engine {
838
875
  this.#amsRegistry = amsRegistry || null;
839
876
  this.#toolRegistry = toolRegistry || null;
840
877
  this.#taskManager = taskManager || null;
841
- this.#skillManager = skillManager || null;
878
+ this.#baseSkillManager = skillManager || null;
879
+ this.#skillManager = this.#baseSkillManager && Array.isArray(config?.plugins?.skills)
880
+ ? createPluginSkillManager(this.#baseSkillManager, config.plugins)
881
+ : this.#baseSkillManager;
842
882
  this.#mcpManager = mcpManager || null;
843
883
  this.#yeaftDir = yeaftDir || null;
844
884
  this.#managedCliReady = managedCliReady || null;
@@ -939,6 +979,9 @@ export class Engine {
939
979
  refreshConfig(config) {
940
980
  if (!config || typeof config !== 'object') return;
941
981
  this.#config = config;
982
+ this.#skillManager = this.#baseSkillManager && Array.isArray(config.plugins?.skills)
983
+ ? createPluginSkillManager(this.#baseSkillManager, config.plugins)
984
+ : this.#baseSkillManager;
942
985
  const fastModelId = config.fastModelId || config.model;
943
986
  this.#fastConfig = fastModelId !== config.model
944
987
  ? { ...config, model: fastModelId }
@@ -954,7 +997,10 @@ export class Engine {
954
997
  */
955
998
  setRuntimeManagers(managers = {}) {
956
999
  if (Object.prototype.hasOwnProperty.call(managers, 'skillManager')) {
957
- this.#skillManager = managers.skillManager || null;
1000
+ this.#baseSkillManager = managers.skillManager || null;
1001
+ this.#skillManager = this.#baseSkillManager && Array.isArray(this.#config?.plugins?.skills)
1002
+ ? createPluginSkillManager(this.#baseSkillManager, this.#config.plugins)
1003
+ : this.#baseSkillManager;
958
1004
  }
959
1005
  if (Object.prototype.hasOwnProperty.call(managers, 'mcpManager')) {
960
1006
  this.#mcpManager = managers.mcpManager || null;
@@ -984,6 +1030,7 @@ export class Engine {
984
1030
  if (this.#toolRegistry) {
985
1031
  return this.#toolRegistry.getToolDefs(this.#config?.language || 'en', {
986
1032
  collabToolPolicy,
1033
+ plugins: this.#config?.plugins,
987
1034
  activeToolNames,
988
1035
  });
989
1036
  }
@@ -1220,10 +1267,11 @@ export class Engine {
1220
1267
  * @param {string} [args.explicitSkillName] — leading /skill:<name> command, if present
1221
1268
  * @returns {string}
1222
1269
  */
1223
- #buildSystemPrompt({ prompt, memoryInjection, vpPersona, activeScope, sessionAnnouncement, projectInstruction, projectLabel, workCenterInstructions, projectDoc, taskCtx, activeTasks, activeToolNames = null, promptNotices = [], explicitSkillName, resolvedSkillContent = null } = {}) {
1224
- // Skill selection is normally resolved once by #runQuery so the prompt and
1225
- // emitted protocol events describe the exact same skills. Keep the local
1226
- // fallback for internal callers that do not need selection events.
1270
+ #buildSystemPrompt({ prompt, memoryInjection, vpPersona, activeScope, sessionAnnouncement, projectInstruction, projectLabel, workCenterInstructions, projectDoc, taskCtx, activeTasks, collabToolPolicy = null, activeToolNames = null, promptNotices = [], explicitSkillName, resolvedSkillContent = null } = {}) {
1271
+ // #runQuery resolves Skill selection at each provider-request boundary so
1272
+ // a live Plugin policy change cannot leave stale content in the next prompt.
1273
+ // Keep the local fallback for internal callers that do not need selection
1274
+ // events.
1227
1275
  let skillContent = typeof resolvedSkillContent === 'string' ? resolvedSkillContent : '';
1228
1276
  if (resolvedSkillContent === null && this.#skillManager) {
1229
1277
  if (explicitSkillName) {
@@ -1234,11 +1282,13 @@ export class Engine {
1234
1282
  }
1235
1283
  }
1236
1284
 
1237
- // Use the same active set for the prompt and provider schema. The prompt
1238
- // only needs these names to select scoped guidance; it does not repeat the
1239
- // catalogue because the API tool definitions are already authoritative.
1285
+ // Prompt guidance must describe the same canonical capability
1286
+ // intersection that reaches provider schemas and execution.
1240
1287
  const registeredToolNames = this.#toolRegistry
1241
- ? this.#toolRegistry.getToolNames()
1288
+ ? this.#toolRegistry.getToolNames({
1289
+ plugins: this.#config?.plugins,
1290
+ collabToolPolicy,
1291
+ })
1242
1292
  : Array.from(this.#tools.keys());
1243
1293
  const toolNames = activeToolNames instanceof Set
1244
1294
  ? registeredToolNames.filter(name => activeToolNames.has(name))
@@ -2219,7 +2269,7 @@ export class Engine {
2219
2269
  const runtimeThreadId = (typeof threadId === 'string' && threadId.trim())
2220
2270
  ? threadId.trim()
2221
2271
  : MAIN_THREAD_ID;
2222
- const executionOrigin = inboundEnvelope?.msg?.meta?.injectedBy === 'route_forward'
2272
+ const executionOrigin = ['route_forward', 'route_forward_result'].includes(inboundEnvelope?.msg?.meta?.injectedBy)
2223
2273
  ? 'route_forward'
2224
2274
  : null;
2225
2275
  // The bridge-provided VP turn id is also persisted on assistant messages and
@@ -2422,8 +2472,12 @@ export class Engine {
2422
2472
  })
2423
2473
  : '';
2424
2474
  const registeredToolNames = this.#toolRegistry
2425
- ? this.#toolRegistry.getToolNames()
2475
+ ? this.#toolRegistry.getToolNames({
2476
+ collabToolPolicy: effectiveCollabToolPolicy,
2477
+ plugins: this.#config?.plugins,
2478
+ })
2426
2479
  : Array.from(this.#tools.keys());
2480
+ const registeredToolNameSet = new Set(registeredToolNames);
2427
2481
  const resolveCurrentActiveToolNames = () => this.#toolRegistry
2428
2482
  ? resolveActiveToolNames({
2429
2483
  toolNames: registeredToolNames,
@@ -2444,6 +2498,7 @@ export class Engine {
2444
2498
  const discoveryTraversals = new Map();
2445
2499
  const currentDiscoverableTools = () => this.#toolRegistry
2446
2500
  ? this.#toolRegistry.getAllTools()
2501
+ .filter(tool => registeredToolNameSet.has(tool.name))
2447
2502
  .filter(tool => CONDITIONAL_BUILTIN_TOOL_NAMES.has(tool.name) || tool.name.startsWith('mcp__'))
2448
2503
  : [];
2449
2504
  const discoveryDirectorySnapshot = (tools, language) => tools
@@ -2469,32 +2524,11 @@ export class Engine {
2469
2524
  }
2470
2525
  };
2471
2526
  let activeToolNames = resolveCurrentActiveToolNames();
2472
- let resolvedSkillContent = '';
2473
- let resolvedSkills = [];
2474
- let skillResolutionError = null;
2475
- if (this.#skillManager) {
2476
- if (explicitSkillName) {
2477
- resolvedSkillContent = this.#skillManager.getPromptContent(explicitSkillName);
2478
- const skill = this.#skillManager.list?.().find(item => item.name === explicitSkillName)
2479
- || (resolvedSkillContent ? { name: explicitSkillName } : null);
2480
- if (resolvedSkillContent && skill) {
2481
- resolvedSkills = [{ ...skill, explicit: true }];
2482
- } else {
2483
- skillResolutionError = `Requested skill "${explicitSkillName}" was not found.`;
2484
- resolvedSkillContent = `## Skill command error\n\n${skillResolutionError} Continue without that skill and tell the user it is unavailable.`;
2485
- }
2486
- } else if (prompt && typeof this.#skillManager.findRelevant === 'function') {
2487
- resolvedSkills = this.#skillManager.findRelevant(prompt).map(skill => ({
2488
- name: skill.name,
2489
- description: skill.description || '',
2490
- trigger: skill.trigger || '',
2491
- category: skill.category,
2492
- tier: skill._tier,
2493
- explicit: false,
2494
- }));
2495
- resolvedSkillContent = resolvedSkills.map(skill => this.#skillManager.getPromptContent(skill.name)).join('\n\n');
2496
- }
2497
- }
2527
+ let { resolvedSkillContent, resolvedSkills, skillResolutionError } = resolveSkillPromptState({
2528
+ skillManager: this.#skillManager,
2529
+ prompt,
2530
+ explicitSkillName,
2531
+ });
2498
2532
 
2499
2533
  let promptNotices = [];
2500
2534
  const buildCurrentSystemPrompt = () => this.#buildSystemPrompt({
@@ -2508,6 +2542,7 @@ export class Engine {
2508
2542
  workCenterInstructions,
2509
2543
  projectDoc: projectDocContext.text,
2510
2544
  activeTasks,
2545
+ collabToolPolicy: effectiveCollabToolPolicy,
2511
2546
  activeToolNames,
2512
2547
  promptNotices,
2513
2548
  explicitSkillName,
@@ -2678,12 +2713,10 @@ export class Engine {
2678
2713
  sessionId: sessionId || null,
2679
2714
  at: queryStartedAt,
2680
2715
  };
2681
- for (const skill of resolvedSkills) {
2682
- yield { type: 'skill_loaded', turnId: queryTurnId, skill };
2683
- }
2684
- if (skillResolutionError) {
2685
- yield { type: 'skill_error', turnId: queryTurnId, skillName: explicitSkillName, message: skillResolutionError };
2686
- }
2716
+ // Skill selection is emitted at the provider-request boundary below. A
2717
+ // persisted Plugin update may replace the filtered SkillManager while this
2718
+ // query is paused on a tool, so reporting it before that boundary could
2719
+ // claim a Skill was loaded even though its content never reached a request.
2687
2720
 
2688
2721
  // Surface the exact memory that entered the prompt. This must be based on
2689
2722
  // the AMS snapshot, not raw FTS candidates, otherwise debug can claim memory
@@ -2729,6 +2762,11 @@ export class Engine {
2729
2762
  let cumulativeInputTokens = 0;
2730
2763
  let cumulativeOutputTokens = 0;
2731
2764
  let activeProviderRequest = null;
2765
+ // Skill events describe the selection injected into each provider request.
2766
+ // The first request must report its initial selection; later loops report
2767
+ // only newly added Skills or a newly introduced explicit-command error.
2768
+ let reportedSkillNames = new Set();
2769
+ let reportedSkillError = null;
2732
2770
  // task-707: tool-callable end-turn signal. Tools (currently only
2733
2771
  // `route_forward`) can set this via toolCtx.requestEndTurn(reason)
2734
2772
  // to break out of the tool-loop after the current batch finishes
@@ -2936,6 +2974,22 @@ export class Engine {
2936
2974
  else discoveredToolNames.delete(name);
2937
2975
  }
2938
2976
  toolDefs = this.#getToolDefs(effectiveCollabToolPolicy, activeToolNames);
2977
+ ({ resolvedSkillContent, resolvedSkills, skillResolutionError } = resolveSkillPromptState({
2978
+ skillManager: this.#skillManager,
2979
+ prompt,
2980
+ explicitSkillName,
2981
+ }));
2982
+ const currentSkillNames = new Set(resolvedSkills.map(skill => skill.name));
2983
+ for (const skill of resolvedSkills) {
2984
+ if (!reportedSkillNames.has(skill.name)) {
2985
+ yield { type: 'skill_loaded', turnId: queryTurnId, skill };
2986
+ }
2987
+ }
2988
+ if (skillResolutionError && skillResolutionError !== reportedSkillError) {
2989
+ yield { type: 'skill_error', turnId: queryTurnId, skillName: explicitSkillName, message: skillResolutionError };
2990
+ }
2991
+ reportedSkillNames = currentSkillNames;
2992
+ reportedSkillError = skillResolutionError;
2939
2993
  systemPrompt = buildCurrentSystemPrompt();
2940
2994
 
2941
2995
  try {
@@ -4187,6 +4241,7 @@ export class Engine {
4187
4241
  const hasTool = this.#toolRegistry
4188
4242
  ? this.#toolRegistry.isAllowed(tc.name, {
4189
4243
  collabToolPolicy: effectiveCollabToolPolicy,
4244
+ plugins: this.#config?.plugins,
4190
4245
  activeToolNames,
4191
4246
  })
4192
4247
  : this.#tools.has(tc.name);
@@ -0,0 +1,170 @@
1
+ /**
2
+ * plugins.js — Agent-level selectable Yeaft capabilities.
3
+ *
4
+ * An Agent owns installed tools, skills, and MCP configuration. Missing plugin
5
+ * fields retain historical behavior (everything enabled); explicit arrays are
6
+ * allowlists and may intentionally be empty.
7
+ */
8
+
9
+ function normalizeNameList(value, field) {
10
+ if (value === undefined) return undefined;
11
+ if (!Array.isArray(value)) throw new Error(`plugins.${field} must be an array`);
12
+
13
+ const names = [];
14
+ const seen = new Set();
15
+ for (const raw of value) {
16
+ if (typeof raw !== 'string' || !raw.trim()) {
17
+ throw new Error(`plugins.${field} entries must be non-empty strings`);
18
+ }
19
+ const name = raw.trim();
20
+ if (!seen.has(name)) {
21
+ seen.add(name);
22
+ names.push(name);
23
+ }
24
+ }
25
+ return names;
26
+ }
27
+
28
+ /** Normalise persisted Agent plugin config while preserving inheritance. */
29
+ export function normalizePluginConfig(value) {
30
+ // Only an omitted field inherits the legacy all-enabled behavior. An
31
+ // explicit `null` is persisted schema, not absence, and must be rejected
32
+ // so every reader can fail closed consistently.
33
+ if (value === undefined) return {};
34
+ if (!value || typeof value !== 'object' || Array.isArray(value)) {
35
+ throw new Error('plugins must be an object');
36
+ }
37
+
38
+ const out = {};
39
+ for (const field of ['tools', 'skills', 'mcpServers']) {
40
+ const names = normalizeNameList(value[field], field);
41
+ if (names !== undefined) out[field] = names;
42
+ }
43
+ for (const key of Object.keys(value)) {
44
+ if (!['tools', 'skills', 'mcpServers'].includes(key)) {
45
+ throw new Error(`unknown plugins key: ${key}`);
46
+ }
47
+ }
48
+ return out;
49
+ }
50
+
51
+ /**
52
+ * Return an explicit deny-all policy for a persisted plugins schema error.
53
+ * This is deliberately distinct from `{}`, whose missing fields inherit the
54
+ * historical all-enabled behavior.
55
+ */
56
+ export function createDenyAllPluginConfig() {
57
+ return { tools: [], skills: [], mcpServers: [] };
58
+ }
59
+
60
+ export function isPluginNameEnabled(plugins, field, name) {
61
+ if (!plugins || !Array.isArray(plugins[field])) return true;
62
+ return plugins[field].includes(name);
63
+ }
64
+
65
+ /**
66
+ * Keep the configured MCP catalog separate from the runtime connection set.
67
+ * The catalog must retain disabled servers so users can enable them later;
68
+ * runtimes must receive only the effective allowlisted subset.
69
+ *
70
+ * The caller owns the raw configured catalog; this helper never connects it.
71
+ */
72
+ export function resolveMcpPluginPolicy(mcpConfig, plugins) {
73
+ const configured = {
74
+ ...(mcpConfig || {}),
75
+ servers: Array.isArray(mcpConfig?.servers) ? mcpConfig.servers : [],
76
+ };
77
+ if (!Array.isArray(plugins?.mcpServers)) {
78
+ return { configured, effective: configured };
79
+ }
80
+
81
+ const allowed = new Set(plugins.mcpServers);
82
+ return {
83
+ configured,
84
+ effective: {
85
+ ...configured,
86
+ servers: configured.servers.filter(server => allowed.has(server?.name)),
87
+ },
88
+ };
89
+ }
90
+
91
+ /**
92
+ * Live delegating view over a SkillManager. It does not mutate the shared
93
+ * manager because Sessions and project runtimes reuse that manager.
94
+ */
95
+ export function createPluginSkillManager(skillManager, plugins) {
96
+ if (!skillManager) return null;
97
+ const hasExplicitSkills = Array.isArray(plugins?.skills);
98
+ const allowed = hasExplicitSkills ? new Set(plugins.skills) : null;
99
+ const isAllowed = name => !allowed || allowed.has(name);
100
+ const has = name => isAllowed(name) && !!skillManager.has?.(name);
101
+ const list = (...args) => (skillManager.list?.(...args) || [])
102
+ .filter(skill => isAllowed(skill?.name));
103
+ const get = name => has(name) ? skillManager.get?.(name) || null : null;
104
+ const resolve = name => has(name) ? skillManager.resolve?.(name) || null : null;
105
+ const view = (name, filePath) => has(name) ? skillManager.view?.(name, filePath) || null : null;
106
+ const findRelevant = (...args) => (skillManager.findRelevant?.(...args) || [])
107
+ .filter(skill => isAllowed(skill?.name));
108
+ const getPromptContent = name => has(name) ? skillManager.getPromptContent?.(name) || '' : '';
109
+
110
+ return {
111
+ has,
112
+ get,
113
+ resolve,
114
+ list,
115
+ view,
116
+ findRelevant,
117
+ getPromptContent,
118
+ getRelevantPromptContent: (...args) => findRelevant(...args)
119
+ .map(skill => getPromptContent(skill.name))
120
+ .filter(Boolean)
121
+ .join('\n\n'),
122
+ listCategories: () => [...new Set(list().map(skill => skill.category).filter(Boolean))].sort(),
123
+ get size() { return list().length; },
124
+ };
125
+ }
126
+
127
+ /** Build a browser-safe catalog from already discovered Agent assets. */
128
+ export function buildPluginCatalog({ toolRegistry, skillManager, mcpConfig, mcpManager } = {}) {
129
+ const tools = typeof toolRegistry?.getAllTools === 'function'
130
+ ? toolRegistry.getAllTools()
131
+ .filter(tool => !tool?.mcpServer)
132
+ .map(tool => ({ id: tool.name, label: tool.name }))
133
+ .sort((a, b) => a.label.localeCompare(b.label))
134
+ : [];
135
+
136
+ const skills = typeof skillManager?.list === 'function'
137
+ ? skillManager.list()
138
+ .map(skill => ({
139
+ id: skill.name,
140
+ label: skill.name,
141
+ description: skill.description || '',
142
+ category: skill.category || null,
143
+ }))
144
+ .sort((a, b) => a.label.localeCompare(b.label))
145
+ : [];
146
+
147
+ const statusByName = new Map((mcpManager?.status?.() || [])
148
+ .map(status => [status.name, status]));
149
+ // A configured catalog is authoritative, including an explicit empty array.
150
+ // Only callers that supplied no catalog at all use live status as a legacy
151
+ // fallback.
152
+ const configuredMcpServers = Array.isArray(mcpConfig?.servers)
153
+ ? mcpConfig.servers
154
+ : (mcpManager?.status?.() || []).map(status => ({ name: status.name, command: '' }));
155
+ const mcpServers = configuredMcpServers
156
+ .filter(server => typeof server?.name === 'string' && server.name)
157
+ .map(server => {
158
+ const status = statusByName.get(server.name);
159
+ return {
160
+ id: server.name,
161
+ label: server.name,
162
+ description: server.command || '',
163
+ ready: status ? !!status.ready : null,
164
+ toolCount: status?.toolCount || 0,
165
+ };
166
+ })
167
+ .sort((a, b) => a.label.localeCompare(b.label));
168
+
169
+ return { tools, skills, mcpServers };
170
+ }
@@ -28,6 +28,39 @@
28
28
  import { resolveMemberId } from '../sessions/roster.js';
29
29
  import { createLoopGuard, extendCausedBy } from './loop-guard.js';
30
30
 
31
+ function routeForwardParentFromEnvelope(envelope) {
32
+ const msg = envelope?.msg;
33
+ const meta = msg?.meta;
34
+ if (!meta || typeof meta !== 'object') return null;
35
+ if (meta.injectedBy === 'route_forward_result') {
36
+ return meta.routeForwardParent && typeof meta.routeForwardParent === 'object'
37
+ ? { ...meta.routeForwardParent }
38
+ : null;
39
+ }
40
+ if (meta.injectedBy !== 'route_forward') return null;
41
+ const forwardId = typeof msg.id === 'string' ? msg.id.trim() : '';
42
+ const sourceVpId = typeof meta.senderVpId === 'string' ? meta.senderVpId.trim() : '';
43
+ if (!forwardId || !sourceVpId) return null;
44
+ return {
45
+ forwardId,
46
+ sourceVpId,
47
+ sourceThreadId: typeof meta.sourceThreadId === 'string' && meta.sourceThreadId.trim()
48
+ ? meta.sourceThreadId.trim()
49
+ : 'main',
50
+ expectedVpIds: Array.isArray(meta.routeForwardExpectedTargets)
51
+ ? meta.routeForwardExpectedTargets.slice()
52
+ : [],
53
+ causedBy: Array.isArray(meta.causedBy) ? meta.causedBy.slice() : [],
54
+ dispatchErrors: Array.isArray(meta.routeForwardDispatchErrors)
55
+ ? meta.routeForwardDispatchErrors.slice()
56
+ : [],
57
+ truncatedAtFanOutCap: Boolean(meta.routeForwardTruncatedAtFanOutCap),
58
+ parentRouteForward: meta.routeForwardParent && typeof meta.routeForwardParent === 'object'
59
+ ? { ...meta.routeForwardParent }
60
+ : null,
61
+ };
62
+ }
63
+
31
64
  /**
32
65
  * Build a router bound to a single GroupCoordinator + loop guard.
33
66
  *
@@ -112,6 +145,7 @@ export function createRouter(deps = {}) {
112
145
  // The guard runs against the *pre-dispatch* chain; that matches the
113
146
  // spec's intent ("depth of forwards already taken").
114
147
  const chain = extendCausedBy(args.inboundEnvelope || null, null);
148
+ const routeForwardParent = routeForwardParentFromEnvelope(args.inboundEnvelope);
115
149
 
116
150
  // Loop guard: for broadcast, use 'all' as the target key so one VP
117
151
  // spamming @all still gets throttled even if each cycle hits different
@@ -155,6 +189,7 @@ export function createRouter(deps = {}) {
155
189
  senderVpId: from,
156
190
  reason: args.reason || null,
157
191
  causedBy: chain,
192
+ ...(routeForwardParent ? { routeForwardParent } : {}),
158
193
  sourceThreadId: typeof args.sourceThreadId === 'string' && args.sourceThreadId.trim()
159
194
  ? args.sourceThreadId.trim()
160
195
  : null,
@@ -163,11 +198,35 @@ export function createRouter(deps = {}) {
163
198
  opts,
164
199
  );
165
200
 
201
+ // `deliver()` queues its work, so the target envelopes still share this
202
+ // stored message object when forward() returns. Record the accepted target
203
+ // set for the active runtime only: it lets a stream Session return one
204
+ // combined result to the caller after an @all fan-out finishes. The
205
+ // transient value is deliberately not required for durable replay.
206
+ if (report?.message?.meta && Array.isArray(report.dispatched)) {
207
+ report.message.meta.routeForwardExpectedTargets = report.dispatched.slice();
208
+ report.message.meta.routeForwardDispatchErrors = Array.isArray(report.errors)
209
+ ? report.errors.slice()
210
+ : [];
211
+ report.message.meta.routeForwardTruncatedAtFanOutCap = Boolean(report.truncatedAtFanOutCap);
212
+ }
213
+
166
214
  // Record AFTER Coordinator accepts. If Coordinator produced zero
167
215
  // dispatches (e.g. task.members gate) we still count it as a hit —
168
216
  // the forwarder still tried, and the guard's job is to throttle the
169
217
  // sender's ability to keep trying.
170
218
  guard.record({ sessionId: meta.id, targetVpId: guardKey });
219
+ if (!Array.isArray(report.dispatched) || report.dispatched.length === 0) {
220
+ return {
221
+ ok: false,
222
+ error: 'no_targets_dispatched',
223
+ detail: {
224
+ errors: Array.isArray(report.errors) ? report.errors : [],
225
+ truncatedAtFanOutCap: Boolean(report.truncatedAtFanOutCap),
226
+ },
227
+ report,
228
+ };
229
+ }
171
230
 
172
231
  return {
173
232
  ok: true,