bare-agent 0.13.0 → 0.14.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
@@ -66,7 +66,7 @@ Every piece works alone — take what you need, ignore the rest.
66
66
 
67
67
  | Component | What it does |
68
68
  |---|---|
69
- | **Loop** | Think → act → observe → repeat. Calls any LLM, executes your tools, loops until done. Returns estimated USD cost per run. Governance via `Loop({ policy })` — wire bareguard's `Gate` through `wireGate(gate)` and every tool call (native, MCP, browsing, mobile) traverses one chokepoint with per-caller `ctx` routing. Bareguard owns the audit log, budget caps, and halt decisions; Loop respects the verdict. Context engineering via `Loop({ assemble })` — a per-round `assemble(msgs, ctx)` chokepoint to recall/compress/trim the window sent to the model (the seam litectx plugs into); returns a view, the canonical transcript stays intact, fail-open. The exported `unitAssembler`/`toUnits`/`fromUnits` adapter lets a consumer work over a neutral unit `{id, role, content, kind, pinned, atomic, tokensApprox}` — bareagent owns the grammar (atomic tool-pair bundling, pinned system/task, a pairing seatbelt), the consumer owns content + relevance. `onError` + `loop:error` surface every silent-ish failure (callback throw, Checkpoint timeout) |
69
+ | **Loop** | Think → act → observe → repeat. Calls any LLM, executes your tools, loops until done. Returns estimated USD cost per run. Governance via `Loop({ policy })` — wire bareguard's `Gate` through `wireGate(gate)` and every tool call (native, MCP, browsing, mobile) traverses one chokepoint with per-caller `ctx` routing. Bareguard owns the audit log, budget caps, and halt decisions; Loop respects the verdict. Context engineering via `Loop({ assemble })` — a per-round `assemble(msgs, ctx)` chokepoint to recall/compress/trim the window sent to the model (the seam litectx plugs into); returns a view, the canonical transcript stays intact, fail-open. The exported `unitAssembler`/`toUnits`/`fromUnits` adapter lets a consumer work over a neutral unit `{id, role, content, kind, pinned, atomic, tokensApprox}` — bareagent owns the grammar (atomic tool-pair bundling, pinned system/task, a pairing seatbelt), the consumer owns content + relevance. The CE function reads its inputs from the per-run `ctx` — litectx's budget-fitter uses `ctx.budget` (and `ctx.task`), so you **must** populate it via `run(msgs, tools, { ctx })`: an unset `ctx.budget` means the fitter has no budget, keeps everything, and returns the window unchanged — a silent no-op, not a bug (see `examples/litectx-assemble.mjs`). For summary-window compaction the Loop also lends a provider-bound `ctx.summarize(excerpt) => Promise<string>` (R-C6): the consumer owns when/what to summarize and the splice, bareagent makes the one model call (counted against the budget via `onLlmResult`, tagged `kind:'summarize'`). `onError` + `loop:error` surface every silent-ish failure (callback throw, Checkpoint timeout) |
70
70
  | **Planner** | Break a goal into a step DAG via LLM. Built-in caching (`cacheTTL`) |
71
71
  | **runPlan** | Execute steps in parallel waves. Dependency-aware, failure propagation, per-step retry |
72
72
  | **Retry** | Exponential/linear backoff with jitter. Respects `err.retryable` |
@@ -82,11 +82,11 @@ Every piece works alone — take what you need, ignore the rest.
82
82
  | **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 |
83
83
  | **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) |
84
84
  | **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 |
85
- | **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 |
85
+ | **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). Connecting runs a server's `command` (which may come from a cwd `.mcp.json`): pass `confirmServer` to vet each before it spawns — otherwise the bridge warns naming every command it runs. Every RPC is time-bounded (`timeout` for the handshake, `callTimeout` for `tools/call`), and a server that breaks its stdin pipe fails the connection instead of crashing the host. Zero deps |
86
86
  | **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 |
87
87
  | **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 |
88
88
 
89
- **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.
89
+ **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. The OpenAI provider warns if it would send your key over plaintext `http://` to a non-loopback host (use `https`, or drop `apiKey` for keyless local endpoints).
90
90
 
91
91
  **Tools:** Any function is a tool. REST APIs, MCP servers, CLI commands, shell scripts — if it's a function, it works. Built-in: `barebrowse` for web browsing, `baremobile` for Android + iOS device control (both optional) — library tools for inline results or CLI session mode for token-efficient disk-based snapshots.
92
92
 
@@ -1,7 +1,7 @@
1
1
  # bareagent — Integration Guide
2
2
 
3
3
  > For AI assistants and developers wiring bareagent into a project.
4
- > v0.13.0 | Node.js >= 18 | one required dep (`bareguard ^0.4.2`) | Apache 2.0
4
+ > v0.14.0 | Node.js >= 18 | one required dep (`bareguard ^0.4.2`) | Apache 2.0
5
5
  >
6
6
  > Full human guide with composition examples, design philosophy, and recipes: [Usage Guide](docs/02-features/usage-guide.md)
7
7
 
@@ -254,7 +254,19 @@ or `content.denyPatterns` over the serialized action.
254
254
  repo) as well as your home/IDE configs. Pass `confirmServer(name, def)
255
255
  => boolean` to `createMCPBridge` to approve each server **before its
256
256
  command is spawned** (return `false` to skip it; a throw fails closed).
