bare-agent 0.8.0 → 0.10.0

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.
package/src/loop.js CHANGED
@@ -1,6 +1,6 @@
1
1
  'use strict';
2
2
 
3
- const { ToolError } = require('./errors');
3
+ const { ToolError, HaltError } = require('./errors');
4
4
 
5
5
  // Average pricing per 1K tokens (USD). Adjust these to match your provider's rates.
6
6
  // Last updated: 2026-03-18. Source: public provider pricing pages.
@@ -43,7 +43,9 @@ class Loop {
43
43
  * @param {object} [options.retry] - Retry instance for backoff on failures.
44
44
  * @param {object} [options.stream] - Stream instance for event emission.
45
45
  * @param {object} [options.store] - Store instance for validate() health check.
46
- * @param {Function} [options.policy] - Async (toolName, args, ctx) => true | string. Recommended wiring: closure that delegates to a bareguard Gate (`require('bare-agent/bareguard').wireGate(gate).policy`). Anything other than `true` denies; a string is fed to the LLM verbatim as the deny reason. All policy/budget/audit decisions live in bareguard — Loop just calls the closure and respects the verdict.
46
+ * @param {Function} [options.policy] - Async (toolName, args, ctx) => true | string. Recommended wiring: closure that delegates to a bareguard Gate (`require('bare-agent/bareguard').wireGate(gate).policy`). Anything other than `true` denies; a string is fed to the LLM verbatim as the deny reason. A throw of `HaltError` exits the loop cleanly. All policy/budget/audit decisions live in bareguard — Loop just calls the closure and respects the verdict.
47
+ * @param {Function} [options.onLlmResult] - Async ({model, provider, usage, costUsd, durationMs, ctx}) called after every successful provider.generate. Wire via `wireGate(gate).onLlmResult` so `budget.maxCostUsd` covers token-only workloads. Errors route through `_reportError` but never kill the loop.
48
+ * @param {Function} [options.onToolResult] - Async ({name, args, result, error, durationMs, ctx}) called after every tool.execute (success and failure). Wire via `wireGate(gate).onToolResult` so `gate.record` sees `ctx`. Errors route through `_reportError` but never kill the loop.
47
49
  * @throws {Error} `[Loop] requires a provider` — when options.provider is missing.
48
50
  */
49
51
  constructor(options = {}) {
@@ -62,6 +64,14 @@ class Loop {
62
64
  throw new Error('[Loop] options.policy must be a function (toolName, args, ctx) => true | string');
63
65
  }
64
66
  this.policy = options.policy || null;
67
+ if (options.onLlmResult != null && typeof options.onLlmResult !== 'function') {
68
+ throw new Error('[Loop] options.onLlmResult must be a function');
69
+ }
70
+ if (options.onToolResult != null && typeof options.onToolResult !== 'function') {
71
+ throw new Error('[Loop] options.onToolResult must be a function');
72
+ }
73
+ this.onLlmResult = options.onLlmResult || null;
74
+ this.onToolResult = options.onToolResult || null;
65
75
  this._stopped = false;
66
76
  this._history = []; // for chat() stateful mode
67
77
  }
@@ -144,17 +154,19 @@ class Loop {
144
154
  let lastUsage = { inputTokens: 0, outputTokens: 0 };
145
155
  let totalCost = 0;
146
156
 
157
+ try {
147
158
  for (let round = 0; round < HARD_ROUND_LIMIT; round++) {
148
159
  if (this._stopped) break;
149
160
 
150
161
  let result;
162
+ const llmStartedAt = Date.now();
151
163
  try {
152
164
  const generate = () => this.provider.generate(msgs, tools, options);
153
165
  result = this.retry ? await this.retry.call(generate) : await generate();
154
166
  } catch (err) {
155
167
  this._reportError('provider', err, { round });
156
168
  if (this.throwOnError) throw err;
157
- return { text: '', toolCalls: [], usage: lastUsage, cost: totalCost, error: err.message };
169
+ return { text: '', toolCalls: [], usage: lastUsage, cost: totalCost, error: err.message, msgs };
158
170
  }
159
171
 
160
172
  lastUsage = result.usage || lastUsage;
@@ -162,12 +174,32 @@ class Loop {
162
174
  const roundCost = estimateCost(model, lastUsage);
163
175
  if (roundCost !== null) totalCost += roundCost;
164
176
 
177
+ // BA1: forward LLM usage to gate.record (via wireGate) so budget.maxCostUsd
178
+ // covers token-heavy / tool-light workloads. Callback errors route through
179
+ // _reportError but never kill the loop — governance failure ≠ run failure.
180
+ if (this.onLlmResult) {
181
+ try {
182
+ await this.onLlmResult({
183
+ model,
184
+ provider: this.provider.name || null,
185
+ usage: result.usage || null,
186
+ costUsd: roundCost,
187
+ durationMs: Date.now() - llmStartedAt,
188
+ ctx,
189
+ });
190
+ } catch (err) {
191
+ if (err instanceof HaltError) throw err;
192
+ this._reportError('onLlmResult', err, { round });
193
+ }
194
+ }
195
+
165
196
  // No tool calls — LLM gave a final text response
166
197
  if (!result.toolCalls || result.toolCalls.length === 0) {
167
198
  this._safeEmit({ type: 'loop:text', data: { text: result.text } });
168
199
  this._safeCall('onText', this.onText, result.text);
169
200
  this._safeEmit({ type: 'loop:done', data: { text: result.text, usage: lastUsage, cost: totalCost } });
170
- return { text: result.text, toolCalls: [], usage: lastUsage, cost: totalCost, error: null };
201
+ msgs.push({ role: 'assistant', content: result.text });
202
+ return { text: result.text, toolCalls: [], usage: lastUsage, cost: totalCost, error: null, msgs };
171
203
  }
172
204
 
173
205
  // Execute tool calls
@@ -229,6 +261,9 @@ class Loop {
229
261
  try {
230
262
  verdict = await this.policy(tc.name, tc.arguments, ctx);
231
263
  } catch (err) {
264
+ // BA2: HaltError bubbles past the per-tool try/catch to the outer
265
+ // handler so halt exits cleanly without ever reaching the LLM.
266
+ if (err instanceof HaltError) throw err;
232
267
  verdict = `[Loop] policy error: ${err.message}`;
233
268
  }
234
269
  if (verdict !== true) {
@@ -241,26 +276,57 @@ class Loop {
241
276
  }
242
277
  }
243
278
 
279
+ const toolStartedAt = Date.now();
280
+ let toolResult;
281
+ let toolError;
244
282
  try {
245
283
  const execute = () => tool.execute(tc.arguments);
246
- const toolResult = this.retry ? await this.retry.call(execute) : await execute();
284
+ toolResult = this.retry ? await this.retry.call(execute) : await execute();
247
285
  const content = typeof toolResult === 'string' ? toolResult : JSON.stringify(toolResult);
248
286
  msgs.push({ role: 'tool', tool_call_id: tc.id, content });
249
287
  this._safeEmit({ type: 'loop:tool_result', data: { tool: tc.name, result: content } });
250
288
  } catch (err) {
251
- const toolErr = err instanceof ToolError ? err : new ToolError(err.message, { context: { tool: tc.name } });
252
- const errMsg = `[Loop] Tool error: ${toolErr.message}`;
289
+ toolError = err instanceof ToolError ? err : new ToolError(err.message, { context: { tool: tc.name } });
290
+ const errMsg = `[Loop] Tool error: ${toolError.message}`;
253
291
  msgs.push({ role: 'tool', tool_call_id: tc.id, content: errMsg });
254
292
  this._safeEmit({ type: 'loop:tool_result', data: { tool: tc.name, error: errMsg } });
255
293
  }
294
+
295
+ // BA1: forward tool result/error to gate.record (via wireGate) with ctx in
296
+ // scope — fixes the lost-_ctx issue that wrapTool can't solve.
297
+ if (this.onToolResult) {
298
+ try {
299
+ await this.onToolResult({
300
+ name: tc.name,
301
+ args: tc.arguments,
302
+ result: toolResult,
303
+ error: toolError || null,
304
+ durationMs: Date.now() - toolStartedAt,
305
+ ctx,
306
+ });
307
+ } catch (err) {
308
+ if (err instanceof HaltError) throw err;
309
+ this._reportError('onToolResult', err, { tool: tc.name });
310
+ }
311
+ }
256
312
  }
257
313
  }
314
+ } catch (err) {
315
+ // BA2: HaltError is a clean governance exit, not a runtime failure.
316
+ // No throw even when throwOnError:true — the gate halted us deliberately.
317
+ if (err instanceof HaltError) {
318
+ this._reportError('halt', err, { rule: err.rule, reason: err.decision?.reason ?? null });
319
+ this._safeEmit({ type: 'loop:done', data: { text: '', halted: true, rule: err.rule, cost: totalCost } });
320
+ return { text: '', toolCalls: [], usage: lastUsage, cost: totalCost, error: `halt:${err.rule}`, msgs };
321
+ }
322
+ throw err;
323
+ }
258
324
 
259
325
  // Hard safety limit — should never fire under normal usage; bareguard's
260
326
  // limits.maxTurns (or the LLM's natural completion) ends the loop first.
261
327
  const warning = `[Loop] hit internal safety limit of ${HARD_ROUND_LIMIT} rounds. Wire bareguard for proper governance — see bare-agent/bareguard.`;
262
328
  this._safeEmit({ type: 'loop:done', data: { text: '', warning, cost: totalCost } });
263
- return { text: '', toolCalls: [], usage: lastUsage, cost: totalCost, error: warning };
329
+ return { text: '', toolCalls: [], usage: lastUsage, cost: totalCost, error: warning, msgs };
264
330
  }
265
331
 
266
332
  /**
@@ -329,9 +395,10 @@ class Loop {
329
395
  async chat(text, tools = [], options = {}) {
330
396
  this._history.push({ role: 'user', content: text });
331
397
  const result = await this.run(this._history, tools, options);
332
- if (result.text) {
333
- this._history.push({ role: 'assistant', content: result.text });
334
- }
398
+ // Sync _history from the full msgs run() built (tool-call messages, tool results,
399
+ // and final assistant text). Strip the leading system message if one was prepended.
400
+ const effectiveSystem = options.system || this.system;
401
+ this._history = effectiveSystem ? result.msgs.slice(1) : result.msgs.slice();
335
402
  return result;
336
403
  }
337
404
 
package/src/mcp-bridge.js CHANGED
@@ -219,29 +219,30 @@ function wrapTools(serverName, mcpTools, rpc) {
219
219
  async function killServer(child) {
220
220
  if (child.exitCode !== null) return;
221
221
 
222
- child.stdin?.destroy();
222
+ // end() sends FIN so the child sees stdin EOF and can exit cleanly;
223
+ // destroy() alone does not always propagate.
224
+ try { child.stdin?.end(); } catch { /* already closed */ }
223
225
  child.stdout?.destroy();
224
226
  child.stderr?.destroy();
225
227
 
226
- await new Promise(resolve => {
227
- const onClose = () => resolve();
228
+ // Short grace, then SIGTERM, then SIGKILL. Each wait clears its timer
229
+ // promptly when the child closes so we don't block the event loop after
230
+ // exit (which kept node:test's file-level wrapper hanging).
231
+ const waitClose = (ms) => new Promise(resolve => {
232
+ let timer;
233
+ const onClose = () => { clearTimeout(timer); resolve(); };
228
234
  child.once('close', onClose);
229
- setTimeout(() => {
235
+ timer = setTimeout(() => {
230
236
  child.removeListener('close', onClose);
231
237
  resolve();
232
- }, 700);
238
+ }, ms);
233
239
  });
234
240
 
241
+ await waitClose(150);
242
+
235
243
  if (child.exitCode === null) {
236
244
  child.kill('SIGTERM');
237
- await new Promise(resolve => {
238
- const onClose = () => resolve();
239
- child.once('close', onClose);
240
- setTimeout(() => {
241
- child.removeListener('close', onClose);
242
- resolve();
243
- }, 700);
244
- });
245
+ await waitClose(300);
245
246
  }
246
247
 
247
248
  if (child.exitCode === null) {
@@ -262,11 +263,12 @@ async function connectAndListTools(name, def, timeout = 15000) {
262
263
  clientInfo: { name: 'bare-agent', version: '0.5.0' },
263
264
  });
264
265
 
265
- const timer = new Promise((_, reject) =>
266
- setTimeout(() => reject(new ToolError(`MCP server "${name}" init timed out after ${timeout}ms`)), timeout)
267
- );
266
+ let timerId;
267
+ const timer = new Promise((_, reject) => {
268
+ timerId = setTimeout(() => reject(new ToolError(`MCP server "${name}" init timed out after ${timeout}ms`)), timeout);
269
+ });
268
270
 
269
- await Promise.race([init, timer]);
271
+ try { await Promise.race([init, timer]); } finally { clearTimeout(timerId); }
270
272
  client.notify('notifications/initialized');
271
273
 
272
274
  const { tools: mcpTools } = await client.rpc('tools/list');
@@ -282,9 +284,10 @@ function buildSystemContext(servers, tools, denied) {
282
284
 
283
285
  const byServer = {};
284
286
  for (const t of tools) {
285
- const parts = t.name.split('_');
286
- const server = parts[0];
287
- (byServer[server] = byServer[server] || []).push(t.name.replace(`${server}_`, ''));
287
+ const sep = t.name.indexOf('_');
288
+ const server = sep > 0 ? t.name.slice(0, sep) : t.name;
289
+ const tool = sep > 0 ? t.name.slice(sep + 1) : '';
290
+ (byServer[server] = byServer[server] || []).push(tool);
288
291
  }
289
292
  for (const [server, toolNames] of Object.entries(byServer)) {
290
293
  lines.push(` ${server}: ${toolNames.join(', ')}`);
@@ -308,6 +311,103 @@ function buildSystemContext(servers, tools, denied) {
308
311
  return lines.join('\n');
309
312
  }
310
313
 
314
+ // --- Meta-tools: mcp_discover + mcp_invoke (v0.9) ---
315
+
316
+ /**
317
+ * Build the LLM-callable meta-tool surface from a fully-connected bridge.
318
+ * Shares the underlying tool array and RPC clients with the bulk surface —
319
+ * one set of connections, one factory, two output forms. The user picks
320
+ * `bridge.tools` (bulk) for small catalogs the LLM should see upfront, or
321
+ * `bridge.metaTools` for large catalogs the LLM should discover on demand.
322
+ *
323
+ * Gov shape: when the LLM calls mcp_invoke, the action sent to gate.check
324
+ * is `{ type: 'mcp_invoke', args: { name, args }, _ctx }` — bareguard sees
325
+ * `mcp_invoke` as the type. To deny specific MCP tools, use bareguard's
326
+ * `tools.denyArgPatterns: { mcp_invoke: [/"name":"linear_admin_.*"/] }`
327
+ * or `content.denyPatterns` over the JSON-serialized form. The inner MCP
328
+ * tool name doesn't travel as `action.type` — that's a deliberate v0.9
329
+ * trade for one consistent gate-check call per LLM tool invocation.
330
+ *
331
+ * @param {Array} tools - The bulk-loaded, name-prefixed tools array.
332
+ * @param {string} discoveredAt - ISO timestamp from .mcp-bridge.json.
333
+ * @returns {Array} [mcp_discover, mcp_invoke]
334
+ */
335
+ function buildMetaTools(tools, discoveredAt) {
336
+ // Catalog descriptors: same info the LLM would see for bulk-loaded tools,
337
+ // but exposed via mcp_discover instead of taking up tool-array slots upfront.
338
+ const catalog = tools.map(t => {
339
+ const sep = t.name.indexOf('_');
340
+ return {
341
+ name: t.name,
342
+ description: t.description || '',
343
+ schema: t.parameters || { type: 'object', properties: {} },
344
+ server: sep > 0 ? t.name.slice(0, sep) : t.name,
345
+ tool: sep > 0 ? t.name.slice(sep + 1) : '',
346
+ };
347
+ });
348
+ const byName = new Map(tools.map(t => [t.name, t]));
349
+
350
+ const mcpDiscover = {
351
+ name: 'mcp_discover',
352
+ description:
353
+ 'List MCP tools currently available across all configured servers. Returns descriptors with name, description, schema, server, and tool. Pass refresh:true to force a fresh discovery (otherwise the catalog is the one loaded at agent startup). Discovery itself is ungated — read-only catalog access. Gov decisions still happen at invoke time via mcp_invoke.',
354
+ parameters: {
355
+ type: 'object',
356
+ properties: {
357
+ refresh: {
358
+ type: 'boolean',
359
+ description: 'Currently a no-op flag in v0.9 — the catalog is loaded once at bridge construction. Set true to signal intent; behavior may change in a later version.',
360
+ },
361
+ server: {
362
+ type: 'string',
363
+ description: 'Optional: filter the catalog to one server name.',
364
+ },
365
+ },
366
+ },
367
+ execute: async ({ server } = {}) => {
368
+ const filtered = server
369
+ ? catalog.filter(t => t.server === server)
370
+ : catalog;
371
+ return {
372
+ tools: filtered,
373
+ cachedAt: discoveredAt || new Date().toISOString(),
374
+ count: filtered.length,
375
+ };
376
+ },
377
+ };
378
+
379
+ const mcpInvoke = {
380
+ name: 'mcp_invoke',
381
+ description:
382
+ 'Invoke an MCP tool by its canonical bareagent name (the `name` field returned by mcp_discover, e.g. "linear_list_issues"). Args are passed through to the underlying MCP server. Returns the tool result. Bareguard governs every invocation — denies fed back as deny strings, halts as [HALT] strings.',
383
+ parameters: {
384
+ type: 'object',
385
+ properties: {
386
+ name: {
387
+ type: 'string',
388
+ description: 'Canonical MCP tool name (from mcp_discover). Format: <server>_<tool>.',
389
+ },
390
+ args: {
391
+ type: 'object',
392
+ description: 'Arguments for the MCP tool, matching its schema (also from mcp_discover).',
393
+ },
394
+ },
395
+ required: ['name'],
396
+ },
397
+ execute: async ({ name, args }) => {
398
+ const tool = byName.get(name);
399
+ if (!tool) {
400
+ throw new ToolError(`mcp_invoke: unknown tool "${name}". Call mcp_discover for the current catalog.`, {
401
+ context: { name, knownNames: [...byName.keys()] },
402
+ });
403
+ }
404
+ return await tool.execute(args || {});
405
+ },
406
+ };
407
+
408
+ return [mcpDiscover, mcpInvoke];
409
+ }
410
+
311
411
  // --- Main entry point ---
312
412
 
313
413
  /**
@@ -316,13 +416,22 @@ function buildSystemContext(servers, tools, denied) {
316
416
  * On subsequent runs, reads .mcp-bridge.json and respects allow/deny per tool.
317
417
  * Re-discovers when TTL expires (default: 24h).
318
418
  *
419
+ * Returns BOTH surfaces (v0.9+):
420
+ * - `tools` — bulk-loaded array of name-prefixed tools (small catalogs;
421
+ * LLM sees them upfront).
422
+ * - `metaTools` — [mcp_discover, mcp_invoke] LLM-callable pair (large catalogs;
423
+ * LLM picks tools dynamically). Shares the same RPC connections.
424
+ *
425
+ * Wire one or the other into Loop's tool array; never both (the LLM would see
426
+ * the same MCP tool twice). Pick by catalog size and token budget.
427
+ *
319
428
  * @param {object} [opts]
320
429
  * @param {string} [opts.bridgePath] - Path to .mcp-bridge.json. Default: .mcp-bridge.json in cwd.
321
430
  * @param {string[]} [opts.configPaths] - IDE config paths for discovery.
322
431
  * @param {string[]} [opts.servers] - Limit to these server names.
323
432
  * @param {number} [opts.timeout=15000] - Per-server init timeout in ms.
324
433
  * @param {boolean} [opts.refresh=false] - Force re-discovery regardless of TTL.
325
- * @returns {Promise<{tools: Array, servers: string[], systemContext: string, denied: Array, close: Function}>}
434
+ * @returns {Promise<{tools: Array, metaTools: Array, servers: string[], systemContext: string, denied: Array, close: Function}>}
326
435
  */
327
436
  async function createMCPBridge(opts = {}) {
328
437
  if ('policy' in opts) {
@@ -341,43 +450,52 @@ async function createMCPBridge(opts = {}) {
341
450
  if (needsRefresh) {
342
451
  // Discover from IDE configs
343
452
  const discovered = discoverServers(opts.configPaths);
453
+
344
454
  if (discovered.size === 0 && !config) {
345
455
  return { tools: [], servers: [], systemContext: '', denied: [], close: async () => {} };
346
456
  }
347
457
 
348
- // Connect to all discovered servers and list their tools
349
- const freshTools = new Map();
350
- const connectResults = new Map();
351
- const errors = [];
352
-
353
- const toDiscover = opts.servers
354
- ? [...discovered.entries()].filter(([n]) => opts.servers.includes(n))
355
- : [...discovered.entries()];
356
-
357
- await Promise.all(toDiscover.map(async ([name, def]) => {
358
- try {
359
- const result = await connectAndListTools(name, def, timeout);
360
- freshTools.set(name, result.mcpTools);
361
- connectResults.set(name, result.client);
362
- } catch (err) {
363
- errors.push({ server: name, error: err.message });
364
- }
365
- }));
366
-
367
- if (errors.length > 0) {
368
- console.warn('[MCP Bridge] Some servers failed to connect:', errors);
369
- }
458
+ // Only attempt connection when discovery found something.
459
+ // If discovered.size === 0 but config exists, fall through and use the existing config
460
+ // rather than wiping it on a transient discovery failure.
461
+ if (discovered.size > 0) {
462
+ const freshTools = new Map();
463
+ const connectResults = new Map();
464
+ const errors = [];
465
+
466
+ const toDiscover = opts.servers
467
+ ? [...discovered.entries()].filter(([n]) => opts.servers.includes(n))
468
+ : [...discovered.entries()];
469
+
470
+ await Promise.all(toDiscover.map(async ([name, def]) => {
471
+ try {
472
+ const result = await connectAndListTools(name, def, timeout);
473
+ freshTools.set(name, result.mcpTools);
474
+ connectResults.set(name, result.client);
475
+ } catch (err) {
476
+ errors.push({ server: name, error: err.message });
477
+ }
478
+ }));
370
479
 
371
- // Merge with existing config (preserves user's allow/deny)
372
- config = mergeBridgeConfig(config, new Map(toDiscover), freshTools);
480
+ if (errors.length > 0) {
481
+ console.warn('[MCP Bridge] Some servers failed to connect:', errors);
482
+ }
373
483
 
374
- // Write the config file
375
- writeBridgeConfig(bridgePath, config);
376
- console.log(`[MCP Bridge] Wrote ${bridgePath}`);
484
+ // Only write config when at least one server connected successfully.
485
+ // If all servers failed, retain the existing config unchanged so
486
+ // user-curated allow/deny settings are not destroyed on transient failures.
487
+ if (freshTools.size > 0) {
488
+ config = mergeBridgeConfig(config, new Map(toDiscover), freshTools);
489
+ writeBridgeConfig(bridgePath, config);
490
+ console.log(`[MCP Bridge] Wrote ${bridgePath}`);
491
+ } else if (!config) {
492
+ return { tools: [], servers: [], systemContext: '', denied: [], close: async () => {} };
493
+ }
377
494
 
378
- // Close the discovery connections — we'll reconnect below using the config
379
- for (const client of connectResults.values()) {
380
- await killServer(client.child);
495
+ // Close the discovery connections — we'll reconnect below using the config
496
+ for (const client of connectResults.values()) {
497
+ await killServer(client.child);
498
+ }
381
499
  }
382
500
  }
383
501
 
@@ -435,8 +553,11 @@ async function createMCPBridge(opts = {}) {
435
553
  const systemContext = buildSystemContext(connected, tools, denied);
436
554
  if (connected.length > 0) console.log(systemContext);
437
555
 
556
+ const metaTools = buildMetaTools(tools, config?.discovered);
557
+
438
558
  return {
439
559
  tools,
560
+ metaTools,
440
561
  servers: connected,
441
562
  denied,
442
563
  systemContext,
@@ -447,4 +568,4 @@ async function createMCPBridge(opts = {}) {
447
568
  };
448
569
  }
449
570
 
450
- module.exports = { createMCPBridge, discoverServers };
571
+ module.exports = { createMCPBridge, discoverServers, buildMetaTools };
package/src/mcp.js CHANGED
@@ -1,5 +1,5 @@
1
1
  'use strict';
2
2
 
3
- const { createMCPBridge, discoverServers } = require('./mcp-bridge');
3
+ const { createMCPBridge, discoverServers, buildMetaTools } = require('./mcp-bridge');
4
4
 
5
- module.exports = { createMCPBridge, discoverServers };
5
+ module.exports = { createMCPBridge, discoverServers, buildMetaTools };
package/src/planner.js CHANGED
@@ -40,7 +40,7 @@ class Planner {
40
40
  */
41
41
  async plan(goal, context = {}) {
42
42
  if (this._cacheTTL > 0) {
43
- const cacheKey = goal + '|' + (context.info || '');
43
+ const cacheKey = JSON.stringify({ goal, info: context.info || '' });
44
44
  const cached = this._cache.get(cacheKey);
45
45
  if (cached && Date.now() < cached.expiresAt) {
46
46
  return cached.result;
@@ -63,7 +63,7 @@ class Planner {
63
63
  const steps = this._parse(result.text);
64
64
 
65
65
  if (this._cacheTTL > 0) {
66
- const cacheKey = goal + '|' + (context.info || '');
66
+ const cacheKey = JSON.stringify({ goal, info: context.info || '' });
67
67
  this._cache.set(cacheKey, { result: steps, expiresAt: Date.now() + this._cacheTTL });
68
68
  }
69
69
 
@@ -74,7 +74,7 @@ class CLIPipeProvider {
74
74
  /**
75
75
  * Spawn the CLI process, pipe prompt to stdin, collect stdout.
76
76
  * @param {string} prompt
77
- * @param {string[]} [extraArgs=[]] - Additional args prepended to this.args.
77
+ * @param {string[]} [extraArgs=[]] - Additional args appended after this.args.
78
78
  * @returns {Promise<string>}
79
79
  */
80
80
  _spawn(prompt, extraArgs = []) {
package/src/retry.js CHANGED
@@ -14,9 +14,9 @@ const DEFAULT_RETRY_ON = (err) => {
14
14
 
15
15
  class Retry {
16
16
  constructor(options = {}) {
17
- this.maxAttempts = options.maxAttempts || 3;
17
+ this.maxAttempts = options.maxAttempts !== undefined ? options.maxAttempts : 3;
18
18
  this.backoff = options.backoff || 'exponential';
19
- this.timeout = options.timeout || 60000;
19
+ this.timeout = options.timeout !== undefined ? options.timeout : 60000;
20
20
  this.retryOn = options.retryOn || DEFAULT_RETRY_ON;
21
21
  this.jitter = options.jitter !== undefined ? options.jitter : false;
22
22
  }
@@ -30,20 +30,28 @@ class Retry {
30
30
  * @throws {Error} Rethrows the last error when maxAttempts is exhausted or error is not retryable.
31
31
  */
32
32
  async call(fn, options = {}) {
33
- const max = options.maxAttempts || this.maxAttempts;
33
+ const max = options.maxAttempts !== undefined ? options.maxAttempts : this.maxAttempts;
34
34
  const retryOn = options.retryOn || this.retryOn;
35
- const timeout = options.timeout || this.timeout;
35
+ const timeout = options.timeout !== undefined ? options.timeout : this.timeout;
36
36
 
37
37
  for (let attempt = 1; attempt <= max; attempt++) {
38
+ let timeoutId;
38
39
  try {
39
40
  const result = await (timeout
40
- ? Promise.race([fn(), new Promise((_, rej) => setTimeout(() => rej(new TimeoutError('[Retry] Timeout')), timeout))])
41
+ ? Promise.race([
42
+ fn(),
43
+ new Promise((_, rej) => {
44
+ timeoutId = setTimeout(() => rej(new TimeoutError('[Retry] Timeout')), timeout);
45
+ }),
46
+ ])
41
47
  : fn());
42
48
  return result;
43
49
  } catch (err) {
44
50
  if (attempt === max || !retryOn(err)) throw err;
45
51
  const delay = this._delay(attempt);
46
52
  await new Promise(r => setTimeout(r, delay));
53
+ } finally {
54
+ if (timeoutId) clearTimeout(timeoutId);
47
55
  }
48
56
  }
49
57
  }
package/src/scheduler.js CHANGED
@@ -22,7 +22,7 @@ class Scheduler {
22
22
  : [];
23
23
  this._timer = null;
24
24
  this._nextId = this._jobs.length
25
- ? Math.max(...this._jobs.map(j => j.id)) + 1
25
+ ? this._jobs.reduce((max, j) => Math.max(max, j.id), 0) + 1
26
26
  : 1;
27
27
  }
28
28
 
@@ -80,15 +80,16 @@ class Scheduler {
80
80
  try {
81
81
  await handler(job);
82
82
  } catch (err) {
83
- this.onError?.(err, job);
83
+ try { this.onError?.(err, job); } catch { /* swallow onError throws */ }
84
+ } finally {
85
+ this._running.delete(job.id);
86
+ if (job.type === 'once') {
87
+ job.status = 'done';
88
+ } else {
89
+ job.nextRun = this._parseSchedule(job.schedule).toISOString();
90
+ }
91
+ this._save();
84
92
  }
85
- this._running.delete(job.id);
86
- if (job.type === 'once') {
87
- job.status = 'done';
88
- } else {
89
- job.nextRun = this._parseSchedule(job.schedule).toISOString();
90
- }
91
- this._save();
92
93
  }
93
94
  };
94
95
  tick();
@@ -24,7 +24,7 @@ class JsonFileStore {
24
24
  ? JSON.parse(readFileSync(this._path, 'utf8'))
25
25
  : [];
26
26
  this._nextId = this._data.length
27
- ? Math.max(...this._data.map(d => d.id)) + 1
27
+ ? this._data.reduce((max, d) => Math.max(max, d.id), 0) + 1
28
28
  : 1;
29
29
  }
30
30
 
package/src/tools.js CHANGED
@@ -3,5 +3,15 @@
3
3
  const { createBrowsingTools } = require('../tools/browse');
4
4
  const { createMobileTools } = require('../tools/mobile');
5
5
  const { createShellTools } = require('../tools/shell');
6
+ const { createSpawnTool, spawnChild } = require('../tools/spawn');
7
+ const { createDeferTool, readQueue: readDeferQueue } = require('../tools/defer');
6
8
 
7
- module.exports = { createBrowsingTools, createMobileTools, createShellTools };
9
+ module.exports = {
10
+ createBrowsingTools,
11
+ createMobileTools,
12
+ createShellTools,
13
+ createSpawnTool,
14
+ spawnChild,
15
+ createDeferTool,
16
+ readDeferQueue,
17
+ };