@bahulam/code 0.1.10 → 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.
Files changed (52) hide show
  1. package/package.json +5 -2
  2. package/src/agents/loader.mjs +26 -4
  3. package/src/auth/bahulam-auth.mjs +2 -13
  4. package/src/auth/tarang-auth.mjs +313 -0
  5. package/src/commands/agent.mjs +7 -7
  6. package/src/commands/plugin-manage.mjs +449 -0
  7. package/src/commands/plugin.mjs +247 -0
  8. package/src/config/cli-args.mjs +16 -0
  9. package/src/config/env.mjs +2 -0
  10. package/src/config/hook-runner.mjs +8 -8
  11. package/src/config/memory-loader.mjs +7 -3
  12. package/src/config/settings-loader.mjs +5 -3
  13. package/src/core/attachments.mjs +2 -2
  14. package/src/core/background-tasks.mjs +186 -0
  15. package/src/core/headless.mjs +59 -3
  16. package/src/core/local-agent.mjs +10 -1
  17. package/src/core/local-store.mjs +10 -10
  18. package/src/core/paths.mjs +10 -96
  19. package/src/core/policy-resolver.mjs +1 -1
  20. package/src/core/project-context-loader.mjs +2 -2
  21. package/src/core/risk-tier.mjs +1 -0
  22. package/src/core/stream-client.mjs +148 -1
  23. package/src/core/system-prompt.mjs +31 -12
  24. package/src/core/tool-executor.mjs +457 -12
  25. package/src/local-service/agent-relay.mjs +139 -12
  26. package/src/local-service/server.mjs +345 -20
  27. package/src/orchestration/approval.mjs +30 -0
  28. package/src/orchestration/completion-triggers.mjs +40 -0
  29. package/src/orchestration/dispatch.mjs +118 -0
  30. package/src/orchestration/events.mjs +19 -0
  31. package/src/orchestration/graph.mjs +126 -0
  32. package/src/orchestration/node-runner.mjs +193 -0
  33. package/src/orchestration/runner.mjs +200 -0
  34. package/src/plugins/executor.mjs +121 -0
  35. package/src/plugins/loader.mjs +123 -123
  36. package/src/plugins/manifest.mjs +291 -0
  37. package/src/plugins/preflight.mjs +227 -0
  38. package/src/plugins/registry.mjs +233 -0
  39. package/src/plugins/state.mjs +290 -0
  40. package/src/terminal/agents.mjs +26 -4
  41. package/src/terminal/init.mjs +2 -2
  42. package/src/terminal/main.mjs +83 -4
  43. package/src/terminal/repl-explore.mjs +1 -1
  44. package/src/terminal/repl-render.mjs +67 -12
  45. package/src/terminal/repl-state.mjs +4 -2
  46. package/src/terminal/repl.mjs +621 -99
  47. package/src/tools/agent.mjs +6 -2
  48. package/src/tools/analyze-image.mjs +1 -1
  49. package/src/tools/project-overview.mjs +7 -7
  50. package/src/tools/registry.mjs +88 -4
  51. package/src/ui/slash-commands.mjs +19 -1
  52. package/src/ui/sub-agent.mjs +14 -8
@@ -17,6 +17,7 @@ import { buildWorkScope, promptProjectRoots } from './work-scope.mjs';
17
17
  import { persistProjectArtifacts } from './project-artifacts.mjs';
18
18
  import { BahulamAuth } from '../auth/bahulam-auth.mjs';
19
19
  import { ApprovalManager } from './approval.mjs';
20
+ import { PluginRegistry } from '../plugins/registry.mjs';
20
21
  // daemon wiring — headless (and `bahulam daemonize`) also starts the socket
21
22
  // server + relay bridge when eventlog is enabled. Without this the daemon
22
23
  // is invisible to attach clients and to paired mobile devices.