257
- Default trusts all discovered servers — unchanged behavior.
257
+ Default trusts all discovered servers — unchanged behavior. **When no
258
+ `confirmServer` is set, the bridge prints a one-time warning naming every
259
+ command it is about to spawn** (before the first spawn, discovery included),
260
+ so a cwd `.mcp.json` can't run a command unannounced — `confirmServer` is
261
+ still how you actually *gate* it.
262
+
263
+ **RPC timeouts (Unreleased).** Every JSON-RPC round-trip is now bounded, so a
264
+ server that never answers can't hang the bridge or the loop. `opts.timeout`
265
+ (default 15 s) bounds the handshake (`initialize` + `tools/list`);
266
+ `opts.callTimeout` (default 120 s, `0` disables) bounds each `tools/call`. A
267
+ timed-out tool call rejects with a `timed out after Nms` `ToolError` rather
268
+ than blocking forever; a server that breaks its stdin pipe surfaces as a
269
+ failed connection, never an uncaught `EPIPE` crash.
258
270
 
259
271
  ## Wiring with bareguard
260
272
 
@@ -294,7 +306,7 @@ if (result.error?.startsWith('halt:')) {
294
306
  }
295
307
  ```
296
308
 
297
- **Why four pieces (`policy` + `onLlmResult` + `onToolResult` + `filterTools`).** `policy` runs `gate.check` *before* every tool call. `onLlmResult` fires after every successful `provider.generate` — without it, `budget.maxCostUsd` never sees LLM cost and is silently undercounted for token-heavy / tool-light workloads (every chatbot). `onToolResult` fires after every `tool.execute` and carries the per-run `ctx` opaque blob into `gate.record` so per-principal accounting works. `filterTools` is a `gate.allows` pre-filter — denied tools are dropped from the catalog the LLM ever sees, no `gate.check` round-trip per call.
309
+ **Why four pieces (`policy` + `onLlmResult` + `onToolResult` + `filterTools`).** `policy` runs `gate.check` *before* every tool call. `onLlmResult` fires after every successful `provider.generate` — without it, `budget.maxCostUsd` never sees LLM cost and is silently undercounted for token-heavy / tool-light workloads (every chatbot). It also fires for the out-of-band `ctx.summarize` call (R-C6) tagged `kind:'summarize'`; main-loop rounds carry `kind:'turn'` — so summary-window tokens count against the budget too, and a consumer can tell the two apart. `onToolResult` fires after every `tool.execute` and carries the per-run `ctx` opaque blob into `gate.record` so per-principal accounting works. `filterTools` is a `gate.allows` pre-filter — denied tools are dropped from the catalog the LLM ever sees, no `gate.check` round-trip per call.
298
310
 
299
311
  Halt-severity decisions exit the loop cleanly via a typed `HaltError` — full mechanics (sealed `msgs`, `halt:<rule>` error token, `loop:done{halted:true}` event, `throwOnError:true` interaction, `halt:unknown` coalesce) are in the **Halt decisions throw `HaltError`** paragraph below. Short version: check `result.error?.startsWith('halt:')` after the run.
300
312
 
@@ -544,6 +556,8 @@ All return `{ text, toolCalls, usage: { inputTokens, outputTokens } }`. CLIPipe
544
556
 
545
557
  **Error body (v0.11.0):** on an HTTP error the OpenAI/Anthropic/Ollama providers throw a `ProviderError` whose `message` carries the upstream error string. The full parsed response is **not** attached to `err.body` by default (so an unexpected field can't leak through logs that dump the error object). Pass `{ exposeErrorBody: true }` to attach it for debugging.
546
558
 
559
+ **Plaintext-key warning (Unreleased):** the OpenAI provider's `baseUrl` accepts `http://` (for local/OpenAI-compatible endpoints), but a `Bearer` key sent over plaintext http to a **non-loopback** host is exposed on the wire. The provider now warns once when that happens. Loopback hosts (`localhost`/`127.0.0.0/8`/`::1` — local proxies, Ollama-style endpoints) stay silent, since that's the legitimate keyless-local case. The header is **not** stripped (some local proxies want a key), so use `https` for any remote endpoint, or drop `apiKey` when the local endpoint needs none.
560
+
547
561
  **Cost estimation:** Loop automatically estimates USD cost per run based on model and token usage. The `cost` field appears in every `loop.run()` result and in `loop:done` stream events. Pricing covers OpenAI and Anthropic models; unknown models use a default average. To adjust rates, edit `COST_PER_1K` at the top of `src/loop.js`.
548
562
 
549
563
  ## Store options
