bare-agent 0.12.0 → 0.12.2

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.
@@ -0,0 +1,14 @@
1
+ # examples
2
+
3
+ Runnable reference scripts for bare-agent. Each is self-contained — the top-of-file docstring documents flags and required env vars. These ship in the npm package, so you can copy them straight out of `node_modules/bare-agent/examples/`.
4
+
5
+ | Example | What it shows |
6
+ |---------|---------------|
7
+ | [`with-bareguard.mjs`](with-bareguard.mjs) | End-to-end Loop + bareguard wiring: budget cap, fs scope, bash allowlist, audit log, humanChannel. The canonical governed-loop reference. |
8
+ | [`mcp-bridge-poc.js`](mcp-bridge-poc.js) | Auto-discover MCP servers from your IDE configs and expose them as bareagent tools. First run writes `.mcp-bridge.json` (edit to deny tools). |
9
+ | [`mcp-bridge-concurrent.js`](mcp-bridge-concurrent.js) | Soak test: fan out concurrent `barebrowse_browse` calls against real domains (Amazon, Wikipedia, GitHub, a dead host) and verify resilience. |
10
+ | [`orchestrator/`](orchestrator/) | Multi-agent dispatch via `spawn`. Three configs, one system prompt — no orchestrator class, no role types. Roles are JSON files. See its [README](orchestrator/README.md). |
11
+ | [`wake.sh`](wake.sh) + [`wake.md`](wake.md) | Reference cron + jq script for firing deferred actions. The runtime half of `createDeferTool` — bareagent emits, `wake.sh` fires. |
12
+ | [`replay-job.js`](replay-job.js) | Supervised replay POC: record a browser task once with the LLM driving, then replay against fresh snapshots with the LLM as locator-only. Falls back to full reasoning when the locator misses, and patches the trace. |
13
+
14
+ For wiring recipes and API details see the [Integration Guide](../bareagent.context.md); for usage patterns and design philosophy see the [Usage Guide](../docs/02-features/usage-guide.md).
@@ -0,0 +1,106 @@
1
+ #!/usr/bin/env node
2
+ 'use strict';
3
+
4
+ /**
5
+ * Concurrent MCP stress test — real domains, real payloads, varying complexity.
6
+ *
7
+ * Usage:
8
+ * node examples/mcp-bridge-concurrent.js
9
+ */
10
+
11
+ const { readFileSync } = require('node:fs');
12
+ const { createMCPBridge } = require('../src/mcp-bridge');
13
+
14
+ async function main() {
15
+ console.log('Connecting to barebrowse...');
16
+ const bridge = await createMCPBridge({ servers: ['barebrowse'], timeout: 15000 });
17
+
18
+ if (bridge.servers.length === 0) {
19
+ console.error('No servers connected');
20
+ process.exit(1);
21
+ }
22
+
23
+ const browse = bridge.tools.find(t => t.name === 'barebrowse_browse');
24
+
25
+ const tasks = [
26
+ {
27
+ label: 'Amazon NL — 2.5 inch HDD SATA case',
28
+ args: { url: 'https://www.amazon.nl/s?k=2.5+inch+hdd+sata+case', maxChars: 3000 },
29
+ verify: (r) => /amazon/i.test(r) || /hdd|sata|case|behuizing/i.test(r),
30
+ shouldFail: false,
31
+ },
32
+ {
33
+ label: 'Wikipedia — Phoenician language',
34
+ args: { url: 'https://en.wikipedia.org/wiki/Phoenician_language', maxChars: 3000 },
35
+ verify: (r) => /phoenician/i.test(r) || /semitic|canaanite/i.test(r),
36
+ shouldFail: false,
37
+ },
38
+ {
39
+ label: 'Dead domain — should fail or timeout',
40
+ args: { url: 'https://this-domain-does-not-exist-xyz-999.com/page', maxChars: 1000 },
41
+ verify: () => true, // any response is fine, we just want to see it doesn't hang or crash others
42
+ shouldFail: true,
43
+ },
44
+ {
45
+ label: 'GitHub — bare-agent repo',
46
+ args: { url: 'https://github.com/nicobailon/bareagent', maxChars: 2000 },
47
+ verify: (r) => /bare.?agent|orchestration|lightweight/i.test(r),
48
+ shouldFail: false,
49
+ },
50
+ {
51
+ label: 'Slow static page — archive.org',
52
+ args: { url: 'https://web.archive.org/web/2024/https://example.com/', maxChars: 2000 },
53
+ verify: (r) => /example|wayback|archive/i.test(r),
54
+ shouldFail: false,
55
+ },
56
+ ];
57
+
58
+ console.log(`\nFiring ${tasks.length} concurrent browse calls...\n`);
59
+
60
+ const t0 = Date.now();
61
+ const results = await Promise.allSettled(
62
+ tasks.map(t => browse.execute(t.args))
63
+ );
64
+ const elapsed = Date.now() - t0;
65
+
66
+ let passed = 0;
67
+ for (let i = 0; i < tasks.length; i++) {
68
+ const task = tasks[i];
69
+ const r = results[i];
70
+ const status = r.status === 'fulfilled' ? 'OK' : 'FAIL';
71
+ const value = r.status === 'fulfilled' ? r.value : r.reason.message;
72
+ let text = typeof value === 'string' ? value : JSON.stringify(value);
73
+ // If barebrowse saved to disk, read the file to verify actual content
74
+ const fileMatch = text.match(/saved to (.+\.yml)/);
75
+ if (fileMatch) {
76
+ try { text += '\n' + readFileSync(fileMatch[1], 'utf8'); } catch {}
77
+ }
78
+ const correct = r.status === 'fulfilled' && task.verify(text);
79
+
80
+ console.log(`[${i + 1}] ${task.label}`);
81
+ if (task.shouldFail) {
82
+ const handled = r.status === 'rejected' || (r.status === 'fulfilled' && /error|fail|not|ERR_/i.test(text.slice(0, 500)));
83
+ console.log(` Status: ${status} | Error handled gracefully: ${handled ? 'YES' : 'NO'}`);
84
+ console.log(` Preview: ${(r.status === 'rejected' ? r.reason.message : text).slice(0, 120)}...`);
85
+ console.log();
86
+ if (handled) passed++;
87
+ } else {
88
+ console.log(` Status: ${status} | Routed correctly: ${correct ? 'YES' : 'NO'}`);
89
+ console.log(` Size: ${text.length} chars`);
90
+ console.log(` Preview: ${text.slice(0, 120)}...`);
91
+ console.log();
92
+ if (correct) passed++;
93
+ }
94
+ }
95
+
96
+ console.log(`--- Result: ${passed}/${tasks.length} routed correctly in ${elapsed}ms ---`);
97
+ console.log(passed === tasks.length ? 'PASS' : 'FAIL');
98
+
99
+ await bridge.close();
100
+ console.log('Closed.');
101
+ }
102
+
103
+ main().catch(err => {
104
+ console.error(err);
105
+ process.exit(1);
106
+ });
@@ -0,0 +1,77 @@
1
+ #!/usr/bin/env node
2
+ 'use strict';
3
+
4
+ /**
5
+ * MCP Bridge POC — auto-discover MCP servers, expose as bareagent tools, run Loop.
6
+ *
7
+ * First run: discovers servers from IDE configs, writes .mcp-bridge.json (all tools allowed).
8
+ * Edit .mcp-bridge.json to deny tools. Changes survive refresh.
9
+ *
10
+ * Usage:
11
+ * OPENAI_API_KEY=sk-... node examples/mcp-bridge-poc.js Go to example.com and describe it
12
+ * ANTHROPIC_API_KEY=sk-... node examples/mcp-bridge-poc.js --provider anthropic What tools do you have?
13
+ * node examples/mcp-bridge-poc.js --refresh # force re-discovery
14
+ */
15
+
16
+ const { Loop } = require('../src/loop');
17
+ const { createMCPBridge } = require('../src/mcp-bridge');
18
+
19
+ async function main() {
20
+ const args = process.argv.slice(2);
21
+
22
+ // Parse flags
23
+ const providerFlag = args.includes('--provider') ? args.splice(args.indexOf('--provider'), 2)[1] : 'openai';
24
+ const refresh = args.includes('--refresh') ? (args.splice(args.indexOf('--refresh'), 1), true) : false;
25
+
26
+ // Everything left is the prompt
27
+ const prompt = args.join(' ') || 'List all your available tools and describe what each one does.';
28
+
29
+ // Provider
30
+ let provider;
31
+ if (providerFlag === 'anthropic') {
32
+ const { AnthropicProvider } = require('../src/provider-anthropic');
33
+ const apiKey = process.env.ANTHROPIC_API_KEY;
34
+ if (!apiKey) { console.error('Set ANTHROPIC_API_KEY'); process.exit(1); }
35
+ provider = new AnthropicProvider({ apiKey, model: 'claude-sonnet-4-20250514' });
36
+ } else {
37
+ const { OpenAIProvider } = require('../src/provider-openai');
38
+ const apiKey = process.env.OPENAI_API_KEY;
39
+ if (!apiKey) { console.error('Set OPENAI_API_KEY'); process.exit(1); }
40
+ provider = new OpenAIProvider({ apiKey, model: 'gpt-4.1-mini' });
41
+ }
42
+
43
+ // Bridge — reads .mcp-bridge.json if it exists, discovers on first run or TTL expiry
44
+ const bridge = await createMCPBridge({ refresh, timeout: 20000 });
45
+
46
+ if (bridge.servers.length === 0) {
47
+ console.error('No MCP servers found. Check your .mcp.json or ~/.claude/mcp_servers.json');
48
+ process.exit(1);
49
+ }
50
+
51
+ console.log();
52
+ console.log(`Prompt: ${prompt}`);
53
+ console.log('---');
54
+
55
+ const loop = new Loop({
56
+ provider,
57
+ maxRounds: 5,
58
+ system: `You are a helpful assistant with access to MCP tools. Use them to accomplish the user\'s request. Be concise.\n\n${bridge.systemContext}`,
59
+ onToolCall: (name, args) => console.log(`[tool] ${name}(${JSON.stringify(args)})`),
60
+ onText: (text) => console.log(`[response] ${text}`),
61
+ });
62
+
63
+ try {
64
+ const result = await loop.run(
65
+ [{ role: 'user', content: prompt }],
66
+ bridge.tools,
67
+ );
68
+ console.log('---');
69
+ console.log('Done.', { usage: result.usage, cost: result.cost ? `$${result.cost.toFixed(4)}` : 'n/a' });
70
+ } catch (err) {
71
+ console.error('Loop error:', err.message);
72
+ } finally {
73
+ await bridge.close();
74
+ }
75
+ }
76
+
77
+ main();
@@ -0,0 +1,53 @@
1
+ # orchestrator/
2
+
3
+ Reference pattern for multi-agent dispatch. **Three configs and a system
4
+ prompt — no orchestrator class, no role types, no DAG runner.** The LLM
5
+ itself is the dispatcher; the only primitive is `spawn(config, input)`.
6
+
7
+ ```
8
+ orchestrator/
9
+ ├── orchestrator.json # the parent agent — picks a specialist per job
10
+ └── specialists/
11
+ ├── summarizer.json # specialist: summarise text given as input
12
+ └── researcher.json # specialist: shell_grep + shell_read across cwd
13
+ ```
14
+
15
+ ## Run it
16
+
17
+ ```bash
18
+ cd examples/orchestrator
19
+ OPENAI_API_KEY=... echo '{"content":"Summarise the README in this repo."}' \
20
+ | bare-agent --config orchestrator.json
21
+ ```
22
+
23
+ The orchestrator's system prompt tells the model:
24
+
25
+ > You receive a job. Decide which specialist handles it (summarizer or
26
+ > researcher), `spawn` that specialist with the relevant input, and return
27
+ > the specialist's result.
28
+
29
+ The model picks `spawn(config: 'specialists/researcher.json', input: ...)`,
30
+ the researcher reads the README via `shell_read`, returns its summary, the
31
+ orchestrator returns that to stdout.
32
+
33
+ ## What's gated
34
+
35
+ Both `orchestrator.json` and `specialists/*.json` declare a `gate` block
36
+ that wires bareguard. Defaults:
37
+
38
+ - `budget.maxCostUsd: 0.50` — hard cap on the *family* (parent + children
39
+ share via `BAREGUARD_BUDGET_FILE`).
40
+ - `limits.maxTurns: 20`, `limits.maxChildren: 3`, `limits.maxDepth: 2` —
41
+ spawn-tree shape bounds.
42
+ - `spawn.ratePerMinute: 5`, `defer.ratePerMinute: 10` — bareguard 0.2 rate
43
+ caps.
44
+ - `bash.allow` and `fs.readScope` scoped per specialist.
45
+
46
+ ## Why this isn't a framework
47
+
48
+ There's no `class Orchestrator`, no `dispatch_to_specialist()`, no shared
49
+ state object. Roles are *configs*, not types. Adding a new specialist is
50
+ adding one JSON file — no code change to bareagent or the orchestrator.
51
+
52
+ The "intelligence" is the orchestrator's system prompt. Substitute a
53
+ better-written prompt and the same primitives handle a 5-specialist team.
@@ -0,0 +1,14 @@
1
+ {
2
+ "systemPrompt": "You are an orchestrator. You receive a job (the user's first message), decide which specialist handles it, then spawn that specialist with the relevant input and return its result.\n\nAvailable specialists (config paths relative to cwd):\n - specialists/summarizer.json — summarises text content given via input.content\n - specialists/researcher.json — searches and reads files in the project\n\nUse the spawn tool: spawn({ config: '<path>', input: { ...whatever the specialist expects... } }). The result.text from spawn is the specialist's final answer — return it verbatim or with a one-line preface.\n\nDo not attempt the work yourself; you are a router. If no specialist fits, say so plainly.",
3
+ "provider": "openai",
4
+ "model": "gpt-4o-mini",
5
+ "tools": ["spawn"],
6
+ "gate": {
7
+ "budget": { "maxCostUsd": 0.50 },
8
+ "limits": { "maxTurns": 20, "maxChildren": 3, "maxDepth": 2 },
9
+ "spawn": { "ratePerMinute": 5 },
10
+ "defer": { "ratePerMinute": 10 },
11
+ "tools": { "allowlist": ["spawn"] },
12
+ "audit": { "path": "./bareagent-audit.jsonl" }
13
+ }
14
+ }
@@ -0,0 +1,12 @@
1
+ {
2
+ "systemPrompt": "You are a researcher. You have shell_read and shell_grep tools scoped to the current working directory. The user's message describes what to find or read. Use shell_grep to locate matches and shell_read to read whole files. Return a concise summary of what you found (3-8 sentences). Do not invent file contents; if you can't find something, say so.",
3
+ "provider": "openai",
4
+ "model": "gpt-4o-mini",
5
+ "tools": ["shell_read", "shell_grep"],
6
+ "gate": {
7
+ "limits": { "maxTurns": 10 },
8
+ "fs": { "readScope": ["."] },
9
+ "tools": { "allowlist": ["shell_read", "shell_grep"] },
10
+ "audit": { "path": "./bareagent-audit.jsonl" }
11
+ }
12
+ }
@@ -0,0 +1,11 @@
1
+ {
2
+ "systemPrompt": "You are a summarizer. The user's message contains text to summarise. Produce a concise (3-5 sentence) summary capturing the main points. Return the summary as your final response — no tool calls needed unless the input is malformed.",
3
+ "provider": "openai",
4
+ "model": "gpt-4o-mini",
5
+ "tools": [],
6
+ "gate": {
7
+ "limits": { "maxTurns": 5 },
8
+ "tools": { "allowlist": [] },
9
+ "audit": { "path": "./bareagent-audit.jsonl" }
10
+ }
11
+ }
@@ -0,0 +1,213 @@
1
+ #!/usr/bin/env node
2
+ 'use strict';
3
+
4
+ /**
5
+ * Replay-job POC — supervised replay of a recorded browser task.
6
+ *
7
+ * The idea: record once with the LLM driving (full reasoning), then on every
8
+ * subsequent run replay the recorded *intents* against a fresh snapshot, with
9
+ * the LLM acting as a locator/supervisor — not a planner. If the locator
10
+ * can't find a match for a step, fall back to full Loop reasoning from that
11
+ * point and overwrite the trace. That's the self-healing path.
12
+ *
13
+ * Why this isn't a barebrowse feature: barebrowse stays dumb (URL → snapshot,
14
+ * ref → action). The "job" concept (trace storage, replay supervisor, scheduler
15
+ * hookup) is composed from bareagent primitives.
16
+ *
17
+ * Usage:
18
+ * # First run — record:
19
+ * OPENAI_API_KEY=sk-... node examples/replay-job.js --record demo-job \
20
+ * "Go to example.com and click the 'More information' link"
21
+ *
22
+ * # Subsequent runs — replay:
23
+ * OPENAI_API_KEY=sk-... node examples/replay-job.js --replay demo-job
24
+ *
25
+ * # Cron'd:
26
+ * *\/15 * * * * node /path/to/replay-job.js --replay demo-job
27
+ *
28
+ * What's deliberately NOT in this POC (next steps, in order):
29
+ * 1. Fingerprint fast-path: hash selector+role+text per step, try direct
30
+ * match before calling the locator LLM. Brings per-step cost to ~0 on
31
+ * stable UIs (Gmail, IG).
32
+ * 2. PostState assertion: after each step, ask the LLM "does this snapshot
33
+ * reflect the expected outcome?" Without this, replay can silently drift
34
+ * on subtly-changed UIs.
35
+ * 3. Trace confidence: rolling success rate per step; below a threshold,
36
+ * re-derive the whole trace instead of patching one entry.
37
+ * 4. Scheduler integration: wrap as a Scheduler trigger so cron is a config
38
+ * line instead of an OS cron entry.
39
+ */
40
+
41
+ const fs = require('node:fs');
42
+ const path = require('node:path');
43
+ const { Loop } = require('../src/loop');
44
+
45
+ const JOBS_DIR = path.join(__dirname, '..', '.jobs');
46
+
47
+ function parseArgs(argv) {
48
+ const mode = argv.includes('--record') ? 'record'
49
+ : argv.includes('--replay') ? 'replay'
50
+ : null;
51
+ const flagIdx = argv.indexOf(`--${mode}`);
52
+ const name = mode ? argv[flagIdx + 1] : null;
53
+ const goal = argv.slice(flagIdx + 2).join(' ');
54
+ return { mode, name, goal };
55
+ }
56
+
57
+ function jobPath(name) { return path.join(JOBS_DIR, `${name}.json`); }
58
+
59
+ function loadProvider() {
60
+ const apiKey = process.env.OPENAI_API_KEY;
61
+ if (!apiKey) { console.error('Set OPENAI_API_KEY'); process.exit(1); }
62
+ const { OpenAIProvider } = require('../src/provider-openai');
63
+ return new OpenAIProvider({ apiKey, model: 'gpt-4.1-mini' });
64
+ }
65
+
66
+ async function loadBrowseTools() {
67
+ const mod = await import('barebrowse/bareagent');
68
+ return mod.createBrowseTools({});
69
+ }
70
+
71
+ // --- RECORD ----------------------------------------------------------------
72
+ // Run Loop normally. The onToolCall hook captures each step as it happens.
73
+ // The trace stores the LLM's free-text "intent" (the assistant message
74
+ // immediately preceding the tool call) alongside the tool name and args.
75
+ // On replay, intent is what the locator LLM matches against — not args.
76
+ async function record({ name, goal }) {
77
+ const provider = loadProvider();
78
+ const { tools, close } = await loadBrowseTools();
79
+
80
+ const trace = [];
81
+ let pendingIntent = '';
82
+
83
+ const loop = new Loop({
84
+ provider,
85
+ maxRounds: 15,
86
+ onText: (text) => { pendingIntent = text; },
87
+ onToolCall: (toolName, args) => {
88
+ trace.push({ intent: pendingIntent.trim().slice(0, 500), tool: toolName, args });
89
+ pendingIntent = '';
90
+ },
91
+ });
92
+
93
+ try {
94
+ const result = await loop.run([{ role: 'user', content: goal }], tools);
95
+ fs.mkdirSync(JOBS_DIR, { recursive: true });
96
+ fs.writeFileSync(jobPath(name), JSON.stringify({ goal, recordedAt: new Date().toISOString(), trace }, null, 2));
97
+ console.log(`[record] saved ${trace.length} steps to ${jobPath(name)}`);
98
+ console.log(`[record] cost: $${(result.cost ?? 0).toFixed(4)}`);
99
+ } finally {
100
+ await close();
101
+ }
102
+ }
103
+
104
+ // --- REPLAY ----------------------------------------------------------------
105
+ // For each recorded step:
106
+ // 1. Take a fresh snapshot.
107
+ // 2. Ask the LLM (no tools, JSON-mode) to map the recorded intent to a ref
108
+ // in the current snapshot — OR return null if no match.
109
+ // 3. On match: execute the tool with the resolved ref. The recorded args
110
+ // that aren't refs (e.g. type's `text`, goto's `url`) carry over verbatim.
111
+ // 4. On miss: fall back to driving Loop from the remaining goal, capture
112
+ // the new sub-trace, and splice it into the saved trace.
113
+ async function replay({ name }) {
114
+ const job = JSON.parse(fs.readFileSync(jobPath(name), 'utf8'));
115
+ const provider = loadProvider();
116
+ const { tools, close } = await loadBrowseTools();
117
+ const toolByName = Object.fromEntries(tools.map((t) => [t.name, t]));
118
+
119
+ let mutated = false;
120
+
121
+ try {
122
+ for (let i = 0; i < job.trace.length; i++) {
123
+ const step = job.trace[i];
124
+ const tool = toolByName[step.tool];
125
+ if (!tool) throw new Error(`unknown tool in trace: ${step.tool}`);
126
+
127
+ // Steps whose args have no ref (goto, browse, back, scroll) replay verbatim.
128
+ if (!('ref' in (step.args || {}))) {
129
+ console.log(`[replay ${i + 1}/${job.trace.length}] ${step.tool}(${JSON.stringify(step.args)})`);
130
+ await tool.execute(step.args);
131
+ continue;
132
+ }
133
+
134
+ // Ref-bearing steps: snapshot, then ask the LLM to locate.
135
+ const snapshot = await toolByName.snapshot.execute({});
136
+ const snapshotText = typeof snapshot === 'string' ? snapshot : (snapshot?.snapshot ?? JSON.stringify(snapshot));
137
+ const located = await locate({ provider, intent: step.intent, tool: step.tool, snapshotText });
138
+
139
+ if (located.ref) {
140
+ const args = { ...step.args, ref: located.ref };
141
+ console.log(`[replay ${i + 1}/${job.trace.length}] ${step.tool}(ref=${located.ref}) — intent="${step.intent.slice(0, 60)}"`);
142
+ await tool.execute(args);
143
+ } else {
144
+ console.warn(`[replay ${i + 1}/${job.trace.length}] locator miss — falling back to full Loop`);
145
+ const remaining = `Continue this task from the current page. Original goal: ${job.goal}. We have completed ${i} of ${job.trace.length} recorded steps; the next intended step was: "${step.intent}".`;
146
+ const subTrace = await driveFallback({ provider, tools, goal: remaining });
147
+ job.trace.splice(i, job.trace.length - i, ...subTrace);
148
+ mutated = true;
149
+ break;
150
+ }
151
+ }
152
+
153
+ if (mutated) {
154
+ job.recordedAt = new Date().toISOString();
155
+ fs.writeFileSync(jobPath(name), JSON.stringify(job, null, 2));
156
+ console.log(`[replay] trace patched after locator miss; saved ${job.trace.length} steps`);
157
+ } else {
158
+ console.log(`[replay] completed ${job.trace.length} steps without falling back`);
159
+ }
160
+ } finally {
161
+ await close();
162
+ }
163
+ }
164
+
165
+ // Locator: structured-output call, no tools, no loop. One LLM call per ref-bearing step.
166
+ async function locate({ provider, intent, tool, snapshotText }) {
167
+ const system = 'You map a recorded intent to a ref in a current ARIA snapshot. Reply with strict JSON: {"ref": "<ref>"} if confident, or {"ref": null, "reason": "<why>"} if no element matches. Never invent refs not present in the snapshot.';
168
+ const user = `Recorded intent: ${intent}\nTool: ${tool}\n\nCurrent snapshot:\n${snapshotText}\n\nReturn JSON only.`;
169
+ const { text } = await provider.generate({
170
+ messages: [{ role: 'system', content: system }, { role: 'user', content: user }],
171
+ });
172
+ try {
173
+ const match = text.match(/\{[\s\S]*\}/);
174
+ return JSON.parse(match ? match[0] : text);
175
+ } catch {
176
+ return { ref: null, reason: 'unparseable locator response' };
177
+ }
178
+ }
179
+
180
+ // Fallback driver: same shape as record(), just used mid-replay when the
181
+ // locator can't resolve a step. Returns the new sub-trace to splice in.
182
+ async function driveFallback({ provider, tools, goal }) {
183
+ const sub = [];
184
+ let pendingIntent = '';
185
+ const loop = new Loop({
186
+ provider,
187
+ maxRounds: 10,
188
+ onText: (text) => { pendingIntent = text; },
189
+ onToolCall: (toolName, args) => {
190
+ sub.push({ intent: pendingIntent.trim().slice(0, 500), tool: toolName, args });
191
+ pendingIntent = '';
192
+ },
193
+ });
194
+ await loop.run([{ role: 'user', content: goal }], tools);
195
+ return sub;
196
+ }
197
+
198
+ async function main() {
199
+ const { mode, name, goal } = parseArgs(process.argv.slice(2));
200
+ if (!mode || !name) {
201
+ console.error('Usage:\n --record <name> "<goal>"\n --replay <name>');
202
+ process.exit(1);
203
+ }
204
+ if (mode === 'record') {
205
+ if (!goal) { console.error('record mode requires a goal string'); process.exit(1); }
206
+ await record({ name, goal });
207
+ } else {
208
+ if (!fs.existsSync(jobPath(name))) { console.error(`no job at ${jobPath(name)}`); process.exit(1); }
209
+ await replay({ name });
210
+ }
211
+ }
212
+
213
+ main().catch((err) => { console.error(err); process.exit(1); });
@@ -0,0 +1,99 @@
1
+ # wake.sh — defer queue runner
2
+
3
+ `examples/wake.sh` is the reference scheduler that fires bareagent's
4
+ deferred actions. It's not a library primitive — it's a small bash script
5
+ you copy into your project and adapt. Bareagent emits JSONL records via
6
+ the `defer` tool; wake.sh reads the queue and re-invokes bareagent with
7
+ the fired action.
8
+
9
+ ## Installation
10
+
11
+ ```bash
12
+ cp examples/wake.sh /usr/local/bin/bareagent-wake
13
+ chmod +x /usr/local/bin/bareagent-wake
14
+ ```
15
+
16
+ ## Cron entry (every minute)
17
+
18
+ ```cron
19
+ * * * * * /usr/local/bin/bareagent-wake
20
+ ```
21
+
22
+ For project-scoped use, run from the project directory:
23
+
24
+ ```cron
25
+ * * * * * cd /path/to/your/project && /usr/local/bin/bareagent-wake
26
+ ```
27
+
28
+ ## Environment overrides
29
+
30
+ | Variable | Default | What it does |
31
+ |---|---|---|
32
+ | `BAREAGENT_DEFER_QUEUE` | `./bareagent-defers.jsonl` | Path to the JSONL defer queue (must match what the `defer` tool writes). |
33
+ | `ORCHESTRATOR_CONFIG` | `./orchestrator.json` | Bareagent config file the wake script invokes for fired actions. |
34
+ | `LOCKFILE` | `/tmp/bareagent-wake.lock` | Single-instance lock via `flock`. |
35
+ | `BAREAGENT_WAKE_LOG_DIR` | `/tmp/bareagent-wake` | Per-fired-action log directory. |
36
+
37
+ ## Dependencies
38
+
39
+ - `jq` — JSONL fold + filter
40
+ - `flock` (Linux util-linux) — single-instance lock
41
+ - `bare-agent` on `$PATH` — `npm install -g bare-agent` or use the full path
42
+
43
+ ## Behaviour
44
+
45
+ 1. **Folds** the queue: `{id, status, ...}` records are append-only; the
46
+ live status of each id is the *latest* line. jq does the fold.
47
+ 2. **Filters** to `status === 'pending' AND when <= now()`.
48
+ 3. For each due record: appends `{id, status: 'fired', ts}` (atomic JSONL
49
+ append on POSIX), then invokes
50
+ `bare-agent --config $ORCHESTRATOR_CONFIG` with the inner action as
51
+ stdin input. Bareagent runs the action through bareguard's gate as a
52
+ fresh action — full pipeline against the inner action, separate audit
53
+ line.
54
+ 4. After the fired invocation completes: appends `{id, status: 'done|failed', ts, exit_code?}`.
55
+
56
+ ## Why bash and not Node
57
+
58
+ The wake script is OS-level glue — cron + filesystem + subprocess. Keeping
59
+ it as a shell script makes the dependency on bareagent (and only bareagent)
60
+ obvious, and avoids users thinking the script is a library to import.
61
+
62
+ ## Customisation points
63
+
64
+ - **Different queue path:** set `BAREAGENT_DEFER_QUEUE` and pass the same
65
+ to your `defer` tool config (or `BAREAGENT_DEFER_QUEUE` env on the
66
+ bareagent process that emits).
67
+ - **Different orchestrator per action type:** parse `record.action.type`
68
+ and pick a config file accordingly. ~5 lines added inside the per-record
69
+ loop.
70
+ - **Different fire-time semantics:** instead of invoking bareagent CLI,
71
+ shell out to a Node script that wires Loop differently. The defer queue
72
+ schema doesn't constrain you.
73
+
74
+ ## Log rotation
75
+
76
+ `logrotate(8)` is the standard answer. Example
77
+ `/etc/logrotate.d/bareagent-wake`:
78
+
79
+ ```
80
+ /tmp/bareagent-wake/*.log {
81
+ daily
82
+ rotate 7
83
+ compress
84
+ missingok
85
+ notifempty
86
+ }
87
+
88
+ /path/to/your/project/bareagent-defers.jsonl {
89
+ weekly
90
+ rotate 4
91
+ compress
92
+ missingok
93
+ notifempty
94
+ copytruncate
95
+ }
96
+ ```
97
+
98
+ `copytruncate` matters for the queue: it preserves the file inode (which
99
+ the defer tool's `appendFile` depends on for atomic POSIX appends).