@bahulam/code 0.1.11 → 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.
@@ -206,6 +206,11 @@ export class LocalAgent {
206
206
  maxTurns = null,
207
207
  stagnationDetection = false,
208
208
  stagnationThreshold = 3,
209
+ // Additional tool schemas beyond the built-in set — e.g. plugin
210
+ // tools a sub-agent node declares. Execution still routes through
211
+ // the (scoped) toolExecutor; this only makes the schemas visible
212
+ // to the model.
213
+ extraToolSchemas = [],
209
214
  }) {
210
215
  this.apiKey = apiKey;
211
216
  this.openRouterKey = openRouterKey;
@@ -218,6 +223,7 @@ export class LocalAgent {
218
223
  this.maxTurns = maxTurns || MAX_ITERATIONS;
219
224
  this.stagnationDetection = stagnationDetection;
220
225
  this.stagnationThreshold = stagnationThreshold;
226
+ this.extraToolSchemas = Array.isArray(extraToolSchemas) ? extraToolSchemas : [];
221
227
  this._cancelled = false;
222
228
  this.promptCache = new PromptCache();
223
229
  }
@@ -498,7 +504,10 @@ export class LocalAgent {
498
504
  }
499
505
 
500
506
  _buildToolDefs() {
501
- return TOOL_SCHEMAS;
507
+ if (!this.extraToolSchemas.length) return TOOL_SCHEMAS;
508
+ const names = new Set(TOOL_SCHEMAS.map(t => t.name));
509
+ const extras = this.extraToolSchemas.filter(t => t?.name && !names.has(t.name));
510
+ return extras.length ? [...TOOL_SCHEMAS, ...extras] : TOOL_SCHEMAS;
502
511
  }
503
512
 
504
513
  _buildSystemPrompt(context, retrievedContext = null) {
@@ -63,6 +63,7 @@ const READ_TOOLS = new Set([
63
63
  'analyze_code',
64
64
  'validate_file', 'validate_structure',
65
65
  'agents_list', 'workflow_list',
66
+ 'bahulam_info', 'job_output',
66
67
  ]);
67
68
 
68
69
  const READ_PATH_KEYS = [
@@ -190,17 +190,69 @@ export class BahulamStreamClient {
190
190
  this._bundledReady = false;
191
191
  }
192
192
 
193
+ _getPluginToolMap() {
194
+ const tools = new Map();
195
+ if (!this.pluginRegistry) return tools;
196
+ for (const tool of this.pluginRegistry.listTools?.() || []) {
197
+ const name = String(tool.name || '').trim();
198
+ if (!name || tools.has(name)) continue;
199
+ tools.set(name, tool);
200
+ }
201
+ return tools;
202
+ }
203
+
193
204
  /**
194
- * Get plugin tool schemas for client_tools injection.
195
- * @returns {Array<{name: string, description: string, input_schema: object}>}
205
+ * Plugin tools are intentionally not advertised as primary client_tools.
206
+ * They are executable by the local callback handler, but the primary model
207
+ * should reach them by delegating to an agent that declares them.
196
208
  */
197
209
  _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
- }));
210
+ return [];
211
+ }
212
+
213
+ _collectAgentScopedToolRefs(context = {}, clientAgents = []) {
214
+ const refs = new Map();
215
+ const pluginTools = this._getPluginToolMap();
216
+ const addAgent = (agent = {}) => {
217
+ const slug = String(agent.slug || agent.command || agent.name || '').trim();
218
+ const tools = Array.isArray(agent.tools) ? agent.tools : [];
219
+ for (const toolName of tools) {
220
+ const name = String(toolName || '').trim();
221
+ if (!name || !pluginTools.has(name)) continue;
222
+ if (!refs.has(name)) refs.set(name, new Set());
223
+ if (slug) refs.get(name).add(slug);
224
+ }
225
+ };
226
+
227
+ for (const agent of clientAgents || []) addAgent(agent);
228
+ for (const agent of context?.agent_ctx?.available_agents || []) addAgent(agent);
229
+ for (const agent of context?.available_agents || []) addAgent(agent);
230
+ if (context?.sub_agent) addAgent(context.sub_agent);
231
+ return refs;
232
+ }
233
+
234
+ /**
235
+ * Plugin tool schemas scoped to sub-agents that declare those tools.
236
+ * This keeps plugin tools out of the primary model's direct tool surface
237
+ * while still giving delegated/custom/plugin agents the schemas they need.
238
+ *
239
+ * @returns {Array<{name: string, description: string, input_schema: object, source_scope: string, plugin_name: string|null, allowed_agents: string[]}>}
240
+ */
241
+ _getClientAgentToolSchemas(context = {}, clientAgents = []) {
242
+ const pluginTools = this._getPluginToolMap();
243
+ if (!pluginTools.size) return [];
244
+ const refs = this._collectAgentScopedToolRefs(context, clientAgents);
245
+ return [...refs.entries()].map(([name, allowedAgents]) => {
246
+ const tool = pluginTools.get(name) || {};
247
+ return {
248
+ name,
249
+ description: tool.description || '',
250
+ input_schema: tool.input_schema || { type: 'object', properties: {} },
251
+ source_scope: 'plugin',
252
+ plugin_name: tool._plugin_name || tool.plugin_name || null,
253
+ allowed_agents: [...allowedAgents],
254
+ };
255
+ });
204
256
  }