@@ -46,7 +47,7 @@ import {
46
47
  * @param {number} [opts.maxCost] - abort if cost exceeds this USD amount
47
48
  * @param {boolean} [opts.verbose] - show progress on stderr
48
49
  */
49
- 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 }) {
50
51
  const startTime = Date.now();
51
52
 
52
53
  const log = (msg) => {
@@ -60,13 +61,67 @@ export async function runHeadless({ instruction, model, timeout = 300, maxCost,
60
61
  // ── Auth ──
61
62
  const auth = new BahulamAuth();
62
63
  const creds = auth.loadCredentials();
63
- 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))) {
64
70
  emit({ type: 'error', error: 'Not logged in. Run: bahulam login' });
65
71
  process.exit(1);
66
72
  }
67
73
 
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.
121
+ const pluginRegistry = new PluginRegistry().scan();
122
+
68
123
  // Projects are registered and indexed only when the agent requests an overview.
69
- const toolExecutor = createToolExecutor();
124
+ const toolExecutor = createToolExecutor({ pluginRegistry });
70
125
 
71
126
  // Auto-approve everything — no prompts
72
127
  const approval = new ApprovalManager({ autoApprove: true });
@@ -104,6 +159,7 @@ export async function runHeadless({ instruction, model, timeout = 300, maxCost,
104
159
  token: creds.token,
105
160
  toolExecutor,
106
161
  approvalManager: approval,
162
+ pluginRegistry,
107
163
  });
108
164
  }
109
165
 
