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 +5 -3
- package/bin/cli.js +225 -36
- package/package.json +4 -4
- package/src/mcp-bridge.js +130 -19
- package/src/mcp.js +2 -2
- package/src/retry.js +9 -1
- package/src/tools.js +11 -1
- package/tools/defer.js +203 -0
- package/tools/spawn.js +242 -0
package/README.md
CHANGED
|
@@ -11,7 +11,7 @@
|
|
|
11
11
|
|
|
12
12
|
```
|
|
13
13
|
|
|
14
|
-
**Agent orchestration in ~2.
|
|
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
|
|
16
|
-
|
|
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
|
|
19
|
-
|
|
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 (
|
|
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 (
|
|
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: ${
|
|
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.
|
|
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.
|
|
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.
|
|
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
|
|
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
|
-
|
|
227
|
-
|
|
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
|
-
},
|
|
238
|
+
}, ms);
|
|
233
239
|
});
|
|
234
240
|
|
|
241
|
+
await waitClose(150);
|
|
242
|
+
|
|
235
243
|
if (child.exitCode === null) {
|
|
236
244
|
child.kill('SIGTERM');
|
|
237
|
-
await
|
|
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
|
-
|
|
266
|
-
|
|
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([
|
|
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 = {
|
|
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 };
|