@bahulam/code 0.1.11 → 0.1.13

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 (42) hide show
  1. package/package.json +1 -1
  2. package/src/auth/tarang-auth.mjs +313 -0
  3. package/src/commands/agent.mjs +7 -7
  4. package/src/commands/install.mjs +295 -0
  5. package/src/commands/plugin-manage.mjs +280 -88
  6. package/src/config/cli-args.mjs +16 -0
  7. package/src/config/settings-loader.mjs +15 -0
  8. package/src/core/background-tasks.mjs +186 -0
  9. package/src/core/headless.mjs +54 -3
  10. package/src/core/local-agent.mjs +10 -1
  11. package/src/core/risk-tier.mjs +1 -0
  12. package/src/core/stream-client.mjs +95 -15
  13. package/src/core/tool-executor.mjs +266 -15
  14. package/src/local-service/agent-relay.mjs +1 -1
  15. package/src/local-service/server.mjs +116 -14
  16. package/src/orchestration/approval.mjs +30 -0
  17. package/src/orchestration/completion-triggers.mjs +40 -0
  18. package/src/orchestration/dispatch.mjs +118 -0
  19. package/src/orchestration/events.mjs +19 -0
  20. package/src/orchestration/graph.mjs +126 -0
  21. package/src/orchestration/node-runner.mjs +193 -0
  22. package/src/orchestration/runner.mjs +200 -0
  23. package/src/plugins/executor.mjs +2 -2
  24. package/src/plugins/manifest.mjs +30 -27
  25. package/src/plugins/pi-compat/loader-hook.mjs +45 -0
  26. package/src/plugins/pi-compat/probe.mjs +294 -0
  27. package/src/plugins/pi-compat/scaffold.mjs +487 -0
  28. package/src/plugins/pi-compat/shim.mjs +134 -0
  29. package/src/plugins/pi-compose.mjs +147 -0
  30. package/src/plugins/preflight.mjs +35 -10
  31. package/src/plugins/registry.mjs +6 -0
  32. package/src/terminal/agents.mjs +8 -3
  33. package/src/terminal/main.mjs +39 -7
  34. package/src/terminal/paste-input.mjs +23 -0
  35. package/src/terminal/repl-render.mjs +65 -10
  36. package/src/terminal/repl-state.mjs +4 -2
  37. package/src/terminal/repl.mjs +624 -103
  38. package/src/tools/agent.mjs +6 -2
  39. package/src/tools/registry.mjs +107 -4
  40. package/src/ui/input-dock.mjs +5 -2
  41. package/src/ui/slash-commands.mjs +1 -1
  42. package/src/ui/sub-agent.mjs +14 -8
@@ -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
  }
@@ -7,6 +7,7 @@
7
7
  import fs from 'fs';
8
8
  import path from 'path';
9
9
  import { load as yamlLoad } from 'js-yaml';
10
+ import { normalizeComposes } from './pi-compose.mjs';
10
11
 