@@ -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) {
@@ -10,9 +10,9 @@ import * as path from 'node:path';
10
10
  import * as readline from 'node:readline';
11
11
  import { bahulamHome } from './paths.mjs';
12
12
 
13
- const KEPLER_DIR = bahulamHome();
14
- const PROJECTS_DIR = path.join(KEPLER_DIR, 'projects');
15
- const REPLAY_EVENT_RECORD_TYPES = new Set(['bahulam_event', 'kepler_event']);
13
+ const BAHULAM_DIR = bahulamHome();
14
+ const PROJECTS_DIR = path.join(BAHULAM_DIR, 'projects');
15
+ const REPLAY_EVENT_RECORD_TYPE = 'bahulam_event';
16
16
 
17
17
  function finiteNumber(value) {
18
18
  const n = Number(value);
@@ -28,7 +28,7 @@ function firstFiniteNumber(...values) {
28
28
  }
29
29
 
30
30
  function replayEventFromRecord(record) {
31
- if (!record || !REPLAY_EVENT_RECORD_TYPES.has(record.type) || !record.event) return null;
31
+ if (!record || record.type !== REPLAY_EVENT_RECORD_TYPE || !record.event) return null;
32
32
  const event = record.event;
33
33
  if (!event || typeof event !== 'object' || !event.type) return null;
34
34
  return {
@@ -334,8 +334,7 @@ async function parseSessionMeta(filePath) {
334
334
  if (!meta.endTime || ts > meta.endTime) meta.endTime = ts;
335
335
  }
336
336
 
337
- // Bahulam replay events may carry cost / error markers. Older local
338
- // transcripts used the same payload under the legacy kepler_event type.
337
+ // Bahulam replay events may carry cost / error markers.
339
338
  const ev = replayEventFromRecord(obj);
340
339
  if (ev) {
341
340
  const data = ev.data || {};
@@ -518,7 +517,8 @@ export async function getSessionDetail(sessionId, options = {}) {
518
517
  continue;
519
518
  }
520
519
 
521
- const message = obj.message || {};
520
+ if (!obj.message || typeof obj.message !== 'object') continue;
521
+ const message = obj.message;
522
522
  entries.push({
523
523
  order: entryOrder,
524
524
  type: obj.type || null,
@@ -965,7 +965,7 @@ export async function getModelBreakdown(days = 30) {
965
965
  * @param {number} n — max entries to return (most recent first)
966
966
  */
967
967
  export function getHistory(n = 50) {
968
- const historyPath = path.join(KEPLER_DIR, 'history.jsonl');
968
+ const historyPath = path.join(BAHULAM_DIR, 'history.jsonl');
969
969
  try {
970
970
  const content = fs.readFileSync(historyPath, 'utf-8');
971
971
  const lines = content.trim().split('\n').filter(Boolean);
@@ -981,8 +981,8 @@ export function getHistory(n = 50) {
981
981
 
982
982
  export function getStorePaths() {
983
983
  return {
984
- bahulamDir: KEPLER_DIR,
984
+ bahulamDir: BAHULAM_DIR,
985
985
  projectsDir: PROJECTS_DIR,
986
- historyPath: path.join(KEPLER_DIR, 'history.jsonl'),
986
+ historyPath: path.join(BAHULAM_DIR, 'history.jsonl'),
987
987
  };
988
988
  }
@@ -16,15 +16,8 @@
16
16
  * hooks.json — project-specific hooks
17
17
  * projects.json — slug → project path mapping
18
18
  *
19
- * ── Legacy fallback ─────────────────────────────────────────────────────
20
- * Pre-rename installs stored everything under ~/.kepler/. The resolver below
21
- * prefers the new path but falls back to the legacy directory when it
22
- * exists and the new one doesn't, so existing users keep their config,
23
- * agents, workflows, and history until they explicitly migrate.
24
- *
25
19
  * Env vars:
26
- * BAHULAM_HOME preferred; explicit override for ~/.bahulam
27
- * KEPLER_HOME legacy; still honored for backward compat
20
+ * BAHULAM_HOME explicit override for ~/.bahulam
28
21
  */
29
22
 
30
23
  import * as fs from 'node:fs';
@@ -32,58 +25,14 @@ import * as path from 'node:path';
32
25
  import * as os from 'node:os';
33
26
  import * as crypto from 'node:crypto';
34
27
 
35
- const NEW_HOME_NAME = '.bahulam';
36
- const LEGACY_HOME_NAME = '.kepler';
37
-
38
- let _legacyNoticeShown = false;
39
-
40
28
  /**
41
29
  * Resolve the CLI home directory. Priority:
42
- * 1. $BAHULAM_HOME (explicit new)
43
- * 2. $KEPLER_HOME (explicit legacy prints a one-time deprecation notice)
44
- * 3. ~/.bahulam (if it exists)
45
- * 4. ~/.kepler (if it exists — prints a one-time migration hint)
46
- * 5. ~/.bahulam (fresh install, will be created on first write)
30
+ * 1. $BAHULAM_HOME (explicit override)
31
+ * 2. ~/.bahulam (standard location, created on first write)
47
32
  */
48
33
  function resolveHome() {
49
34
  if (process.env.BAHULAM_HOME) return process.env.BAHULAM_HOME;
50
- if (process.env.KEPLER_HOME) {
51
- maybeNoticeLegacyEnv();
52
- return process.env.KEPLER_HOME;
53
- }
54
- const home = os.homedir();
55
- const newPath = path.join(home, NEW_HOME_NAME);
56
- const legacyPath = path.join(home, LEGACY_HOME_NAME);
57
- try {
58
- if (fs.existsSync(newPath)) return newPath;
59
- } catch {}
60
- try {
61
- if (fs.existsSync(legacyPath)) {
62
- maybeNoticeLegacyDir(legacyPath, newPath);
63
- return legacyPath;
64
- }
65
- } catch {}
66
- return newPath;
67
- }
68
-
69
- function maybeNoticeLegacyEnv() {
70
- if (_legacyNoticeShown || process.env.B0_QUIET_MIGRATION === '1') return;
71
- _legacyNoticeShown = true;
72
- try {
73
- process.stderr.write(
74
- ' \x1b[2mnote: KEPLER_HOME is deprecated; set BAHULAM_HOME instead.\x1b[0m\n'
75
- );
76
- } catch {}
77
- }
78
-
79
- function maybeNoticeLegacyDir(legacyPath, newPath) {
80
- if (_legacyNoticeShown || process.env.B0_QUIET_MIGRATION === '1') return;
81
- _legacyNoticeShown = true;
82
- try {
83
- process.stderr.write(
84
- ` \x1b[2mnote: reading legacy ${legacyPath}. Move to ${newPath} when convenient (silence with B0_QUIET_MIGRATION=1).\x1b[0m\n`
85
- );
86
- } catch {}
35
+ return path.join(os.homedir(), '.bahulam');
87
36
  }
88
37
 
89
38
  /**
@@ -91,7 +40,6 @@ function maybeNoticeLegacyDir(legacyPath, newPath) {
91
40
  * Uses first 16 chars of SHA-256 (same as Claude Code).
92
41
  */
93
42
  export function projectHash(projectDir) {
94
- // Resolve symlinks (macOS: /tmp → /private/tmp) so the hash is stable
95
43
  let resolved = projectDir;
96
44
  try {
97
45
  resolved = fs.realpathSync(projectDir);
@@ -104,14 +52,11 @@ export function projectHash(projectDir) {
104
52
  .slice(0, 16);
105
53
  }
106
54
 
107
- /** Root ~/.bahulam/ directory (or legacy ~/.kepler/ if that's what's present). */
55
+ /** Root ~/.bahulam/ directory. */
108
56
  export function bahulamHome() {
109
57
  return resolveHome();
110
58
  }
111
59
 
112
- /** Backward-compat alias. Prefer `bahulamHome()` in new code. */
113
- export const keplerHome = bahulamHome;
114
-
115
60
  /** ~/.bahulam/projects/{hash}/ for a given project path. */
116
61
  export function projectDir(projectPath) {
117
62
  return path.join(bahulamHome(), 'projects', projectHash(projectPath));
@@ -163,20 +108,6 @@ export function historyPath() {
163
108
  }
164
109
 
165
110
  // ── daemon session paths ─────────────────────────────────────
166
- //
167
- // Daemon-owned sessions (bahulamd, detach/attach) live at:
168
- // ~/.bahulam/sessions/<sess_id>/ per-session dir
169
- // meta.json cwd, model, opened_at, ...
170
- // events.jsonl (+ events-1.jsonl, ...) append-only event log
171
- // snapshot-<seq>.json periodic compacted snapshot
172
- // approvals/ pending + decided approvals
173
- // input-lock.json who holds input right now
174
- // daemon.pid pid of the owning daemon
175
- // ~/.bahulam/sockets/<sess_id>.sock Unix socket (0600)
176
- //
177
- // These are DIFFERENT from the projects/<hash>/sessions/ archive above.
178
- // The archive is a historical index keyed on project path; daemon sessions
179
- // are keyed on session id and are the live source of truth while running.
180
111
 
181
112
  /** ~/.bahulam/sessions/ — root for daemon-owned sessions. */
182
113
  export function daemonSessionsRoot() {
@@ -188,7 +119,7 @@ export function daemonSessionDir(sessionId) {
188
119
  return path.join(daemonSessionsRoot(), sessionId);
189
120
  }
190
121
 
191
- /** ~/.bahulam/sockets/ — root for daemon Unix sockets (Phase 1). */
122
+ /** ~/.bahulam/sockets/ — root for daemon Unix sockets. */
192
123
  export function daemonSocketsDir() {
193
124
  return path.join(bahulamHome(), 'sockets');
194
125
  }
@@ -198,29 +129,12 @@ export function daemonSocketPath(sessionId) {
198
129
  return path.join(daemonSocketsDir(), `${sessionId}.sock`);
199
130
  }
200
131
 
201
- // ── Project-local config directory (.bahulam/ next to CLAUDE.md/etc) ────
202
- //
203
- // Project-scoped stuff (agents/*.yaml, memory/*.md, hooks/, settings.json,
204
- // tasks/) used to live in .kepler/ inside the project. Same resolver logic
205
- // applies — prefer .bahulam/, fall back to .kepler/ when only the legacy
206
- // dir exists.
207
-
208
- const PROJECT_NEW_NAME = '.bahulam';
209
- const PROJECT_LEGACY_NAME = '.kepler';
132
+ // ── Project-local config directory (.bahulam/ inside the project) ────
210
133
 
211
134
  /**
212
- * Resolve the project-local config directory for `cwd`. Same priority as
213
- * the home resolver. Returns an absolute path; the directory may not
214
- * exist yet (callers that write should mkdir -p first).
135
+ * Resolve the project-local config directory for `cwd`.
136
+ * Returns an absolute path; the directory may not exist yet.
215
137
  */
216
138
  export function projectConfigDir(cwd = process.cwd()) {
217
- const newPath = path.join(cwd, PROJECT_NEW_NAME);
218
- const legacyPath = path.join(cwd, PROJECT_LEGACY_NAME);
219
- try {
220
- if (fs.existsSync(newPath)) return newPath;
221
- } catch {}
222
- try {
223
- if (fs.existsSync(legacyPath)) return legacyPath;
224
- } catch {}
225
- return newPath;
139
+ return path.join(cwd, '.bahulam');
226
140
  }
@@ -5,7 +5,7 @@ import * as path from 'node:path';
5
5
  export const DEFAULT_POLICY = Object.freeze({
6
6
  version: 1,
7
7
  context: {
8
- loadEveryTurn: ['KEPLER.md', 'project.md', 'style.md', 'goal.md', 'plan.md', 'tasks/*.md'],
8
+ loadEveryTurn: ['BAHULAM.md', 'project.md', 'style.md', 'goal.md', 'plan.md', 'tasks/*.md'],
9
9
  showReloadNotice: true,
10
10
  injectCommandOptions: true,
11
11
  injectActionableTips: true,
@@ -69,8 +69,8 @@ export function loadProjectContext({ cwd = process.cwd(), previous = null } = {}
69
69
  const bahulamDir = projectConfigDir(cwd);
70
70
  const files = [];
71
71
  for (const file of loadBahulamMemory({ cwd })) {
72
- const label = file.path.endsWith(path.join('.bahulam', 'KEPLER.md'))
73
- ? 'KEPLER.md'
72
+ const label = file.path.endsWith(path.join('.bahulam', 'BAHULAM.md'))
73
+ ? 'BAHULAM.md'
74
74
  : path.basename(file.path);
75
75
  files.push({
76
76
  label,
@@ -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 = [
@@ -11,6 +11,7 @@
11
11
 
12
12
  import { llmToolResultContent, sendCallback, sendSkippedCallback, sendApprovalDecision } from './callback-client.mjs';
13
13
  import { ApprovalManager } from './approval.mjs';
14
+ import { upsertFacts } from './memory-disk.mjs';
14
15
  import { normalizeBillingBrandCopy, quotaErrorDetail, rateLimitErrorMessage } from './rate-limit-display.mjs';
15
16
  import * as telemetry from '../telemetry/index.mjs';
16
17
 
@@ -93,6 +94,17 @@ function transportDebug(message, data = {}) {
93
94
  } catch {}
94
95
  }
95
96
 
97
+ function memoryFactsFromComplete(data) {
98
+ const facts = data?.memory_facts_to_persist;
99
+ if (!Array.isArray(facts) || facts.length === 0) return [];
100
+ return facts.filter(fact => (
101
+ fact
102
+ && typeof fact === 'object'
103
+ && fact.fact_id
104
+ && String(fact.content || '').trim()
105
+ ));
106
+ }
107
+
96
108
  // Full jitter around the scheduled delay: pick a value in [delay*0.5, delay*1.5].
97
109
  // Spreads out reconnect storms so N clients dropping simultaneously don't
98
110
  // synchronize their retries. Clamped to the same 30s ceiling as the base delay.
@@ -136,6 +148,7 @@ export class BahulamStreamClient {
136
148
  approvalManager = null,
137
149
  reconnectMaxElapsedMs = null,
138
150
  mode = null,
151
+ pluginRegistry = null,
139
152
  }) {
140
153
  this.baseUrl = (baseUrl || '').replace(/\/$/, '');
141
154
  this.token = token;
@@ -148,7 +161,7 @@ export class BahulamStreamClient {
148
161
  this.retryDelayMs = null;
149
162
  this.pendingToolCallbacks = new Map();
150
163
  this.reconnectMaxElapsedMs = reconnectMaxElapsedMs
151
- ?? Number(process.env.KEPLER_RECONNECT_MAX_ELAPSED_MS || 300_000);
164
+ ?? Number(process.env.BAHULAM_RECONNECT_MAX_ELAPSED_MS || 300_000);
152
165
  // Set by backend on first turn, reused on subsequent turns. Headless mode
153
166
  // (which starts fresh per invocation) can pre-seed via TARANG_SESSION_ID
154
167
  // so multi-turn benchmarks share one backend session across `node` runs.
@@ -173,9 +186,107 @@ export class BahulamStreamClient {
173
186
  || (process.env.TARANG_ENV === 'remote' ? 'remote' : null)
174
187
  || (process.env.TARANG_ENV === 'bundled' ? 'bundled' : null)
175
188
  || 'remote';
189
+ this.pluginRegistry = pluginRegistry || null;
176
190
  this._bundledReady = false;
177
191
  }
178
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
+
204
+ /**
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.
208
+ */
209
+ _getPluginToolSchemas() {
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
+ });
256
+ }
257
+
258
+ /**
259
+ * Get plugin agent schemas for client_agents injection.
260
+ * @returns {Array<{slug: string, name: string, role: string, description: string, tools: string[]}>}
261
+ */
262
+ _getPluginAgentSchemas() {
263
+ if (!this.pluginRegistry) return [];
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
+ }));
288
+ }
289
+
179
290
  /**
180
291
  * Ensure the bundled runtime is spawned and this.baseUrl points at it.
181
292
  * No-op in remote mode. Callers that hit the backend should invoke this
@@ -251,6 +362,12 @@ export class BahulamStreamClient {
251
362
  const body = { instruction, context };
252
363
  if (messages && messages.length > 0) body.messages = messages;
253
364
  if (this.sessionId) body.session_id = this.sessionId;
365
+ const clientTools = this._getPluginToolSchemas();
366
+ if (clientTools.length > 0) body.client_tools = clientTools;
367
+ const clientAgents = this._getPluginAgentSchemas();
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;
254
371
  const requestId = `cli-${_uuidLike()}`;
255
372
 
256
373
  // daemon cache-guard hook. If BAHULAM_CAPTURE_REQUEST is set to a file
@@ -461,6 +578,10 @@ export class BahulamStreamClient {
461
578
  return;
462
579
  }
463
580
 
581
+ if (event === EVENT_TYPES.COMPLETE) {
582
+ this._persistMemoryFactsFromComplete(data);
583
+ }
584
+
464
585
  // Tool requests — show to user, then execute locally and POST callback.
465
586
  if (event === EVENT_TYPES.TOOL_REQUEST || event === EVENT_TYPES.TOOL_CALL) {
466
587
  yield rendered;
@@ -486,6 +607,23 @@ export class BahulamStreamClient {
486
607
  }
487
608
  }
488
609
 
610
+ _persistMemoryFactsFromComplete(data) {
611
+ const facts = memoryFactsFromComplete(data);
612
+ if (facts.length === 0) return;
613
+ try {
614
+ upsertFacts(facts, process.cwd());
615
+ telemetry.track('memory.disk.upserted', { facts: facts.length });
616
+ } catch (err) {
617
+ telemetry.track('memory.disk.upsert_failed', {
618
+ facts: facts.length,
619
+ message: err?.message || String(err),
620
+ });
621
+ if (data && typeof data === 'object') {
622
+ data.memory_persist_error = err?.message || String(err);
623
+ }
624
+ }
625
+ }
626
+
489
627
  async *_reconnectAfterDrop(err) {
490
628
  const taskId = this.currentTaskId;
491
629
  if (!taskId || this.lastEventId == null) {
@@ -728,6 +866,7 @@ export class BahulamStreamClient {
728
866
  const callId = call_id || request_id;
729
867
  const toolName = tool;
730
868
  const isInternal = Boolean(data?.internal || data?.sub_agent);
869
+ const subAgentRunId = data?.run_id || data?.sub_agent_run_id || null;
731
870
 
732
871
  if (this.verbose) {
733
872
  process.stderr.write(`\x1b[2m[tool] ${toolName}(${JSON.stringify(args).slice(0, 80)}...)\x1b[0m\n`);
@@ -745,6 +884,8 @@ export class BahulamStreamClient {
745
884
  _cancelled: true,
746
885
  internal: isInternal,
747
886
  sub_agent: data?.sub_agent || null,
887
+ run_id: subAgentRunId,
888
+ sub_agent_run_id: subAgentRunId,
748
889
  local_callback: false,
749
890
  },
750
891
  };
@@ -756,6 +897,10 @@ export class BahulamStreamClient {
756
897
  try {
757
898
  result = await this.toolExecutor.execute(toolName, args || {}, {
758
899
  signal: this._toolAbort?.signal,
900
+ toolCallSource: 'model',
901
+ internal: isInternal,
902
+ subAgent: data?.sub_agent || null,
903
+ subAgentRunId,
759
904
  });
760
905
  } catch (err) {
761
906
  if (err?.name === 'AbortError' || this._cancelled) {
@@ -795,6 +940,8 @@ export class BahulamStreamClient {
795
940
  duration_ms: durationMs,
796
941
  internal: isInternal,
797
942
  sub_agent: data?.sub_agent || null,
943
+ run_id: subAgentRunId,
944
+ sub_agent_run_id: subAgentRunId,
798
945
  local_callback: true,
799
946
  },
800
947
  };