@@ -0,0 +1,78 @@
1
+ // examples/litectx-assemble.mjs
2
+ //
3
+ // RT-1 — wire litectx's budget-fit `assemble` verb into bareagent's Loop context-assembly seam,
4
+ // and show the ONE footgun: you must populate `ctx.budget`, or the fit is a silent no-op.
5
+ //
6
+ // Run: node examples/litectx-assemble.mjs
7
+ // (runs litectx's real verb if installed — `npm install litectx` — otherwise an inline
8
+ // stand-in with identical budget semantics, so the lesson runs zero-dep.)
9
+ //
10
+ // How the seam works:
11
+ // - Loop({ assemble }) calls `assemble(msgs, ctx)` before EVERY provider call, sending the
12
+ // returned view (the canonical transcript is never mutated).
13
+ // - bareagent's `unitAssembler()` wraps a litectx-shaped `assemble(units, ctx)` into that
14
+ // msgs-level seam — bareagent owns the grammar (atomic tool-pair bundling, pinned system/task),
15
+ // litectx owns content + relevance.
16
+ // - litectx reads its inputs from the per-run `ctx`: `ctx.budget` (token budget) and `ctx.task`
17
+ // (recall intent). You pass that ctx via `loop.run(msgs, tools, { ctx })`.
18
+ //
19
+ // THE FOOTGUN: an unset `ctx.budget` is NOT a litectx bug. With no budget the fit defaults to
20
+ // Infinity, keeps everything, and returns the window unchanged — so litectx's core verb LOOKS
21
+ // broken when it is really a wiring omission. Always pass `ctx.budget`.
22
+
23
+ import { createRequire } from 'node:module';
24
+ const require = createRequire(import.meta.url);
25
+ const { unitAssembler } = require('bare-agent');
26
+
27
+ // litectx's real assemble verb if installed; else an inline stand-in with the same budget semantics
28
+ // (best-effort, recency-anchored, never drops `pinned`, returns the { units, dropped, tokens } envelope).
29
+ let assembleVerb;
30
+ try {
31
+ ({ assemble: assembleVerb } = require('litectx')); // free function on the main entry (litectx 0.11+)
32
+ console.log("using litectx's real assemble() verb\n");
33
+ } catch {
34
+ console.log('litectx not installed — using an inline stand-in with identical budget semantics\n');
35
+ const tok = (u) => (Number.isFinite(u.tokensApprox) ? u.tokensApprox : Math.ceil((u.content?.length ?? 0) / 4));
36
+ assembleVerb = (units, ctx = {}) => {
37
+ const budget = Number.isFinite(ctx.budget) ? ctx.budget : Infinity;
38
+ const keep = new Set();
39
+ let used = 0;
40
+ for (const u of units) if (u.pinned) { keep.add(u.id); used += tok(u); } // pinned always kept
41
+ // newest-first, skip-and-continue greedy over the un-pinned remainder
42
+ const rest = units.map((u, i) => ({ u, i })).filter(({ u }) => !u.pinned).sort((a, b) => b.i - a.i);
43
+ for (const { u } of rest) if (used + tok(u) <= budget) { keep.add(u.id); used += tok(u); }
44
+ const kept = units.filter((u) => keep.has(u.id));
45
+ const dropped = units.filter((u) => !keep.has(u.id)).map((u) => ({ id: u.id, reason: 'budget' }));
46
+ return { units: kept, dropped, tokens: used };
47
+ };
48
+ }
49
+
50
+ // the seam bareagent's Loop calls: assemble(msgs, ctx) => msgs
51
+ const assemble = unitAssembler(assembleVerb);
52
+
53
+ // a transcript grown past budget: a pinned system prompt + the task, then several tool rounds.
54
+ const msgs = [
55
+ { role: 'system', content: 'You are a helpful coding agent. '.repeat(20) },
56
+ { role: 'user', content: 'Find and fix the rate-limiter bug in the auth service.' },
57
+ ];
58
+ for (let i = 1; i <= 8; i++) {
59
+ const id = `call_${i}`;
60
+ msgs.push({ role: 'assistant', content: `Round ${i}: inspecting.`, tool_calls: [{ id, type: 'function', function: { name: 'read_file', arguments: `{"path":"src/auth/round${i}.js"}` } }] });
61
+ msgs.push({ role: 'tool', tool_call_id: id, content: `// round ${i} file contents — `.repeat(40) });
62
+ }
63
+ const before = msgs.length;
64
+
65
+ // (1) ctx.budget SET — the fit drops the oldest un-pinned rounds to fit the budget.
66
+ const fitted = await assemble(msgs, { budget: 400, task: 'rate-limiter bug' });
67
+ console.log(`with ctx.budget=400 : ${before} msgs -> ${fitted.length} msgs (fit dropped ${before - fitted.length})`);
68
+
69
+ // (2) ctx.budget UNSET — the footgun. No budget => Infinity => nothing drops => window unchanged.
70
+ const noop = await assemble(msgs, { task: 'rate-limiter bug' }); // <-- budget missing
71
+ console.log(`with ctx.budget unset: ${before} msgs -> ${noop.length} msgs (fit dropped ${before - noop.length}) <-- silent no-op!`);
72
+
73
+ // the pinned system prompt + task always survive the fit (pin, don't hide):
74
+ console.log(`\nsystem prompt survives the tight fit: ${fitted.some((m) => m.role === 'system')}`);
75
+ console.log(`task (first user turn) survives the tight fit: ${fitted.some((m) => m.role === 'user')}`);
76
+
77
+ console.log('\nLesson: wire it as loop.run(msgs, tools, { ctx: { budget, task } }).');
78
+ console.log('An unset ctx.budget is not a litectx bug — the fitter correctly keeps everything when given no budget.');
package/examples/wake.sh CHANGED
@@ -63,6 +63,14 @@ echo "$PENDING" | while IFS= read -r record; do
63
63
  ID=$(echo "$record" | jq -r '.id')
64
64
  ACTION=$(echo "$record" | jq -c '.action')
65
65
 
66
+ # The defer tool generates ids as def_<base36>_<hex>. Anything else means a
67
+ # hand-edited / untrusted queue line — reject before $ID reaches a file path
68
+ # below (defence-in-depth against path traversal via a crafted id).
69
+ case "$ID" in
70
+ def_[a-z0-9]*_[a-f0-9]*) ;;
71
+ *) echo "[wake $NOW] skipping record with unexpected id: $ID" >&2; continue ;;
72
+ esac
73
+
66
74
  # Append "fired" status line first (defer queue is append-only).
67
75
  printf '{"id":"%s","status":"fired","ts":"%s"}\n' "$ID" "$NOW" >> "$QUEUE"
68
76
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "bare-agent",
3
- "version": "0.13.0",
3
+ "version": "0.14.0",
4
4
  "files": [
5
5
  "index.js",
6
6
  "index.d.ts",
@@ -99,7 +99,7 @@
99
99
  },