11
12
  /**
12
13
  * Parse a YAML text string into an object using js-yaml.
@@ -26,40 +27,40 @@ function normalizeToolNames(value) {
26
27
  }).filter(Boolean);
27
28
  }
28
29
 
29
- function loadAgentHandler(agentDef, pluginDir) {
30
- const handler = String(agentDef.handler || agentDef.file || '').trim();
31
- if (!handler || !pluginDir) return {};
30
+ function loadAgentFile(agentDef, pluginDir) {
31
+ const file = String(agentDef.file || agentDef.handler || '').trim();
32
+ if (!file || !pluginDir) return {};
32
33
  try {
33
- const handlerPath = path.resolve(pluginDir, handler);
34
- const raw = fs.readFileSync(handlerPath, 'utf-8');
35
- return path.extname(handlerPath).toLowerCase() === '.json'
34
+ const filePath = path.resolve(pluginDir, file);
35
+ const raw = fs.readFileSync(filePath, 'utf-8');
36
+ return path.extname(filePath).toLowerCase() === '.json'
36
37
  ? JSON.parse(raw)
37
38
  : parseYaml(raw);
38
39
  } catch (err) {
39
40
  if (process.env.DEBUG) {
40
- console.error(`Failed to load plugin agent handler ${handler}: ${err.message}`);
41
+ console.error(`Failed to load plugin agent file ${file}: ${err.message}`);
41
42
  }
42
43
  return {};
43
44
  }
44
45
  }
45
46
 
46
47
  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
48
+ const fileConfig = loadAgentFile(agentDef, pluginDir);
49
+ const metadata = fileConfig.metadata || fileConfig.meta || {};
50
+ const agent = fileConfig.agent || fileConfig.spec?.agent || {};
51
+ const fileTools = (
52
+ fileConfig.tools
53
+ || fileConfig.spec?.tools
53
54
  || agent.tools
54
55
  || []
55
56
  );
56
57
  const inlineTools = normalizeToolNames(agentDef.tools);
57
58
 
58
59
  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',
60
+ slug: agentDef.slug || metadata.slug || fileConfig.slug || metadata.name || fileConfig.name || agentDef.name || '',
61
+ name: agentDef.name || metadata.name || fileConfig.name || agentDef.slug || '',
62
+ description: agentDef.description || metadata.description || fileConfig.description || '',
63
+ role: agentDef.role || metadata.role || fileConfig.role || 'specialist',
63
64
  system_prompt: (
64
65
  agentDef.system_prompt
65
66
  || agentDef.systemPrompt
@@ -67,16 +68,16 @@ function normalizeAgentDef(agentDef, pluginName, pluginDir) {
67
68
  || agent.system_prompt
68
69
  || agent.systemPrompt
69
70
  || agent.prompt
70
- || handlerConfig.system_prompt
71
- || handlerConfig.prompt
71
+ || fileConfig.system_prompt
72
+ || fileConfig.prompt
72
73
  || ''
73
74
  ),
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 || '',
75
+ tools: inlineTools.length ? inlineTools : normalizeToolNames(fileTools),
76
+ model: agentDef.model || agent.model || fileConfig.model || null,
77
+ models: agentDef.models || agent.models || fileConfig.models || undefined,
78
+ max_tokens: agentDef.max_tokens || agent.max_tokens || fileConfig.max_tokens || undefined,
79
+ max_iterations: agentDef.max_iterations || agent.max_iterations || fileConfig.max_iterations || undefined,
80
+ file: agentDef.file || agentDef.handler || '',
80
81
  source: `plugin:${pluginName}`,
81
82
  source_scope: 'plugin',
82
83
  };
@@ -164,7 +165,7 @@ export function normalizeManifest(raw, source = '') {
164
165
  name: toolDef.name || '',
165
166
  description: toolDef.description || '',
166
167
  input_schema: toolDef.parameters || toolDef.input_schema || toolDef.inputSchema || { type: 'object', properties: {} },
167
- handler: toolDef.handler || toolDef.file || '',
168
+ tool: toolDef.tool || toolDef.file || toolDef.handler || '',
168
169
  plugin_name: name,
169
170
  };
170
171
  if (tool.name) tools.push(tool);
@@ -185,6 +186,7 @@ export function normalizeManifest(raw, source = '') {
185
186
  // Inline wins on name collision so authors can override a portable
186
187
  // config for the local plugin without editing mcp.json.
187
188
  const mcpServers = _readMcpServers(spec.mcpServers, source);
189
+ const composes = normalizeComposes(spec.composes);
188
190
 
189
191
  return {
190
192
  apiVersion,
@@ -201,6 +203,7 @@ export function normalizeManifest(raw, source = '') {
201
203
  agents,
202
204
  workspace,
203
205
  mcpServers,
206
+ composes,
204
207
  },
205
208
  source,
206
209
  _dir: source ? path.dirname(source) : '',
@@ -277,7 +280,7 @@ export function validatePluginManifest(manifest) {
277
280
  if (manifest.spec) {
278
281
  for (const tool of (manifest.spec.tools || [])) {
279
282
  if (!tool.name) errors.push('Tool missing name');
280
- if (!tool.handler) errors.push(`Tool "${tool.name || '(unnamed)'}" missing handler path`);
283
+ if (!tool.tool) errors.push(`Tool "${tool.name || '(unnamed)'}" missing tool module path (tool: ./tools/<name>.mjs)`);
281
284
  }
282
285
  for (const agent of (manifest.spec.agents || [])) {
283
286
  if (!agent.slug && !agent.name) errors.push('Agent missing slug or name');
@@ -0,0 +1,45 @@
1
+ /**
2
+ * Node ESM loader hook that intercepts `import { pi } from 'pi'` and
3
+ * resolves it to a virtual module which re-exports our shim.
4
+ *
5
+ * Registered from probe.mjs via child_process spawn with:
6
+ * node --import ./loader-hook.mjs -e '<probe script>'
7
+ *
8
+ * The virtual module source is generated at load time so it can inline
9
+ * the shim import URL (avoids brittle relative paths across cwd's).
10
+ */
11
+
12
+ import { pathToFileURL } from 'node:url';
13
+ import { register } from 'node:module';
14
+ import * as path from 'node:path';
15
+ import { fileURLToPath } from 'node:url';
16
+
17
+ const HERE = path.dirname(fileURLToPath(import.meta.url));
18
+ const SHIM_URL = pathToFileURL(path.join(HERE, 'shim.mjs')).href;
19
+ const VIRTUAL_URL = 'bahulam-pi-shim:v1';
20
+
21
+ // Register ourselves as a loader — this file is `--import`ed, and the
22
+ // register() call installs the resolve/load hooks below into a worker
23
+ // data URL. Simpler than a standalone hooks file.
24
+ register(`data:text/javascript,${encodeURIComponent(`
25
+ export function resolve(specifier, context, nextResolve) {
26
+ if (specifier === 'pi') return { shortCircuit: true, url: '${VIRTUAL_URL}' };
27
+ return nextResolve(specifier, context);
28
+ }
29
+ export function load(url, context, nextLoad) {
30
+ if (url === '${VIRTUAL_URL}') {
31
+ return {
32
+ shortCircuit: true,
33
+ format: 'module',
34
+ source: \`
35
+ import { createPiShim } from ${JSON.stringify(SHIM_URL)};
36
+ const captured = globalThis.__bahulam_pi_captured ||= { tools: [], commands: [] };
37
+ const pluginName = process.env.BAHULAM_PI_PLUGIN || 'pi';
38
+ export const pi = createPiShim({ pluginName, captured });
39
+ export default pi;
40
+ \`,
41
+ };
42
+ }
43
+ return nextLoad(url, context);
44
+ }
45
+ `)}`, import.meta.url);