@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,186 @@
1
+ /**
2
+ * BackgroundTasks — the one registry for long-running processes the agent
3
+ * starts (docker build, npm run dev, test suites). Jobs get a run id,
4
+ * a per-job timeout with SIGTERM→SIGKILL escalation, output spooled to
5
+ * .bahulam/tmp/jobs/<id>.log (with a bounded in-memory tail), completion
6
+ * listeners for wake-on-finish delivery, and best-effort cleanup of the
7
+ * whole process group when the CLI exits.
8
+ */
9
+ import { spawn } from 'node:child_process';
10
+ import * as fs from 'node:fs';
11
+ import * as path from 'node:path';
12
+
13
+ const DEFAULT_TIMEOUT_MS = 30 * 60 * 1000;
14
+ const KILL_ESCALATION_MS = 5000;
15
+ const MAX_TAIL_BYTES = 64 * 1024;
16
+
17
+ function stripAnsi(str) {
18
+ // eslint-disable-next-line no-control-regex
19
+ return String(str || '').replace(/\x1b\[[0-9;]*[a-zA-Z]/g, '');
20
+ }
21
+
22
+ class BackgroundTasks {
23
+ constructor() {
24
+ this.jobs = new Map();
25
+ this._seq = 0;
26
+ this._listeners = new Set();
27
+ this._exitHookInstalled = false;
28
+ }
29
+
30
+ onExit(listener) {
31
+ this._listeners.add(listener);
32
+ return () => this._listeners.delete(listener);
33
+ }
34
+
35
+ start({ command, cwd = process.cwd(), timeoutMs = DEFAULT_TIMEOUT_MS, name = '', on_complete = null }) {
36
+ this._installExitHook();
37
+ const id = `job-${++this._seq}-${Date.now().toString(36)}`;
38
+ const logDir = path.join(cwd, '.bahulam', 'tmp', 'jobs');
39
+ fs.mkdirSync(logDir, { recursive: true });
40
+ const logPath = path.join(logDir, `${id}.log`);
41
+ const logStream = fs.createWriteStream(logPath);
42
+
43
+ const proc = spawn('bash', ['-c', command], {
44
+ cwd,
45
+ env: { ...process.env },
46
+ stdio: ['ignore', 'pipe', 'pipe'],
47
+ detached: process.platform !== 'win32',
48
+ });
49
+
50
+ const job = {
51
+ id,
52
+ name: name || command.slice(0, 60),
53
+ command,
54
+ cwd,
55
+ pid: proc.pid,
56
+ status: 'running',
57
+ exit_code: null,
58
+ started_at: Date.now(),
59
+ ended_at: null,
60
+ log_path: logPath,
61
+ tail: '',
62
+ timed_out: false,
63
+ on_complete,
64
+ _proc: proc,
65
+ _done: null,
66
+ };
67
+
68
+ const appendTail = (chunk) => {
69
+ const next = job.tail + chunk.toString();
70
+ job.tail = next.length > MAX_TAIL_BYTES ? next.slice(next.length - MAX_TAIL_BYTES) : next;
71
+ };
72
+ proc.stdout.on('data', (d) => { logStream.write(d); appendTail(d); });
73
+ proc.stderr.on('data', (d) => { logStream.write(d); appendTail(d); });
74
+
75
+ let killTimer = null;
76
+ const timer = timeoutMs > 0 ? setTimeout(() => {
77
+ job.timed_out = true;
78
+ this._kill(job, 'SIGTERM');
79
+ killTimer = setTimeout(() => this._kill(job, 'SIGKILL'), KILL_ESCALATION_MS);
80
+ }, timeoutMs) : null;
81
+ if (timer?.unref) timer.unref();
82
+
83
+ job._done = new Promise((resolve) => {
84
+ proc.on('close', (code) => {
85
+ clearTimeout(timer);
86
+ clearTimeout(killTimer);
87
+ job.exit_code = code;
88
+ job.ended_at = Date.now();
89
+ job.status = job.timed_out ? 'timeout'
90
+ : job.status === 'killed' ? 'killed'
91
+ : code === 0 ? 'completed' : 'failed';
92
+ job.tail = stripAnsi(job.tail);
93
+ logStream.end();
94
+ for (const listener of this._listeners) {
95
+ try { listener(this.describe(job.id)); } catch { /* listeners are best-effort */ }
96
+ }
97
+ resolve(this.describe(job.id));
98
+ });
99
+ proc.on('error', (err) => {
100
+ job.status = 'failed';
101
+ job.tail = `${job.tail}\n${err.message}`.trim();
102
+ job.ended_at = Date.now();
103
+ logStream.end();
104
+ resolve(this.describe(job.id));
105
+ });
106
+ });
107
+
108
+ proc.unref();
109
+ this.jobs.set(id, job);
110
+ return this.describe(id);
111
+ }
112
+
113
+ /** Await a job's completion; resolves with its final description. */
114
+ wait(id) {
115
+ const job = this.jobs.get(id);
116
+ if (!job) return Promise.resolve(null);
117
+ if (job.status !== 'running') return Promise.resolve(this.describe(id));
118
+ // Background jobs are unref'd so fire-and-forget tasks do not pin the CLI
119
+ // open. When a caller explicitly awaits wait(id), temporarily ref the
120
+ // process so fast commands still get their close event before Node decides
121
+ // the top-level await is unsettled.
122
+ try { job._proc?.ref?.(); } catch { /* best effort */ }
123
+ return job._done.finally(() => {
124
+ try { job._proc?.unref?.(); } catch { /* best effort */ }
125
+ });
126
+ }
127
+
128
+ describe(id) {
129
+ const job = this.jobs.get(id);
130
+ if (!job) return null;
131
+ return {
132
+ id: job.id,
133
+ name: job.name,
134
+ command: job.command,
135
+ pid: job.pid,
136
+ status: job.status,
137
+ exit_code: job.exit_code,
138
+ duration_s: Math.round(((job.ended_at || Date.now()) - job.started_at) / 1000),
139
+ log_path: job.log_path,
140
+ tail: job.tail,
141
+ timed_out: job.timed_out,
142
+ on_complete: job.on_complete || null,
143
+ };
144
+ }
145
+
146
+ list() {
147
+ return [...this.jobs.keys()].map(id => {
148
+ const d = this.describe(id);
149
+ return { ...d, tail: undefined };
150
+ });
151
+ }
152
+
153
+ kill(id) {
154
+ const job = this.jobs.get(id);
155
+ if (!job) return null;
156
+ if (job.status === 'running') {
157
+ job.status = 'killed';
158
+ this._kill(job, 'SIGTERM');
159
+ setTimeout(() => this._kill(job, 'SIGKILL'), KILL_ESCALATION_MS)?.unref?.();
160
+ }
161
+ return this.describe(id);
162
+ }
163
+
164
+ _kill(job, signal) {
165
+ if (!job?._proc?.pid) return;
166
+ try {
167
+ if (process.platform !== 'win32') {
168
+ process.kill(-job._proc.pid, signal);
169
+ return;
170
+ }
171
+ } catch { /* fall through */ }
172
+ try { job._proc.kill(signal); } catch { /* already exited */ }
173
+ }
174
+
175
+ _installExitHook() {
176
+ if (this._exitHookInstalled) return;
177
+ this._exitHookInstalled = true;
178
+ process.on('exit', () => {
179
+ for (const job of this.jobs.values()) {
180
+ if (job.status === 'running') this._kill(job, 'SIGKILL');
181
+ }
182
+ });
183
+ }
184
+ }
185
+
186
+ export const backgroundTasks = new BackgroundTasks();
@@ -47,7 +47,7 @@ import {
47
47
  * @param {number} [opts.maxCost] - abort if cost exceeds this USD amount
48
48
  * @param {boolean} [opts.verbose] - show progress on stderr
49
49
  */
50
- export async function runHeadless({ instruction, model, timeout = 300, maxCost, verbose = false, cacheReport = null, local = false, vision = [] }) {
50
+ export async function runHeadless({ instruction, model, timeout = 300, maxCost, verbose = false, cacheReport = null, local = false, vision = [], agent = null, workflow = null }) {
51
51
  const startTime = Date.now();
52
52
 
53
53
  const log = (msg) => {
@@ -61,12 +61,63 @@ export async function runHeadless({ instruction, model, timeout = 300, maxCost,
61
61
  // ── Auth ──
62
62
  const auth = new BahulamAuth();
63
63
  const creds = auth.loadCredentials();
64
- if (!creds.token) {
64
+ const graphTarget = agent || workflow;
65
+ const anthKey = process.env.ANTHROPIC_API_KEY || creds.anthropicKey;
66
+ const orKey = process.env.OPENROUTER_API_KEY || creds.openRouterKey;
67
+ // Graph runs execute locally and only need a model key; everything
68
+ // else still requires login (the backend runs the agent loop).
69
+ if (!creds.token && !(graphTarget && (anthKey || orKey))) {
65
70
  emit({ type: 'error', error: 'Not logged in. Run: bahulam login' });
66
71
  process.exit(1);
67
72
  }
68
73
 
69
- // Scan plugins so client_tools and client_agents are sent to the backend.
74
+ // ── Deterministic graph target: --agent <slug> / --workflow <name> ──
75
+ if (graphTarget) {
76
+ const { dispatch } = await import('../orchestration/dispatch.mjs');
77
+ const { listLocalWorkflows } = await import('../agents/workflow_scaffold.mjs');
78
+ const pluginRegistry = new PluginRegistry().scan();
79
+ const toolExecutor = createToolExecutor({ pluginRegistry });
80
+ const timer = setTimeout(() => {
81
+ emit({ type: 'timeout', duration_s: timeout });
82
+ process.exit(2);
83
+ }, timeout * 1000);
84
+
85
+ const outcome = await dispatch({
86
+ type: 'invoke',
87
+ source: 'cli:headless',
88
+ target: agent ? { kind: 'agent', slug: agent } : { kind: 'workflow', slug: workflow },
89
+ params: { instruction: instruction || '' },
90
+ channel: null,
91
+ substrate: 'direct',
92
+ }, {
93
+ toolExecutor,
94
+ listRunnables: () => toolExecutor.listRunnables(),
95
+ listLocalWorkflows: () => listLocalWorkflows(process.cwd()),
96
+ renderEvent: (event) => emit({ type: event.type, ...event.data }),
97
+ credentials: { apiKey: anthKey, openRouterKey: orKey },
98
+ defaultModel: model || null,
99
+ cwd: process.cwd(),
100
+ });
101
+
102
+ clearTimeout(timer);
103
+ if (!outcome.dispatched) {
104
+ emit({ type: 'error', error: outcome.reason });
105
+ process.exit(1);
106
+ }
107
+ const result = outcome.result || {};
108
+ emit({
109
+ type: 'result',
110
+ status: result.status || (result.success === false ? 'failed' : 'completed'),
111
+ channel: outcome.channel,
112
+ output: result.output || '',
113
+ node_results: result.node_results || undefined,
114
+ duration_s: Math.round((Date.now() - startTime) / 1000),
115
+ });
116
+ process.exit(result.status === 'failed' || result.success === false ? 1 : 0);
117
+ }
118
+
119
+ // Scan plugins so client_agents and agent-scoped plugin tool schemas
120
+ // are sent to the backend.
70
121
  const pluginRegistry = new PluginRegistry().scan();
71
122
 
72
123
  // Projects are registered and indexed only when the agent requests an overview.
@@ -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
  };