@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
@@ -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;
@@ -777,12 +850,59 @@ export function createToolExecutor({
777
850
  const name = String(toolDef.name || '').trim();
778
851
  if (!name || toolMap[name]) continue;
779
852
  const pluginName = toolDef._plugin_name || toolDef.plugin_name || null;
853
+ if (toolDef._composed?.kind === 'pi') {
854
+ // Composed pi tools resolve at invocation time: look up the
855
+ // installed pi package's directory, load the specific handler
856
+ // via the shim-backed probe, invoke, return the result. Handler
857
+ // cache is per-session (per tool name) to amortize the ~50ms
858
+ // child-process overhead on repeat calls.
859
+ let _piInvokeP = null;
860
+ registerPluginTool(name, async (args, options = {}) => {
861
+ try {
862
+ if (!_piInvokeP) {
863
+ const { loadPiToolHandler } = await import('../plugins/pi-compat/probe.mjs');
864
+ const packageName = toolDef._composed.package_name;
865
+ const originalName = toolDef._composed.original_name;
866
+ const { bahulamHome } = await import('./paths.mjs');
867
+ const piBaseDir = path.join(bahulamHome(), 'plugins-pi');
868
+ const piDir = path.join(piBaseDir, packageName.replace(/[/@]/g, '_'));
869
+ if (!fs.existsSync(piDir)) {
870
+ return {
871
+ success: false,
872
+ output: `Composed pi tool '${name}' unavailable: pi package ${packageName} is not installed. Run \`bahulam plugin install pi:${packageName}\`.`,
873
+ _tool: name,
874
+ _plugin: pluginName,
875
+ _composed: toolDef._composed,
876
+ };
877
+ }
878
+ _piInvokeP = loadPiToolHandler(piDir, originalName, { pluginName: packageName });
879
+ }
880
+ const invoke = await _piInvokeP;
881
+ const result = await invoke(args || {});
882
+ return {
883
+ ...(result && typeof result === 'object' ? result : { success: true, output: String(result) }),
884
+ _tool: name,
885
+ _plugin: pluginName,
886
+ _composed: toolDef._composed,
887
+ };
888
+ } catch (err) {
889
+ return {
890
+ success: false,
891
+ output: `Composed pi tool '${name}' failed: ${err.message}`,
892
+ _tool: name,
893
+ _plugin: pluginName,
894
+ _composed: toolDef._composed,
895
+ };
896
+ }
897
+ }, { pluginName, source: 'pi', composed: toolDef._composed });
898
+ continue;
899
+ }
780
900
  registerPluginTool(name, async (args, options = {}) => {
781
- const handler = await loadPluginTool(toolDef._plugin_dir, toolDef.handler);
901
+ const handler = await loadPluginTool(toolDef._plugin_dir, toolDef.tool);
782
902
  if (!handler) {
783
903
  return {
784
904
  success: false,
785
- output: `Plugin tool handler could not be loaded: ${name}`,
905
+ output: `Plugin tool module could not be loaded: ${name}`,
786
906
  _tool: name,
787
907
  _plugin: pluginName,
788
908
  };
@@ -826,15 +946,57 @@ export function createToolExecutor({
826
946
  _plugin: pluginName,
827
947
  };
828
948
  }
829
- });
949
+ }, { pluginName, source: 'plugin' });
830
950
  }
831
951
  }
832
952
 
