@bahulam/code 0.1.10 → 0.1.12
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/auth/tarang-auth.mjs +313 -0
- package/src/commands/agent.mjs +7 -7
- package/src/commands/plugin-manage.mjs +449 -0
- package/src/commands/plugin.mjs +247 -0
- package/src/config/cli-args.mjs +16 -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/background-tasks.mjs +186 -0
- package/src/core/headless.mjs +59 -3
- package/src/core/local-agent.mjs +10 -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/risk-tier.mjs +1 -0
- package/src/core/stream-client.mjs +148 -1
- package/src/core/system-prompt.mjs +31 -12
- package/src/core/tool-executor.mjs +457 -12
- package/src/local-service/agent-relay.mjs +139 -12
- package/src/local-service/server.mjs +345 -20
- package/src/orchestration/approval.mjs +30 -0
- package/src/orchestration/completion-triggers.mjs +40 -0
- package/src/orchestration/dispatch.mjs +118 -0
- package/src/orchestration/events.mjs +19 -0
- package/src/orchestration/graph.mjs +126 -0
- package/src/orchestration/node-runner.mjs +193 -0
- package/src/orchestration/runner.mjs +200 -0
- 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 +26 -4
- package/src/terminal/init.mjs +2 -2
- package/src/terminal/main.mjs +83 -4
- package/src/terminal/repl-explore.mjs +1 -1
- package/src/terminal/repl-render.mjs +67 -12
- package/src/terminal/repl-state.mjs +4 -2
- package/src/terminal/repl.mjs +621 -99
- package/src/tools/agent.mjs +6 -2
- package/src/tools/analyze-image.mjs +1 -1
- package/src/tools/project-overview.mjs +7 -7
- package/src/tools/registry.mjs +88 -4
- package/src/ui/slash-commands.mjs +19 -1
- package/src/ui/sub-agent.mjs +14 -8
|
@@ -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,17 +16,22 @@ 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';
|
|
23
23
|
import { streamResponse } from './streaming.mjs';
|
|
24
24
|
import { sendApprovalDecision, sendCallback } from './callback-client.mjs';
|
|
25
25
|
import { HookRunner } from '../config/hook-runner.mjs';
|
|
26
|
+
import { loadBahulamSettings } from '../config/settings-loader.mjs';
|
|
27
|
+
import { BUILTIN_AGENTS } from '../terminal/agents.mjs';
|
|
26
28
|
import { buildFileDiff } from './file-diff.mjs';
|
|
27
29
|
import { buildWorkScope } from './work-scope.mjs';
|
|
28
30
|
import { loadDiskMemory, ensureBahulamDir, globalMemoryPath, projectMemoryPath } from './memory-disk.mjs';
|
|
31
|
+
import { backgroundTasks } from './background-tasks.mjs';
|
|
29
32
|
import { resolveLintCommand } from './lint-resolver.mjs';
|
|
33
|
+
import { PluginRegistry } from '../plugins/registry.mjs';
|
|
34
|
+
import { loadPluginTool } from '../plugins/executor.mjs';
|
|
30
35
|
import * as fs from 'node:fs';
|
|
31
36
|
import * as os from 'node:os';
|
|
32
37
|
import * as path from 'node:path';
|
|
@@ -48,6 +53,19 @@ export function createToolExecutor({
|
|
|
48
53
|
interactionHandler = null,
|
|
49
54
|
onAutoRegisterStart = null,
|
|
50
55
|
onAutoRegisterDone = null,
|
|
56
|
+
pluginRegistry = null,
|
|
57
|
+
// Optional emit hook: called (debounced per key) after any plugin
|
|
58
|
+
// state write commits. Wired by the workspace server so writes turn
|
|
59
|
+
// into SSE `plugin_state_changed` events for live view updates.
|
|
60
|
+
// REPL/headless callers leave this null — state still works, just
|
|
61
|
+
// no reactive pulse.
|
|
62
|
+
stateEmit = null,
|
|
63
|
+
// Execution channel. 'main' (REPL/headless/CLI): plugin agents are
|
|
64
|
+
// workspace-scoped and excluded from listings and the agent-context
|
|
65
|
+
// envelope unless allowlisted in settings plugins.agent_allowlist.
|
|
66
|
+
// 'workspace' (plugin workspace sessions via agent-relay): the
|
|
67
|
+
// session plugin's agents are fully available.
|
|
68
|
+
channel = 'main',
|
|
51
69
|
} = {}) {
|
|
52
70
|
// Cross-session memory cache. Ships in getAgentContext() on every turn,
|
|
53
71
|
// so we need it to be byte-identical when the underlying disk file hasn't
|
|
@@ -78,7 +96,7 @@ export function createToolExecutor({
|
|
|
78
96
|
_memoryCache = { key, facts, digest };
|
|
79
97
|
return _memoryCache;
|
|
80
98
|
}
|
|
81
|
-
const occRegistry = createToolRegistry();
|
|
99
|
+
const occRegistry = createToolRegistry({ pluginRegistry, stateEmit });
|
|
82
100
|
const skillTool = occRegistry.get('Skill');
|
|
83
101
|
if (skillTool) skillTool._skillsLoader = skillsLoader;
|
|
84
102
|
const installer = skillInstaller || new SkillInstaller({
|
|
@@ -146,7 +164,7 @@ export function createToolExecutor({
|
|
|
146
164
|
}
|
|
147
165
|
|
|
148
166
|
function longRunningObservationTimeoutMs() {
|
|
149
|
-
const configured = Number(process.env.
|
|
167
|
+
const configured = Number(process.env.BAHULAM_LONG_RUNNING_TIMEOUT_MS);
|
|
150
168
|
return Number.isFinite(configured) && configured > 0 ? configured : 15_000;
|
|
151
169
|
}
|
|
152
170
|
|
|
@@ -243,17 +261,140 @@ export function createToolExecutor({
|
|
|
243
261
|
source_scope: agent.source_scope || 'unknown',
|
|
244
262
|
source: agent.source || '',
|
|
245
263
|
content_hash: agent.content_hash || '',
|
|
264
|
+
runnable: agent.runnable !== false,
|
|
246
265
|
};
|
|
247
266
|
}
|
|
248
267
|
|
|
268
|
+
function pluginAgentToLocalShape(agentDef) {
|
|
269
|
+
const pluginName = agentDef._plugin_name
|
|
270
|
+
|| String(agentDef.source || '').replace(/^plugin:/, '')
|
|
271
|
+
|| 'unknown';
|
|
272
|
+
const source = `plugin:${pluginName}`;
|
|
273
|
+
const base = {
|
|
274
|
+
...agentDef,
|
|
275
|
+
slug: agentDef.slug || agentDef.name || '',
|
|
276
|
+
name: agentDef.name || agentDef.slug || '',
|
|
277
|
+
description: agentDef.description || '',
|
|
278
|
+
role: agentDef.role || 'specialist',
|
|
279
|
+
model: agentDef.model || null,
|
|
280
|
+
models: agentDef.models || undefined,
|
|
281
|
+
tools: Array.isArray(agentDef.tools)
|
|
282
|
+
? agentDef.tools
|
|
283
|
+
: (Array.isArray(agentDef.agent_tools) ? agentDef.agent_tools : []),
|
|
284
|
+
capabilities: Array.isArray(agentDef.capabilities) ? agentDef.capabilities : [],
|
|
285
|
+
domains: Array.isArray(agentDef.domains) ? agentDef.domains : [],
|
|
286
|
+
system_prompt: agentDef.system_prompt || agentDef.prompt || agentDef.instructions || '',
|
|
287
|
+
prompt: agentDef.prompt || agentDef.system_prompt || agentDef.instructions || '',
|
|
288
|
+
source_scope: 'plugin',
|
|
289
|
+
source,
|
|
290
|
+
};
|
|
291
|
+
const spec = {
|
|
292
|
+
...agentToSpec(base),
|
|
293
|
+
source,
|
|
294
|
+
source_scope: 'plugin',
|
|
295
|
+
plugin_name: pluginName,
|
|
296
|
+
};
|
|
297
|
+
if (spec.config?.metadata && typeof spec.config.metadata === 'object') {
|
|
298
|
+
spec.config.metadata.source = source;
|
|
299
|
+
spec.config.metadata.source_scope = 'plugin';
|
|
300
|
+
}
|
|
301
|
+
const content = JSON.stringify(spec);
|
|
302
|
+
return {
|
|
303
|
+
...base,
|
|
304
|
+
slug: spec.slug,
|
|
305
|
+
spec,
|
|
306
|
+
source,
|
|
307
|
+
source_scope: 'plugin',
|
|
308
|
+
content_hash: crypto.createHash('sha256').update(content).digest('hex'),
|
|
309
|
+
};
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
function listPluginAgents() {
|
|
313
|
+
if (!pluginRegistry) return [];
|
|
314
|
+
return pluginRegistry.listAgents()
|
|
315
|
+
.map(pluginAgentToLocalShape)
|
|
316
|
+
.filter(agent => agent.slug);
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
// Plugin agents are workspace-scoped entities. They enter the
|
|
320
|
+
// main-loop registry only via an explicit settings allowlist.
|
|
321
|
+
function pluginAgentAllowlist() {
|
|
322
|
+
try {
|
|
323
|
+
const { settings } = loadBahulamSettings({ cwd: process.cwd() });
|
|
324
|
+
const list = settings?.plugins?.agent_allowlist;
|
|
325
|
+
return Array.isArray(list) ? list.map(item => String(item)) : [];
|
|
326
|
+
} catch {
|
|
327
|
+
return [];
|
|
328
|
+
}
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
const BUILTIN_RUNNABLES = BUILTIN_AGENTS.map(def => ({
|
|
332
|
+
slug: def.command,
|
|
333
|
+
name: def.name,
|
|
334
|
+
description: def.description || '',
|
|
335
|
+
role: 'builtin',
|
|
336
|
+
model: null,
|
|
337
|
+
models: undefined,
|
|
338
|
+
tools: [],
|
|
339
|
+
capabilities: [],
|
|
340
|
+
domains: [],
|
|
341
|
+
source_scope: 'builtin',
|
|
342
|
+
source: 'builtin',
|
|
343
|
+
content_hash: '',
|
|
344
|
+
read_only: Boolean(def.readOnly),
|
|
345
|
+
runnable: true,
|
|
346
|
+
}));
|
|
347
|
+
|
|
348
|
+
// The deterministic sub-agent registry. Resolution precedence:
|
|
349
|
+
// project agent → global agent → builtin → allowlisted plugin agent.
|
|
350
|
+
// In workspace-channel executors the session plugin's agents are
|
|
351
|
+
// runnable without an allowlist entry.
|
|
352
|
+
function listRunnables() {
|
|
353
|
+
const bySlug = new Map();
|
|
354
|
+
for (const agent of listLocalAgents(process.cwd())) {
|
|
355
|
+
if (agent.slug && !bySlug.has(agent.slug)) {
|
|
356
|
+
bySlug.set(agent.slug, { ...agent, runnable: true });
|
|
357
|
+
}
|
|
358
|
+
}
|
|
359
|
+
for (const builtin of BUILTIN_RUNNABLES) {
|
|
360
|
+
if (!bySlug.has(builtin.slug)) bySlug.set(builtin.slug, builtin);
|
|
361
|
+
}
|
|
362
|
+
const allowlist = new Set(pluginAgentAllowlist());
|
|
363
|
+
for (const agent of listPluginAgents()) {
|
|
364
|
+
if (!agent.slug || bySlug.has(agent.slug)) continue;
|
|
365
|
+
if (channel === 'workspace' || allowlist.has(agent.slug)) {
|
|
366
|
+
bySlug.set(agent.slug, { ...agent, runnable: true });
|
|
367
|
+
}
|
|
368
|
+
}
|
|
369
|
+
return [...bySlug.values()];
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
// Installed plugin agents NOT admitted to the main-loop registry —
|
|
373
|
+
// still discoverable (scope:'plugin') but flagged not runnable.
|
|
374
|
+
function listWorkspaceScopedPluginAgents() {
|
|
375
|
+
const runnableSlugs = new Set(listRunnables().map(agent => agent.slug));
|
|
376
|
+
return listPluginAgents()
|
|
377
|
+
.filter(agent => agent.slug && !runnableSlugs.has(agent.slug))
|
|
378
|
+
.map(agent => ({ ...agent, runnable: false }));
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
// Agent-context envelope population: the runnable registry minus
|
|
382
|
+
// builtins (the backend has its own delegation vocabulary for those;
|
|
383
|
+
// adding them to available_agents would change wire behavior).
|
|
384
|
+
function listAvailableAgents() {
|
|
385
|
+
return listRunnables().filter(agent => agent.source_scope !== 'builtin');
|
|
386
|
+
}
|
|
387
|
+
|
|
249
388
|
function filterLocalAgents(args = {}) {
|
|
250
389
|
const scope = String(args.scope || '').trim();
|
|
251
|
-
if (scope &&
|
|
252
|
-
throw new Error('scope must be "project" or "
|
|
390
|
+
if (scope && !['project', 'global', 'plugin', 'builtin'].includes(scope)) {
|
|
391
|
+
throw new Error('scope must be "project", "global", "plugin", or "builtin"');
|
|
253
392
|
}
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
393
|
+
const pool = scope === 'plugin'
|
|
394
|
+
? [...listRunnables(), ...listWorkspaceScopedPluginAgents()]
|
|
395
|
+
: listRunnables();
|
|
396
|
+
const combined = pool.filter(agent => !scope || agent.source_scope === scope);
|
|
397
|
+
return combined.filter(agent => agentMatches(agent, args.query || args.name || ''));
|
|
257
398
|
}
|
|
258
399
|
|
|
259
400
|
function selectAgentsForSync(args = {}) {
|
|
@@ -618,11 +759,197 @@ export function createToolExecutor({
|
|
|
618
759
|
return { output: lines.join('\n'), files, directories, truncated };
|
|
619
760
|
}
|
|
620
761
|
|
|
762
|
+
// ── Plugin tool map ──────────────────────────────────────────
|
|
763
|
+
// Plugin tools are registered here alongside the built-in toolMap.
|
|
764
|
+
// They are dispatched with lower priority (built-in tools win on name collision).
|
|
765
|
+
const pluginToolMap = new Map(); // name → async handler function
|
|
766
|
+
|
|
767
|
+
function registerPluginTool(name, handler, metadata = {}) {
|
|
768
|
+
if (pluginToolMap.has(name)) {
|
|
769
|
+
if (process.env.DEBUG) {
|
|
770
|
+
console.warn(`Plugin tool "${name}" already registered from another plugin — skipping.`);
|
|
771
|
+
}
|
|
772
|
+
return false;
|
|
773
|
+
}
|
|
774
|
+
handler._pluginTool = metadata;
|
|
775
|
+
pluginToolMap.set(name, handler);
|
|
776
|
+
return true;
|
|
777
|
+
}
|
|
778
|
+
|
|
779
|
+
// Per-plugin state handles are opened lazily on first tool call and
|
|
780
|
+
// cached process-wide. `makePluginState` itself dedupes on plugin
|
|
781
|
+
// name, so this Map only exists to avoid re-attaching stateEmit on
|
|
782
|
+
// every registered tool.
|
|
783
|
+
const _pluginStateHandles = new Map(); // pluginName -> state proxy
|
|
784
|
+
async function _pluginStateFor(pluginName) {
|
|
785
|
+
if (!pluginName) return null;
|
|
786
|
+
if (_pluginStateHandles.has(pluginName)) return _pluginStateHandles.get(pluginName);
|
|
787
|
+
const { makePluginState } = await import('../plugins/state.mjs');
|
|
788
|
+
const state = makePluginState(pluginName, { emit: stateEmit });
|
|
789
|
+
_pluginStateHandles.set(pluginName, state);
|
|
790
|
+
return state;
|
|
791
|
+
}
|
|
792
|
+
|
|
793
|
+
/**
|
|
794
|
+
* Register one MCP tool under `<serverName>.<toolName>` (namespaced
|
|
795
|
+
* to prevent collisions between plugins that ship servers with the
|
|
796
|
+
* same tool name). The MCP client is owned by the caller (agent-
|
|
797
|
+
* relay) which spawns/tears it down with the workspace lifetime.
|
|
798
|
+
* Handler receives the same options shape as JS plugin tools so
|
|
799
|
+
* `state`, `signal`, `pluginName` all work uniformly.
|
|
800
|
+
*/
|
|
801
|
+
function registerMcpTool(pluginName, serverName, toolName, mcpClient, toolSchema = {}) {
|
|
802
|
+
const qualified = `${serverName}.${toolName}`;
|
|
803
|
+
if (toolMap[qualified] || pluginToolMap.has(qualified)) {
|
|
804
|
+
if (process.env.DEBUG) {
|
|
805
|
+
console.warn(`MCP tool "${qualified}" from plugin "${pluginName}" collides with an existing tool — skipping.`);
|
|
806
|
+
}
|
|
807
|
+
return false;
|
|
808
|
+
}
|
|
809
|
+
const mcpHandler = async (args, options = {}) => {
|
|
810
|
+
// The lazy-state getter matches JS plugin tools so an MCP
|
|
811
|
+
// "wrapper" tool can trivially write its result to the same
|
|
812
|
+
// Shared Blackboard (rare, but useful for cache-and-return).
|
|
813
|
+
const handlerOpts = {
|
|
814
|
+
...options,
|
|
815
|
+
pluginName,
|
|
816
|
+
mcpServer: serverName,
|
|
817
|
+
get state() {
|
|
818
|
+
if (this._stateP) return this._stateP;
|
|
819
|
+
this._stateP = _pluginStateFor(pluginName);
|
|
820
|
+
return this._stateP;
|
|
821
|
+
},
|
|
822
|
+
};
|
|
823
|
+
try {
|
|
824
|
+
const result = await mcpClient.callTool(toolName, args || {});
|
|
825
|
+
// callTool returns joined text for text/* content; pass through as output.
|
|
826
|
+
const output = typeof result === 'string' ? result : (result?.output ?? result);
|
|
827
|
+
// Allow the caller (state-writer wrapper) to introspect via handlerOpts.
|
|
828
|
+
void handlerOpts;
|
|
829
|
+
return { success: true, output, _tool: qualified, _plugin: pluginName, _mcp_server: serverName };
|
|
830
|
+
} catch (err) {
|
|
831
|
+
return {
|
|
832
|
+
success: false,
|
|
833
|
+
output: `MCP tool error (${qualified}): ${err.message}`,
|
|
834
|
+
_tool: qualified,
|
|
835
|
+
_plugin: pluginName,
|
|
836
|
+
_mcp_server: serverName,
|
|
837
|
+
};
|
|
838
|
+
}
|
|
839
|
+
};
|
|
840
|
+
mcpHandler._pluginTool = { pluginName, source: 'mcp', serverName, toolName };
|
|
841
|
+
pluginToolMap.set(qualified, mcpHandler);
|
|
842
|
+
// Track schema for tool-listing surfaces (also help /tools discovery).
|
|
843
|
+
pluginToolMap.get(qualified)._mcp = { pluginName, serverName, toolName, schema: toolSchema };
|
|
844
|
+
return true;
|
|
845
|
+
}
|
|
846
|
+
|
|
847
|
+
function registerPluginToolsFromRegistry() {
|
|
848
|
+
if (!pluginRegistry) return;
|
|
849
|
+
for (const toolDef of pluginRegistry.listTools?.() || []) {
|
|
850
|
+
const name = String(toolDef.name || '').trim();
|
|
851
|
+
if (!name || toolMap[name]) continue;
|
|
852
|
+
const pluginName = toolDef._plugin_name || toolDef.plugin_name || null;
|
|
853
|
+
registerPluginTool(name, async (args, options = {}) => {
|
|
854
|
+
const handler = await loadPluginTool(toolDef._plugin_dir, toolDef.tool);
|
|
855
|
+
if (!handler) {
|
|
856
|
+
return {
|
|
857
|
+
success: false,
|
|
858
|
+
output: `Plugin tool module could not be loaded: ${name}`,
|
|
859
|
+
_tool: name,
|
|
860
|
+
_plugin: pluginName,
|
|
861
|
+
};
|
|
862
|
+
}
|
|
863
|
+
// Shared-blackboard injection: handlers opt in by naming
|
|
864
|
+
// `state` in their signature (`async call(args, { state })`).
|
|
865
|
+
// The property is a getter so the SQLite file is only
|
|
866
|
+
// opened when a handler actually asks for it — plugins
|
|
867
|
+
// that never touch state pay zero disk / init cost.
|
|
868
|
+
const handlerOpts = {
|
|
869
|
+
...options,
|
|
870
|
+
pluginName,
|
|
871
|
+
get state() { /* eslint-disable no-unused-vars */
|
|
872
|
+
// Sync getter fronting an async loader — first
|
|
873
|
+
// access returns a Promise, which is unusual
|
|
874
|
+
// for handler code but common enough as
|
|
875
|
+
// `const s = await opts.state`. The awaited
|
|
876
|
+
// value is cached on this options object so
|
|
877
|
+
// repeat accesses in the same call don't re-await.
|
|
878
|
+
if (this._stateP) return this._stateP;
|
|
879
|
+
this._stateP = _pluginStateFor(pluginName);
|
|
880
|
+
return this._stateP;
|
|
881
|
+
},
|
|
882
|
+
};
|
|
883
|
+
try {
|
|
884
|
+
const result = await handler.call(args || {}, handlerOpts);
|
|
885
|
+
if (result && typeof result === 'object' && 'success' in result) {
|
|
886
|
+
return { ...result, _tool: name, _plugin: pluginName };
|
|
887
|
+
}
|
|
888
|
+
return {
|
|
889
|
+
success: true,
|
|
890
|
+
output: typeof result === 'string' ? result : JSON.stringify(result),
|
|
891
|
+
_tool: name,
|
|
892
|
+
_plugin: pluginName,
|
|
893
|
+
};
|
|
894
|
+
} catch (err) {
|
|
895
|
+
return {
|
|
896
|
+
success: false,
|
|
897
|
+
output: `Plugin tool error (${name}): ${err.message}`,
|
|
898
|
+
_tool: name,
|
|
899
|
+
_plugin: pluginName,
|
|
900
|
+
};
|
|
901
|
+
}
|
|
902
|
+
}, { pluginName, source: 'plugin' });
|
|
903
|
+
}
|
|
904
|
+
}
|
|
905
|
+
|
|
906
|
+
function pluginAgentForTool(toolName, pluginName) {
|
|
907
|
+
if (!pluginRegistry) return null;
|
|
908
|
+
return (pluginRegistry.listAgents?.() || []).find(agent => {
|
|
909
|
+
const agentPlugin = agent._plugin_name
|
|
910
|
+
|| String(agent.source || '').replace(/^plugin:/, '')
|
|
911
|
+
|| null;
|
|
912
|
+
if (pluginName && agentPlugin && agentPlugin !== pluginName) return false;
|
|
913
|
+
return Array.isArray(agent.tools) && agent.tools.includes(toolName);
|
|
914
|
+
}) || null;
|
|
915
|
+
}
|
|
916
|
+
|
|
917
|
+
function primaryModelPluginToolBlock(name, handler, options = {}) {
|
|
918
|
+
if (!handler?._pluginTool) return null;
|
|
919
|
+
if (options.toolCallSource !== 'model') return null;
|
|
920
|
+
if (options.internal || options.subAgent || options.allowPrimaryPluginToolCall) return null;
|
|
921
|
+
|
|
922
|
+
const pluginName = handler._pluginTool.pluginName || 'plugin';
|
|
923
|
+
// Tools-only plugins have no delegation owner: with no agent to
|
|
924
|
+
// route through, the primary agent uses the tools directly (the
|
|
925
|
+
// user consented by enabling the plugin). The delegate-only rule
|
|
926
|
+
// applies only when the plugin ships an owning agent.
|
|
927
|
+
const pluginShipsAgents = (pluginRegistry?.listAgents?.() || [])
|
|
928
|
+
.some(agent => (agent._plugin_name || '') === pluginName);
|
|
929
|
+
if (!pluginShipsAgents) return null;
|
|
930
|
+
|
|
931
|
+
const agent = pluginAgentForTool(name, pluginName);
|
|
932
|
+
const delegateHint = agent?.slug
|
|
933
|
+
? `Delegate to the '${agent.slug}' sub-agent instead, or run it explicitly with /run ${agent.slug} "...".`
|
|
934
|
+
: `Delegate to the plugin's sub-agent instead, or run the plugin agent explicitly.`;
|
|
935
|
+
return {
|
|
936
|
+
success: false,
|
|
937
|
+
output: `Plugin tool '${name}' is scoped to plugin '${pluginName}' and should not be called directly by the primary agent. ${delegateHint}`,
|
|
938
|
+
_tool: name,
|
|
939
|
+
_plugin: pluginName,
|
|
940
|
+
_blocked: true,
|
|
941
|
+
_requires_agent_delegation: true,
|
|
942
|
+
_agent: agent?.slug || null,
|
|
943
|
+
};
|
|
944
|
+
}
|
|
945
|
+
|
|
621
946
|
async function executeToolWithHooks(name, args, options = {}) {
|
|
622
|
-
const handler = toolMap[name];
|
|
947
|
+
const handler = toolMap[name] || pluginToolMap.get(name);
|
|
623
948
|
if (!handler) {
|
|
624
949
|
return { success: false, output: `Unknown tool: ${name}`, _tool: name };
|
|
625
950
|
}
|
|
951
|
+
const pluginToolBlock = primaryModelPluginToolBlock(name, handler, options);
|
|
952
|
+
if (pluginToolBlock) return pluginToolBlock;
|
|
626
953
|
const hooks = hookRunner || new HookRunner({ cwd: process.cwd() });
|
|
627
954
|
try {
|
|
628
955
|
throwIfAborted(options.signal);
|
|
@@ -976,6 +1303,30 @@ export function createToolExecutor({
|
|
|
976
1303
|
args._classification = classification.classification; // 'safe' or 'contained'
|
|
977
1304
|
const cwd = await commandCwd(args);
|
|
978
1305
|
|
|
1306
|
+
// Background execution: start via the BackgroundTasks registry
|
|
1307
|
+
// and return immediately. Safety checks above still apply;
|
|
1308
|
+
// results are retrieved with job_output / killed with job_kill.
|
|
1309
|
+
if (args.run_in_background) {
|
|
1310
|
+
const job = backgroundTasks.start({
|
|
1311
|
+
command: args.command,
|
|
1312
|
+
cwd,
|
|
1313
|
+
timeoutMs: args.timeout ? Math.min(Number(args.timeout), 3_600_000) : undefined,
|
|
1314
|
+
// Deterministic wake-on-finish: completion dispatches the
|
|
1315
|
+
// named agent through the trigger funnel (chain-guarded).
|
|
1316
|
+
on_complete: args.on_complete_agent ? {
|
|
1317
|
+
target: `agent:${String(args.on_complete_agent).trim()}`,
|
|
1318
|
+
instruction: args.on_complete_instruction || null,
|
|
1319
|
+
} : null,
|
|
1320
|
+
});
|
|
1321
|
+
return {
|
|
1322
|
+
success: true,
|
|
1323
|
+
output: `Background job started: ${job.id} (pid ${job.pid}). `
|
|
1324
|
+
+ `Check progress with job_output {"job_id": "${job.id}"}; stop with job_kill.`,
|
|
1325
|
+
job_id: job.id,
|
|
1326
|
+
_tool: 'shell',
|
|
1327
|
+
};
|
|
1328
|
+
}
|
|
1329
|
+
|
|
979
1330
|
// Pre-check: if command is rm/unlink, verify targets exist first
|
|
980
1331
|
const rmMatch = (args.command || '').match(/^rm\s+(?:-\w+\s+)*(.+)$/);
|
|
981
1332
|
if (rmMatch) {
|
|
@@ -1906,9 +2257,47 @@ export function createToolExecutor({
|
|
|
1906
2257
|
},
|
|
1907
2258
|
|
|
1908
2259
|
// User-defined agents — metadata first, project YAML + backend sync on demand.
|
|
2260
|
+
job_output: async (args = {}) => {
|
|
2261
|
+
const jobId = String(args.job_id || '').trim();
|
|
2262
|
+
if (!jobId) {
|
|
2263
|
+
const jobs = backgroundTasks.list();
|
|
2264
|
+
return {
|
|
2265
|
+
success: true,
|
|
2266
|
+
output: jobs.length
|
|
2267
|
+
? JSON.stringify({ jobs }, null, 2)
|
|
2268
|
+
: 'No background jobs in this session.',
|
|
2269
|
+
jobs,
|
|
2270
|
+
_tool: 'job_output',
|
|
2271
|
+
};
|
|
2272
|
+
}
|
|
2273
|
+
const job = args.block
|
|
2274
|
+
? await backgroundTasks.wait(jobId)
|
|
2275
|
+
: backgroundTasks.describe(jobId);
|
|
2276
|
+
if (!job) return { success: false, output: `Unknown job: ${jobId}`, _tool: 'job_output' };
|
|
2277
|
+
const tailLines = Number(args.tail_lines) || 80;
|
|
2278
|
+
const tail = String(job.tail || '').split('\n').slice(-tailLines).join('\n');
|
|
2279
|
+
return {
|
|
2280
|
+
success: true,
|
|
2281
|
+
output: `${job.id} · ${job.status}`
|
|
2282
|
+
+ (job.exit_code != null ? ` (exit ${job.exit_code})` : '')
|
|
2283
|
+
+ ` · ${job.duration_s}s\n${tail}`,
|
|
2284
|
+
job: { ...job, tail: undefined },
|
|
2285
|
+
_tool: 'job_output',
|
|
2286
|
+
};
|
|
2287
|
+
},
|
|
2288
|
+
|
|
2289
|
+
job_kill: async (args = {}) => {
|
|
2290
|
+
const job = backgroundTasks.kill(String(args.job_id || '').trim());
|
|
2291
|
+
if (!job) return { success: false, output: `Unknown job: ${args.job_id}`, _tool: 'job_kill' };
|
|
2292
|
+
return { success: true, output: `${job.id} → ${job.status}`, job, _tool: 'job_kill' };
|
|
2293
|
+
},
|
|
2294
|
+
|
|
1909
2295
|
agents_list: async (args = {}) => {
|
|
1910
2296
|
const agents = filterLocalAgents(args).map(compactAgentMetadata);
|
|
1911
2297
|
const payload = { agents, count: agents.length };
|
|
2298
|
+
if (agents.some(agent => agent.runnable === false)) {
|
|
2299
|
+
payload.note = 'Agents with runnable:false are workspace-scoped plugin agents; add their slug to settings plugins.agent_allowlist to invoke them from the main loop.';
|
|
2300
|
+
}
|
|
1912
2301
|
return {
|
|
1913
2302
|
success: true,
|
|
1914
2303
|
output: JSON.stringify(payload, null, 2),
|
|
@@ -1939,7 +2328,8 @@ export function createToolExecutor({
|
|
|
1939
2328
|
: null,
|
|
1940
2329
|
next_actions: [
|
|
1941
2330
|
`Edit ${result.filePath}`,
|
|
1942
|
-
`Run /
|
|
2331
|
+
`Run /run ${result.slug} "<task>" or delegate to it from chat immediately`,
|
|
2332
|
+
`Optional: /agents sync ${result.slug} to publish it to the backend for account/cloud reuse`,
|
|
1943
2333
|
],
|
|
1944
2334
|
};
|
|
1945
2335
|
return {
|
|
@@ -2293,6 +2683,8 @@ export function createToolExecutor({
|
|
|
2293
2683
|
},
|
|
2294
2684
|
};
|
|
2295
2685
|
|
|
2686
|
+
registerPluginToolsFromRegistry();
|
|
2687
|
+
|
|
2296
2688
|
return {
|
|
2297
2689
|
/**
|
|
2298
2690
|
* Execute a Bahulam tool by name.
|
|
@@ -2306,7 +2698,33 @@ export function createToolExecutor({
|
|
|
2306
2698
|
|
|
2307
2699
|
/** List all available tool names. */
|
|
2308
2700
|
listTools() {
|
|
2309
|
-
return Object.keys(toolMap);
|
|
2701
|
+
return [...Object.keys(toolMap), ...pluginToolMap.keys()];
|
|
2702
|
+
},
|
|
2703
|
+
|
|
2704
|
+
/**
|
|
2705
|
+
* Register one MCP-backed tool as `<serverName>.<toolName>`.
|
|
2706
|
+
* Called by the workspace lifecycle after spawning per-plugin
|
|
2707
|
+
* MCP clients. Returns true on success, false on name collision.
|
|
2708
|
+
*/
|
|
2709
|
+
registerMcpTool(pluginName, serverName, toolName, mcpClient, toolSchema) {
|
|
2710
|
+
return registerMcpTool(pluginName, serverName, toolName, mcpClient, toolSchema);
|
|
2711
|
+
},
|
|
2712
|
+
|
|
2713
|
+
/**
|
|
2714
|
+
* Unregister every MCP tool sourced from one server, called on
|
|
2715
|
+
* plugin teardown / workspace close so subsequent sessions don't
|
|
2716
|
+
* see stale `serverName.tool` entries.
|
|
2717
|
+
*/
|
|
2718
|
+
unregisterMcpServer(pluginName, serverName) {
|
|
2719
|
+
let removed = 0;
|
|
2720
|
+
for (const [key, fn] of pluginToolMap) {
|
|
2721
|
+
const meta = fn?._mcp;
|
|
2722
|
+
if (meta && meta.pluginName === pluginName && meta.serverName === serverName) {
|
|
2723
|
+
pluginToolMap.delete(key);
|
|
2724
|
+
removed++;
|
|
2725
|
+
}
|
|
2726
|
+
}
|
|
2727
|
+
return removed;
|
|
2310
2728
|
},
|
|
2311
2729
|
|
|
2312
2730
|
getProjectResources() {
|
|
@@ -2340,6 +2758,21 @@ export function createToolExecutor({
|
|
|
2340
2758
|
return results;
|
|
2341
2759
|
},
|
|
2342
2760
|
|
|
2761
|
+
listRunnables,
|
|
2762
|
+
|
|
2763
|
+
// Plugin tool schemas (name/description/input_schema) for callers
|
|
2764
|
+
// that compose model-facing tool lists — e.g. the graph engine's
|
|
2765
|
+
// direct substrate giving a plugin agent its declared tools.
|
|
2766
|
+
listPluginToolSchemas() {
|
|
2767
|
+
if (!pluginRegistry) return [];
|
|
2768
|
+
return (pluginRegistry.listTools?.() || []).map(tool => ({
|
|
2769
|
+
name: tool.name,
|
|
2770
|
+
description: tool.description || '',
|
|
2771
|
+
input_schema: tool.input_schema || { type: 'object', properties: {} },
|
|
2772
|
+
plugin_name: tool._plugin_name || tool.plugin_name || null,
|
|
2773
|
+
})).filter(tool => tool.name);
|
|
2774
|
+
},
|
|
2775
|
+
|
|
2343
2776
|
getAgentContext() {
|
|
2344
2777
|
const global = projectRegistry.getGlobalContext();
|
|
2345
2778
|
const mem = _readMemorySnapshot();
|
|
@@ -2355,7 +2788,7 @@ export function createToolExecutor({
|
|
|
2355
2788
|
// hasn't changed between turns.
|
|
2356
2789
|
memory_facts: mem.facts,
|
|
2357
2790
|
memory_digest: mem.digest,
|
|
2358
|
-
available_agents:
|
|
2791
|
+
available_agents: listAvailableAgents().map(agent => ({
|
|
2359
2792
|
slug: agent.slug,
|
|
2360
2793
|
name: agent.name,
|
|
2361
2794
|
description: agent.description,
|
|
@@ -2366,8 +2799,20 @@ export function createToolExecutor({
|
|
|
2366
2799
|
capabilities: agent.capabilities,
|
|
2367
2800
|
domains: agent.domains,
|
|
2368
2801
|
source_scope: agent.source_scope,
|
|
2802
|
+
source: agent.source,
|
|
2369
2803
|
spec: agent.spec,
|
|
2370
2804
|
})),
|
|
2805
|
+
// Background jobs the model should know about. Stable fields
|
|
2806
|
+
// only (no durations) so the entry — and the prompt cache —
|
|
2807
|
+
// changes on status transitions, not every turn.
|
|
2808
|
+
...(backgroundTasks.list().length ? {
|
|
2809
|
+
background_jobs: backgroundTasks.list().map(job => ({
|
|
2810
|
+
id: job.id,
|
|
2811
|
+
name: job.name,
|
|
2812
|
+
status: job.status,
|
|
2813
|
+
exit_code: job.exit_code,
|
|
2814
|
+
})),
|
|
2815
|
+
} : {}),
|
|
2371
2816
|
available_workflows: listLocalWorkflows(process.cwd()).map(workflow => ({
|
|
2372
2817
|
slug: workflow.slug,
|
|
2373
2818
|
name: workflow.name,
|