@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.
@@ -0,0 +1,193 @@
1
+ import { LocalAgent } from '../core/local-agent.mjs';
2
+ import { backgroundTasks } from '../core/background-tasks.mjs';
3
+ import { classifyShell, TIERS } from '../core/risk-tier.mjs';
4
+ import { createScopedToolExecutor } from '../terminal/agents.mjs';
5
+ import { namespaceNodeEvents } from './events.mjs';
6
+
7
+ /**
8
+ * Execute one agent node. Substrates:
9
+ * 'direct' — a per-node LocalAgent instance calling the model API
10
+ * directly. One instance per node makes the node's resolved
11
+ * model deterministic (LocalAgent fixes model at construction).
12
+ * 'session' — an injected adapter (ctx.sessionSubstrate) that runs the
13
+ * node through the logged-in backend flow. The engine stays
14
+ * terminal-agnostic; the REPL provides the adapter.
15
+ *
16
+ * Every tool call flows through a scoped executor that enforces the
17
+ * node's effective allowlist and tags calls internal/subAgent, so hooks,
18
+ * plugin-tool gating, and attribution behave exactly like direct agent
19
+ * runs today.
20
+ */
21
+ export async function* runNode(node, agent, instruction, ctx, options = {}) {
22
+ if (node.type === 'job') {
23
+ yield* namespaceNodeEvents(runJobNode(node, ctx, options), { node, runId: options.runId });
24
+ return;
25
+ }
26
+ if (node.type === 'service') {
27
+ yield* namespaceNodeEvents(runServiceNode(node, ctx, options), { node, runId: options.runId });
28
+ return;
29
+ }
30
+ const effectiveAgent = withEffectiveTools(node, agent);
31
+ const scopedExecutor = createScopedToolExecutor(ctx.toolExecutor, effectiveAgent, {
32
+ projectRoot: ctx.cwd || process.cwd(),
33
+ });
34
+ const substrate = selectSubstrate(ctx, options);
35
+
36
+ if (substrate === 'session') {
37
+ yield* namespaceNodeEvents(
38
+ ctx.sessionSubstrate(effectiveAgent, node, instruction, { scopedExecutor }),
39
+ { node, runId: options.runId },
40
+ );
41
+ return;
42
+ }
43
+
44
+ const model = node.model || effectiveAgent.model || ctx.defaultModel || null;
45
+ const { apiKey = null, openRouterKey = null } = ctx.credentials || {};
46
+ if (!apiKey && !openRouterKey) {
47
+ throw new Error(
48
+ `Cannot run node '${node.id}' locally: no model API key. ` +
49
+ 'Set ANTHROPIC_API_KEY or OPENROUTER_API_KEY, or log in and use the session substrate.',
50
+ );
51
+ }
52
+
53
+ // Plugin tools the agent declares need their schemas in the model's
54
+ // tool list (built-ins are hardcoded in LocalAgent; plugin schemas are
55
+ // dynamic). Execution still flows through the scoped executor.
56
+ const declaredTools = new Set(
57
+ (Array.isArray(effectiveAgent.tools) ? effectiveAgent.tools : []).map(String),
58
+ );
59
+ const extraToolSchemas = (ctx.toolExecutor?.listPluginToolSchemas?.() || [])
60
+ .filter(schema => declaredTools.has(schema.name));
61
+
62
+ const localAgent = new LocalAgent({
63
+ apiKey,
64
+ openRouterKey,
65
+ model,
66
+ toolExecutor: scopedExecutor,
67
+ cwd: ctx.cwd || process.cwd(),
68
+ systemPromptOverride: effectiveAgent.prompt || effectiveAgent.system_prompt || null,
69
+ maxTurns: effectiveAgent.max_iterations || null,
70
+ extraToolSchemas,
71
+ });
72
+ if (options.signal) options.signal.addEventListener('abort', () => localAgent.cancel?.(), { once: true });
73
+
74
+ yield* namespaceNodeEvents(localAgent.execute(instruction, {}), { node, runId: options.runId });
75
+ }
76
+
77
+ // A job node runs a process instead of an LLM. It blocks its graph edge
78
+ // until the process exits (graph-level parallelism provides concurrency),
79
+ // and its handoff is the exit code plus the output tail. Commands are
80
+ // risk-classified up front — dangerous patterns fail the node
81
+ // deterministically rather than prompting mid-graph.
82
+ async function* runJobNode(node, ctx, options = {}) {
83
+ const command = String(node.command || '').trim();
84
+ const tier = classifyShell(command);
85
+ if (tier === TIERS.SHELL_DANGEROUS) {
86
+ throw new Error(`Job '${node.id}' blocked: command matches a high-risk pattern (${command.slice(0, 80)})`);
87
+ }
88
+
89
+ const started = backgroundTasks.start({
90
+ command,
91
+ cwd: ctx.cwd || process.cwd(),
92
+ timeoutMs: node.timeout_s ? node.timeout_s * 1000 : undefined,
93
+ name: node.id,
94
+ });
95
+ yield { type: 'status', data: { message: `job ${node.id}: ${command.slice(0, 80)} (${started.id})` } };
96
+ if (options.signal) {
97
+ options.signal.addEventListener('abort', () => backgroundTasks.kill(started.id), { once: true });
98
+ }
99
+
100
+ const done = await backgroundTasks.wait(started.id);
101
+ const ok = done.status === 'completed';
102
+ const summaryLine = `job ${node.id} ${done.status} (exit ${done.exit_code ?? 'n/a'}, ${done.duration_s}s)`;
103
+ const tail = String(done.tail || '').trim();
104
+ if (!ok) {
105
+ throw new Error(`${summaryLine}${tail ? `\n${tail.slice(-2000)}` : ''}`);
106
+ }
107
+ yield {
108
+ type: 'complete',
109
+ data: {
110
+ final_response: `${summaryLine}${tail ? `\n${tail}` : ''}`,
111
+ job_id: done.id,
112
+ exit_code: done.exit_code,
113
+ log_path: done.log_path,
114
+ },
115
+ };
116
+ }
117
+
118
+ // A service node starts a long-lived process (dev server) and completes
119
+ // at READINESS, not exit — downstream nodes run against the live service.
120
+ // The process is registered in options.serviceJobs so the graph runner
121
+ // kills it when the run ends. Readiness: ready.port (TCP connect),
122
+ // ready.log_pattern (regex on output tail), or ready.delay_s (default 2s).
123
+ async function* runServiceNode(node, ctx, options = {}) {
124
+ const command = String(node.command || '').trim();
125
+ const tier = classifyShell(command);
126
+ if (tier === TIERS.SHELL_DANGEROUS) {
127
+ throw new Error(`Service '${node.id}' blocked: command matches a high-risk pattern (${command.slice(0, 80)})`);
128
+ }
129
+
130
+ const started = backgroundTasks.start({
131
+ command,
132
+ cwd: ctx.cwd || process.cwd(),
133
+ timeoutMs: 0, // services live until the graph run ends
134
+ name: node.id,
135
+ });
136
+ if (Array.isArray(options.serviceJobs)) options.serviceJobs.push(started.id);
137
+ yield { type: 'status', data: { message: `service ${node.id}: ${command.slice(0, 80)} (${started.id})` } };
138
+
139
+ const ready = node.ready || {};
140
+ const timeoutMs = (Number(ready.timeout_s) || 60) * 1000;
141
+ const deadline = Date.now() + timeoutMs;
142
+ const poll = async () => {
143
+ const job = backgroundTasks.describe(started.id);
144
+ if (!job || job.status !== 'running') {
145
+ throw new Error(`service ${node.id} exited before becoming ready (${job?.status}, exit ${job?.exit_code})\n${String(job?.tail || '').slice(-1000)}`);
146
+ }
147
+ if (ready.port) return await portOpen(Number(ready.port));
148
+ if (ready.log_pattern) return new RegExp(ready.log_pattern).test(job.tail || '');
149
+ return true; // no probe declared → ready after the initial delay
150
+ };
151
+
152
+ await new Promise(resolve => setTimeout(resolve, (Number(ready.delay_s) || 2) * 1000));
153
+ while (!(await poll())) {
154
+ if (Date.now() > deadline) {
155
+ backgroundTasks.kill(started.id);
156
+ throw new Error(`service ${node.id} not ready within ${timeoutMs / 1000}s`);
157
+ }
158
+ await new Promise(resolve => setTimeout(resolve, 1000));
159
+ }
160
+
161
+ yield {
162
+ type: 'complete',
163
+ data: {
164
+ final_response: `service ${node.id} ready (${started.id}${ready.port ? `, port ${ready.port}` : ''}). It stays up for the rest of this run.`,
165
+ job_id: started.id,
166
+ },
167
+ };
168
+ }
169
+
170
+ async function portOpen(port) {
171
+ const { Socket } = await import('node:net');
172
+ return new Promise(resolve => {
173
+ const socket = new Socket();
174
+ const done = (ok) => { socket.destroy(); resolve(ok); };
175
+ socket.setTimeout(750, () => done(false));
176
+ socket.once('error', () => done(false));
177
+ socket.connect(port, '127.0.0.1', () => done(true));
178
+ });
179
+ }
180
+
181
+ function selectSubstrate(ctx, options) {
182
+ const requested = options.substrate || 'auto';
183
+ if (requested === 'session' || requested === 'direct') return requested;
184
+ return typeof ctx.sessionSubstrate === 'function' && ctx.auth?.token ? 'session' : 'direct';
185
+ }
186
+
187
+ // node.tools, when present, narrows the agent's own allowlist (intersection).
188
+ function withEffectiveTools(node, agent) {
189
+ const agentTools = Array.isArray(agent.tools) ? agent.tools : [];
190
+ if (!Array.isArray(node.tools) || !node.tools.length) return agent;
191
+ const narrowed = new Set(node.tools);
192
+ return { ...agent, tools: agentTools.filter(tool => narrowed.has(tool)) };
193
+ }
@@ -0,0 +1,200 @@
1
+ import * as crypto from 'node:crypto';
2
+ import { backgroundTasks } from '../core/background-tasks.mjs';
3
+ import { normalizeGraphSpec, validateGraph } from './graph.mjs';
4
+ import { deriveApprovalScope } from './approval.mjs';
5
+ import { runNode } from './node-runner.mjs';
6
+ import { graphEvent } from './events.mjs';
7
+
8
+ /**
9
+ * Execute a graph of agent nodes locally, in-process. Yields the same
10
+ * event vocabulary the main renderer already consumes, plus lifecycle
11
+ * events: graph_run_start, graph_node_start, graph_node_result,
12
+ * graph_run_result.
13
+ *
14
+ * pattern: 'sequential' runs nodes one at a time in topological order.
15
+ * pattern: 'parallel' groups nodes into dependency levels — nodes in the
16
+ * same level (no path between them) execute concurrently; their events
17
+ * are buffered and flushed contiguously in topological order so the
18
+ * output stays deterministic while wall-clock shrinks. Handoffs are
19
+ * snapshotted at level start: same-level nodes never see each other,
20
+ * only completed upstream levels.
21
+ */
22
+ export async function* runGraph(graphSpec, triggerInput = {}, ctx = {}, options = {}) {
23
+ const spec = normalizeGraphSpec(graphSpec);
24
+ const agentNodes = validateGraph(spec);
25
+ const runId = options.runId || `gr-${crypto.randomUUID().slice(0, 12)}`;
26
+
27
+ const resolveAgent = ctx.resolveAgent || (() => null);
28
+ for (const node of agentNodes) {
29
+ if (node.type === 'job' || node.type === 'service') continue;
30
+ if (!node.agent) node.agent = resolveAgent(node.agent_slug);
31
+ if (!node.agent) throw new Error(`Unknown agent '${node.agent_slug}' in graph '${spec.name}'`);
32
+ }
33
+
34
+ const approvalScope = deriveApprovalScope(agentNodes, resolveAgent);
35
+ yield graphEvent('graph_run_start', runId, {
36
+ graph: spec.name,
37
+ pattern: spec.pattern,
38
+ node_count: agentNodes.length,
39
+ approval_scope: approvalScope,
40
+ });
41
+
42
+ const levels = spec.pattern === 'parallel'
43
+ ? dependencyLevels(agentNodes, spec.edges)
44
+ : agentNodes.map(node => [node]);
45
+
46
+ const nodeResults = [];
47
+ const handoffs = [];
48
+ let status = 'completed';
49
+ // Service nodes register their process here; the run is their lifetime
50
+ // scope — everything still running is killed when the graph ends,
51
+ // whatever path it ends by (finally covers errors and early consumer
52
+ // abandonment too).
53
+ const serviceJobs = [];
54
+ const nodeOptions = { ...options, runId, serviceJobs };
55
+
56
+ try {
57
+ for (const level of levels) {
58
+ if (status === 'failed') break;
59
+ const levelHandoffs = [...handoffs];
60
+
61
+ for (const node of level) {
62
+ yield graphEvent('graph_node_start', runId, {
63
+ node_id: node.id,
64
+ agent: node.agent_slug || node.id,
65
+ parallel_group: level.length > 1 ? level.map(n => n.id) : undefined,
66
+ });
67
+ }
68
+
69
+ // Solo level: stream events live (no buffering) — this is the /run
70
+ // and sequential-pattern hot path.
71
+ if (level.length === 1) {
72
+ const node = level[0];
73
+ const instruction = buildNodeInstruction(node, triggerInput, levelHandoffs, spec.global_params);
74
+ let output = '';
75
+ let accumulated = '';
76
+ let nodeStatus = 'completed';
77
+ try {
78
+ for await (const event of runNode(node, node.agent, instruction, ctx, nodeOptions)) {
79
+ if (event.type === 'content' || event.type === 'content_partial') {
80
+ accumulated += event.data?.text || event.data?.content || '';
81
+ }
82
+ if (event.type === 'complete' && typeof event.data?.final_response === 'string') {
83
+ output = event.data.final_response;
84
+ }
85
+ yield event;
86
+ }
87
+ } catch (err) {
88
+ nodeStatus = 'failed';
89
+ output = err?.message || String(err);
90
+ }
91
+ if (!output && nodeStatus === 'completed') output = accumulated;
92
+ const result = { node_id: node.id, agent: node.agent_slug || node.id, status: nodeStatus, output };
93
+ nodeResults.push(result);
94
+ yield graphEvent('graph_node_result', runId, result);
95
+ if (result.status === 'failed' && !node.continue_on_error) {
96
+ status = 'failed';
97
+ } else if (result.status === 'completed' && result.output) {
98
+ handoffs.push({ node_id: result.node_id, agent: result.agent, output: result.output });
99
+ }
100
+ continue;
101
+ }
102
+
103
+ // Parallel level: start every node concurrently; drain buffers in
104
+ // topological order so events stay contiguous per node.
105
+ const runs = level.map(node => collectNodeRun(
106
+ node,
107
+ buildNodeInstruction(node, triggerInput, levelHandoffs, spec.global_params),
108
+ ctx,
109
+ nodeOptions,
110
+ ));
111
+
112
+ for (let i = 0; i < level.length; i++) {
113
+ const { events, result } = await runs[i];
114
+ for (const event of events) yield event;
115
+ nodeResults.push(result);
116
+ yield graphEvent('graph_node_result', runId, result);
117
+ if (result.status === 'failed' && !level[i].continue_on_error) {
118
+ status = 'failed';
119
+ } else if (result.status === 'completed' && result.output) {
120
+ handoffs.push({ node_id: result.node_id, agent: result.agent, output: result.output });
121
+ }
122
+ }
123
+ }
124
+
125
+ } finally {
126
+ for (const id of serviceJobs) {
127
+ try { backgroundTasks.kill(id); } catch { /* already gone */ }
128
+ }
129
+ }
130
+
131
+ const result = {
132
+ run_id: runId,
133
+ graph: spec.name,
134
+ status,
135
+ node_results: nodeResults,
136
+ output: nodeResults.length ? nodeResults[nodeResults.length - 1].output : '',
137
+ };
138
+ yield graphEvent('graph_run_result', runId, result);
139
+ return result;
140
+ }
141
+
142
+ async function collectNodeRun(node, instruction, ctx, options) {
143
+ const events = [];
144
+ let output = '';
145
+ let accumulated = '';
146
+ let nodeStatus = 'completed';
147
+ try {
148
+ for await (const event of runNode(node, node.agent, instruction, ctx, options)) {
149
+ if (event.type === 'content' || event.type === 'content_partial') {
150
+ accumulated += event.data?.text || event.data?.content || '';
151
+ }
152
+ if (event.type === 'complete' && typeof event.data?.final_response === 'string') {
153
+ output = event.data.final_response;
154
+ }
155
+ events.push(event);
156
+ }
157
+ } catch (err) {
158
+ nodeStatus = 'failed';
159
+ output = err?.message || String(err);
160
+ }
161
+ if (!output && nodeStatus === 'completed') output = accumulated;
162
+ return {
163
+ events,
164
+ result: { node_id: node.id, agent: node.agent_slug || node.id, status: nodeStatus, output },
165
+ };
166
+ }
167
+
168
+ // Group agent nodes by dependency depth: level N nodes depend only on
169
+ // nodes in levels < N. agentNodes arrive topologically ordered, so each
170
+ // node's predecessors are resolved before it.
171
+ function dependencyLevels(agentNodes, edges) {
172
+ const ids = new Set(agentNodes.map(node => node.id));
173
+ const preds = new Map(agentNodes.map(node => [node.id, []]));
174
+ for (const edge of edges) {
175
+ if (ids.has(edge.source) && ids.has(edge.target)) preds.get(edge.target).push(edge.source);
176
+ }
177
+ const depth = new Map();
178
+ const levels = [];
179
+ for (const node of agentNodes) {
180
+ const upstream = preds.get(node.id);
181
+ const d = upstream.length ? 1 + Math.max(...upstream.map(id => depth.get(id) ?? 0)) : 0;
182
+ depth.set(node.id, d);
183
+ (levels[d] ||= []).push(node);
184
+ }
185
+ return levels;
186
+ }
187
+
188
+ function buildNodeInstruction(node, triggerInput, handoffs, globalParams) {
189
+ const parts = [node.prompt || triggerInput.instruction || ''];
190
+ if (globalParams && Object.keys(globalParams).length) {
191
+ parts.push(`Parameters:\n${JSON.stringify(globalParams, null, 2)}`);
192
+ }
193
+ if (handoffs.length) {
194
+ const upstream = handoffs
195
+ .map(h => `--- handoff from ${h.agent} ---\n${h.output}`)
196
+ .join('\n\n');
197
+ parts.push(`Results from earlier steps:\n\n${upstream}`);
198
+ }
199
+ return parts.filter(Boolean).join('\n\n');
200
+ }
@@ -73,8 +73,8 @@ export async function createPluginToolExecutor(manifest, opts = {}) {
73
73
  const pluginName = manifest.metadata?.name || '';
74
74
 
75
75
  for (const toolDef of tools) {
76
- if (!toolDef.handler) continue;
77
- const handler = await loadPluginTool(pluginDir, toolDef.handler);
76
+ if (!toolDef.tool) continue;
77
+ const handler = await loadPluginTool(pluginDir, toolDef.tool);
78
78
  if (handler) {
79
79
  handlers.set(toolDef.name, { handler, toolDef });
80
80
  }
@@ -26,40 +26,40 @@ function normalizeToolNames(value) {
26
26
  }).filter(Boolean);
27
27
  }
