bare-agent 0.8.0 → 0.9.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/README.md CHANGED
@@ -11,7 +11,7 @@
11
11
 
12
12
  ```
13
13
 
14
- **Agent orchestration in ~2.4K lines of core. One required dep ([bareguard](https://npmjs.com/package/bareguard)). Apache 2.0.**
14
+ **Agent orchestration in ~2.7K lines of core. One required dep ([bareguard](https://npmjs.com/package/bareguard) ^0.2.0). Apache 2.0.**
15
15
 
16
16
  Lightweight enough to understand completely. Complete enough to not reinvent wheels. Not a framework, not 50,000 lines of opinions — just composable building blocks for agents. Single-gate governance via bareguard: every tool call traverses one policy hook, one audit log, one budget cap.
17
17
 
@@ -76,7 +76,9 @@ Every piece works alone — take what you need, ignore the rest.
76
76
  | **Browsing** | Web navigation, clicking, typing, reading via `barebrowse` (17 tools). Two modes: library tools (inline snapshots, pass to Loop) or CLI session (disk-based snapshots, token-efficient for multi-step flows). Optional `assess` tool (privacy scan) when `wearehere` is installed |
77
77
  | **Mobile** | Android + iOS device control via `baremobile`. Same two modes: library tools (`createMobileTools` — action tools auto-return snapshots) or CLI session (`baremobile` CLI — disk-based snapshots) |
78
78
  | **Shell** | Cross-platform `shell_read`, `shell_grep`, `shell_run` (argv, no shell), `shell_exec` (raw shell). Pure Node — no `grep`/`rg`/`findstr` dependency. Injection-proof `shell_run` for policy-gated use |
79
- | **MCP Bridge** | Auto-discover MCP servers from IDE configs (Claude Code, Cursor, etc.), expose as bareagent tools. Static allow/deny via `.mcp-bridge.json`, `systemContext` for LLM awareness. Runtime policy lives in `Loop({ policy })` — one hook for MCP + native tools alike. Zero deps |
79
+ | **MCP Bridge** | Auto-discover MCP servers from IDE configs (Claude Code, Cursor, etc.), expose as bareagent tools. Static allow/deny via `.mcp-bridge.json`, `systemContext` for LLM awareness. Runtime policy lives in `Loop({ policy })` — one hook for MCP + native tools alike. Returns both bulk `tools` (one per MCP tool) and `metaTools` (`mcp_discover` + `mcp_invoke` for token-thrifty access to large catalogs). Zero deps |
80
+ | **Spawn** | Fork a child bareagent process as a specialist agent. LLM-callable form blocks until child exits; library form returns a handle (`wait`, `onLine`, `kill`). One JSONL channel per child — child stderr captured and re-emitted as `child:stderr` events on the parent stream. Threads `BAREGUARD_AUDIT_PATH` / `BAREGUARD_PARENT_RUN_ID` / `BAREGUARD_BUDGET_FILE` / `BAREGUARD_SPAWN_DEPTH` so the family stitches into one audit + budget. `bareguard ^0.2.0` adds `spawn.ratePerMinute` + `limits.maxDepth` per-family caps |
81
+ | **Defer** | Append a `{action, when}` record to a JSONL queue for a separate waker (cron / systemd timer / `examples/wake.sh`) to fire later. Two-phase governance: emit-time `gate.check` on the `defer` action; fire-time `gate.check` on the inner action when the waker re-invokes. `bareguard ^0.2.0` adds `defer.ratePerMinute` family-wide cap |
80
82
 
81
83
  **Providers:** OpenAI-compatible (OpenAI, OpenRouter, Groq, vLLM, LM Studio), Anthropic, Ollama, CLIPipe (any CLI tool via stdin/stdout with real-time streaming), Fallback, or bring your own (one method: `generate`). All return the same shape — swap freely.
82
84
 
@@ -84,7 +86,7 @@ Every piece works alone — take what you need, ignore the rest.
84
86
 
85
87
  **Cross-language:** Runs as a subprocess. Communicate via JSONL on stdin/stdout from Python, Go, Rust, Ruby, Java, or anything that can spawn a process. Ready-made wrappers in [`contrib/`](contrib/README.md).
86
88
 
87
- **Deps:** 1 required (`bareguard` for governance — single-gate policy + audit + budget). Optional: `cron-parser` (cron expressions), `better-sqlite3` (SQLite store), `barebrowse` (web browsing), `baremobile` (Android + iOS device control), `wearehere` (privacy assessment via barebrowse).
89
+ **Deps:** 1 required (`bareguard ^0.2.0` for governance — single-gate policy + audit + budget + per-family rate caps). Optional: `cron-parser` (cron expressions), `better-sqlite3` (SQLite store), `barebrowse` (web browsing), `baremobile` (Android + iOS device control), `wearehere` (privacy assessment via barebrowse).
88
90
 
89
91
  ---
90
92
 
package/bin/cli.js CHANGED
@@ -1,7 +1,35 @@
1
1
  #!/usr/bin/env node
2
2
  'use strict';
3
3
 
4
+ /**
5
+ * bin/cli.js — bareagent subprocess entry point.
6
+ *
7
+ * Two modes (auto-detected by flag presence):
8
+ *
9
+ * 1. Stdio JSONL mode (no --config):
10
+ * Reads JSONL requests `{ method, params: { goal | messages } }` from stdin,
11
+ * runs Loop with no special tools, emits JSONL events on stdout. Used by
12
+ * contrib/ subprocess wrappers and ad-hoc invocations.
13
+ *
14
+ * 2. Config-driven agent mode (--config <path>):
15
+ * Loads a JSON specialist/orchestrator config, wires the configured tools
16
+ * and bareguard Gate, reads ONE input record from stdin, runs Loop, emits
17
+ * JSONL events on stdout, exits when loop:done fires. This is what the
18
+ * `spawn` tool uses to fork child agents (PRD §10.6).
19
+ *
20
+ * Config schema (v0.9):
21
+ * {
22
+ * "systemPrompt": "string",
23
+ * "provider": "openai" | "anthropic" | "ollama",
24
+ * "model": "gpt-4o-mini" (etc),
25
+ * "tools": ["shell_read", "shell_grep", "spawn", "defer", ...],
26
+ * "gate": { ...bareguard config; humanChannel headless-defaults to deny }
27
+ * }
28
+ */
29
+
4
30
  const { createInterface } = require('node:readline');
31
+ const fs = require('node:fs');
32
+ const path = require('node:path');
5
33
  const { Loop } = require('../src/loop');
6
34
  const { Stream } = require('../src/stream');
7
35
  const { JsonlTransport } = require('../src/transport-jsonl');
@@ -12,60 +40,221 @@ const flag = (name) => {
12
40
  return i >= 0 ? args[i + 1] : undefined;
13
41
  };
14
42
 
15
- const providerName = flag('provider') || 'openai';
16
- const model = flag('model');
43
+ const configPath = flag('config');
44
+
45
+ if (configPath) {
46
+ runConfigMode(configPath).catch((err) => {
47
+ process.stdout.write(JSON.stringify({ type: 'loop:error', data: { source: 'cli', error: err.message } }) + '\n');
48
+ process.exit(1);
49
+ });
50
+ } else {
51
+ runStdioMode();
52
+ }
53
+
54
+ // ─── Mode 2: config-driven ────────────────────────────────────────────────
55
+
56
+ async function runConfigMode(cfgPath) {
57
+ const cfg = readConfig(cfgPath);
58
+ const stream = new Stream({ transport: new JsonlTransport() });
59
+
60
+ // Provider
61
+ const provider = createProvider(cfg.provider || 'openai', cfg.model);
62
+
63
+ // Tools — registry resolved by name from a curated set of built-ins.
64
+ const tools = await resolveTools(cfg.tools || [], { stream });
65
+
66
+ // Bareguard Gate (optional but strongly recommended for spawn children)
67
+ let policy = null;
68
+ let wrapToolsFn = (t) => t;
69
+ if (cfg.gate) {
70
+ try {
71
+ const { Gate } = require('bareguard');
72
+ const { wireGate } = require('../src/bareguard-adapter');
73
+
74
+ // Headless humanChannel default: warn once, deny safely. Overridden if
75
+ // the config explicitly sets humanChannel (rare in JSON, but supported
76
+ // via a require path).
77
+ let humanChannel = cfg.gate.humanChannel;
78
+ if (typeof humanChannel === 'string') {
79
+ // Allow `humanChannel: "./my-channel.js"` — load from a file relative to config.
80
+ const fnPath = path.resolve(path.dirname(cfgPath), humanChannel);
81
+ humanChannel = require(fnPath);
82
+ }
83
+ if (typeof humanChannel !== 'function') {
84
+ let warned = false;
85
+ humanChannel = async (event) => {
86
+ if (!warned) {
87
+ process.stderr.write(`[cli] no humanChannel configured — ${event.kind} on ${event.rule} auto-denying.\n`);
88
+ warned = true;
89
+ }
90
+ return { decision: 'deny' };
91
+ };
92
+ }
93
+
94
+ const gate = new Gate({ ...cfg.gate, humanChannel });
95
+ await gate.init();
96
+ const wired = wireGate(gate);
97
+ policy = wired.policy;
98
+ wrapToolsFn = wired.wrapTools;
99
+ } catch (err) {
100
+ process.stderr.write(`[cli] failed to wire bareguard: ${err.message}. Continuing without policy gate.\n`);
101
+ }
102
+ }
103
+
104
+ // Read ONE input record from stdin (JSON or raw string). Treat blank stdin
105
+ // as no input — let the systemPrompt drive the loop alone.
106
+ const stdin = await readStdin();
107
+ const initialMessage = buildInitialMessage(cfg, stdin);
108
+
109
+ const loop = new Loop({
110
+ provider,
111
+ system: cfg.systemPrompt || null,
112
+ stream,
113
+ policy,
114
+ onError: (err, meta) => {
115
+ process.stderr.write(`[loop:error ${meta.source}] ${err.message}\n`);
116
+ },
117
+ });
118
+
119
+ const wrapped = wrapToolsFn(tools);
120
+ await loop.run([initialMessage], wrapped);
121
+ // Stream's loop:done event has already been emitted; exit clean.
122
+ process.exit(0);
123
+ }
17
124
 
18
- function createProvider() {
19
- if (providerName === 'openai') {
125
+ function readConfig(cfgPath) {
126
+ const abs = path.resolve(cfgPath);
127
+ let raw;
128
+ try { raw = fs.readFileSync(abs, 'utf8'); }
129
+ catch (err) { throw new Error(`[cli] cannot read config at ${abs}: ${err.message}`); }
130
+ try { return JSON.parse(raw); }
131
+ catch (err) { throw new Error(`[cli] config at ${abs} is not valid JSON: ${err.message}`); }
132
+ }
133
+
134
+ function readStdin() {
135
+ return new Promise((resolve) => {
136
+ let buf = '';
137
+ if (process.stdin.isTTY) return resolve('');
138
+ process.stdin.setEncoding('utf8');
139
+ process.stdin.on('data', (chunk) => { buf += chunk; });
140
+ process.stdin.on('end', () => resolve(buf.trim()));
141
+ // Safety: don't hang forever if stdin never closes.
142
+ setTimeout(() => resolve(buf.trim()), 100).unref();
143
+ });
144
+ }
145
+
146
+ function buildInitialMessage(cfg, stdin) {
147
+ if (!stdin) {
148
+ return { role: 'user', content: cfg.defaultPrompt || 'Begin.' };
149
+ }
150
+ // Try to parse as JSON; fall back to raw string.
151
+ let parsed;
152
+ try { parsed = JSON.parse(stdin); } catch { /* fine */ }
153
+ if (parsed && typeof parsed === 'object') {
154
+ if (typeof parsed.content === 'string') {
155
+ return { role: 'user', content: parsed.content };
156
+ }
157
+ return { role: 'user', content: JSON.stringify(parsed) };
158
+ }
159
+ return { role: 'user', content: stdin };
160
+ }
161
+
162
+ async function resolveTools(names, ctx) {
163
+ const tools = [];
164
+ for (const name of names) {
165
+ const resolved = await resolveOneTool(name, ctx);
166
+ if (resolved) tools.push(...(Array.isArray(resolved) ? resolved : [resolved]));
167
+ }
168
+ return tools;
169
+ }
170
+
171
+ async function resolveOneTool(name, ctx) {
172
+ switch (name) {
173
+ case 'shell_read':
174
+ case 'shell_grep':
175
+ case 'shell_run':
176
+ case 'shell_exec': {
177
+ const { createShellTools } = require('../tools/shell');
178
+ const { tools } = createShellTools();
179
+ return tools.find(t => t.name === name) || null;
180
+ }
181
+ case 'shell_*': {
182
+ const { createShellTools } = require('../tools/shell');
183
+ return createShellTools().tools;
184
+ }
185
+ case 'spawn': {
186
+ const { createSpawnTool } = require('../tools/spawn');
187
+ return createSpawnTool({ stream: ctx.stream }).tool;
188
+ }
189
+ case 'defer': {
190
+ const { createDeferTool } = require('../tools/defer');
191
+ return createDeferTool().tool;
192
+ }
193
+ default:
194
+ process.stderr.write(`[cli] unknown tool name in config: ${name}\n`);
195
+ return null;
196
+ }
197
+ }
198
+
199
+ // ─── Mode 1: stdio JSONL (legacy) ─────────────────────────────────────────
200
+
201
+ function runStdioMode() {
202
+ const providerName = flag('provider') || 'openai';
203
+ const model = flag('model');
204
+ const stream = new Stream({ transport: new JsonlTransport() });
205
+ const loop = new Loop({ provider: createProvider(providerName, model), stream });
206
+
207
+ let pending = 0;
208
+ let closing = false;
209
+
210
+ const rl = createInterface({ input: process.stdin });
211
+ rl.on('line', async (line) => {
212
+ pending++;
213
+ try {
214
+ const req = JSON.parse(line);
215
+ const messages = req.params?.messages || [
216
+ { role: 'user', content: req.params?.goal || '' },
217
+ ];
218
+ const result = await loop.run(messages, []);
219
+ stream.emit({ type: 'result', data: result });
220
+ } catch (err) {
221
+ stream.emit({ type: 'error', data: { error: err.message } });
222
+ } finally {
223
+ pending--;
224
+ if (closing && pending === 0) process.exit(0);
225
+ }
226
+ });
227
+
228
+ rl.on('close', () => {
229
+ closing = true;
230
+ if (pending === 0) process.exit(0);
231
+ });
232
+ }
233
+
234
+ // ─── Shared: provider construction ────────────────────────────────────────
235
+
236
+ function createProvider(name, model) {
237
+ if (name === 'openai') {
20
238
  const { OpenAIProvider } = require('../src/provider-openai');
21
239
  return new OpenAIProvider({
22
240
  apiKey: process.env.OPENAI_API_KEY,
23
241
  ...(model && { model }),
24
242
  });
25
243
  }
26
- if (providerName === 'anthropic') {
244
+ if (name === 'anthropic') {
27
245
  const { AnthropicProvider } = require('../src/provider-anthropic');
28
246
  return new AnthropicProvider({
29
247
  apiKey: process.env.ANTHROPIC_API_KEY,
30
248
  ...(model && { model }),
31
249
  });
32
250
  }
33
- if (providerName === 'ollama') {
251
+ if (name === 'ollama') {
34
252
  const { OllamaProvider } = require('../src/provider-ollama');
35
253
  return new OllamaProvider({
36
254
  ...(model && { model }),
37
255
  ...(flag('url') && { url: flag('url') }),
38
256
  });
39
257
  }
40
- process.stderr.write(`Unknown provider: ${providerName}\n`);
258
+ process.stderr.write(`Unknown provider: ${name}\n`);
41
259
  process.exit(1);
42
260
  }
43
-
44
- const stream = new Stream({ transport: new JsonlTransport() });
45
- const loop = new Loop({ provider: createProvider(), stream });
46
-
47
- let pending = 0;
48
- let closing = false;
49
-
50
- const rl = createInterface({ input: process.stdin });
51
- rl.on('line', async (line) => {
52
- pending++;
53
- try {
54
- const req = JSON.parse(line);
55
- const messages = req.params?.messages || [
56
- { role: 'user', content: req.params?.goal || '' },
57
- ];
58
- const result = await loop.run(messages, []);
59
- stream.emit({ type: 'result', data: result });
60
- } catch (err) {
61
- stream.emit({ type: 'error', data: { error: err.message } });
62
- } finally {
63
- pending--;
64
- if (closing && pending === 0) process.exit(0);
65
- }
66
- });
67
-
68
- rl.on('close', () => {
69
- closing = true;
70
- if (pending === 0) process.exit(0);
71
- });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "bare-agent",
3
- "version": "0.8.0",
3
+ "version": "0.9.0",
4
4
  "files": [
5
5
  "index.js",
6
6
  "src/",
@@ -9,7 +9,7 @@
9
9
  "LICENSE",
10
10
  "NOTICE"
11
11
  ],
12
- "description": "Lightweight, composable agent orchestration for autonomous agents. Single-gate governance via bareguard, cross-platform shell tools, MCP bridge. ~2.4K lines core, one required dep.",
12
+ "description": "Lightweight, composable agent orchestration for autonomous agents. Multi-agent primitives (spawn, defer, MCP meta-tools), single-gate governance via bareguard, cross-platform shell tools, MCP bridge. ~2.7K lines core, one required dep.",
13
13
  "license": "Apache-2.0",
14
14
  "author": "hamr0",
15
15
  "repository": {
@@ -44,7 +44,7 @@
44
44
  "governance"
45
45
  ],
46
46
  "dependencies": {
47
- "bareguard": "^0.1.1"
47
+ "bareguard": "^0.2.0"
48
48
  },
49
49
  "optionalDependencies": {
50
50
  "barebrowse": "^0.5.0",
@@ -60,6 +60,6 @@
60
60
  }
61
61
  },
62
62
  "scripts": {
63
- "test": "node --test test/**/*.test.js"
63
+ "test": "node --test --test-force-exit test/**/*.test.js"
64
64
  }
65
65
  }
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');
@@ -308,6 +310,103 @@ function buildSystemContext(servers, tools, denied) {
308
310
  return lines.join('\n');
309
311
  }
310
312
 
313
+ // --- Meta-tools: mcp_discover + mcp_invoke (v0.9) ---
314
+
315
+ /**
316
+ * Build the LLM-callable meta-tool surface from a fully-connected bridge.
317
+ * Shares the underlying tool array and RPC clients with the bulk surface —
318
+ * one set of connections, one factory, two output forms. The user picks
319
+ * `bridge.tools` (bulk) for small catalogs the LLM should see upfront, or
320
+ * `bridge.metaTools` for large catalogs the LLM should discover on demand.
321
+ *
322
+ * Gov shape: when the LLM calls mcp_invoke, the action sent to gate.check
323
+ * is `{ type: 'mcp_invoke', args: { name, args }, _ctx }` — bareguard sees
324
+ * `mcp_invoke` as the type. To deny specific MCP tools, use bareguard's
325
+ * `tools.denyArgPatterns: { mcp_invoke: [/"name":"linear_admin_.*"/] }`
326
+ * or `content.denyPatterns` over the JSON-serialized form. The inner MCP
327
+ * tool name doesn't travel as `action.type` — that's a deliberate v0.9
328
+ * trade for one consistent gate-check call per LLM tool invocation.
329
+ *
330
+ * @param {Array} tools - The bulk-loaded, name-prefixed tools array.
331
+ * @param {string} discoveredAt - ISO timestamp from .mcp-bridge.json.
332
+ * @returns {Array} [mcp_discover, mcp_invoke]
333
+ */
334
+ function buildMetaTools(tools, discoveredAt) {
335
+ // Catalog descriptors: same info the LLM would see for bulk-loaded tools,
336
+ // but exposed via mcp_discover instead of taking up tool-array slots upfront.
337
+ const catalog = tools.map(t => {
338
+ const sep = t.name.indexOf('_');
339
+ return {
340
+ name: t.name,
341
+ description: t.description || '',
342
+ schema: t.parameters || { type: 'object', properties: {} },
343
+ server: sep > 0 ? t.name.slice(0, sep) : t.name,
344
+ tool: sep > 0 ? t.name.slice(sep + 1) : '',
345
+ };
346
+ });
347
+ const byName = new Map(tools.map(t => [t.name, t]));
348
+
349
+ const mcpDiscover = {
350
+ name: 'mcp_discover',
351
+ description:
352
+ '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.',
353
+ parameters: {
354
+ type: 'object',
355
+ properties: {
356
+ refresh: {
357
+ type: 'boolean',
358
+ 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.',
359
+ },
360
+ server: {
361
+ type: 'string',
362
+ description: 'Optional: filter the catalog to one server name.',
363
+ },
364
+ },
365
+ },
366
+ execute: async ({ server } = {}) => {
367
+ const filtered = server
368
+ ? catalog.filter(t => t.server === server)
369
+ : catalog;
370
+ return {
371
+ tools: filtered,
372
+ cachedAt: discoveredAt || new Date().toISOString(),
373
+ count: filtered.length,
374
+ };
375
+ },
376
+ };
377
+
378
+ const mcpInvoke = {
379
+ name: 'mcp_invoke',
380
+ description:
381
+ '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.',
382
+ parameters: {
383
+ type: 'object',
384
+ properties: {
385
+ name: {
386
+ type: 'string',
387
+ description: 'Canonical MCP tool name (from mcp_discover). Format: <server>_<tool>.',
388
+ },
389
+ args: {
390
+ type: 'object',
391
+ description: 'Arguments for the MCP tool, matching its schema (also from mcp_discover).',
392
+ },
393
+ },
394
+ required: ['name'],
395
+ },
396
+ execute: async ({ name, args }) => {
397
+ const tool = byName.get(name);
398
+ if (!tool) {
399
+ throw new ToolError(`mcp_invoke: unknown tool "${name}". Call mcp_discover for the current catalog.`, {
400
+ context: { name, knownNames: [...byName.keys()] },
401
+ });
402
+ }
403
+ return await tool.execute(args || {});
404
+ },
405
+ };
406
+
407
+ return [mcpDiscover, mcpInvoke];
408
+ }
409
+
311
410
  // --- Main entry point ---
312
411
 
313
412
  /**
@@ -316,13 +415,22 @@ function buildSystemContext(servers, tools, denied) {
316
415
  * On subsequent runs, reads .mcp-bridge.json and respects allow/deny per tool.
317
416
  * Re-discovers when TTL expires (default: 24h).
318
417
  *
418
+ * Returns BOTH surfaces (v0.9+):
419
+ * - `tools` — bulk-loaded array of name-prefixed tools (small catalogs;
420
+ * LLM sees them upfront).
421
+ * - `metaTools` — [mcp_discover, mcp_invoke] LLM-callable pair (large catalogs;
422
+ * LLM picks tools dynamically). Shares the same RPC connections.
423
+ *
424
+ * Wire one or the other into Loop's tool array; never both (the LLM would see
425
+ * the same MCP tool twice). Pick by catalog size and token budget.
426
+ *
319
427
  * @param {object} [opts]
320
428
  * @param {string} [opts.bridgePath] - Path to .mcp-bridge.json. Default: .mcp-bridge.json in cwd.
321
429
  * @param {string[]} [opts.configPaths] - IDE config paths for discovery.
322
430
  * @param {string[]} [opts.servers] - Limit to these server names.
323
431
  * @param {number} [opts.timeout=15000] - Per-server init timeout in ms.
324
432
  * @param {boolean} [opts.refresh=false] - Force re-discovery regardless of TTL.
325
- * @returns {Promise<{tools: Array, servers: string[], systemContext: string, denied: Array, close: Function}>}
433
+ * @returns {Promise<{tools: Array, metaTools: Array, servers: string[], systemContext: string, denied: Array, close: Function}>}
326
434
  */
327
435
  async function createMCPBridge(opts = {}) {
328
436
  if ('policy' in opts) {
@@ -435,8 +543,11 @@ async function createMCPBridge(opts = {}) {
435
543
  const systemContext = buildSystemContext(connected, tools, denied);
436
544
  if (connected.length > 0) console.log(systemContext);
437
545
 
546
+ const metaTools = buildMetaTools(tools, config?.discovered);
547
+
438
548
  return {
439
549
  tools,
550
+ metaTools,
440
551
  servers: connected,
441
552
  denied,
442
553
  systemContext,
@@ -447,4 +558,4 @@ async function createMCPBridge(opts = {}) {
447
558
  };
448
559
  }
449
560
 
450
- module.exports = { createMCPBridge, discoverServers };
561
+ 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/retry.js CHANGED
@@ -35,15 +35,23 @@ class Retry {
35
35
  const timeout = 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/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
+ };
package/tools/defer.js ADDED
@@ -0,0 +1,203 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * tools/defer.js — emit a deferred-action record to a JSONL queue.
5
+ *
6
+ * LLM-callable form: `defer({ action, when })` appends ONE JSONL record
7
+ * to the defer queue file and returns `{ id }`. bareagent does NOT wake
8
+ * up later — the running process exits when the loop ends. An external
9
+ * scheduler (cron + `examples/wake.sh`) reads the queue and fires due
10
+ * actions by re-invoking bareagent.
11
+ *
12
+ * Two-phase gate semantics (per bareagent PRD §10.7 + bareguard PRD §14):
13
+ * - At emit (this tool): one gate.check on `{ type: 'defer', args: { action, when }, _ctx }`
14
+ * runs the full pipeline (defer.ratePerMinute, tools.allowlist on `defer`,
15
+ * content.* over the JSON-serialized form). Bareguard does NOT extract
16
+ * args.action and run a second pipeline against it at emit time.
17
+ * - At fire (wake.sh invokes bareagent with the inner action): a separate
18
+ * gate.check runs the full pipeline against the inner action as a fresh
19
+ * action. Two distinct gate.check calls, two distinct audit lines,
20
+ * reconstructable via parent_run_id.
21
+ *
22
+ * Queue file format — one JSON record per line, append-only:
23
+ * { id, ts_emitted, when, action, parent_run_id, status }
24
+ * Status updates are appends, not edits: wake.sh appends
25
+ * { id, status: 'fired', ts }
26
+ * Reconstruction folds by `id` (latest wins).
27
+ *
28
+ * Default queue path: ./bareagent-defers.jsonl (cwd-only, project-scoped).
29
+ * Override via BAREAGENT_DEFER_QUEUE env var or createDeferTool({queuePath}).
30
+ */
31
+
32
+ const fs = require('node:fs');
33
+ const fsp = require('node:fs/promises');
34
+ const path = require('node:path');
35
+ const crypto = require('node:crypto');
36
+
37
+ const DEFAULT_QUEUE_PATH = './bareagent-defers.jsonl';
38
+ const ID_PREFIX = 'def_';
39
+
40
+ /**
41
+ * Generate a sortable, unique id. 9-char base36 timestamp + 20-char hex
42
+ * random. Lexicographically sortable by emit time; unique enough for any
43
+ * realistic defer rate. Same shape as the PRD's `def_01J...` sketch.
44
+ */
45
+ function generateId() {
46
+ const ts = Date.now().toString(36).padStart(9, '0');
47
+ const rand = crypto.randomBytes(10).toString('hex');
48
+ return `${ID_PREFIX}${ts}_${rand}`;
49
+ }
50
+
51
+ /**
52
+ * Resolve the active queue path. Precedence:
53
+ * 1. Caller-supplied option (createDeferTool({ queuePath: '...' }))
54
+ * 2. BAREAGENT_DEFER_QUEUE env var
55
+ * 3. ./bareagent-defers.jsonl
56
+ */
57
+ function resolveQueuePath(option) {
58
+ return option
59
+ || process.env.BAREAGENT_DEFER_QUEUE
60
+ || DEFAULT_QUEUE_PATH;
61
+ }
62
+
63
+ /**
64
+ * Validate a `when` field. Accepts an ISO 8601 timestamp string. Rejects
65
+ * past timestamps loosely (more than 60s in the past) — the wake script
66
+ * would fire them immediately, which is almost always not what the agent
67
+ * meant. Future timestamps within reason are accepted as-is.
68
+ *
69
+ * Returns { ok: true, iso } on success, { ok: false, error } on failure.
70
+ */
71
+ function validateWhen(when) {
72
+ if (typeof when !== 'string' || !when) {
73
+ return { ok: false, error: 'when must be an ISO 8601 timestamp string' };
74
+ }
75
+ const t = Date.parse(when);
76
+ if (Number.isNaN(t)) {
77
+ return { ok: false, error: `when is not a valid ISO 8601 timestamp: ${when}` };
78
+ }
79
+ const driftMs = Date.now() - t;
80
+ if (driftMs > 60_000) {
81
+ return { ok: false, error: `when is more than 60s in the past (drift=${driftMs}ms) — would fire immediately` };
82
+ }
83
+ return { ok: true, iso: new Date(t).toISOString() };
84
+ }
85
+
86
+ /**
87
+ * Validate an `action` field. Must be an object with a string `type`.
88
+ * Anything else is the LLM either confused or trying to defer something
89
+ * meaningless.
90
+ */
91
+ function validateAction(action) {
92
+ if (!action || typeof action !== 'object' || Array.isArray(action)) {
93
+ return { ok: false, error: 'action must be an object' };
94
+ }
95
+ if (typeof action.type !== 'string' || !action.type) {
96
+ return { ok: false, error: 'action.type must be a non-empty string' };
97
+ }
98
+ return { ok: true };
99
+ }
100
+
101
+ /**
102
+ * Append one JSONL record to the queue file. fs.promises.appendFile is
103
+ * atomic for writes < PIPE_BUF on POSIX (4KB on Linux); a JSON record
104
+ * with a small action is well under that.
105
+ */
106
+ async function appendRecord(queuePath, record) {
107
+ const dir = path.dirname(path.resolve(queuePath));
108
+ // Best-effort dir creation; ignore "already exists".
109
+ try { await fsp.mkdir(dir, { recursive: true }); } catch { /* fine */ }
110
+ const line = JSON.stringify(record) + '\n';
111
+ if (line.length > 4000) {
112
+ // Soft guard — if the action payload is huge, the audit-and-fire chain
113
+ // will still work but POSIX atomicity guarantee is gone. Warn.
114
+ process.stderr.write(`[defer] record is ${line.length}B (> ~4KB POSIX_PIPE_BUF) — atomicity not guaranteed\n`);
115
+ }
116
+ await fsp.appendFile(queuePath, line);
117
+ }
118
+
119
+ /**
120
+ * Read the queue and reconstruct the live status of each id by folding
121
+ * append-only status lines (latest wins). Exposed for tests + library
122
+ * users; the wake script does its own jq-based fold.
123
+ */
124
+ async function readQueue(queuePath) {
125
+ const path = resolveQueuePath(queuePath);
126
+ try {
127
+ const text = await fsp.readFile(path, 'utf8');
128
+ const records = {};
129
+ for (const line of text.split('\n')) {
130
+ if (!line.trim()) continue;
131
+ let r;
132
+ try { r = JSON.parse(line); } catch { continue; }
133
+ if (!r.id) continue;
134
+ records[r.id] = { ...records[r.id], ...r };
135
+ }
136
+ return Object.values(records);
137
+ } catch (err) {
138
+ if (err.code === 'ENOENT') return [];
139
+ throw err;
140
+ }
141
+ }
142
+
143
+ /**
144
+ * @param {object} [options]
145
+ * @param {string} [options.queuePath] - Override queue file path.
146
+ * @returns {{tool: object, readQueue: Function}}
147
+ */
148
+ function createDeferTool(options = {}) {
149
+ const queuePath = resolveQueuePath(options.queuePath);
150
+
151
+ const tool = {
152
+ name: 'defer',
153
+ description:
154
+ 'Append a deferred action to the queue. The action will be fired at or after `when` by the external wake script (cron + examples/wake.sh). bareagent does NOT wake up — the queue is project-scoped JSONL on disk. Returns { id }. Use sparingly: defer.ratePerMinute caps emits per agent family (default 15/min in bareguard 0.2).',
155
+ parameters: {
156
+ type: 'object',
157
+ properties: {
158
+ action: {
159
+ type: 'object',
160
+ description: 'The action to fire. Must have a string `type` field naming a tool the wake-time agent can invoke (e.g. `{ type: "spawn", args: { config: "specialists/check-ci.json" } }`).',
161
+ },
162
+ when: {
163
+ type: 'string',
164
+ description: 'ISO 8601 timestamp for when to fire (e.g. "2026-04-30T18:00:00Z"). Must not be more than 60s in the past.',
165
+ },
166
+ },
167
+ required: ['action', 'when'],
168
+ },
169
+ execute: async ({ action, when }) => {
170
+ const a = validateAction(action);
171
+ if (!a.ok) throw new Error(`[defer] ${a.error}`);
172
+ const w = validateWhen(when);
173
+ if (!w.ok) throw new Error(`[defer] ${w.error}`);
174
+
175
+ const record = {
176
+ id: generateId(),
177
+ ts_emitted: new Date().toISOString(),
178
+ when: w.iso,
179
+ action,
180
+ parent_run_id:
181
+ process.env.BAREGUARD_RUN_ID
182
+ || process.env.BAREGUARD_PARENT_RUN_ID
183
+ || null,
184
+ status: 'pending',
185
+ };
186
+ await appendRecord(queuePath, record);
187
+ return { id: record.id };
188
+ },
189
+ };
190
+
191
+ return {
192
+ tool,
193
+ readQueue: () => readQueue(queuePath),
194
+ queuePath,
195
+ };
196
+ }
197
+
198
+ module.exports = {
199
+ createDeferTool,
200
+ readQueue,
201
+ generateId, // exported for tests
202
+ resolveQueuePath, // exported for tests
203
+ };
package/tools/spawn.js ADDED
@@ -0,0 +1,242 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * tools/spawn.js — fork a child bareagent process.
5
+ *
6
+ * LLM-callable form: `spawn({ config, input? })` blocks until the child
7
+ * exits and returns the child's final result. Per the PRD: LLMs don't
8
+ * manage handles across tool calls, so blocking is the only sane LLM
9
+ * surface. Library callers can use the lower-level `spawnChild()` export
10
+ * for fire-and-forget / handle-based use.
11
+ *
12
+ * The child is bareagent itself, invoked as:
13
+ * <node> <bin/cli.js> --config <config-path>
14
+ *
15
+ * Env-var threading (per bareguard 0.1.1+ stitching contract):
16
+ * - BAREGUARD_AUDIT_PATH — single audit file across the family
17
+ * - BAREGUARD_BUDGET_FILE — shared budget ledger
18
+ * - BAREGUARD_PARENT_RUN_ID — parent's run_id becomes child's parent
19
+ * - BAREGUARD_SPAWN_DEPTH — incremented; bareguard.limits.maxDepth caps it
20
+ *
21
+ * Stream model (per v0.9 §10.6 decision):
22
+ * ONE JSONL channel per child. Child stdout is the structured event
23
+ * stream. Child stderr is captured here and re-emitted as
24
+ * `{type: 'child:stderr', text, ts}` events on the parent's stream
25
+ * (if any). No two-channel split.
26
+ *
27
+ * Action shape sent to gate.check (when wired through wireGate):
28
+ * { type: 'spawn', args: { config, input }, _ctx }
29
+ * Bareguard treats `args` as opaque — content patterns scan the
30
+ * JSON-serialized form. spawn.ratePerMinute (bareguard 0.2+) caps emits
31
+ * per-family.
32
+ */
33
+
34
+ const { spawn: cpSpawn } = require('node:child_process');
35
+ const path = require('node:path');
36
+ const readline = require('node:readline');
37
+
38
+ const DEFAULT_TIMEOUT_MS = 10 * 60 * 1000; // 10 min — children should finish or be killed
39
+
40
+ /**
41
+ * Resolve the bareagent CLI path. Prefers the local repo's bin/cli.js so
42
+ * the test suite + dev runs use the in-tree CLI; falls back to npx.
43
+ */
44
+ function resolveCliPath() {
45
+ // tools/spawn.js → ../bin/cli.js (works in dev tree and when installed via npm)
46
+ return path.resolve(__dirname, '..', 'bin', 'cli.js');
47
+ }
48
+
49
+ /**
50
+ * Library-level: spawn a child and return a handle.
51
+ *
52
+ * Returns: {
53
+ * wait() — Promise<{ text, usage, cost, error, events }>
54
+ * onLine(fn) — subscribe to every JSONL event from child stdout
55
+ * kill(sig?) — terminate the child
56
+ * pid — child process id
57
+ * }
58
+ *
59
+ * Use this from library code; the LLM-callable tool below wraps it with blocking semantics.
60
+ */
61
+ function spawnChild({ config, input, cliPath, timeoutMs, stream } = {}) {
62
+ if (typeof config !== 'string' || !config) {
63
+ throw new Error('[spawn] requires { config: <path> }');
64
+ }
65
+ const cli = cliPath || resolveCliPath();
66
+ const child = cpSpawn(process.execPath, [cli, '--config', config], {
67
+ stdio: ['pipe', 'pipe', 'pipe'],
68
+ env: {
69
+ ...process.env,
70
+ BAREGUARD_AUDIT_PATH: process.env.BAREGUARD_AUDIT_PATH || '',
71
+ BAREGUARD_BUDGET_FILE: process.env.BAREGUARD_BUDGET_FILE || '',
72
+ BAREGUARD_PARENT_RUN_ID: process.env.BAREGUARD_RUN_ID
73
+ || process.env.BAREGUARD_PARENT_RUN_ID
74
+ || '',
75
+ BAREGUARD_SPAWN_DEPTH: String((Number(process.env.BAREGUARD_SPAWN_DEPTH) || 0) + 1),
76
+ },
77
+ });
78
+
79
+ if (input !== undefined) {
80
+ child.stdin.write(JSON.stringify(input) + '\n');
81
+ }
82
+ child.stdin.end();
83
+
84
+ const events = [];
85
+ const lineSubscribers = [];
86
+ const onLine = (fn) => { lineSubscribers.push(fn); return () => {
87
+ const i = lineSubscribers.indexOf(fn);
88
+ if (i >= 0) lineSubscribers.splice(i, 1);
89
+ }; };
90
+
91
+ // stdout — JSONL events from the child loop
92
+ const outRl = readline.createInterface({ input: child.stdout, crlfDelay: Infinity });
93
+ outRl.on('line', (line) => {
94
+ if (!line) return;
95
+ let event;
96
+ try { event = JSON.parse(line); }
97
+ catch {
98
+ // Not JSON — treat as raw text on the child's stdout (rare; surface as event)
99
+ event = { type: 'child:stdout_raw', text: line, ts: new Date().toISOString() };
100
+ }
101
+ events.push(event);
102
+ for (const fn of lineSubscribers) {
103
+ try { fn(event); } catch (err) {
104
+ // never let a subscriber kill the read loop
105
+ process.stderr.write(`[spawn] onLine subscriber threw: ${err.message}\n`);
106
+ }
107
+ }
108
+ });
109
+
110
+ // stderr — re-emit as child:stderr events on the same JSONL channel.
111
+ // Per the v0.9 decision: one stream per child. Wake.sh captures everything
112
+ // (events + debug) by redirecting child stdout alone; stderr was the
113
+ // *parent's* problem to consolidate into the JSONL stream.
114
+ const errRl = readline.createInterface({ input: child.stderr, crlfDelay: Infinity });
115
+ errRl.on('line', (line) => {
116
+ if (!line) return;
117
+ const event = { type: 'child:stderr', text: line, ts: new Date().toISOString() };
118
+ events.push(event);
119
+ if (stream) {
120
+ try { stream.emit(event); } catch { /* swallow */ }
121
+ }
122
+ });
123
+
124
+ // Pre-register close-event promises NOW (not lazily inside child.on('exit')).
125
+ // The close event can fire before the exit handler runs; attaching .once()
126
+ // after the fact would hang forever.
127
+ const outClosePromise = new Promise(r => outRl.once('close', r));
128
+ const errClosePromise = new Promise(r => errRl.once('close', r));
129
+
130
+ // Timeout: kill child if it overruns. The grace period after SIGTERM is 5s
131
+ // before SIGKILL — enough for the child to flush its final JSONL line.
132
+ let killTimer = null;
133
+ if (timeoutMs && timeoutMs > 0) {
134
+ killTimer = setTimeout(() => {
135
+ try { child.kill('SIGTERM'); } catch { /* already dead */ }
136
+ setTimeout(() => { try { child.kill('SIGKILL'); } catch { /* already dead */ } }, 5000).unref();
137
+ }, timeoutMs);
138
+ killTimer.unref();
139
+ }
140
+
141
+ const exitPromise = new Promise((resolve) => {
142
+ child.on('exit', async (code, signal) => {
143
+ if (killTimer) clearTimeout(killTimer);
144
+ // Drain stdio readlines before resolving — last line may still be in buffer.
145
+ await Promise.all([outClosePromise, errClosePromise]);
146
+ resolve({ code, signal });
147
+ });
148
+ child.on('error', (err) => {
149
+ if (killTimer) clearTimeout(killTimer);
150
+ resolve({ code: null, signal: null, spawnError: err });
151
+ });
152
+ });
153
+
154
+ async function wait() {
155
+ const { code, signal, spawnError } = await exitPromise;
156
+ if (spawnError) {
157
+ return {
158
+ text: '',
159
+ usage: { inputTokens: 0, outputTokens: 0 },
160
+ cost: 0,
161
+ error: `[spawn] failed to spawn child: ${spawnError.message}`,
162
+ events,
163
+ exitCode: null,
164
+ signal: null,
165
+ };
166
+ }
167
+ // Pluck the final loop:done event — that's the canonical child result.
168
+ const done = events.findLast?.(e => e.type === 'loop:done')
169
+ || [...events].reverse().find(e => e.type === 'loop:done');
170
+ if (done) {
171
+ return {
172
+ text: done.data?.text || '',
173
+ usage: done.data?.usage || { inputTokens: 0, outputTokens: 0 },
174
+ cost: done.data?.cost ?? 0,
175
+ error: done.data?.warning || null,
176
+ events,
177
+ exitCode: code,
178
+ signal,
179
+ };
180
+ }
181
+ // No loop:done — child exited abnormally or never reached the LLM.
182
+ const errEvent = events.find(e => e.type === 'loop:error' || e.type === 'error');
183
+ return {
184
+ text: '',
185
+ usage: { inputTokens: 0, outputTokens: 0 },
186
+ cost: 0,
187
+ error: errEvent?.data?.error || `[spawn] child exited (code=${code}, signal=${signal}) without loop:done`,
188
+ events,
189
+ exitCode: code,
190
+ signal,
191
+ };
192
+ }
193
+
194
+ function kill(sig = 'SIGTERM') {
195
+ try { child.kill(sig); } catch { /* already dead */ }
196
+ }
197
+
198
+ return { wait, onLine, kill, pid: child.pid };
199
+ }
200
+
201
+ /**
202
+ * LLM-callable spawn tool. Blocks; returns the child's final result.
203
+ *
204
+ * @param {object} [options]
205
+ * @param {string} [options.cliPath] - Override the bareagent CLI path (default: ./bin/cli.js relative to this file).
206
+ * @param {number} [options.timeoutMs] - Force-kill child after this many ms (default 10 min).
207
+ * @param {object} [options.stream] - bareagent Stream instance — child:stderr events get re-emitted here.
208
+ * @returns {{tool: object, spawnChild: Function}}
209
+ */
210
+ function createSpawnTool(options = {}) {
211
+ const tool = {
212
+ name: 'spawn',
213
+ description:
214
+ 'Fork a child bareagent process with the given config file and optional JSON input. Blocks until the child finishes; returns its final {text, usage, cost, error, events}. Use this to delegate work to a specialist agent. Per-family limits (maxChildren, maxDepth, spawn.ratePerMinute) are enforced by bareguard.',
215
+ parameters: {
216
+ type: 'object',
217
+ properties: {
218
+ config: {
219
+ type: 'string',
220
+ description: 'Path to a bareagent config JSON file (specialist definition). Resolved relative to the parent process cwd.',
221
+ },
222
+ input: {
223
+ description: 'Optional JSON input passed to the child on stdin (any shape; the child config decides how to interpret it).',
224
+ },
225
+ },
226
+ required: ['config'],
227
+ },
228
+ execute: async ({ config, input }) => {
229
+ const handle = spawnChild({
230
+ config,
231
+ input,
232
+ cliPath: options.cliPath,
233
+ timeoutMs: options.timeoutMs ?? DEFAULT_TIMEOUT_MS,
234
+ stream: options.stream,
235
+ });
236
+ return await handle.wait();
237
+ },
238
+ };
239
+ return { tool, spawnChild };
240
+ }
241
+
242
+ module.exports = { createSpawnTool, spawnChild };