205
257
 
206
258
  /**
@@ -209,13 +261,30 @@ export class BahulamStreamClient {
209
261
  */
210
262
  _getPluginAgentSchemas() {
211
263
  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
- }));
264
+ // Only plugin agents admitted to the main-loop registry (settings
265
+ // plugins.agent_allowlist, or the session plugin in workspace-channel
266
+ // executors) are advertised. Workspace-scoped plugin agents stay out
267
+ // of the main-turn payload; without an executor registry, fall back
268
+ // to advertising everything (legacy behavior).
269
+ const runnables = this.toolExecutor?.listRunnables?.();
270
+ const admitted = Array.isArray(runnables)
271
+ ? new Set(runnables.filter(a => a.source_scope === 'plugin').map(a => a.slug))
272
+ : null;
273
+ return this.pluginRegistry.listAgents()
274
+ .filter(a => !admitted || admitted.has(a.slug || a.name || ''))
275
+ .map(a => ({
276
+ slug: a.slug || a.name || '',
277
+ name: a.name || a.slug || '',
278
+ role: a.role || 'specialist',
279
+ description: a.description || '',
280
+ tools: Array.isArray(a.tools) ? a.tools : [],
281
+ system_prompt: a.system_prompt || a.systemPrompt || a.prompt || '',
282
+ model: a.model || null,
283
+ models: a.models || null,
284
+ source: a.source || (a._plugin_name ? `plugin:${a._plugin_name}` : 'plugin'),
285
+ source_scope: 'plugin',
286
+ plugin_name: a._plugin_name || null,
287
+ }));
219
288
  }
220
289
 