28
28
 
29
- function loadAgentHandler(agentDef, pluginDir) {
30
- const handler = String(agentDef.handler || agentDef.file || '').trim();
31
- if (!handler || !pluginDir) return {};
29
+ function loadAgentFile(agentDef, pluginDir) {
30
+ const file = String(agentDef.file || agentDef.handler || '').trim();
31
+ if (!file || !pluginDir) return {};
32
32
  try {
33
- const handlerPath = path.resolve(pluginDir, handler);
34
- const raw = fs.readFileSync(handlerPath, 'utf-8');
35
- return path.extname(handlerPath).toLowerCase() === '.json'
33
+ const filePath = path.resolve(pluginDir, file);
34
+ const raw = fs.readFileSync(filePath, 'utf-8');
35
+ return path.extname(filePath).toLowerCase() === '.json'
36
36
  ? JSON.parse(raw)
37
37
  : parseYaml(raw);
38
38
  } catch (err) {
39
39
  if (process.env.DEBUG) {
40
- console.error(`Failed to load plugin agent handler ${handler}: ${err.message}`);
40
+ console.error(`Failed to load plugin agent file ${file}: ${err.message}`);
41
41
  }
42
42
  return {};
43
43
  }
44
44
  }
45
45
 
46
46
  function normalizeAgentDef(agentDef, pluginName, pluginDir) {
47
- const handlerConfig = loadAgentHandler(agentDef, pluginDir);
48
- const metadata = handlerConfig.metadata || handlerConfig.meta || {};
49
- const agent = handlerConfig.agent || handlerConfig.spec?.agent || {};
50
- const handlerTools = (
51
- handlerConfig.tools
52
- || handlerConfig.spec?.tools
47
+ const fileConfig = loadAgentFile(agentDef, pluginDir);
48
+ const metadata = fileConfig.metadata || fileConfig.meta || {};
49
+ const agent = fileConfig.agent || fileConfig.spec?.agent || {};
50
+ const fileTools = (
51
+ fileConfig.tools
52
+ || fileConfig.spec?.tools
53
53
  || agent.tools
54
54
  || []
55
55
  );
56
56
  const inlineTools = normalizeToolNames(agentDef.tools);
57
57
 
58
58
  return {
59
- slug: agentDef.slug || metadata.slug || handlerConfig.slug || metadata.name || handlerConfig.name || agentDef.name || '',
60
- name: agentDef.name || metadata.name || handlerConfig.name || agentDef.slug || '',
61
- description: agentDef.description || metadata.description || handlerConfig.description || '',
62
- role: agentDef.role || metadata.role || handlerConfig.role || 'specialist',
59
+ slug: agentDef.slug || metadata.slug || fileConfig.slug || metadata.name || fileConfig.name || agentDef.name || '',
60
+ name: agentDef.name || metadata.name || fileConfig.name || agentDef.slug || '',
61
+ description: agentDef.description || metadata.description || fileConfig.description || '',
62
+ role: agentDef.role || metadata.role || fileConfig.role || 'specialist',
63
63
  system_prompt: (
64
64
  agentDef.system_prompt
65
65
  || agentDef.systemPrompt
@@ -67,16 +67,16 @@ function normalizeAgentDef(agentDef, pluginName, pluginDir) {
67
67
  || agent.system_prompt
68
68
  || agent.systemPrompt
69
69
  || agent.prompt
70
- || handlerConfig.system_prompt
71
- || handlerConfig.prompt
70
+ || fileConfig.system_prompt
71
+ || fileConfig.prompt
72
72
  || ''
73
73
  ),
74
- tools: inlineTools.length ? inlineTools : normalizeToolNames(handlerTools),
75
- model: agentDef.model || agent.model || handlerConfig.model || null,
76
- models: agentDef.models || agent.models || handlerConfig.models || undefined,
77
- max_tokens: agentDef.max_tokens || agent.max_tokens || handlerConfig.max_tokens || undefined,
78
- max_iterations: agentDef.max_iterations || agent.max_iterations || handlerConfig.max_iterations || undefined,
79
- handler: agentDef.handler || agentDef.file || '',
74
+ tools: inlineTools.length ? inlineTools : normalizeToolNames(fileTools),
75
+ model: agentDef.model || agent.model || fileConfig.model || null,
76
+ models: agentDef.models || agent.models || fileConfig.models || undefined,
77
+ max_tokens: agentDef.max_tokens || agent.max_tokens || fileConfig.max_tokens || undefined,
78
+ max_iterations: agentDef.max_iterations || agent.max_iterations || fileConfig.max_iterations || undefined,
79
+ file: agentDef.file || agentDef.handler || '',
80
80
  source: `plugin:${pluginName}`,
81
81
  source_scope: 'plugin',
82
82
  };
@@ -164,7 +164,7 @@ export function normalizeManifest(raw, source = '') {
164
164
  name: toolDef.name || '',
165
165
  description: toolDef.description || '',
166
166
  input_schema: toolDef.parameters || toolDef.input_schema || toolDef.inputSchema || { type: 'object', properties: {} },
167
- handler: toolDef.handler || toolDef.file || '',
167
+ tool: toolDef.tool || toolDef.file || toolDef.handler || '',
168
168
  plugin_name: name,
169
169
  };
170
170
  if (tool.name) tools.push(tool);
@@ -277,7 +277,7 @@ export function validatePluginManifest(manifest) {
277
277
  if (manifest.spec) {
278
278
  for (const tool of (manifest.spec.tools || [])) {
279
279
  if (!tool.name) errors.push('Tool missing name');
280
- if (!tool.handler) errors.push(`Tool "${tool.name || '(unnamed)'}" missing handler path`);
280
+ if (!tool.tool) errors.push(`Tool "${tool.name || '(unnamed)'}" missing tool module path (tool: ./tools/<name>.mjs)`);
281
281
  }
282
282
  for (const agent of (manifest.spec.agents || [])) {
283
283
  if (!agent.slug && !agent.name) errors.push('Agent missing slug or name');
@@ -117,23 +117,23 @@ export async function preflightPlugin(pluginDir, opts = {}) {
117
117
  if (!tool.description || tool.description.length < 8) {
118
118
  warnings.push(`Tool "${t}": description is missing or very short (<8 chars) — the model uses this to decide when to call it`);
119
119
  }
120
- if (!tool.handler) { errors.push(`Tool "${t}": missing handler path`); continue; }
120
+ if (!tool.tool) { errors.push(`Tool "${t}": missing tool module path (tool: ./tools/<name>.mjs)`); continue; }
121
121
 
122
- const handlerPath = path.resolve(pluginDir, tool.handler);
122
+ const toolModulePath = path.resolve(pluginDir, tool.tool);
123
123
  // Traversal guard
124
- const inside = handlerPath === pluginDir || handlerPath.startsWith(pluginDir + path.sep);
125
- if (!inside) errors.push(`Tool "${t}": handler path escapes the plugin directory`);
126
- else if (!fs.existsSync(handlerPath)) errors.push(`Tool "${t}": handler file not found: ${tool.handler}`);
124
+ const inside = toolModulePath === pluginDir || toolModulePath.startsWith(pluginDir + path.sep);
125
+ if (!inside) errors.push(`Tool "${t}": tool module path escapes the plugin directory`);
126
+ else if (!fs.existsSync(toolModulePath)) errors.push(`Tool "${t}": tool module not found: ${tool.tool}`);
127
127
  else {
128
128
  try {
129
129
  // Cache-bust because a previous install may have imported an older
130
130
  // copy at the same path in this process.
131
- const mod = await import(`${pathToFileURL(handlerPath).href}?preflight=${Date.now()}`);
131
+ const mod = await import(`${pathToFileURL(toolModulePath).href}?preflight=${Date.now()}`);
132
132
  if (typeof mod.call !== 'function') {
133
- errors.push(`Tool "${t}": handler ${tool.handler} does not export an async \`call\` function`);
133
+ errors.push(`Tool "${t}": tool module ${tool.tool} does not export an async \`call\` function`);
134
134
  }
135
135
  } catch (err) {
136
- errors.push(`Tool "${t}": handler ${tool.handler} failed to import: ${err.message}`);
136
+ errors.push(`Tool "${t}": tool module ${tool.tool} failed to import: ${err.message}`);
137
137
  }
138
138
  }
139
139
 
@@ -113,7 +113,7 @@ const TOOL_ALIASES = new Map([
113
113
  ['grep', 'search_code'],
114
114
  ]);
115
115
 
116
- function canonicalToolName(value) {
116
+ export function canonicalToolName(value) {
117
117
  const key = String(value || '').trim().toLowerCase();
118
118
  return TOOL_ALIASES.get(key) || key;
119
119
  }
@@ -133,7 +133,7 @@ function normalizeScopedArgs(toolName, args = {}, { projectRoot = null } = {}) {
133
133
  return next;
134
134
  }
135
135
 
136
- function createScopedToolExecutor(baseExecutor, agent, { projectRoot = null } = {}) {
136
+ export function createScopedToolExecutor(baseExecutor, agent, { projectRoot = null } = {}) {
137
137
  const tools = Array.isArray(agent.tools) ? agent.tools : [];
138
138
  const allowed = new Set(tools.map(canonicalToolName).filter(Boolean));
139
139
  if (!allowed.size) return baseExecutor;
@@ -152,7 +152,11 @@ function createScopedToolExecutor(baseExecutor, agent, { projectRoot = null } =
152
152
  baseExecutor,
153
153
  toolName,
154
154
  normalizeScopedArgs(toolName, args, { projectRoot }),
155
- options,
155
+ {
156
+ ...options,
157
+ internal: true,
158
+ subAgent: agent.slug || agent.command || agent.name || true,
159
+ },
156
160
  );
157
161
  },
158
162
  };
@@ -317,6 +321,7 @@ export async function runAgentDefinition(agentDefinition, instruction, ctx, sess
317
321
  token: creds.token,
318
322
  toolExecutor,
319
323
  approvalManager: agentApproval,
324
+ pluginRegistry: options.pluginRegistry || ctx.pluginRegistry || null,
320
325
  });
321
326
 
322
327
  session.turns++;
@@ -306,6 +306,8 @@ async function main() {
306
306
  \x1b[1mUsage:\x1b[0m
307
307
  bahulam Start interactive REPL
308
308
  bahulam "instruction" Run a single instruction
309
+ bahulam --agent <slug> -p "x" Run a named agent (local deterministic graph)
310
+ bahulam --workflow <name> -p Run a named workflow (local deterministic graph)
309
311
  bahulam --headless -p "x" Non-interactive: auto-approve, JSONL output
310
312
  bahulam --headless -p "x" --vision screenshot.png
311
313
  Attach an image via the vision analysis pipeline
@@ -360,7 +362,7 @@ async function main() {
360
362
  /architect <query> Spawn architecture planning agent
361
363
  /agents create <name> Create project-local user-defined agent YAML
362
364
  /agents edit <name> Open a local agent YAML in your editor
363
- /agents sync [name] Sync all or one local agent to Supabase
365
+ /agents sync [name] Optionally publish local agents to backend/account
364
366
  /attach <image-path> Attach an image to next prompt
365
367
  /attach clipboard Attach image copied to macOS/Windows clipboard
366
368
  /exit Exit the REPL
@@ -480,7 +482,9 @@ async function main() {
480
482
  const daemonSpawned = process.env.BAHULAM_DAEMON_SPAWNED === '1';
481
483
  const daemonPrompt = daemonSpawned ? (process.env.BAHULAM_DAEMON_INITIAL_PROMPT || '').trim() : '';
482
484
  const effectivePrompt = args.prompt || (daemonSpawned && daemonPrompt) || '';
483
- if (effectivePrompt && (daemonSpawned || process.argv.includes('--headless') || !process.stdin.isTTY)) {
485
+ const hasGraphTarget = Boolean(args.agent || args.workflow);
486
+ if ((effectivePrompt || hasGraphTarget)
487
+ && (daemonSpawned || process.argv.includes('--headless') || !process.stdin.isTTY || hasGraphTarget)) {
484
488
  const { runHeadless } = await import('../core/headless.mjs');
485
489
  await runHeadless({
486
490
  instruction: effectivePrompt,
@@ -490,6 +494,8 @@ async function main() {
490
494
  cacheReport: args.cacheReport,
491
495
  local: args.local,
492
496
  vision: args.vision,
497
+ agent: args.agent,
498
+ workflow: args.workflow,
493
499
  });
494
500
  return;
495
501
  }