953
+ function pluginAgentForTool(toolName, pluginName) {
954
+ if (!pluginRegistry) return null;
955
+ return (pluginRegistry.listAgents?.() || []).find(agent => {
956
+ const agentPlugin = agent._plugin_name
957
+ || String(agent.source || '').replace(/^plugin:/, '')
958
+ || null;
959
+ if (pluginName && agentPlugin && agentPlugin !== pluginName) return false;
960
+ return Array.isArray(agent.tools) && agent.tools.includes(toolName);
961
+ }) || null;
962
+ }
963
+
964
+ function primaryModelPluginToolBlock(name, handler, options = {}) {
965
+ if (!handler?._pluginTool) return null;
966
+ if (options.toolCallSource !== 'model') return null;
967
+ if (options.internal || options.subAgent || options.allowPrimaryPluginToolCall) return null;
968
+
969
+ const pluginName = handler._pluginTool.pluginName || 'plugin';
970
+ // Tools-only plugins have no delegation owner: with no agent to
971
+ // route through, the primary agent uses the tools directly (the
972
+ // user consented by enabling the plugin). The delegate-only rule
973
+ // applies only when the plugin ships an owning agent.
974
+ const pluginShipsAgents = (pluginRegistry?.listAgents?.() || [])
975
+ .some(agent => (agent._plugin_name || '') === pluginName);
976
+ if (!pluginShipsAgents) return null;
977
+
978
+ const agent = pluginAgentForTool(name, pluginName);
979
+ const delegateHint = agent?.slug
980
+ ? `Delegate to the '${agent.slug}' sub-agent instead, or run it explicitly with /run ${agent.slug} "...".`
981
+ : `Delegate to the plugin's sub-agent instead, or run the plugin agent explicitly.`;
982
+ return {
983
+ success: false,
984
+ output: `Plugin tool '${name}' is scoped to plugin '${pluginName}' and should not be called directly by the primary agent. ${delegateHint}`,
985
+ _tool: name,
986
+ _plugin: pluginName,
987
+ _blocked: true,
988
+ _requires_agent_delegation: true,
989
+ _agent: agent?.slug || null,
990
+ };
991
+ }
992
+
833
993
  async function executeToolWithHooks(name, args, options = {}) {
834
994
  const handler = toolMap[name] || pluginToolMap.get(name);
835
995
  if (!handler) {
836
996
  return { success: false, output: `Unknown tool: ${name}`, _tool: name };
837
997
  }
998
+ const pluginToolBlock = primaryModelPluginToolBlock(name, handler, options);
999
+ if (pluginToolBlock) return pluginToolBlock;
838
1000
  const hooks = hookRunner || new HookRunner({ cwd: process.cwd() });
839
1001
  try {
840
1002
  throwIfAborted(options.signal);
@@ -1188,6 +1350,30 @@ export function createToolExecutor({
1188
1350
  args._classification = classification.classification; // 'safe' or 'contained'
1189
1351
  const cwd = await commandCwd(args);
1190
1352
 
1353
+ // Background execution: start via the BackgroundTasks registry
1354
+ // and return immediately. Safety checks above still apply;
1355
+ // results are retrieved with job_output / killed with job_kill.
1356
+ if (args.run_in_background) {
1357
+ const job = backgroundTasks.start({
1358
+ command: args.command,
1359
+ cwd,
1360
+ timeoutMs: args.timeout ? Math.min(Number(args.timeout), 3_600_000) : undefined,
1361
+ // Deterministic wake-on-finish: completion dispatches the
1362
+ // named agent through the trigger funnel (chain-guarded).
1363
+ on_complete: args.on_complete_agent ? {
1364
+ target: `agent:${String(args.on_complete_agent).trim()}`,
1365
+ instruction: args.on_complete_instruction || null,
1366
+ } : null,
1367
+ });
1368
+ return {
1369
+ success: true,
1370
+ output: `Background job started: ${job.id} (pid ${job.pid}). `
1371
+ + `Check progress with job_output {"job_id": "${job.id}"}; stop with job_kill.`,
1372
+ job_id: job.id,
1373
+ _tool: 'shell',
1374
+ };
1375
+ }
1376
+
1191
1377
  // Pre-check: if command is rm/unlink, verify targets exist first
1192
1378
  const rmMatch = (args.command || '').match(/^rm\s+(?:-\w+\s+)*(.+)$/);
1193
1379
  if (rmMatch) {
@@ -2118,9 +2304,47 @@ export function createToolExecutor({
2118
2304
  },
2119
2305
 
2120
2306
  // User-defined agents — metadata first, project YAML + backend sync on demand.
2307
+ job_output: async (args = {}) => {
2308
+ const jobId = String(args.job_id || '').trim();
2309
+ if (!jobId) {
2310
+ const jobs = backgroundTasks.list();
2311
+ return {
2312
+ success: true,
2313
+ output: jobs.length
2314
+ ? JSON.stringify({ jobs }, null, 2)
2315
+ : 'No background jobs in this session.',
2316
+ jobs,
2317
+ _tool: 'job_output',
2318
+ };
2319
+ }
2320
+ const job = args.block
2321
+ ? await backgroundTasks.wait(jobId)
2322
+ : backgroundTasks.describe(jobId);
2323
+ if (!job) return { success: false, output: `Unknown job: ${jobId}`, _tool: 'job_output' };
2324
+ const tailLines = Number(args.tail_lines) || 80;
2325
+ const tail = String(job.tail || '').split('\n').slice(-tailLines).join('\n');
2326
+ return {
2327
+ success: true,
2328
+ output: `${job.id} · ${job.status}`
2329
+ + (job.exit_code != null ? ` (exit ${job.exit_code})` : '')
2330
+ + ` · ${job.duration_s}s\n${tail}`,
2331
+ job: { ...job, tail: undefined },
2332
+ _tool: 'job_output',
2333
+ };
2334
+ },
2335
+
2336
+ job_kill: async (args = {}) => {
2337
+ const job = backgroundTasks.kill(String(args.job_id || '').trim());
2338
+ if (!job) return { success: false, output: `Unknown job: ${args.job_id}`, _tool: 'job_kill' };
2339
+ return { success: true, output: `${job.id} → ${job.status}`, job, _tool: 'job_kill' };
2340
+ },
2341
+
2121
2342
  agents_list: async (args = {}) => {
2122
2343
  const agents = filterLocalAgents(args).map(compactAgentMetadata);
2123
2344
  const payload = { agents, count: agents.length };
2345
+ if (agents.some(agent => agent.runnable === false)) {
2346
+ 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.';
2347
+ }
2124
2348
  return {
2125
2349
  success: true,
2126
2350
  output: JSON.stringify(payload, null, 2),
@@ -2151,7 +2375,8 @@ export function createToolExecutor({
2151
2375
  : null,
2152
2376
  next_actions: [
2153
2377
  `Edit ${result.filePath}`,
2154
- `Run /agents sync ${result.slug} when ready`,
2378
+ `Run /run ${result.slug} "<task>" or delegate to it from chat immediately`,
2379
+ `Optional: /agents sync ${result.slug} to publish it to the backend for account/cloud reuse`,
2155
2380
  ],
2156
2381
  };
2157
2382
  return {
@@ -2580,6 +2805,21 @@ export function createToolExecutor({
2580
2805
  return results;
2581
2806
  },
2582
2807
 
2808
+ listRunnables,
2809
+
2810
+ // Plugin tool schemas (name/description/input_schema) for callers
2811
+ // that compose model-facing tool lists — e.g. the graph engine's
2812
+ // direct substrate giving a plugin agent its declared tools.
2813
+ listPluginToolSchemas() {
2814
+ if (!pluginRegistry) return [];
2815
+ return (pluginRegistry.listTools?.() || []).map(tool => ({
2816
+ name: tool.name,
2817
+ description: tool.description || '',
2818
+ input_schema: tool.input_schema || { type: 'object', properties: {} },
2819
+ plugin_name: tool._plugin_name || tool.plugin_name || null,
2820
+ })).filter(tool => tool.name);
2821
+ },
2822
+
2583
2823
  getAgentContext() {
2584
2824
  const global = projectRegistry.getGlobalContext();
2585
2825
  const mem = _readMemorySnapshot();
@@ -2609,6 +2849,17 @@ export function createToolExecutor({
2609
2849
  source: agent.source,
2610
2850
  spec: agent.spec,
2611
2851
  })),
2852
+ // Background jobs the model should know about. Stable fields
2853
+ // only (no durations) so the entry — and the prompt cache —
2854
+ // changes on status transitions, not every turn.
2855
+ ...(backgroundTasks.list().length ? {
2856
+ background_jobs: backgroundTasks.list().map(job => ({
2857
+ id: job.id,
2858
+ name: job.name,
2859
+ status: job.status,
2860
+ exit_code: job.exit_code,
2861
+ })),
2862
+ } : {}),
2612
2863
  available_workflows: listLocalWorkflows(process.cwd()).map(workflow => ({
2613
2864
  slug: workflow.slug,
2614
2865
  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