@yeaft/webchat-agent 1.0.409 → 1.0.411

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.409",
3
+ "version": "1.0.411",
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",
@@ -13,8 +13,33 @@ import { join } from 'path';
13
13
  import { DEFAULT_YEAFT_DIR } from './init.js';
14
14
  import { normalizeProviderModels, parseModelRef, serializeModelForPersistence } from './models.js';
15
15
  import { normaliseTelemetrySection, normaliseYeaftSection } from './config.js';
16
+ import { normalizePluginConfig } from './plugins.js';
16
17
  import { isGitHubCopilotProvider, serializeKnownProviderForPersistence } from './llm/known-providers.js';
17
18
 
19
+ /**
20
+ * Read config.json before any public mutation. A missing file is a valid
21
+ * first-run state, but an existing malformed file, non-object root, or invalid
22
+ * Plugins schema must never be replaced by an unrelated Settings/MCP write.
23
+ * Otherwise the runtime's fail-closed policy could silently become inheritance
24
+ * (all capabilities enabled) on the next config reload.
25
+ *
26
+ * @param {string} configPath
27
+ * @returns {Record<string, unknown>}
28
+ * @throws {Error} when an existing config cannot be safely preserved
29
+ */
30
+ function readConfigForWrite(configPath) {
31
+ if (!existsSync(configPath)) return {};
32
+ const json = JSON.parse(readFileSync(configPath, 'utf8'));
33
+ if (!json || typeof json !== 'object' || Array.isArray(json)
34
+ || Object.getPrototypeOf(json) !== Object.prototype) {
35
+ throw new Error('config.json must contain an object');
36
+ }
37
+ if (Object.prototype.hasOwnProperty.call(json, 'plugins')) {
38
+ normalizePluginConfig(json.plugins);
39
+ }
40
+ return json;
41
+ }
42
+
18
43
  /**
19
44
  * Read the LLM-relevant portion of config.json.
20
45
  *
@@ -119,15 +144,13 @@ export function updateLlmConfig(update, dir) {
119
144
  const root = dir || process.env.YEAFT_DIR || DEFAULT_YEAFT_DIR;
120
145
  const configPath = join(root, 'config.json');
121
146
 
122
- // Read existing config (preserve non-LLM fields)
123
- let existing = {};
124
- if (existsSync(configPath)) {
125
- try {
126
- existing = JSON.parse(readFileSync(configPath, 'utf8'));
127
- } catch {
128
- // Start fresh if corrupt
129
- existing = {};
130
- }
147
+ // Preserve all existing fields only when the on-disk document and its
148
+ // Plugins policy are valid. Never turn a failed read into a fresh config.
149
+ let existing;
150
+ try {
151
+ existing = readConfigForWrite(configPath);
152
+ } catch (err) {
153
+ return { error: `Failed to read config.json: ${err?.message || err}` };
131
154
  }
132
155
 
133
156
  // Validate providers structure
@@ -265,14 +288,14 @@ export function updateYeaftSettings(update, dir) {
265
288
  }
266
289
  }
267
290
 
268
- // Read existing config (preserve LLM and other top-level fields).
269
- let existing = {};
270
- if (existsSync(configPath)) {
271
- try {
272
- existing = JSON.parse(readFileSync(configPath, 'utf8'));
273
- } catch {
274
- existing = {};
275
- }
291
+ // Preserve all existing fields only when the on-disk document and its
292
+ // Plugins policy are valid. A Settings update must not repair bad JSON into
293
+ // a config whose missing Plugins fields inherit all capabilities.
294
+ let existing;
295
+ try {
296
+ existing = readConfigForWrite(configPath);
297
+ } catch (err) {
298
+ return { error: `Failed to read config.json: ${err?.message || err}` };
276
299
  }
277
300
 
278
301
  const prev = normaliseYeaftSection(existing.yeaft);
@@ -336,7 +359,12 @@ export function updateTelemetrySettings(update, dir) {
336
359
  }
337
360
  const root = dir || process.env.YEAFT_DIR || DEFAULT_YEAFT_DIR;
338
361
  const configPath = join(root, 'config.json');
339
- const existing = readConfigJson(configPath);
362
+ let existing;
363
+ try {
364
+ existing = readConfigForWrite(configPath);
365
+ } catch (err) {
366
+ return { error: `Failed to read config.json: ${err?.message || err}` };
367
+ }
340
368
  const merged = normaliseTelemetrySection({
341
369
  ...(existing.telemetry && typeof existing.telemetry === 'object' ? existing.telemetry : {}),
342
370
  ...update,
@@ -427,13 +455,11 @@ export function updateSearchSettings(update, dir) {
427
455
  return { error: 'tavilyApiKey must be a string' };
428
456
  }
429
457
 
430
- let existing = {};
431
- if (existsSync(configPath)) {
432
- try {
433
- existing = JSON.parse(readFileSync(configPath, 'utf8'));
434
- } catch {
435
- existing = {};
436
- }
458
+ let existing;
459
+ try {
460
+ existing = readConfigForWrite(configPath);
461
+ } catch (err) {
462
+ return { error: `Failed to read config.json: ${err?.message || err}` };
437
463
  }
438
464
  const prev = (existing && typeof existing.search === 'object' && existing.search) || {};
439
465
  const merged = { ...prev };
@@ -500,6 +526,49 @@ export async function fetchTavilyUsage(dir) {
500
526
  }
501
527
  }
502
528
 
529
+ // ─── Agent plugin selection ────────────────────────────────────
530
+
531
+ /**
532
+ * Read the Agent-local plugin allowlists. Missing category fields mean
533
+ * inheritance (all discovered capabilities remain available).
534
+ */
535
+ export function getPluginConfig(dir) {
536
+ const root = dir || process.env.YEAFT_DIR || DEFAULT_YEAFT_DIR;
537
+ const configPath = join(root, 'config.json');
538
+ try {
539
+ const json = readConfigForWrite(configPath);
540
+ return { plugins: normalizePluginConfig(json.plugins) };
541
+ } catch (err) {
542
+ return { error: `Failed to read plugin config: ${err?.message || err}` };
543
+ }
544
+ }
545
+
546
+ /**
547
+ * Persist Agent-local plugin allowlists without touching providers, MCP server
548
+ * definitions, or any other config.json field.
549
+ */
550
+ export function updatePluginConfig(plugins, dir) {
551
+ const root = dir || process.env.YEAFT_DIR || DEFAULT_YEAFT_DIR;
552
+ const configPath = join(root, 'config.json');
553
+ let normalized;
554
+ let existing;
555
+ try {
556
+ existing = readConfigForWrite(configPath);
557
+ normalized = normalizePluginConfig(plugins);
558
+ } catch (err) {
559
+ return { error: `Failed to read plugin config: ${err?.message || err}` };
560
+ }
561
+
562
+ if (Object.keys(normalized).length === 0) delete existing.plugins;
563
+ else existing.plugins = normalized;
564
+ try {
565
+ writeFileSync(configPath, JSON.stringify(existing, null, 2) + '\n', 'utf8');
566
+ } catch (err) {
567
+ return { error: `Failed to write plugin config: ${err?.message || err}` };
568
+ }
569
+ return { plugins: normalized };
570
+ }
571
+
503
572
  // ─── MCP server config (mcpServers array in config.json) ──
