@bahulam/code 0.1.21 → 0.1.23

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.
@@ -31,6 +31,7 @@ import { backgroundTasks } from './background-tasks.mjs';
31
31
  import { normalizeLintOutput, resolveLintCommand } from './lint-resolver.mjs';
32
32
  import { PluginRegistry } from '../plugins/registry.mjs';
33
33
  import { loadPluginTool } from '../plugins/executor.mjs';
34
+ import { makePluginState } from '../plugins/state.mjs';
34
35
  import * as fs from 'node:fs';
35
36
  import * as os from 'node:os';
36
37
  import * as path from 'node:path';
@@ -60,9 +61,9 @@ export function createToolExecutor({
60
61
  // no reactive pulse.
61
62
  stateEmit = null,
62
63
  delegateRunner = 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.
64
+ // Execution channel. 'main' (REPL/headless/CLI): plugin entry agents and
65
+ // allowlisted plugin agents are listed in the agent-context envelope.
66
+ // Other plugin helpers stay workspace-scoped.
66
67
  // 'workspace' (plugin workspace sessions via agent-relay): the
67
68
  // session plugin's agents are fully available.
68
69
  channel = 'main',
@@ -632,20 +633,82 @@ export function createToolExecutor({
632
633
  return true;
633
634
  }
634
635
 
635
- // Per-plugin state handles are opened lazily on first tool call and
636
- // cached process-wide. `makePluginState` itself dedupes on plugin
637
- // name, so this Map only exists to avoid re-attaching stateEmit on
638
- // every registered tool.
636
+ // Per-plugin state handles are opened lazily on first call and cached
637
+ // process-wide. `makePluginState` itself dedupes on plugin name, so
638
+ // this Map only exists to avoid re-attaching stateEmit on every
639
+ // registered tool — and to keep the agent-context summary reading the
640
+ // same connection a plugin tool writes through.
639
641
  const _pluginStateHandles = new Map(); // pluginName -> state proxy
640
- async function _pluginStateFor(pluginName) {
642
+
643
+ /**
644
+ * The normalized `config.state` block a plugin declared, or null when
645
+ * it declared none. Declared tables are created and additively
646
+ * migrated when the DB opens, so this is also the schema the plugin's
647
+ * own handlers query via `state.query()`.
648
+ */
649
+ function pluginStateDecl(pluginName) {
650
+ if (!pluginName || typeof pluginRegistry?.list !== 'function') return null;
651
+ const plugin = pluginRegistry.list().find(p => p.metadata?.name === pluginName);
652
+ return plugin?.config?.state || null;
653
+ }
654
+
655
+ function _pluginStateFor(pluginName) {
641
656
  if (!pluginName) return null;
642
657
  if (_pluginStateHandles.has(pluginName)) return _pluginStateHandles.get(pluginName);
643
- const { makePluginState } = await import('../plugins/state.mjs');
644
- const state = makePluginState(pluginName, { emit: stateEmit });
658
+ const decl = pluginStateDecl(pluginName);
659
+ const state = makePluginState(pluginName, {
660
+ emit: stateEmit,
661
+ tables: decl?.tables || [],
662
+ });
645
663
  _pluginStateHandles.set(pluginName, state);
646
664
  return state;
647
665
  }
648
666
 
667
+ // ── Declared plugin state → agent context ────────────────────
668
+ // Tier 1 of the state-visibility contract. Plugins that declare
669
+ // `config.state.context_always` get exactly those keys and streams
670
+ // injected into the agent context each turn, so a fresh session opens
671
+ // already knowing what the previous one left behind — instead of
672
+ // spending a tool call to rediscover it, or silently redoing work.
673
+ //
674
+ // Opt-in by construction: a plugin that declares nothing contributes
675
+ // nothing, and an opted-in plugin that has recorded nothing yet is
676
+ // skipped rather than shipping an empty block.
677
+ //
678
+ // Reads are keyed on the state DB's size+mtime so the payload stays
679
+ // byte-identical between turns when nothing wrote. Same reasoning as
680
+ // the memory digest above: a context block that churns on every turn
681
+ // invalidates the backend's prompt cache on every ExecuteRequest.
682
+ const _pluginStateCache = new Map(); // pluginName -> { key, entry }
683
+
684
+ function pluginStateContext() {
685
+ if (typeof pluginRegistry?.list !== 'function') return [];
686
+ const out = [];
687
+ for (const plugin of pluginRegistry.list()) {
688
+ const name = plugin.metadata?.name;
689
+ const decl = plugin.config?.state;
690
+ if (!name || !decl?.context_always?.length) continue;
691
+ try {
692
+ const state = _pluginStateFor(name);
693
+ const stat = fs.existsSync(state.path) ? fs.statSync(state.path) : null;
694
+ const key = stat ? `${stat.size}:${Math.round(stat.mtimeMs)}` : 'missing';
695
+ const cached = _pluginStateCache.get(name);
696
+ if (cached && cached.key === key) {
697
+ out.push(cached.entry);
698
+ continue;
699
+ }
700
+ const summary = state.summary(decl.context_always);
701
+ const hasContent = Object.keys(summary.kv).length > 0
702
+ || Object.values(summary.streams).some(rows => rows.length > 0);
703
+ if (!hasContent) continue;
704
+ const entry = { plugin: name, ...summary };
705
+ _pluginStateCache.set(name, { key, entry });
706
+ out.push(entry);
707
+ } catch { /* one broken plugin must never break the session */ }
708
+ }
709
+ return out;
710
+ }
711
+
649
712
  /**
650
713
  * Register one MCP tool under `<serverName>.<toolName>` (namespaced
651
714
  * to prevent collisions between plugins that ship servers with the
@@ -706,6 +769,56 @@ export function createToolExecutor({
706
769
  const name = String(toolDef.name || '').trim();
707
770
  if (!name || toolMap[name]) continue;
708
771
  const pluginName = toolDef._plugin_name || toolDef.plugin_name || null;
772
+ if (toolDef._state_tool) {
773
+ // Manifest-declared state query tool (config.state.context_tools).
774
+ // There is no module to import — the author declared a table
775
+ // and an optional WHERE clause; the CLI supplies the handler.
776
+ // `readTable` refuses undeclared tables, so author-supplied
777
+ // SQL can't be steered into arbitrary table access.
778
+ const spec = toolDef._state_tool;
779
+ registerPluginTool(name, async (args) => {
780
+ try {
781
+ const state = _pluginStateFor(spec.plugin || pluginName);
782
+ if (!state) {
783
+ return {
784
+ success: false,
785
+ output: `Plugin state unavailable for '${name}'.`,
786
+ _tool: name,
787
+ _plugin: pluginName,
788
+ };
789
+ }
790
+ const bound = (spec.params || []).map(key => args?.[key] ?? null);
791
+ // An unfiltered call should list everything, not match
792
+ // nothing — `topic = NULL` is never true in SQL. Apply
793
+ // the WHERE only once a declared param was supplied.
794
+ const useWhere = Boolean(spec.where) && bound.some(v => v !== null);
795
+ const rows = state.readTable(spec.table, {
796
+ where: useWhere ? spec.where : '',
797
+ params: useWhere ? bound : [],
798
+ limit: args?.limit ?? spec.limit,
799
+ });
800
+ return {
801
+ success: true,
802
+ output: rows.length
803
+ ? JSON.stringify(rows, null, 2)
804
+ : `No rows in ${spec.table}${useWhere ? ' matching those filters' : ''}.`,
805
+ rows,
806
+ count: rows.length,
807
+ _tool: name,
808
+ _plugin: pluginName,
809
+ _state_tool: true,
810
+ };
811
+ } catch (err) {
812
+ return {
813
+ success: false,
814
+ output: `Plugin state tool error (${name}): ${err.message}`,
815
+ _tool: name,
816
+ _plugin: pluginName,
817
+ };
818
+ }
819
+ }, { pluginName, source: 'state', stateTool: spec });
820
+ continue;
821
+ }
709
822
  if (toolDef._composed?.kind === 'pi') {
710
823
  // Composed pi tools resolve at invocation time: look up the
711
824
  // installed pi package's directory, load the specific handler
@@ -2281,7 +2394,7 @@ export function createToolExecutor({
2281
2394
  const agents = filterLocalAgents(args).map(compactAgentMetadata);
2282
2395
  const payload = { agents, count: agents.length };
2283
2396
  if (agents.some(agent => agent.runnable === false)) {
2284
- 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.';
2397
+ payload.note = 'Agents with runnable:false are workspace-scoped plugin helpers; declare an entry_agent or add their slug to settings plugins.agent_allowlist to invoke them from the main loop.';
2285
2398
  }
2286
2399
  return {
2287
2400
  success: true,
@@ -2777,6 +2890,7 @@ export function createToolExecutor({
2777
2890
  getAgentContext() {
2778
2891
  const global = projectRegistry.getGlobalContext();
2779
2892
  const mem = _readMemorySnapshot();
2893
+ const pluginState = pluginStateContext();
2780
2894
  return {
2781
2895
  identity: global.identity,
2782
2896
  preferences: global.preferences,
@@ -2804,6 +2918,14 @@ export function createToolExecutor({
2804
2918
  spec: agent.spec,
2805
2919
  })),
2806
2920
  sub_agent_observability: agentRegistry.observability(),
2921
+ // Cross-session plugin state. Only plugins that opted in via
2922
+ // config.state.context_always appear here, and only the keys
2923
+ // and streams they named — this is how a plugin's local app
2924
+ // state survives a session boundary without the agent having
2925
+ // to know to go looking for it.
2926
+ ...(pluginState.length ? {
2927
+ plugin_state: pluginState,
2928
+ } : {}),
2807
2929
  // Background jobs the model should know about. Stable fields
2808
2930
  // only (no durations) so the entry — and the prompt cache —
2809
2931
  // changes on status transitions, not every turn.
@@ -640,10 +640,7 @@ function scanPlugins(session) {
640
640
  const dirs = new Map();
641
641
  try {
642
642
  const registry = new PluginRegistry({
643
- pluginDirs: [
644
- path.join(session.root_path || process.cwd(), '.bahulam', 'plugins'),
645
- path.join(os.homedir(), '.bahulam', 'plugins'),
646
- ],
643
+ pluginDirs: [path.join(os.homedir(), '.bahulam', 'plugins')],
647
644
  }).scan();
648
645
  for (const manifest of registry.list()) {
649
646
  const pluginName = manifest.metadata?.name || '';
@@ -13,6 +13,7 @@
13
13
  import { spawn } from 'child_process';
14
14
 
15
15
  const MCP_PROTOCOL_VERSION = '2024-11-05';
16
+ const DEFAULT_REQUEST_TIMEOUT_MS = 60_000;
16
17
 
17
18
  export class McpClient {
18
19
  /**
@@ -102,7 +103,9 @@ export class McpClient {
102
103
  }
103
104
  });
104
105
  this.connected = true;
105
- return this._initRemote();
106
+ await this._initRemote();
107
+ this._notifyRemote('notifications/initialized', {});
108
+ return this.serverInfo;
106
109
  }
107
110
 
108
111
  async _connectWebSocket() {
@@ -117,6 +120,7 @@ export class McpClient {
117
120
  capabilities: {},
118
121
  clientInfo: { name: 'bahulam-code', version: '2.0.0' },
119
122
  });
123
+ this._notifyRemote('notifications/initialized', {});
120
124
  return this.serverInfo;
121
125
  }
122
126
 
@@ -132,6 +136,7 @@ export class McpClient {
132
136
  capabilities: {},
133
137
  clientInfo: { name: 'bahulam-code', version: '2.0.0' },
134
138
  });
139
+ this._notifyRemote('notifications/initialized', {});
135
140
  return this.serverInfo;
136
141
  }
137
142
 
@@ -145,11 +150,39 @@ export class McpClient {
145
150
  return result;
146
151
  }
147
152
 
153
+ _notifyRemote(method, params) {
154
+ if (this.transport?.request) {
155
+ // WebSocket / sHTTP transports have native request() — use send()
156
+ // for notifications (no id, no response expected).
157
+ if (this.transport.send) {
158
+ this.transport.send({ jsonrpc: '2.0', method, params }).catch(() => {});
159
+ }
160
+ } else if (this.transport) {
161
+ // SSE transport — fire-and-forget via send()
162
+ this.transport.send({ jsonrpc: '2.0', method, params }).catch(() => {});
163
+ }
164
+ }
165
+
148
166
  async _transportRequest(method, params) {
149
167
  return new Promise((resolve, reject) => {
150
168
  const id = ++this.requestId;
151
- this.pending.set(id, { resolve, reject });
152
- this.transport.send({ jsonrpc: '2.0', id, method, params });
169
+ const timeout = setTimeout(() => {
170
+ if (this.pending.has(id)) {
171
+ this.pending.delete(id);
172
+ reject(new Error(`MCP request timeout: ${method} (${DEFAULT_REQUEST_TIMEOUT_MS}ms)`));
173
+ }
174
+ }, DEFAULT_REQUEST_TIMEOUT_MS);
175
+ this.pending.set(id, {
176
+ resolve: (val) => { clearTimeout(timeout); resolve(val); },
177
+ reject: (err) => { clearTimeout(timeout); reject(err); },
178
+ });
179
+ this.transport.send({ jsonrpc: '2.0', id, method, params }).catch(err => {
180
+ if (this.pending.has(id)) {
181
+ this.pending.delete(id);
182
+ clearTimeout(timeout);
183
+ reject(err);
184
+ }
185
+ });
153
186
  });
154
187
  }
155
188
 
@@ -177,7 +210,14 @@ export class McpClient {
177
210
  result = await this._request('tools/call', params);
178
211
  }
179
212
  if (result?.content && Array.isArray(result.content)) {
180
- return result.content.filter(c => c.type === 'text').map(c => c.text).join('\n');
213
+ const textParts = result.content.filter(c => c.type === 'text').map(c => c.text);
214
+ const imageParts = result.content.filter(c => c.type === 'image');
215
+ if (imageParts.length > 0 && textParts.length === 0) {
216
+ return result;
217
+ }
218
+ if (textParts.length > 0) {
219
+ return textParts.join('\n');
220
+ }
181
221
  }
182
222
  return result;
183
223
  }
@@ -222,7 +262,16 @@ export class McpClient {
222
262
  _request(method, params) {
223
263
  return new Promise((resolve, reject) => {
224
264
  const id = ++this.requestId;
225
- this.pending.set(id, { resolve, reject });
265
+ const timeout = setTimeout(() => {
266
+ if (this.pending.has(id)) {
267
+ this.pending.delete(id);
268
+ reject(new Error(`MCP request timeout: ${method} (${DEFAULT_REQUEST_TIMEOUT_MS}ms)`));
269
+ }
270
+ }, DEFAULT_REQUEST_TIMEOUT_MS);
271
+ this.pending.set(id, {
272
+ resolve: (val) => { clearTimeout(timeout); resolve(val); },
273
+ reject: (err) => { clearTimeout(timeout); reject(err); },
274
+ });
226
275
  const msg = JSON.stringify({ jsonrpc: '2.0', id, method, params });
227
276
  this.process.stdin.write(msg + '\n');
228
277
  });
@@ -0,0 +1,98 @@
1
+ /**
2
+ * MCP Settings Loader — bridges settings-based mcpServers to the tool executor.
3
+ *
4
+ * The agent-relay path already spawns MCP servers from plugin manifests.
5
+ * This module provides the same capability for CLI/REPL/headless mode by
6
+ * reading mcpServers from the settings chain (~/.claude/settings.json etc.)
7
+ * and optionally from ~/.bahulam/config.json.
8
+ *
9
+ * Usage:
10
+ * import { loadMcpServers } from '../mcp/loader.mjs';
11
+ * const mcpClients = await loadMcpServers(toolExecutor, settings);
12
+ * // ... later ...
13
+ * await mcpClients.disconnectAll();
14
+ */
15
+
16
+ import { McpClient } from './client.mjs';
17
+
18
+ /**
19
+ * Recursively expand ${VAR} patterns in a config object using process.env.
20
+ * Matches the behavior in agent-relay.mjs _expandEnvInMcpConfig.
21
+ * @param {*} value
22
+ * @returns {*}
23
+ */
24
+ function expandEnv(value) {
25
+ if (typeof value === 'string') {
26
+ return value.replace(/\$\{(\w+)\}/g, (_, name) => process.env[name] ?? '');
27
+ }
28
+ if (Array.isArray(value)) return value.map(expandEnv);
29
+ if (value && typeof value === 'object') {
30
+ return Object.fromEntries(
31
+ Object.entries(value).map(([k, v]) => [k, expandEnv(v)]),
32
+ );
33
+ }
34
+ return value;
35
+ }
36
+
37
+ /**
38
+ * Load and connect MCP servers from settings, registering their tools
39
+ * with the tool executor.
40
+ *
41
+ * @param {object} toolExecutor - the createToolExecutor() instance
42
+ * @param {object} settings - loaded settings (from loadSettings())
43
+ * @param {object} [options]
44
+ * @param {string} [options.pluginName='settings'] - namespace for tool registration
45
+ * @returns {Promise<{clients: Array, disconnectAll: Function}>}
46
+ */
47
+ export async function loadMcpServers(toolExecutor, settings, options = {}) {
48
+ const pluginName = options.pluginName || 'settings';
49
+ const servers = settings?.mcpServers || {};
50
+ const clients = [];
51
+
52
+ for (const [name, config] of Object.entries(servers)) {
53
+ if (!config || typeof config !== 'object') continue;
54
+ if (!config.command && !config.url) continue;
55
+
56
+ try {
57
+ // Merge the config's env block into process.env BEFORE expanding
58
+ // ${VAR} patterns in args/env. This matches the Claude Desktop
59
+ // convention: the env block sets vars for the spawned process
60
+ // AND for ${VAR} expansion in the same config.
61
+ if (config.env && typeof config.env === 'object') {
62
+ for (const [k, v] of Object.entries(config.env)) {
63
+ if (typeof v === 'string' && !process.env[k]) {
64
+ process.env[k] = v;
65
+ }
66
+ }
67
+ }
68
+ const expanded = expandEnv(config);
69
+ const client = new McpClient(expanded);
70
+ await client.connect();
71
+ const tools = await client.listTools();
72
+ for (const tool of tools) {
73
+ if (toolExecutor.registerMcpTool) {
74
+ toolExecutor.registerMcpTool(pluginName, name, tool.name, client, tool.inputSchema || {});
75
+ }
76
+ }
77
+ clients.push({ name, client });
78
+ if (process.env.MCP_DEBUG) {
79
+ process.stderr.write(`[mcp:settings] ${name}: ${tools.length} tools registered\n`);
80
+ }
81
+ } catch (err) {
82
+ // One server failure must never block the session.
83
+ if (process.env.MCP_DEBUG) {
84
+ process.stderr.write(`[mcp:settings] ${name} failed: ${err.message}\n`);
85
+ }
86
+ }
87
+ }
88
+
89
+ return {
90
+ clients,
91
+ async disconnectAll() {
92
+ for (const { name, client } of clients) {
93
+ try { toolExecutor?.unregisterMcpServer?.(pluginName, name); } catch {}
94
+ try { await client.disconnect(); } catch {}
95
+ }
96
+ },
97
+ };
98
+ }
@@ -16,6 +16,7 @@ export class StreamableHttpTransport {
16
16
  this.timeout = options.timeout || 30000;
17
17
  this.sessionId = options.sessionId || null;
18
18
  this.connected = false;
19
+ this.requestId = 0;
19
20
  }
20
21
 
21
22
  async connect() {
@@ -42,7 +43,7 @@ export class StreamableHttpTransport {
42
43
  * Collects all events and returns the final result.
43
44
  */
44
45
  async request(method, params) {
45
- const id = Date.now();
46
+ const id = ++this.requestId;
46
47
  const body = { jsonrpc: '2.0', id, method, params };
47
48
 
48
49
  const headers = {