100
100
  "devDependencies": {
101
101
  "@types/node": "^22.19.19",
102
- "litectx": "^0.11.0",
102
+ "litectx": "^0.13.0",
103
103
  "typescript": "^5.7.0"
104
104
  }
105
105
  }
package/src/loop.d.ts CHANGED
@@ -27,9 +27,18 @@ export type LoopOptions = {
27
27
  * thrown HaltError propagates. `ctx` is the per-run opaque blob (`run(msgs, tools, { ctx })`), the
28
28
  * same object forwarded to `policy`; litectx reads `ctx.task` (intent) and `ctx.budget`. The
29
29
  * neutral-unit signature `assemble(units, ctx)` is provided by bareagent's msgs⇄units adapter
30
- * (src/context-units.js), which composes over this msgs-level seam.
30
+ * (src/context-units.js), which composes over this msgs-level seam. When `ctx` is an object, the
31
+ * Loop also lends a provider-bound `ctx.summarize(excerpt, opts?) => Promise<string>` (R-C6,
32
+ * non-enumerable): assemble calls it to roll a summary window — bareagent makes the one model
33
+ * call, the consumer owns the trigger/N/splice. Its usage is forwarded to `onLlmResult` so the
34
+ * summary tokens count against the budget.
31
35
  */
32
36
  assemble?: Function | undefined;
37
+ /**
38
+ * - async (event) => void after each LLM call; forwards usage to
39
+ * gate.record (via wireGate). `event.kind` discriminates the source: `'turn'` for a main-loop round,
40
+ * `'summarize'` for an out-of-band `ctx.summarize` call (R-C6). Both count against the budget.
41
+ */
33
42
  onLlmResult?: Function | undefined;
34
43
  onToolResult?: Function | undefined;
35
44
  /**
package/src/loop.js CHANGED
@@ -32,8 +32,14 @@ const { ToolError, HaltError } = require('./errors');
32
32
  * thrown HaltError propagates. `ctx` is the per-run opaque blob (`run(msgs, tools, { ctx })`), the
33
33
  * same object forwarded to `policy`; litectx reads `ctx.task` (intent) and `ctx.budget`. The
34
34
  * neutral-unit signature `assemble(units, ctx)` is provided by bareagent's msgs⇄units adapter
35
- * (src/context-units.js), which composes over this msgs-level seam.
36
- * @property {Function} [onLlmResult]
35
+ * (src/context-units.js), which composes over this msgs-level seam. When `ctx` is an object, the
36
+ * Loop also lends a provider-bound `ctx.summarize(excerpt, opts?) => Promise<string>` (R-C6,
37
+ * non-enumerable): assemble calls it to roll a summary window — bareagent makes the one model
38
+ * call, the consumer owns the trigger/N/splice. Its usage is forwarded to `onLlmResult` so the
39
+ * summary tokens count against the budget.
40
+ * @property {Function} [onLlmResult] - async (event) => void after each LLM call; forwards usage to
41
+ * gate.record (via wireGate). `event.kind` discriminates the source: `'turn'` for a main-loop round,
42
+ * `'summarize'` for an out-of-band `ctx.summarize` call (R-C6). Both count against the budget.
37
43
  * @property {Function} [onToolResult]
38
44
  * @property {number} [maxRounds] - Removed in v0.8; presence throws a migration error.
39
45
  */
@@ -105,6 +111,38 @@ function estimateCost(model, usage) {
105
111
  );
106
112
  }
107
113
 
