@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.
- package/package.json +5 -2
- package/src/agents/loader.mjs +26 -4
- package/src/auth/bahulam-auth.mjs +2 -13
- package/src/commands/plugin-manage.mjs +449 -0
- package/src/commands/plugin.mjs +247 -0
- package/src/config/env.mjs +2 -0
- package/src/config/hook-runner.mjs +8 -8
- package/src/config/memory-loader.mjs +7 -3
- package/src/config/settings-loader.mjs +5 -3
- package/src/core/attachments.mjs +2 -2
- package/src/core/headless.mjs +6 -1
- package/src/core/local-store.mjs +10 -10
- package/src/core/paths.mjs +10 -96
- package/src/core/policy-resolver.mjs +1 -1
- package/src/core/project-context-loader.mjs +2 -2
- package/src/core/stream-client.mjs +68 -1
- package/src/core/system-prompt.mjs +31 -12
- package/src/core/tool-executor.mjs +251 -10
- package/src/local-service/agent-relay.mjs +139 -12
- package/src/local-service/server.mjs +230 -7
- package/src/plugins/executor.mjs +121 -0
- package/src/plugins/loader.mjs +123 -123
- package/src/plugins/manifest.mjs +291 -0
- package/src/plugins/preflight.mjs +227 -0
- package/src/plugins/registry.mjs +233 -0
- package/src/plugins/state.mjs +290 -0
- package/src/terminal/agents.mjs +18 -1
- package/src/terminal/init.mjs +2 -2
- package/src/terminal/main.mjs +75 -2
- package/src/terminal/repl-explore.mjs +1 -1
- package/src/terminal/repl-render.mjs +2 -2
- package/src/terminal/repl.mjs +49 -3
- package/src/tools/analyze-image.mjs +1 -1
- package/src/tools/project-overview.mjs +7 -7
- package/src/ui/slash-commands.mjs +18 -0
|
@@ -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.
|
|
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
|
|
2
|
+
* System Prompt Builder — loads and merges CLAUDE.md and BAHULAM.md files.
|
|
3
3
|
*
|
|
4
4
|
* Features:
|
|
5
|
-
* - Loads CLAUDE.md from:
|
|
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
|
|
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 {
|
|
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
|
|
24
|
-
const globalPath
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
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
|
|
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
|
|
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.
|
|
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 &&
|
|
252
|
-
throw new Error('scope must be "project" or "
|
|
322
|
+
if (scope && !['project', 'global', 'plugin'].includes(scope)) {
|
|
323
|
+
throw new Error('scope must be "project", "global", or "plugin"');
|
|
253
324
|
}
|
|
254
|
-
|
|
255
|
-
.filter(agent => !scope || agent.source_scope === scope)
|
|
256
|
-
|
|
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:
|
|
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 => ({
|