504
573
 
505
574
  /**
@@ -579,24 +648,6 @@ function validateMcpServer(entry) {
579
648
  return null;
580
649
  }
581
650
 
582
- /**
583
- * Read existing config.json (silently start fresh on missing / corrupt).
584
- * Internal helper used by the MCP CRUD trio to share one parse path.
585
- *
586
- * @param {string} configPath
587
- * @returns {object}
588
- */
589
- function readConfigJson(configPath) {
590
- if (!existsSync(configPath)) return {};
591
- try {
592
- const raw = readFileSync(configPath, 'utf8');
593
- const json = JSON.parse(raw);
594
- return (json && typeof json === 'object') ? json : {};
595
- } catch {
596
- return {};
597
- }
598
- }
599
-
600
651
  /**
601
652
  * List MCP servers currently saved in config.json. Returns an array — empty
602
653
  * when none configured. Each entry is the normalised on-disk shape, NOT
@@ -609,12 +660,12 @@ export function listMcpServers(dir) {
609
660
  const root = dir || process.env.YEAFT_DIR || DEFAULT_YEAFT_DIR;
610
661
  const configPath = join(root, 'config.json');
611
662
  try {
612
- const json = readConfigJson(configPath);
663
+ const json = readConfigForWrite(configPath);
613
664
  const raw = Array.isArray(json.mcpServers) ? json.mcpServers : [];
614
665
  const servers = raw.map(normaliseMcpServer).filter(Boolean);
615
666
  return { servers };
616
- } catch (e) {
617
- return { error: `Failed to read config.json: ${e.message}` };
667
+ } catch (err) {
668
+ return { error: `Failed to read config.json: ${err?.message || err}` };
618
669
  }
619
670
  }
620
671
 
@@ -634,7 +685,12 @@ export function upsertMcpServer(server, dir) {
634
685
 
635
686
  const root = dir || process.env.YEAFT_DIR || DEFAULT_YEAFT_DIR;
636
687
  const configPath = join(root, 'config.json');
637
- const existing = readConfigJson(configPath);
688
+ let existing;
689
+ try {
690
+ existing = readConfigForWrite(configPath);
691
+ } catch (err) {
692
+ return { error: `Failed to read config.json: ${err?.message || err}` };
693
+ }
638
694
  const list = Array.isArray(existing.mcpServers) ? existing.mcpServers.slice() : [];
639
695
 
640
696
  const normalised = normaliseMcpServer(server);
@@ -677,7 +733,12 @@ export function removeMcpServer(name, dir) {
677
733
  const target = name.trim();
678
734
  const root = dir || process.env.YEAFT_DIR || DEFAULT_YEAFT_DIR;
679
735
  const configPath = join(root, 'config.json');
680
- const existing = readConfigJson(configPath);
736
+ let existing;
737
+ try {
738
+ existing = readConfigForWrite(configPath);
739
+ } catch (err) {
740
+ return { error: `Failed to read config.json: ${err?.message || err}` };
741
+ }
681
742
  const list = Array.isArray(existing.mcpServers) ? existing.mcpServers.slice() : [];
682
743
  const next = list.filter(s => !(s && typeof s === 'object' && s.name === target));
683
744
  const removed = next.length !== list.length;
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))
@@ -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);