114
+ // R-C6: default instruction for the provider-bound `ctx.summarize` lent to the assemble seam.
115
+ const DEFAULT_SUMMARY_INSTRUCTION =
116
+ 'You are a precise conversation summarizer. Produce a concise, factual summary of the following ' +
117
+ 'conversation excerpt. Preserve concrete facts, decisions, and identifiers (names, ids, file ' +
118
+ 'paths, numbers), and note any open or unresolved threads. Do not invent information. Output ' +
119
+ 'only the summary prose, with no preamble.';
120
+
121
+ // Flatten an excerpt (array of OpenAI-format messages, or a raw string) into one prose block for the
122
+ // summarizer's single user turn. Rendering to text — rather than forwarding raw messages — sidesteps
123
+ // tool-call/result pairing entirely: a summary input never needs to be a valid wire transcript.
124
+ /**
125
+ * @param {Array<any>|string|null|undefined} excerpt
126
+ * @returns {string}
127
+ */
128
+ function renderForSummary(excerpt) {
129
+ if (excerpt == null) return '';
130
+ if (typeof excerpt === 'string') return excerpt;
131
+ if (!Array.isArray(excerpt)) return String(excerpt);
132
+ const parts = [];
133
+ for (const m of excerpt) {
134
+ if (m == null) continue;
135
+ if (typeof m === 'string') { parts.push(m); continue; }
136
+ const role = m.role || 'message';
137
+ let text = m.content != null ? String(m.content) : '';
138
+ if (Array.isArray(m.tool_calls) && m.tool_calls.length) {
139
+ text += (text ? '\n' : '') + `[tool_calls: ${JSON.stringify(m.tool_calls)}]`;
140
+ }
141
+ parts.push(`${role}: ${text}`);
142
+ }
143
+ return parts.join('\n\n');
144
+ }
145
+
108
146
  class Loop {
109
147
  /**
110
148
  * `policy` is async `(toolName, args, ctx) => true | string`. Recommended wiring: a closure
@@ -250,6 +288,65 @@ class Loop {
250
288
  let lastUsage = { inputTokens: 0, outputTokens: 0 };
251
289
  let totalCost = 0;
252
290
 
291
+ // R-C6: lend a provider-bound summarizer to the assemble seam via `ctx.summarize`. litectx owns
292
+ // the trigger/N/splice (its restorable COMPRESS path keeps summarized turns recoverable by id);
293
+ // bareagent lends ONLY the single model call. Attached NON-ENUMERABLE so it never shows up in the
294
+ // caller's ctx via JSON/iteration/deepEqual — preserving the `assemble(units, ctx)` identity
295
+ // contract (test/loop-assemble.test.js). `summarize(excerpt, opts?) => Promise<string>`:
296
+ // excerpt — array of OpenAI-format messages (or a raw string) litectx wants compressed
297
+ // opts — { instruction?, ...generateOpts } (instruction overrides the default; the rest pass
298
+ // through to provider.generate; temperature defaults to 0 for determinism)
299
+ // The summary call's usage is forwarded to onLlmResult so its tokens count against the budget
300
+ // (BA1 lineage — token-only flows must not be invisible to the gate); a HaltError there is a
301
+ // governance exit and propagates, matching the main-loop onLlmResult contract.
302
+ if (ctx && typeof ctx === 'object') {
303
+ const loop = this;
304
+ /**
305
+ * @param {Array<any>|string} excerpt
306
+ * @param {Record<string, any>} [opts]
307
+ * @returns {Promise<string>}
308
+ */
309
+ const summarize = async (excerpt, opts = {}) => {
310
+ const { instruction, ...genOpts } = opts || {};
311
+ const prompt = [
312
+ { role: 'system', content: instruction || DEFAULT_SUMMARY_INSTRUCTION },
313
+ { role: 'user', content: renderForSummary(excerpt) },
314
+ ];
315
+ const startedAt = Date.now();
316
+ const result = await loop.provider.generate(prompt, [], { temperature: 0, ...genOpts });
317
+ const usage = (result && result.usage) || null;
318
+ const model = loop.provider.model || null;
319
+ const cost = estimateCost(model, usage);
320
+ if (cost !== null) totalCost += cost;
321
+ loop._safeEmit({ type: 'loop:summarize', data: { usage, costUsd: cost, durationMs: Date.now() - startedAt } });
322
+ if (loop.onLlmResult) {
323
+ try {
324
+ await loop.onLlmResult({
325
+ model,
326
+ provider: loop.provider.name || null,
327
+ usage,
328
+ costUsd: cost,
329
+ durationMs: Date.now() - startedAt,
330
+ ctx,
331
+ kind: 'summarize',
332
+ });
333
+ } catch (err) {
334
+ if (err instanceof HaltError) throw err;
335
+ loop._reportError('onLlmResult', err, { phase: 'summarize' });
336
+ }
337
+ }
338
+ return (result && result.text) || '';
339
+ };
340
+ // Fail-OPEN to match the assemble seam's own contract: a frozen / sealed / non-configurable ctx
341
+ // must NOT crash the agent. On failure the seam is simply unavailable (consumers already handle
342
+ // ctx.summarize being absent — it only exists when ctx is an object), reported, never silent.
343
+ try {
344
+ Object.defineProperty(ctx, 'summarize', { value: summarize, enumerable: false, configurable: true, writable: true });
345
+ } catch (err) {
346
+ this._reportError('summarize-attach', err);
347
+ }
348
+ }
349
+
253
350
  try {
254
351
  for (let round = 0; round < HARD_ROUND_LIMIT; round++) {
255
352
  if (this._stopped) break;
@@ -301,6 +398,7 @@ class Loop {
301
398
  costUsd: roundCost,
302
399
  durationMs: Date.now() - llmStartedAt,
303
400
  ctx,
401
+ kind: 'turn',
304
402
  });
305
403
  } catch (err) {
306
404
  if (err instanceof HaltError) throw err;
@@ -52,7 +52,7 @@ export type DeniedTool = {
52
52
  * JSON-RPC stdio client over a spawned MCP server.
53
53
  */
54
54
  export type RpcClient = {
55
- rpc: (method: string, params?: object) => Promise<any>;
55
+ rpc: (method: string, params?: object, timeoutMs?: number) => Promise<any>;
56
56
  notify: (method: string, params?: object) => void;
57
57
  child: import("node:child_process").ChildProcessWithoutNullStreams;
58
58
  stderr: string;
@@ -76,7 +76,9 @@ export type RpcClient = {
76
76
  * @param {string} [opts.bridgePath] - Path to .mcp-bridge.json. Default: .mcp-bridge.json in cwd.
77
77
  * @param {string[]} [opts.configPaths] - IDE config paths for discovery.
78
78
  * @param {string[]} [opts.servers] - Limit to these server names.
79
- * @param {number} [opts.timeout=15000] - Per-server init timeout in ms.
79
+ * @param {number} [opts.timeout=15000] - Per-server handshake timeout in ms (initialize + tools/list).
80
+ * @param {number} [opts.callTimeout=120000] - Per-invocation timeout in ms for tools/call. Bounds a
81
+ * server that accepts a tool call but never responds. Set 0 to disable (unbounded).
80
82
  * @param {boolean} [opts.refresh=false] - Force re-discovery regardless of TTL.
81
83
  * @param {(name: string, def: ServerDef) => boolean | Promise<boolean>} [opts.confirmServer]
82
84
  * Vet each discovered server BEFORE its `command` is spawned. Connecting to an
@@ -92,6 +94,7 @@ export function createMCPBridge(opts?: {
92
94
  configPaths?: string[] | undefined;
93
95
  servers?: string[] | undefined;
94
96
  timeout?: number | undefined;
97
+ callTimeout?: number | undefined;
95
98
  refresh?: boolean | undefined;
96
99
  confirmServer?: ((name: string, def: ServerDef) => boolean | Promise<boolean>) | undefined;
97
100
  }): Promise<{
package/src/mcp-bridge.js CHANGED
@@ -54,7 +54,7 @@ const { ToolError } = require('./errors');
54
54
  /**
55
55
  * JSON-RPC stdio client over a spawned MCP server.
56
56
  * @typedef {object} RpcClient
57
- * @property {(method: string, params?: object) => Promise<any>} rpc
57
+ * @property {(method: string, params?: object, timeoutMs?: number) => Promise<any>} rpc
58
58
  * @property {(method: string, params?: object) => void} notify
59
59
  * @property {import('node:child_process').ChildProcessWithoutNullStreams} child
60
60
  * @property {string} stderr
@@ -215,11 +215,25 @@ function createRpcClient(name, def) {
215
215
  ...(cwd && { cwd }),
216
216
  });
217
217
 
218
- /** @type {Map<number, {resolve: (v: any) => void, reject: (e: any) => void}>} */
218
+ /** @type {Map<number, {resolve: (v: any) => void, reject: (e: any) => void, timer: NodeJS.Timeout | null}>} */
219
219
  const pending = new Map();
220
220
  let nextId = 1;
221
221
  let buffer = '';
222
222
 
223
+ // Settle a pending request exactly once, clearing its timeout timer. Returns
224
+ // false if the id was already settled (response/close/timeout raced) so callers
225
+ // can avoid double-settling. Every settle path (response, close, write error,
226
+ // timeout) funnels through here.
227
+ /** @param {number} id @param {boolean} ok @param {any} payload @returns {boolean} */
228
+ function settle(id, ok, payload) {
229
+ const p = pending.get(id);
230
+ if (!p) return false;
231
+ pending.delete(id);
232
+ if (p.timer) clearTimeout(p.timer);
233
+ if (ok) p.resolve(payload); else p.reject(payload);
234
+ return true;
235
+ }
236
+
223
237
  child.stdout.setEncoding('utf8');
224
238
  child.stdout.on('data', (chunk) => {
225
239
  buffer += chunk;
@@ -231,15 +245,12 @@ function createRpcClient(name, def) {
231
245
  let msg;
232
246
  try { msg = JSON.parse(line); } catch { continue; }
233
247
  if (!msg.id) continue;
234
- const p = pending.get(msg.id);
235
- if (!p) continue;
236
- pending.delete(msg.id);
237
248
  if (msg.error) {
238
- p.reject(new ToolError(`MCP server "${name}": ${msg.error.message}`, {
249
+ settle(msg.id, false, new ToolError(`MCP server "${name}": ${msg.error.message}`, {
239
250
  context: { code: msg.error.code },
240
251
  }));
241
252
  } else {
242
- p.resolve(msg.result);
253
+ settle(msg.id, true, msg.result);
243
254
  }
244
255
  }
245
256
  });
@@ -248,24 +259,49 @@ function createRpcClient(name, def) {
248
259
  child.stderr?.setEncoding('utf8');
249
260
  child.stderr?.on('data', (chunk) => { stderrBuf += chunk; });
250
261
 
262
+ // A child can exit (crash, fast-exit before init, killed) at any moment.
263
+ // Writing to its stdin then emits an 'error' on the pipe; with NO listener,
264
+ // Node re-throws it as an uncaught exception and takes down the HOST process.
265
+ // Swallow it here — pending rpc()s are rejected by the 'close' handler below,
266
+ // and rpc()/notify() guard writability before writing.
267
+ child.stdin.on('error', () => { /* child gone; surfaced via close + write guards */ });
268
+
251
269
  child.on('close', (code) => {
252
- for (const [id, { reject }] of pending) {
253
- reject(new ToolError(`MCP server "${name}" exited (code ${code}). stderr: ${stderrBuf.slice(-500)}`));
270
+ for (const id of [...pending.keys()]) {
271
+ settle(id, false, new ToolError(`MCP server "${name}" exited (code ${code}). stderr: ${stderrBuf.slice(-500)}`));
254
272
  }
255
- pending.clear();
256
273
  });
257
274
 
258
275
  /**
276
+ * Send a JSON-RPC request and await its response. Bounded by `timeoutMs`: a
277
+ * server that accepts the write but never answers (or answers a different id)
278
+ * would otherwise hang the caller forever — only `initialize` used to be
279
+ * bounded, leaving `tools/list` and `tools/call` open-ended. Pass 0 to disable.
259
280
  * @param {string} method
260
281
  * @param {object} [params]
282
+ * @param {number} [timeoutMs=0] - Reject if no response arrives within this many ms (0 = no limit).
261
283
  * @returns {Promise<any>}
262
284
  */
263
- function rpc(method, params = {}) {
285
+ function rpc(method, params = {}, timeoutMs = 0) {
264
286
  const id = nextId++;
265
287
  return new Promise((resolve, reject) => {
266
- pending.set(id, { resolve, reject });
288
+ if (!child.stdin.writable) {
289
+ return reject(new ToolError(`MCP server "${name}" stdin is not writable (process exited or pipe closed). stderr: ${stderrBuf.slice(-500)}`));
290
+ }
291
+ /** @type {NodeJS.Timeout | null} */
292
+ let timer = null;
293
+ if (timeoutMs > 0) {
294
+ timer = setTimeout(() => {
295
+ settle(id, false, new ToolError(`MCP server "${name}" "${method}" timed out after ${timeoutMs}ms. stderr: ${stderrBuf.slice(-500)}`));
296
+ }, timeoutMs);
297
+ timer.unref?.();
298
+ }
299
+ pending.set(id, { resolve, reject, timer });
267
300
  const msg = JSON.stringify({ jsonrpc: '2.0', id, method, params }) + '\n';
268
- child.stdin.write(msg);
301
+ child.stdin.write(msg, (err) => {
302
+ // settle() no-ops if 'close'/timeout already settled this id.
303
+ if (err) settle(id, false, new ToolError(`MCP server "${name}" write failed: ${err.message}. stderr: ${stderrBuf.slice(-500)}`));
304
+ });
269
305
  });
270
306
  }
271
307
 
@@ -274,8 +310,9 @@ function createRpcClient(name, def) {
274
310
  * @param {object} [params]
275
311
  */
276
312
  function notify(method, params = {}) {
313
+ if (!child.stdin.writable) return;
277
314
  const msg = JSON.stringify({ jsonrpc: '2.0', method, params }) + '\n';
278
- child.stdin.write(msg);
315
+ child.stdin.write(msg, () => { /* write errors swallowed via stdin 'error' handler */ });
279
316
  }
280
317
 
281
318
  return { rpc, notify, child, get stderr() { return stderrBuf; } };
@@ -301,16 +338,17 @@ function unwrapContent(content) {
301
338
  /**
302
339
  * @param {string} serverName
303
340
  * @param {McpTool[]} mcpTools
304
- * @param {(method: string, params?: object) => Promise<any>} rpc
341
+ * @param {(method: string, params?: object, timeoutMs?: number) => Promise<any>} rpc
342
+ * @param {number} [callTimeout=0] - Per-invocation timeout (ms) for tools/call; 0 = no limit.
305
343
  * @returns {ToolDef[]}
306
344
  */
307
- function wrapTools(serverName, mcpTools, rpc) {
345
+ function wrapTools(serverName, mcpTools, rpc, callTimeout = 0) {
308
346
  return mcpTools.map(t => ({
309
347
  name: `${serverName}_${t.name}`,
310
348
  description: t.description || '',
311
349
  parameters: t.inputSchema || { type: 'object', properties: {} },
312
350
  execute: async (args) => {
313
- const result = await rpc('tools/call', { name: t.name, arguments: args });
351
+ const result = await rpc('tools/call', { name: t.name, arguments: args }, callTimeout);
314
352
  if (result.isError) {
315
353
  throw new ToolError(unwrapContent(result.content) || 'MCP tool error', {
316
354
  context: { server: serverName, tool: t.name },
@@ -374,21 +412,17 @@ async function connectAndListTools(name, def, timeout = 15000) {
374
412
  const client = createRpcClient(name, def);
375
413
 
376
414
  try {
377
- const init = client.rpc('initialize', {
415
+ // Both handshake round-trips are bounded by `timeout`. tools/list used to be
416
+ // unbounded — a server that answered initialize but never replied to
417
+ // tools/list would hang discovery (and the whole bridge) indefinitely.
418
+ await client.rpc('initialize', {
378
419
  protocolVersion: '2024-11-05',
379
420
  capabilities: {},
380
421
  clientInfo: { name: 'bare-agent', version: '0.5.0' },
381
- });
382
-
383
- let timerId;
384
- const timer = new Promise((_, reject) => {
385
- timerId = setTimeout(() => reject(new ToolError(`MCP server "${name}" init timed out after ${timeout}ms`)), timeout);
386
- });
387
-
388
- try { await Promise.race([init, timer]); } finally { clearTimeout(timerId); }
422
+ }, timeout);
389
423
  client.notify('notifications/initialized');
390
424
 
391
- const { tools: mcpTools } = await client.rpc('tools/list');
425
+ const { tools: mcpTools } = await client.rpc('tools/list', {}, timeout);
392
426
 
393
427
  return { mcpTools, client };
394
428
  } catch (err) {
@@ -562,7 +596,9 @@ function buildMetaTools(tools, discoveredAt) {
562
596
  * @param {string} [opts.bridgePath] - Path to .mcp-bridge.json. Default: .mcp-bridge.json in cwd.
563
597
  * @param {string[]} [opts.configPaths] - IDE config paths for discovery.
564
598
  * @param {string[]} [opts.servers] - Limit to these server names.
565
- * @param {number} [opts.timeout=15000] - Per-server init timeout in ms.
599
+ * @param {number} [opts.timeout=15000] - Per-server handshake timeout in ms (initialize + tools/list).
600
+ * @param {number} [opts.callTimeout=120000] - Per-invocation timeout in ms for tools/call. Bounds a
601
+ * server that accepts a tool call but never responds. Set 0 to disable (unbounded).
566
602
  * @param {boolean} [opts.refresh=false] - Force re-discovery regardless of TTL.
567
603
  * @param {(name: string, def: ServerDef) => boolean | Promise<boolean>} [opts.confirmServer]
568
604
  * Vet each discovered server BEFORE its `command` is spawned. Connecting to an
@@ -583,6 +619,8 @@ async function createMCPBridge(opts = {}) {
583
619
  }
584
620
  const bridgePath = opts.bridgePath || DEFAULT_BRIDGE_PATH();
585
621
  const timeout = opts.timeout || 15000;
622
+ // 0 is a valid explicit "unbounded"; only undefined falls back to the default.
623
+ const callTimeout = opts.callTimeout ?? 120000;
586
624
 
587
625
  // Vet a server before spawning its command. Fail-closed: an undefined hook
588
626
  // trusts all (unchanged behavior); a throw denies.
@@ -594,6 +632,24 @@ async function createMCPBridge(opts = {}) {
594
632
  catch { return false; }
595
633
  };
596
634
 
635
+ // Connecting to a server EXECUTES its `command`, which can originate from a
636
+ // cwd-relative .mcp.json in an untrusted repo (discoverServers reads project
637
+ // configs). With no confirmServer hook, every discovered command runs unvetted.
638
+ // Warn ONCE per call, BEFORE the first spawn — and the first spawn is the
639
+ // discovery phase on a cold/refresh run, not the main-connect phase below.
640
+ let warnedUnvetted = false;
641
+ /** @param {Array<{name: string, command: string, args?: string[]}>} specs */
642
+ const warnUnvettedSpawn = (specs) => {
643
+ if (confirmServer || warnedUnvetted || specs.length === 0) return;
644
+ warnedUnvetted = true;
645
+ const cmds = specs.map(s => `${s.name} → ${s.command} ${(s.args || []).join(' ')}`.trim());
646
+ console.warn(
647
+ `[MCP Bridge] spawning ${specs.length} server command(s) without a confirmServer hook:\n ` +
648
+ cmds.join('\n ') +
649
+ `\n Pass { confirmServer } to vet each command before it runs.`,
650
+ );
651
+ };
652
+
597
653
  let config = readBridgeConfig(bridgePath);
598
654
  const needsRefresh = opts.refresh || !config || isExpired(config);
599
655
 
@@ -621,6 +677,8 @@ async function createMCPBridge(opts = {}) {
621
677
  ? [...discovered.entries()].filter(([n]) => reqServers.includes(n))
622
678
  : [...discovered.entries()];
623
679
 
680
+ warnUnvettedSpawn(toDiscover.map(([name, def]) => ({ name, command: def.command, args: def.args })));
681
+
624
682
  await Promise.all(toDiscover.map(async ([name, def]) => {
625
683
  try {
626
684
  // Denied by confirmServer: skip silently — this is the caller's own
@@ -674,6 +732,11 @@ async function createMCPBridge(opts = {}) {
674
732
  return { tools: [], servers: [], systemContext: '', denied: [], close: async () => {} };
675
733
  }
676
734
 
735
+ // Warn before the main-connect spawn too. On a warm run (config exists, no
736
+ // refresh) this is the first and only spawn; on a cold run the discovery phase
737
+ // already warned, so the once-flag makes this a no-op.
738
+ warnUnvettedSpawn(serverNames.map(n => ({ name: n, command: cfg.servers[n].command, args: cfg.servers[n].args })));
739
+
677
740
  // Connect to servers and wrap only allowed tools
678
741
  /** @type {ToolDef[]} */
679
742
  const tools = [];
@@ -702,7 +765,7 @@ async function createMCPBridge(opts = {}) {
702
765
 
703
766
  // Only wrap tools that are allowed in config
704
767
  const allowed = mcpTools.filter(t => allowedToolNames.includes(t.name));
705
- const wrapped = wrapTools(name, allowed, client.rpc);
768
+ const wrapped = wrapTools(name, allowed, client.rpc, callTimeout);
706
769
 
707
770
  tools.push(...wrapped);
708
771
  children.push(client.child);
@@ -15,10 +15,6 @@ export type OpenAIOptions = {
15
15
  */
16
16
  exposeErrorBody?: boolean | undefined;
17
17
  };
18
- /** @typedef {import('../types').Message} Message */
19
- /** @typedef {import('../types').ToolDef} ToolDef */
20
- /** @typedef {import('../types').ToolCall} ToolCall */
21
- /** @typedef {import('../types').GenerateResult} GenerateResult */
22
18
  /**
23
19
  * @typedef {object} OpenAIOptions
24
20
  * @property {string} [apiKey]
@@ -54,4 +50,5 @@ export class OpenAIProvider {
54
50
  * @returns {Promise<any>}
55
51
  */
56
52
  _request(path: string, body: Record<string, any>): Promise<any>;
53
+ _warnedInsecure: boolean | undefined;
57
54
  }
@@ -9,6 +9,12 @@ const { ProviderError } = require('./errors');
9
9
  /** @typedef {import('../types').ToolCall} ToolCall */
10
10
  /** @typedef {import('../types').GenerateResult} GenerateResult */
11
11
 
12
+ /** @param {string} hostname @returns {boolean} */
13
+ function isLoopbackHost(hostname) {
14
+ const h = hostname.replace(/^\[|\]$/g, ''); // strip IPv6 brackets
15
+ return h === 'localhost' || h === '127.0.0.1' || h === '::1' || h.startsWith('127.');
16
+ }
17
+
12
18
  /**
13
19
  * @typedef {object} OpenAIOptions
14
20
  * @property {string} [apiKey]
@@ -84,6 +90,17 @@ class OpenAIProvider {
84
90
  const transport = url.protocol === 'https:' ? https : http;
85
91
  const payload = JSON.stringify(body);
86
92
 
93
+ // Sending a Bearer key over plaintext http to a non-loopback host exposes
94
+ // it to anyone on-path. Loopback (local proxies / Ollama-style endpoints)
95
+ // is the legitimate keyless case, so only warn for remote http. Warn once.
96
+ if (this.apiKey && url.protocol === 'http:' && !isLoopbackHost(url.hostname) && !this._warnedInsecure) {
97
+ this._warnedInsecure = true;
98
+ console.warn(
99
+ `[OpenAIProvider] sending Authorization key over PLAINTEXT http to ${url.hostname} — ` +
100
+ `the key is exposed on the wire. Use https, or drop the apiKey for keyless local endpoints.`,
101
+ );
102
+ }
103
+
87
104
  const req = transport.request(url, {
88
105
  method: 'POST',
89
106
  headers: {