221
290
  /**
@@ -297,6 +366,8 @@ export class BahulamStreamClient {
297
366
  if (clientTools.length > 0) body.client_tools = clientTools;
298
367
  const clientAgents = this._getPluginAgentSchemas();
299
368
  if (clientAgents.length > 0) body.client_agents = clientAgents;
369
+ const clientAgentTools = this._getClientAgentToolSchemas(context, clientAgents);
370
+ if (clientAgentTools.length > 0) body.client_agent_tools = clientAgentTools;
300
371
  const requestId = `cli-${_uuidLike()}`;
301
372
 
302
373
  // daemon cache-guard hook. If BAHULAM_CAPTURE_REQUEST is set to a file
@@ -795,6 +866,7 @@ export class BahulamStreamClient {
795
866
  const callId = call_id || request_id;
796
867
  const toolName = tool;
797
868
  const isInternal = Boolean(data?.internal || data?.sub_agent);
869
+ const subAgentRunId = data?.run_id || data?.sub_agent_run_id || null;
798
870
 
799
871
  if (this.verbose) {
800
872
  process.stderr.write(`\x1b[2m[tool] ${toolName}(${JSON.stringify(args).slice(0, 80)}...)\x1b[0m\n`);
@@ -812,6 +884,8 @@ export class BahulamStreamClient {
812
884
  _cancelled: true,
813
885
  internal: isInternal,
814
886
  sub_agent: data?.sub_agent || null,
887
+ run_id: subAgentRunId,
888
+ sub_agent_run_id: subAgentRunId,
815
889
  local_callback: false,
816
890
  },
817
891
  };
@@ -823,6 +897,10 @@ export class BahulamStreamClient {
823
897
  try {
824
898
  result = await this.toolExecutor.execute(toolName, args || {}, {
825
899
  signal: this._toolAbort?.signal,
900
+ toolCallSource: 'model',
901
+ internal: isInternal,
902
+ subAgent: data?.sub_agent || null,
903
+ subAgentRunId,
826
904
  });
827
905
  } catch (err) {
828
906
  if (err?.name === 'AbortError' || this._cancelled) {
@@ -862,6 +940,8 @@ export class BahulamStreamClient {
862
940
  duration_ms: durationMs,
863
941
  internal: isInternal,
864
942
  sub_agent: data?.sub_agent || null,
943
+ run_id: subAgentRunId,
944
+ sub_agent_run_id: subAgentRunId,
865
945
  local_callback: true,
866
946
  },
867
947
  };
@@ -23,9 +23,12 @@ 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';
30
33
  import { PluginRegistry } from '../plugins/registry.mjs';
31
34
  import { loadPluginTool } from '../plugins/executor.mjs';
@@ -57,6 +60,12 @@ export function createToolExecutor({
57
60
  // REPL/headless callers leave this null — state still works, just
58
61
  // no reactive pulse.
59
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',
60
69
  } = {}) {
61
70
  // Cross-session memory cache. Ships in getAgentContext() on every turn,
62
71
  // so we need it to be byte-identical when the underlying disk file hasn't
@@ -87,7 +96,7 @@ export function createToolExecutor({
87
96
  _memoryCache = { key, facts, digest };
88
97
  return _memoryCache;
89
98
  }
90
- const occRegistry = createToolRegistry();
99
+ const occRegistry = createToolRegistry({ pluginRegistry, stateEmit });
91
100
  const skillTool = occRegistry.get('Skill');
92
101
  if (skillTool) skillTool._skillsLoader = skillsLoader;
93
102
  const installer = skillInstaller || new SkillInstaller({
@@ -252,6 +261,7 @@ export function createToolExecutor({
252
261
  source_scope: agent.source_scope || 'unknown',
253
262
  source: agent.source || '',
254
263
  content_hash: agent.content_hash || '',
264
+ runnable: agent.runnable !== false,
255
265
  };
256
266
  }
257
267
 
@@ -306,24 +316,84 @@ export function createToolExecutor({
306
316
  .filter(agent => agent.slug);
307
317
  }
308
318
 
309
- function listAvailableAgents() {
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() {
310
353
  const bySlug = new Map();
311
354
  for (const agent of listLocalAgents(process.cwd())) {
312
- if (agent.slug && !bySlug.has(agent.slug)) bySlug.set(agent.slug, agent);
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);
313
361
  }
362
+ const allowlist = new Set(pluginAgentAllowlist());
314
363
  for (const agent of listPluginAgents()) {
315
- if (agent.slug && !bySlug.has(agent.slug)) bySlug.set(agent.slug, agent);
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
+ }
316
368
  }
317
369
  return [...bySlug.values()];
318
370
  }
319
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
+
320
388
  function filterLocalAgents(args = {}) {
321
389
  const scope = String(args.scope || '').trim();
322
- if (scope && !['project', 'global', 'plugin'].includes(scope)) {
323
- throw new Error('scope must be "project", "global", or "plugin"');
390
+ if (scope && !['project', 'global', 'plugin', 'builtin'].includes(scope)) {
391
+ throw new Error('scope must be "project", "global", "plugin", or "builtin"');
324
392
  }
325
- const combined = listAvailableAgents()
326
- .filter(agent => !scope || agent.source_scope === scope);
393
+ const pool = scope === 'plugin'
394
+ ? [...listRunnables(), ...listWorkspaceScopedPluginAgents()]
395
+ : listRunnables();
396
+ const combined = pool.filter(agent => !scope || agent.source_scope === scope);
327
397
  return combined.filter(agent => agentMatches(agent, args.query || args.name || ''));
328
398
  }
329
399
 
@@ -694,13 +764,14 @@ export function createToolExecutor({
694
764
  // They are dispatched with lower priority (built-in tools win on name collision).
695
765
  const pluginToolMap = new Map(); // name → async handler function
696
766
 
697
- function registerPluginTool(name, handler) {
767
+ function registerPluginTool(name, handler, metadata = {}) {
698
768
  if (pluginToolMap.has(name)) {
699
769
  if (process.env.DEBUG) {
700
770
  console.warn(`Plugin tool "${name}" already registered from another plugin — skipping.`);
701
771
  }
702
772
  return false;
703
773
  }
774
+ handler._pluginTool = metadata;
704
775
  pluginToolMap.set(name, handler);
705
776
  return true;
706
777
  }
@@ -735,7 +806,7 @@ export function createToolExecutor({
735
806
  }
736
807
  return false;
737
808
  }
738
- pluginToolMap.set(qualified, async (args, options = {}) => {
809
+ const mcpHandler = async (args, options = {}) => {
739
810
  // The lazy-state getter matches JS plugin tools so an MCP
740
811
  // "wrapper" tool can trivially write its result to the same
741
812
  // Shared Blackboard (rare, but useful for cache-and-return).
@@ -765,7 +836,9 @@ export function createToolExecutor({
765
836
  _mcp_server: serverName,
766
837
  };
767
838
  }
768
- });
839
+ };
840
+ mcpHandler._pluginTool = { pluginName, source: 'mcp', serverName, toolName };
841
+ pluginToolMap.set(qualified, mcpHandler);
769
842
  // Track schema for tool-listing surfaces (also help /tools discovery).
770
843
  pluginToolMap.get(qualified)._mcp = { pluginName, serverName, toolName, schema: toolSchema };
771
844
  return true;
@@ -778,11 +851,11 @@ export function createToolExecutor({
778
851
  if (!name || toolMap[name]) continue;
779
852
  const pluginName = toolDef._plugin_name || toolDef.plugin_name || null;
780
853
  registerPluginTool(name, async (args, options = {}) => {
781
- const handler = await loadPluginTool(toolDef._plugin_dir, toolDef.handler);
854
+ const handler = await loadPluginTool(toolDef._plugin_dir, toolDef.tool);
782
855
  if (!handler) {
783
856
  return {
784
857
  success: false,
785
- output: `Plugin tool handler could not be loaded: ${name}`,
858
+ output: `Plugin tool module could not be loaded: ${name}`,
786
859
  _tool: name,
787
860
  _plugin: pluginName,
788
861
  };
@@ -826,15 +899,57 @@ export function createToolExecutor({
826
899
  _plugin: pluginName,
827
900
  };
828
901
  }
829
- });
902
+ }, { pluginName, source: 'plugin' });
830
903
  }
831
904
  }
832
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
+
833
946
  async function executeToolWithHooks(name, args, options = {}) {
834
947
  const handler = toolMap[name] || pluginToolMap.get(name);
835
948
  if (!handler) {
836
949
  return { success: false, output: `Unknown tool: ${name}`, _tool: name };
837
950
  }
951
+ const pluginToolBlock = primaryModelPluginToolBlock(name, handler, options);
952
+ if (pluginToolBlock) return pluginToolBlock;
838
953
  const hooks = hookRunner || new HookRunner({ cwd: process.cwd() });
839
954
  try {
840
955
  throwIfAborted(options.signal);
@@ -1188,6 +1303,30 @@ export function createToolExecutor({
1188
1303
  args._classification = classification.classification; // 'safe' or 'contained'
1189
1304
  const cwd = await commandCwd(args);
1190
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
+
1191
1330
  // Pre-check: if command is rm/unlink, verify targets exist first
1192
1331
  const rmMatch = (args.command || '').match(/^rm\s+(?:-\w+\s+)*(.+)$/);
1193
1332
  if (rmMatch) {
@@ -2118,9 +2257,47 @@ export function createToolExecutor({
2118
2257
  },
2119
2258
 
2120
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
+
2121
2295
  agents_list: async (args = {}) => {
2122
2296
  const agents = filterLocalAgents(args).map(compactAgentMetadata);
2123
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
+ }
2124
2301
  return {
2125
2302
  success: true,
2126
2303
  output: JSON.stringify(payload, null, 2),
@@ -2151,7 +2328,8 @@ export function createToolExecutor({
2151
2328
  : null,
2152
2329
  next_actions: [
2153
2330
  `Edit ${result.filePath}`,
2154
- `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`,
2155
2333
  ],
2156
2334
  };
2157
2335
  return {
@@ -2580,6 +2758,21 @@ export function createToolExecutor({
2580
2758
  return results;
2581
2759
  },
2582
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
+
2583
2776
  getAgentContext() {
2584
2777
  const global = projectRegistry.getGlobalContext();
2585
2778
  const mem = _readMemorySnapshot();
@@ -2609,6 +2802,17 @@ export function createToolExecutor({
2609
2802
  source: agent.source,
2610
2803
  spec: agent.spec,
2611
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
+ } : {}),
2612
2816
  available_workflows: listLocalWorkflows(process.cwd()).map(workflow => ({
2613
2817
  slug: workflow.slug,
2614
2818
  name: workflow.name,
@@ -617,7 +617,7 @@ export class LocalAgentRelay {
617
617
  try { this.emit('plugin_state_changed', evt); }
618
618
  catch { /* never let SSE failure break a tool call */ }
619
619
  };
620
- const toolExecutor = createToolExecutor({ pluginRegistry, stateEmit });
620
+ const toolExecutor = createToolExecutor({ pluginRegistry, stateEmit, channel: 'workspace' });
621
621
  await toolExecutor.waitForAutoRegister?.();
622
622
  await toolExecutor.registerProjectRoots?.([this.session.root_path], { forceRefresh: false });
623
623