@bahulam/code 0.1.10 → 0.1.11

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,7 @@
11
11
 
12
12
  import { llmToolResultContent, sendCallback, sendSkippedCallback, sendApprovalDecision } from './callback-client.mjs';
13
13
  import { ApprovalManager } from './approval.mjs';
14
+ import { upsertFacts } from './memory-disk.mjs';
14
15
  import { normalizeBillingBrandCopy, quotaErrorDetail, rateLimitErrorMessage } from './rate-limit-display.mjs';
15
16
  import * as telemetry from '../telemetry/index.mjs';
16
17
 
@@ -93,6 +94,17 @@ function transportDebug(message, data = {}) {
93
94
  } catch {}
94
95
  }
95
96
 
97
+ function memoryFactsFromComplete(data) {
98
+ const facts = data?.memory_facts_to_persist;
99
+ if (!Array.isArray(facts) || facts.length === 0) return [];
100
+ return facts.filter(fact => (
101
+ fact
102
+ && typeof fact === 'object'
103
+ && fact.fact_id
104
+ && String(fact.content || '').trim()
105
+ ));
106
+ }
107
+
96
108
  // Full jitter around the scheduled delay: pick a value in [delay*0.5, delay*1.5].
97
109
  // Spreads out reconnect storms so N clients dropping simultaneously don't
98
110
  // synchronize their retries. Clamped to the same 30s ceiling as the base delay.
