@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.
Files changed (52) hide show
  1. package/package.json +5 -2
  2. package/src/agents/loader.mjs +26 -4
  3. package/src/auth/bahulam-auth.mjs +2 -13
  4. package/src/auth/tarang-auth.mjs +313 -0
  5. package/src/commands/agent.mjs +7 -7
  6. package/src/commands/plugin-manage.mjs +449 -0
  7. package/src/commands/plugin.mjs +247 -0
  8. package/src/config/cli-args.mjs +16 -0
  9. package/src/config/env.mjs +2 -0
  10. package/src/config/hook-runner.mjs +8 -8
  11. package/src/config/memory-loader.mjs +7 -3
  12. package/src/config/settings-loader.mjs +5 -3
  13. package/src/core/attachments.mjs +2 -2
  14. package/src/core/background-tasks.mjs +186 -0
  15. package/src/core/headless.mjs +59 -3
  16. package/src/core/local-agent.mjs +10 -1
  17. package/src/core/local-store.mjs +10 -10
  18. package/src/core/paths.mjs +10 -96
  19. package/src/core/policy-resolver.mjs +1 -1
  20. package/src/core/project-context-loader.mjs +2 -2
  21. package/src/core/risk-tier.mjs +1 -0
  22. package/src/core/stream-client.mjs +148 -1
  23. package/src/core/system-prompt.mjs +31 -12
  24. package/src/core/tool-executor.mjs +457 -12
  25. package/src/local-service/agent-relay.mjs +139 -12
  26. package/src/local-service/server.mjs +345 -20
  27. package/src/orchestration/approval.mjs +30 -0
  28. package/src/orchestration/completion-triggers.mjs +40 -0
  29. package/src/orchestration/dispatch.mjs +118 -0
  30. package/src/orchestration/events.mjs +19 -0
  31. package/src/orchestration/graph.mjs +126 -0
  32. package/src/orchestration/node-runner.mjs +193 -0
  33. package/src/orchestration/runner.mjs +200 -0
  34. package/src/plugins/executor.mjs +121 -0
  35. package/src/plugins/loader.mjs +123 -123
  36. package/src/plugins/manifest.mjs +291 -0
  37. package/src/plugins/preflight.mjs +227 -0
  38. package/src/plugins/registry.mjs +233 -0
  39. package/src/plugins/state.mjs +290 -0
  40. package/src/terminal/agents.mjs +26 -4
  41. package/src/terminal/init.mjs +2 -2
  42. package/src/terminal/main.mjs +83 -4
  43. package/src/terminal/repl-explore.mjs +1 -1
  44. package/src/terminal/repl-render.mjs +67 -12
  45. package/src/terminal/repl-state.mjs +4 -2
  46. package/src/terminal/repl.mjs +621 -99
  47. package/src/tools/agent.mjs +6 -2
  48. package/src/tools/analyze-image.mjs +1 -1
  49. package/src/tools/project-overview.mjs +7 -7
  50. package/src/tools/registry.mjs +88 -4
  51. package/src/ui/slash-commands.mjs +19 -1
  52. package/src/ui/sub-agent.mjs +14 -8
@@ -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,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.KEPLER_LONG_RUNNING_TIMEOUT_MS);
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 && scope !== 'project' && scope !== 'global') {
252
- throw new Error('scope must be "project" or "global"');
390
+ if (scope && !['project', 'global', 'plugin', 'builtin'].includes(scope)) {
391
+ throw new Error('scope must be "project", "global", "plugin", or "builtin"');
253
392
  }
254
- return listLocalAgents(process.cwd())
255
- .filter(agent => !scope || agent.source_scope === scope)
256
- .filter(agent => agentMatches(agent, args.query || args.name || ''));
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 /agents sync ${result.slug} when ready`,
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: listLocalAgents(process.cwd()).map(agent => ({
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,