@@ -136,6 +148,7 @@ export class BahulamStreamClient {
136
148
  approvalManager = null,
137
149
  reconnectMaxElapsedMs = null,
138
150
  mode = null,
151
+ pluginRegistry = null,
139
152
  }) {
140
153
  this.baseUrl = (baseUrl || '').replace(/\/$/, '');
141
154
  this.token = token;
@@ -148,7 +161,7 @@ export class BahulamStreamClient {
148
161
  this.retryDelayMs = null;
149
162
  this.pendingToolCallbacks = new Map();
150
163
  this.reconnectMaxElapsedMs = reconnectMaxElapsedMs
151
- ?? Number(process.env.KEPLER_RECONNECT_MAX_ELAPSED_MS || 300_000);
164
+ ?? Number(process.env.BAHULAM_RECONNECT_MAX_ELAPSED_MS || 300_000);
152
165
  // Set by backend on first turn, reused on subsequent turns. Headless mode
153
166
  // (which starts fresh per invocation) can pre-seed via TARANG_SESSION_ID
154
167
  // so multi-turn benchmarks share one backend session across `node` runs.
@@ -173,9 +186,38 @@ export class BahulamStreamClient {
173
186
  || (process.env.TARANG_ENV === 'remote' ? 'remote' : null)
174
187
  || (process.env.TARANG_ENV === 'bundled' ? 'bundled' : null)
175
188
  || 'remote';
189
+ this.pluginRegistry = pluginRegistry || null;
176
190
  this._bundledReady = false;
177
191
  }
178
192
 
193
+ /**
194
+ * Get plugin tool schemas for client_tools injection.
195
+ * @returns {Array<{name: string, description: string, input_schema: object}>}
196
+ */
197
+ _getPluginToolSchemas() {
198
+ if (!this.pluginRegistry) return [];
199
+ return this.pluginRegistry.listTools().map(t => ({
200
+ name: t.name,
201
+ description: t.description || '',
202
+ input_schema: t.input_schema || { type: 'object', properties: {} },
203
+ }));
204
+ }
205
+
206
+ /**
207
+ * Get plugin agent schemas for client_agents injection.
208
+ * @returns {Array<{slug: string, name: string, role: string, description: string, tools: string[]}>}
209
+ */
210
+ _getPluginAgentSchemas() {
211
+ if (!this.pluginRegistry) return [];
212
+ return this.pluginRegistry.listAgents().map(a => ({
213
+ slug: a.slug || a.name || '',
214
+ name: a.name || a.slug || '',
215
+ role: a.role || 'specialist',
216
+ description: a.description || '',
217
+ tools: Array.isArray(a.tools) ? a.tools : [],
218
+ }));
219
+ }
220
+
179
221
  /**
180
222
  * Ensure the bundled runtime is spawned and this.baseUrl points at it.
181
223
  * No-op in remote mode. Callers that hit the backend should invoke this
@@ -251,6 +293,10 @@ export class BahulamStreamClient {
251
293
  const body = { instruction, context };
252
294
  if (messages && messages.length > 0) body.messages = messages;
253
295
  if (this.sessionId) body.session_id = this.sessionId;
296
+ const clientTools = this._getPluginToolSchemas();
297
+ if (clientTools.length > 0) body.client_tools = clientTools;
298
+ const clientAgents = this._getPluginAgentSchemas();
299
+ if (clientAgents.length > 0) body.client_agents = clientAgents;
254
300
  const requestId = `cli-${_uuidLike()}`;
255
301
 
256
302
  // daemon cache-guard hook. If BAHULAM_CAPTURE_REQUEST is set to a file
@@ -461,6 +507,10 @@ export class BahulamStreamClient {
461
507
  return;
462
508
  }
463
509
 
510
+ if (event === EVENT_TYPES.COMPLETE) {
511
+ this._persistMemoryFactsFromComplete(data);
512
+ }
513
+
464
514
  // Tool requests — show to user, then execute locally and POST callback.
465
515
  if (event === EVENT_TYPES.TOOL_REQUEST || event === EVENT_TYPES.TOOL_CALL) {
466
516
  yield rendered;
@@ -486,6 +536,23 @@ export class BahulamStreamClient {
486
536
  }
487
537
  }
488
538
 
539
+ _persistMemoryFactsFromComplete(data) {
540
+ const facts = memoryFactsFromComplete(data);
541
+ if (facts.length === 0) return;
542
+ try {
543
+ upsertFacts(facts, process.cwd());
544
+ telemetry.track('memory.disk.upserted', { facts: facts.length });
545
+ } catch (err) {
546
+ telemetry.track('memory.disk.upsert_failed', {
547
+ facts: facts.length,
548
+ message: err?.message || String(err),
549
+ });
550
+ if (data && typeof data === 'object') {
551
+ data.memory_persist_error = err?.message || String(err);
552
+ }
553
+ }
554
+ }
555
+
489
556
  async *_reconnectAfterDrop(err) {
490
557
  const taskId = this.currentTaskId;
491
558
  if (!taskId || this.lastEventId == null) {
@@ -1,8 +1,8 @@
1
1
  /**
2
- * System Prompt Builder — loads and merges CLAUDE.md and KEPLER.md files.
2
+ * System Prompt Builder — loads and merges CLAUDE.md and BAHULAM.md files.
3
3
  *
4
4
  * Features:
5
- * - Loads CLAUDE.md from: ~/.claude/CLAUDE.md, project root, parent dirs
5
+ * - Loads CLAUDE.md and BAHULAM.md from: global dir, project root, parent dirs
6
6
  * - Merges in order (global -> project -> local)
7
7
  * - Splits at cache boundary (static prefix cached, dynamic suffix not)
8
8
  * - Includes tool schemas in the system prompt
@@ -13,27 +13,46 @@ import os from 'os';
13
13
  import { loadBahulamMemory } from '../config/memory-loader.mjs';
14
14
 
15
15
  /**
16
- * Load all CLAUDE.md files and merge them in order.
16
+ * Load all instruction files and merge them in order (global → parent → project).
17
+ *
18
+ * Three first-class formats, per industry standard (2026):
19
+ * AGENTS.md — universal baseline: build rules, code style, monorepo layout.
20
+ * Loaded by 30+ agents (Cursor, Copilot CLI, Gemini CLI, Claude Code).
21
+ * BAHULAM.md — Bahulam-native persistent memory: tool directives, preferences,
22
+ * project-specific context that travels every session.
23
+ * CLAUDE.md — Claude Code / Claude-specific instructions and memory tiers.
24
+ *
25
+ * Search order per directory: AGENTS.md → BAHULAM.md → .bahulam/BAHULAM.md
26
+ * → CLAUDE.md → .claude/CLAUDE.md
27
+ *
17
28
  * @param {string} [cwd] - current working directory
18
- * @returns {string[]} Array of CLAUDE.md contents in merge order
29
+ * @returns {Array<{source,content,path}>} files in merge order
19
30
  */
20
31
  export function loadClaudeMdFiles(cwd = process.cwd()) {
21
32
  const files = [];
22
33
 
23
- // 1. Global: ~/.claude/CLAUDE.md
24
- const globalPath = path.join(os.homedir(), '.claude', 'CLAUDE.md');
25
- if (fs.existsSync(globalPath)) {
26
- try {
27
- files.push({ source: 'global', content: fs.readFileSync(globalPath, 'utf-8') });
28
- } catch { /* skip */ }
34
+ // 1. Global files
35
+ for (const globalPath of [
36
+ path.join(os.homedir(), '.bahulam', 'AGENTS.md'),
37
+ path.join(os.homedir(), '.bahulam', 'BAHULAM.md'),
38
+ path.join(os.homedir(), '.claude', 'CLAUDE.md'),
39
+ ]) {
40
+ if (fs.existsSync(globalPath)) {
41
+ try {
42
+ files.push({ source: 'global', content: fs.readFileSync(globalPath, 'utf-8'), path: globalPath });
43
+ } catch { /* skip */ }
44
+ }
29
45
  }
30
46
 
31
- // 2. Walk from cwd up to root, collecting CLAUDE.md files
47
+ // 2. Walk from cwd up to root, collecting per-directory instruction files
32
48
  const projectFiles = [];
33
49
  let dir = path.resolve(cwd);
34
50
  const root = path.parse(dir).root;
35
51
  while (dir !== root) {
36
52
  const candidates = [
53
+ path.join(dir, 'AGENTS.md'),
54
+ path.join(dir, 'BAHULAM.md'),
55
+ path.join(dir, '.bahulam', 'BAHULAM.md'),
37
56
  path.join(dir, 'CLAUDE.md'),
38
57
  path.join(dir, '.claude', 'CLAUDE.md'),
39
58
  ];
@@ -47,7 +66,7 @@ export function loadClaudeMdFiles(cwd = process.cwd()) {
47
66
  dir = path.dirname(dir);
48
67
  }
49
68
 
50
- // Reverse so parent dirs come first (global -> project -> local)
69
+ // Reverse so parent dirs come first (global project local)
51
70
  projectFiles.reverse();
52
71
  files.push(...projectFiles);
53
72
 
@@ -16,7 +16,7 @@ import { analyzeCode } from '../context/ast-parser.mjs';
16
16
  import { ProjectRegistry } from '../tools/project-overview.mjs';
17
17
  import { SkillInstaller } from '../skills/installer.mjs';
18
18
  import { SkillsLoader } from '../skills/loader.mjs';
19
- import { createAgentFile, listLocalAgents, syncAgentsToBackend } from '../agents/scaffold.mjs';
19
+ import { agentToSpec, createAgentFile, listLocalAgents, syncAgentsToBackend } from '../agents/scaffold.mjs';
20
20
  import { createWorkflowFile, listLocalWorkflows, WORKFLOW_SYNC_ENDPOINT, slugifyWorkflowName } from '../agents/workflow_scaffold.mjs';
21
21
  import { BahulamAuth } from '../auth/bahulam-auth.mjs';
22
22
  import { detectImageFile } from './attachments.mjs';
@@ -27,6 +27,8 @@ import { buildFileDiff } from './file-diff.mjs';
27
27
  import { buildWorkScope } from './work-scope.mjs';
28
28
  import { loadDiskMemory, ensureBahulamDir, globalMemoryPath, projectMemoryPath } from './memory-disk.mjs';
29
29
  import { resolveLintCommand } from './lint-resolver.mjs';
30
+ import { PluginRegistry } from '../plugins/registry.mjs';
31
+ import { loadPluginTool } from '../plugins/executor.mjs';
30
32
  import * as fs from 'node:fs';
31
33
  import * as os from 'node:os';
32
34
  import * as path from 'node:path';
@@ -48,6 +50,13 @@ export function createToolExecutor({
48
50
  interactionHandler = null,
49
51
  onAutoRegisterStart = null,
50
52
  onAutoRegisterDone = null,
53
+ pluginRegistry = null,
54
+ // Optional emit hook: called (debounced per key) after any plugin
55
+ // state write commits. Wired by the workspace server so writes turn
56
+ // into SSE `plugin_state_changed` events for live view updates.
57
+ // REPL/headless callers leave this null — state still works, just
58
+ // no reactive pulse.
59
+ stateEmit = null,
51
60
  } = {}) {
52
61
  // Cross-session memory cache. Ships in getAgentContext() on every turn,
53
62
  // so we need it to be byte-identical when the underlying disk file hasn't
@@ -146,7 +155,7 @@ export function createToolExecutor({
146
155
  }
147
156
 
148
157
  function longRunningObservationTimeoutMs() {
149
- const configured = Number(process.env.KEPLER_LONG_RUNNING_TIMEOUT_MS);
158
+ const configured = Number(process.env.BAHULAM_LONG_RUNNING_TIMEOUT_MS);
150
159
  return Number.isFinite(configured) && configured > 0 ? configured : 15_000;
151
160
  }
152
161
 
@@ -246,14 +255,76 @@ export function createToolExecutor({
246
255
  };
247
256
  }
248
257
 
258
+ function pluginAgentToLocalShape(agentDef) {
259
+ const pluginName = agentDef._plugin_name
260
+ || String(agentDef.source || '').replace(/^plugin:/, '')
261
+ || 'unknown';
262
+ const source = `plugin:${pluginName}`;
263
+ const base = {
264
+ ...agentDef,
265
+ slug: agentDef.slug || agentDef.name || '',
266
+ name: agentDef.name || agentDef.slug || '',
267
+ description: agentDef.description || '',
268
+ role: agentDef.role || 'specialist',
269
+ model: agentDef.model || null,
270
+ models: agentDef.models || undefined,
271
+ tools: Array.isArray(agentDef.tools)
272
+ ? agentDef.tools
273
+ : (Array.isArray(agentDef.agent_tools) ? agentDef.agent_tools : []),
274
+ capabilities: Array.isArray(agentDef.capabilities) ? agentDef.capabilities : [],
275
+ domains: Array.isArray(agentDef.domains) ? agentDef.domains : [],
276
+ system_prompt: agentDef.system_prompt || agentDef.prompt || agentDef.instructions || '',
277
+ prompt: agentDef.prompt || agentDef.system_prompt || agentDef.instructions || '',
278
+ source_scope: 'plugin',
279
+ source,
280
+ };
281
+ const spec = {
282
+ ...agentToSpec(base),
283
+ source,
284
+ source_scope: 'plugin',
285
+ plugin_name: pluginName,
286
+ };
287
+ if (spec.config?.metadata && typeof spec.config.metadata === 'object') {
288
+ spec.config.metadata.source = source;
289
+ spec.config.metadata.source_scope = 'plugin';
290
+ }
291
+ const content = JSON.stringify(spec);
292
+ return {
293
+ ...base,
294
+ slug: spec.slug,
295
+ spec,
296
+ source,
297
+ source_scope: 'plugin',
298
+ content_hash: crypto.createHash('sha256').update(content).digest('hex'),
299
+ };
300
+ }
301
+
302
+ function listPluginAgents() {
303
+ if (!pluginRegistry) return [];
304
+ return pluginRegistry.listAgents()
305
+ .map(pluginAgentToLocalShape)
306
+ .filter(agent => agent.slug);
307
+ }
308
+
309
+ function listAvailableAgents() {
310
+ const bySlug = new Map();
311
+ for (const agent of listLocalAgents(process.cwd())) {
312
+ if (agent.slug && !bySlug.has(agent.slug)) bySlug.set(agent.slug, agent);
313
+ }
314
+ for (const agent of listPluginAgents()) {
315
+ if (agent.slug && !bySlug.has(agent.slug)) bySlug.set(agent.slug, agent);
316
+ }
317
+ return [...bySlug.values()];
318
+ }
319
+
249
320
  function filterLocalAgents(args = {}) {
250
321
  const scope = String(args.scope || '').trim();
251
- if (scope && scope !== 'project' && scope !== 'global') {
252
- throw new Error('scope must be "project" or "global"');
322
+ if (scope && !['project', 'global', 'plugin'].includes(scope)) {
323
+ throw new Error('scope must be "project", "global", or "plugin"');
253
324
  }
254
- return listLocalAgents(process.cwd())
255
- .filter(agent => !scope || agent.source_scope === scope)
256
- .filter(agent => agentMatches(agent, args.query || args.name || ''));
325
+ const combined = listAvailableAgents()
326
+ .filter(agent => !scope || agent.source_scope === scope);
327
+ return combined.filter(agent => agentMatches(agent, args.query || args.name || ''));
257
328
  }
258
329
 
259
330
  function selectAgentsForSync(args = {}) {
@@ -618,8 +689,149 @@ export function createToolExecutor({
618
689
  return { output: lines.join('\n'), files, directories, truncated };
619
690
  }
620
691
 
692
+ // ── Plugin tool map ──────────────────────────────────────────
693
+ // Plugin tools are registered here alongside the built-in toolMap.
694
+ // They are dispatched with lower priority (built-in tools win on name collision).
695
+ const pluginToolMap = new Map(); // name → async handler function
696
+
697
+ function registerPluginTool(name, handler) {
698
+ if (pluginToolMap.has(name)) {
699
+ if (process.env.DEBUG) {
700
+ console.warn(`Plugin tool "${name}" already registered from another plugin — skipping.`);
701
+ }
702
+ return false;
703
+ }
704
+ pluginToolMap.set(name, handler);
705
+ return true;
706
+ }
707
+
708
+ // Per-plugin state handles are opened lazily on first tool call and
709
+ // cached process-wide. `makePluginState` itself dedupes on plugin
710
+ // name, so this Map only exists to avoid re-attaching stateEmit on
711
+ // every registered tool.
712
+ const _pluginStateHandles = new Map(); // pluginName -> state proxy
713
+ async function _pluginStateFor(pluginName) {
714
+ if (!pluginName) return null;
715
+ if (_pluginStateHandles.has(pluginName)) return _pluginStateHandles.get(pluginName);
716
+ const { makePluginState } = await import('../plugins/state.mjs');
717
+ const state = makePluginState(pluginName, { emit: stateEmit });
718
+ _pluginStateHandles.set(pluginName, state);
719
+ return state;
720
+ }
721
+
722
+ /**
723
+ * Register one MCP tool under `<serverName>.<toolName>` (namespaced
724
+ * to prevent collisions between plugins that ship servers with the
725
+ * same tool name). The MCP client is owned by the caller (agent-
726
+ * relay) which spawns/tears it down with the workspace lifetime.
727
+ * Handler receives the same options shape as JS plugin tools so
728
+ * `state`, `signal`, `pluginName` all work uniformly.
729
+ */
730
+ function registerMcpTool(pluginName, serverName, toolName, mcpClient, toolSchema = {}) {
731
+ const qualified = `${serverName}.${toolName}`;
732
+ if (toolMap[qualified] || pluginToolMap.has(qualified)) {
733
+ if (process.env.DEBUG) {
734
+ console.warn(`MCP tool "${qualified}" from plugin "${pluginName}" collides with an existing tool — skipping.`);
735
+ }
736
+ return false;
737
+ }
738
+ pluginToolMap.set(qualified, async (args, options = {}) => {
739
+ // The lazy-state getter matches JS plugin tools so an MCP
740
+ // "wrapper" tool can trivially write its result to the same
741
+ // Shared Blackboard (rare, but useful for cache-and-return).
742
+ const handlerOpts = {
743
+ ...options,
744
+ pluginName,
745
+ mcpServer: serverName,
746
+ get state() {
747
+ if (this._stateP) return this._stateP;
748
+ this._stateP = _pluginStateFor(pluginName);
749
+ return this._stateP;
750
+ },
751
+ };
752
+ try {
753
+ const result = await mcpClient.callTool(toolName, args || {});
754
+ // callTool returns joined text for text/* content; pass through as output.
755
+ const output = typeof result === 'string' ? result : (result?.output ?? result);
756
+ // Allow the caller (state-writer wrapper) to introspect via handlerOpts.
757
+ void handlerOpts;
758
+ return { success: true, output, _tool: qualified, _plugin: pluginName, _mcp_server: serverName };
759
+ } catch (err) {
760
+ return {
761
+ success: false,
762
+ output: `MCP tool error (${qualified}): ${err.message}`,
763
+ _tool: qualified,
764
+ _plugin: pluginName,
765
+ _mcp_server: serverName,
766
+ };
767
+ }
768
+ });
769
+ // Track schema for tool-listing surfaces (also help /tools discovery).
770
+ pluginToolMap.get(qualified)._mcp = { pluginName, serverName, toolName, schema: toolSchema };
771
+ return true;
772
+ }
773
+
774
+ function registerPluginToolsFromRegistry() {
775
+ if (!pluginRegistry) return;
776
+ for (const toolDef of pluginRegistry.listTools?.() || []) {
777
+ const name = String(toolDef.name || '').trim();
778
+ if (!name || toolMap[name]) continue;
779
+ const pluginName = toolDef._plugin_name || toolDef.plugin_name || null;
780
+ registerPluginTool(name, async (args, options = {}) => {
781
+ const handler = await loadPluginTool(toolDef._plugin_dir, toolDef.handler);
782
+ if (!handler) {
783
+ return {
784
+ success: false,
785
+ output: `Plugin tool handler could not be loaded: ${name}`,
786
+ _tool: name,
787
+ _plugin: pluginName,
788
+ };
789
+ }
790
+ // Shared-blackboard injection: handlers opt in by naming
791
+ // `state` in their signature (`async call(args, { state })`).
792
+ // The property is a getter so the SQLite file is only
793
+ // opened when a handler actually asks for it — plugins
794
+ // that never touch state pay zero disk / init cost.
795
+ const handlerOpts = {
796
+ ...options,
797
+ pluginName,
798
+ get state() { /* eslint-disable no-unused-vars */
799
+ // Sync getter fronting an async loader — first
800
+ // access returns a Promise, which is unusual
801
+ // for handler code but common enough as
802
+ // `const s = await opts.state`. The awaited
803
+ // value is cached on this options object so
804
+ // repeat accesses in the same call don't re-await.
805
+ if (this._stateP) return this._stateP;
806
+ this._stateP = _pluginStateFor(pluginName);
807
+ return this._stateP;
808
+ },
809
+ };
810
+ try {
811
+ const result = await handler.call(args || {}, handlerOpts);
812
+ if (result && typeof result === 'object' && 'success' in result) {
813
+ return { ...result, _tool: name, _plugin: pluginName };
814
+ }
815
+ return {
816
+ success: true,
817
+ output: typeof result === 'string' ? result : JSON.stringify(result),
818
+ _tool: name,
819
+ _plugin: pluginName,
820
+ };
821
+ } catch (err) {
822
+ return {
823
+ success: false,
824
+ output: `Plugin tool error (${name}): ${err.message}`,
825
+ _tool: name,
826
+ _plugin: pluginName,
827
+ };
828
+ }
829
+ });
830
+ }
831
+ }
832
+
621
833
  async function executeToolWithHooks(name, args, options = {}) {
622
- const handler = toolMap[name];
834
+ const handler = toolMap[name] || pluginToolMap.get(name);
623
835
  if (!handler) {
624
836
  return { success: false, output: `Unknown tool: ${name}`, _tool: name };
625
837
  }
@@ -2293,6 +2505,8 @@ export function createToolExecutor({
2293
2505
  },
2294
2506
  };
2295
2507
 
2508
+ registerPluginToolsFromRegistry();
2509
+
2296
2510
  return {
2297
2511
  /**
2298
2512
  * Execute a Bahulam tool by name.
@@ -2306,7 +2520,33 @@ export function createToolExecutor({
2306
2520
 
2307
2521
  /** List all available tool names. */
2308
2522
  listTools() {
2309
- return Object.keys(toolMap);
2523
+ return [...Object.keys(toolMap), ...pluginToolMap.keys()];
2524
+ },
2525
+
2526
+ /**
2527
+ * Register one MCP-backed tool as `<serverName>.<toolName>`.
2528
+ * Called by the workspace lifecycle after spawning per-plugin
2529
+ * MCP clients. Returns true on success, false on name collision.
2530
+ */
2531
+ registerMcpTool(pluginName, serverName, toolName, mcpClient, toolSchema) {
2532
+ return registerMcpTool(pluginName, serverName, toolName, mcpClient, toolSchema);
2533
+ },
2534
+
2535
+ /**
2536
+ * Unregister every MCP tool sourced from one server, called on
2537
+ * plugin teardown / workspace close so subsequent sessions don't
2538
+ * see stale `serverName.tool` entries.
2539
+ */
2540
+ unregisterMcpServer(pluginName, serverName) {
2541
+ let removed = 0;
2542
+ for (const [key, fn] of pluginToolMap) {
2543
+ const meta = fn?._mcp;
2544
+ if (meta && meta.pluginName === pluginName && meta.serverName === serverName) {
2545
+ pluginToolMap.delete(key);
2546
+ removed++;
2547
+ }
2548
+ }
2549
+ return removed;
2310
2550
  },
2311
2551
 
2312
2552
  getProjectResources() {
@@ -2355,7 +2595,7 @@ export function createToolExecutor({
2355
2595
  // hasn't changed between turns.
2356
2596
  memory_facts: mem.facts,
2357
2597
  memory_digest: mem.digest,
2358
- available_agents: listLocalAgents(process.cwd()).map(agent => ({
2598
+ available_agents: listAvailableAgents().map(agent => ({
2359
2599
  slug: agent.slug,
2360
2600
  name: agent.name,
2361
2601
  description: agent.description,
@@ -2366,6 +2606,7 @@ export function createToolExecutor({
2366
2606
  capabilities: agent.capabilities,
2367
2607
  domains: agent.domains,
2368
2608
  source_scope: agent.source_scope,
2609
+ source: agent.source,
2369
2610
  spec: agent.spec,
2370
2611
  })),
2371
2612
  available_workflows: listLocalWorkflows(process.cwd()).map(workflow => ({