bare-agent 0.12.2 → 0.13.1

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,225 @@
1
+ 'use strict';
2
+
3
+ // RT-1 — the msgs⇄units adapter for the `assemble` context-assembly seam (src/loop.js).
4
+ //
5
+ // Division of labour (litectx CE-PRD §8.2, line 321 — the frozen contract):
6
+ // - bareagent owns GRAMMAR: msgs→units, units→msgs, atomic bundling of a tool-call + its result(s),
7
+ // `pinned` flags, the pairing seatbelt, and fail-open. Broken tool-pairing and a dropped system
8
+ // prompt are UNREPRESENTABLE here, by construction — not by trusting the consumer.
9
+ // - litectx owns CONTENT + RELEVANCE: a single verb `assemble(units, ctx) → { units, dropped, tokens }`
10
+ // (the AssembleResult envelope — `dropped[]` is load-bearing, ships in the same slice, never silent)
11
+ // that does SELECT (keep / drop / reorder / recall-inject) + COMPRESS (rewrite a unit's `content`) +
12
+ // fit-to-`ctx.budget`, best-effort. It never learns the message grammar. `unitAssembler` unwraps `.units`.
13
+ //
14
+ // The neutral unit (the SOCKET litectx codes against — exactly these 7 enumerable fields):
15
+ // { id, role, content, kind, pinned, atomic, tokensApprox }
16
+ // id — stable within one assemble() call; litectx references units by it.
17
+ // role — 'system' | 'user' | 'assistant' | 'tool' (the message grammar role).
18
+ // content — flat string view litectx reads to score and MAY rewrite to COMPRESS.
19
+ // kind — litectx's memory-node kind ('code'|'doc'|'fact'|'episode') on units litectx
20
+ // INJECTS from recall. Transcript-derived units carry `null` — and that is the
21
+ // CORRECT value, not a placeholder. `role` and `kind` are orthogonal: `role`
22
+ // (user/assistant/tool/system) carries conversational position = bareagent's
23
+ // grammar, present on every unit; `kind` carries litectx's graph-node type, present
24
+ // only on units litectx owns. Typing a live turn would force litectx to learn the
25
+ // provider's conversation structure — the exact coupling the keystone boundary
26
+ // forbids (PRD §1.2 / litectx CE-PRD §8.2). RATIFIED by litectx (2026-06-12): do
27
+ // NOT define transcript-kinds. injected = kinded, transcript = null.
28
+ // pinned — true ⇒ bareagent guarantees the unit is never dropped and never reordered
29
+ // (system prompt, first user/task turn). litectx still sees its tokensApprox so the
30
+ // pin counts against the budget: pin-don't-hide.
31
+ // atomic — group-id (string) | null. litectx keeps/drops units sharing one group-id WHOLE,
32
+ // never split. bareagent pre-bundles an assistant tool-call + ALL its result(s) into
33
+ // one unit, so each bundle is its OWN group (group-id = the unit's id); everything
34
+ // else is null. NOTE: string|null, not boolean — a boolean collapses every bundle
35
+ // under one key and litectx would fit them all-or-nothing (test/context-units.test.js (real-litectx sweep)).
36
+ // tokensApprox — chars/4 estimate over the unit's backing messages, for litectx's budget math.
37
+ //
38
+ // `_msgs` (the verbatim backing messages) is attached NON-ENUMERABLE: it does not appear in the 7-field
39
+ // view, JSON, or iteration. It is bareagent's reconstruction backing — litectx must not read it. A unit
40
+ // with no backing (one litectx minted via recall-inject) is synthesised into a single message on the
41
+ // way out.
42
+
43
+ /** chars/4 token estimate over a list of messages (matches poc2 / the Loop's own heuristic). */
44
+ function approxTokens(msgs) {
45
+ let c = 0;
46
+ for (const m of msgs) {
47
+ if (m.content != null) c += String(m.content).length;
48
+ if (m.tool_calls) c += JSON.stringify(m.tool_calls).length;
49
+ }
50
+ return Math.ceil(c / 4);
51
+ }
52
+
53
+ /** Flat string view of a unit's backing messages — what litectx scores on and may rewrite. */
54
+ function renderContent(msgs) {
55
+ const parts = [];
56
+ for (const m of msgs) {
57
+ if (m.content != null && String(m.content).length) parts.push(String(m.content));
58
+ }
59
+ return parts.join('\n');
60
+ }
61
+
62
+ let _seq = 0;
63
+ /** Attach the verbatim backing + original-content fingerprint as non-enumerable carry. */
64
+ function withBacking(unit, msgs) {
65
+ Object.defineProperty(unit, '_msgs', { value: msgs, enumerable: false, writable: true });
66
+ Object.defineProperty(unit, '_origContent', { value: unit.content, enumerable: false, writable: true });
67
+ return unit;
68
+ }
69
+
70
+ /**
71
+ * msgs → neutral units. Bundles each assistant-tool-call message with the contiguous tool result(s)
72
+ * that answer its ids into ONE atomic unit (so pairing can never be split). system + first user turn
73
+ * are pinned.
74
+ * @param {Array<Record<string, any>>} msgs
75
+ * @returns {Array<Record<string, any>>}
76
+ */
77
+ function toUnits(msgs) {
78
+ const units = [];
79
+ let seenUser = false;
80
+ for (let i = 0; i < msgs.length; i++) {
81
+ const m = msgs[i];
82
+
83
+ if (m.role === 'system') {
84
+ units.push(withBacking({ id: `u${_seq++}`, role: 'system', content: renderContent([m]), kind: null, pinned: true, atomic: null, tokensApprox: approxTokens([m]) }, [m]));
85
+ continue;
86
+ }
87
+ if (m.role === 'user') {
88
+ const pinned = !seenUser; // first user turn = the task; pin it
89
+ seenUser = true;
90
+ units.push(withBacking({ id: `u${_seq++}`, role: 'user', content: renderContent([m]), kind: null, pinned, atomic: null, tokensApprox: approxTokens([m]) }, [m]));
91
+ continue;
92
+ }
93
+ if (m.role === 'assistant' && Array.isArray(m.tool_calls) && m.tool_calls.length) {
94
+ // bundle this assistant message with the contiguous tool results answering its ids
95
+ const ids = new Set(m.tool_calls.map((t) => t.id));
96
+ const group = [m];
97
+ let j = i + 1;
98
+ while (j < msgs.length && msgs[j].role === 'tool' && ids.has(msgs[j].tool_call_id)) {
99
+ group.push(msgs[j]);
100
+ j++;
101
+ }
102
+ // `atomic` is litectx's group-id (string|null), NOT a boolean: units sharing one are kept/dropped
103
+ // whole. bareagent already pre-bundles a tool-call + its result(s) into ONE unit, so each bundle is
104
+ // its OWN group — group-id = the unit's id. (A boolean would collapse every bundle under one key,
105
+ // making litectx fit them all-or-nothing — see test/context-units.test.js (real-litectx sweep).)
106
+ const gid = `u${_seq++}`;
107
+ units.push(withBacking({ id: gid, role: 'assistant', content: renderContent(group.slice(1)), kind: null, pinned: false, atomic: gid, tokensApprox: approxTokens(group) }, group));
108
+ i = j - 1;
109
+ continue;
110
+ }
111
+ // plain assistant text, or a stray tool message (handled by the seatbelt on the way out)
112
+ units.push(withBacking({ id: `u${_seq++}`, role: m.role, content: renderContent([m]), kind: null, pinned: false, atomic: null, tokensApprox: approxTokens([m]) }, [m]));
113
+ }
114
+ return units;
115
+ }
116
+
117
+ /**
118
+ * Drop any tool-result whose tool_call_id has no open assistant tool-call before it, and any assistant
119
+ * tool-call message left with zero surviving results. The final grammar guard: even if litectx hands
120
+ * back something that would orphan a pair, the wire is always valid. Returns a fresh array.
121
+ */
122
+ function pairingSeatbelt(msgs) {
123
+ // pass 1: which tool_call ids actually have a result present?
124
+ const resultIds = new Set();
125
+ for (const m of msgs) if (m.role === 'tool' && m.tool_call_id != null) resultIds.add(m.tool_call_id);
126
+
127
+ const out = [];
128
+ const open = new Set();
129
+ for (const m of msgs) {
130
+ if (m.role === 'assistant' && Array.isArray(m.tool_calls) && m.tool_calls.length) {
131
+ const surviving = m.tool_calls.filter((tc) => resultIds.has(tc.id));
132
+ if (!surviving.length) continue; // assistant tool-call with no results at all → drop the message
133
+ for (const tc of surviving) open.add(tc.id);
134
+ out.push(surviving.length === m.tool_calls.length ? m : { ...m, tool_calls: surviving });
135
+ continue;
136
+ }
137
+ if (m.role === 'tool') {
138
+ if (!open.has(m.tool_call_id)) continue; // orphan result → drop
139
+ open.delete(m.tool_call_id);
140
+ out.push(m);
141
+ continue;
142
+ }
143
+ out.push(m);
144
+ }
145
+ return out;
146
+ }
147
+
148
+ /**
149
+ * units → msgs. Honors drop (absent units), reorder (order of the returned array), recall-inject
150
+ * (units with no backing → one synthesised message), and COMPRESS (a unit whose `content` was rewritten
151
+ * is reconstructed from the new content). Atomic units keep their assistant tool-call message verbatim
152
+ * so pairing holds; a content rewrite lands on the tool RESULT. A multi-result atomic bundle whose
153
+ * content was rewritten is kept VERBATIM — a flat string can't be faithfully split back into N
154
+ * results, and splitting is grammar (bareagent's), not litectx's to attempt. This isn't a special
155
+ * case: litectx's compress() is a pure text→text render that returns verbatim when handed no single
156
+ * parseable format (compress.js — "never returns less than the body losslessly"), so a flattened
157
+ * multi-result unit round-trips unchanged on both sides. RATIFIED by litectx (2026-06-12). The pairing
158
+ * seatbelt is the final guard.
159
+ * @param {Array<Record<string, any>>} units
160
+ * @returns {Array<Record<string, any>>}
161
+ */
162
+ function fromUnits(units) {
163
+ const msgs = [];
164
+ for (const u of units) {
165
+ if (!u || typeof u !== 'object') continue;
166
+ const backing = u._msgs;
167
+
168
+ if (!Array.isArray(backing)) {
169
+ // litectx-minted (recall-inject): synthesise one message from role + content
170
+ msgs.push({ role: u.role || 'user', content: u.content == null ? '' : String(u.content) });
171
+ continue;
172
+ }
173
+
174
+ const edited = u.content !== u._origContent;
175
+ if (!edited) {
176
+ for (const m of backing) msgs.push(m); // verbatim — preserves tool_call_id pairing exactly
177
+ continue;
178
+ }
179
+
180
+ if (!u.atomic) {
181
+ // single backing message, content rewritten
182
+ msgs.push({ ...backing[0], content: u.content == null ? '' : String(u.content) });
183
+ continue;
184
+ }
185
+
186
+ // atomic + content rewritten: assistant message stays verbatim (pairing); rewrite lands on the
187
+ // single result. Multi-result bundle → keep verbatim (unsplittable).
188
+ const results = backing.slice(1);
189
+ if (results.length === 1) {
190
+ msgs.push(backing[0]);
191
+ msgs.push({ ...results[0], content: u.content == null ? '' : String(u.content) });
192
+ } else {
193
+ for (const m of backing) msgs.push(m);
194
+ }
195
+ }
196
+ return pairingSeatbelt(msgs);
197
+ }
198
+
199
+ /**
200
+ * Wrap litectx's `assemble(units, ctx)` verb into the Loop's msgs-level `assemble(msgs, ctx)` seam.
201
+ * litectx ships the **`AssembleResult` envelope** `{ units, dropped, tokens }` (CE-PRD §8.2: `dropped[]`
202
+ * is load-bearing — it ships in the same slice, never silently truncated). This wrapper accepts that
203
+ * envelope (uses `.units`) OR a bare `units` array (a simpler consumer). `dropped`/`tokens` are litectx's
204
+ * accounting; the Loop's seam is msgs-in/msgs-out, so they're not threaded onward here (the canonical
205
+ * transcript already holds every dropped unit by id — restorable on demand).
206
+ * Fail-OPEN at this layer too: any other return shape → the original msgs are sent unchanged. A thrown
207
+ * error (incl. HaltError) is left to the Loop's own fail-open / HaltError handling — not swallowed here.
208
+ * @param {(units: Array<Record<string, any>>, ctx: any) => (any | Promise<any>)} assembleUnits
209
+ * @returns {(msgs: Array<Record<string, any>>, ctx: any) => Promise<Array<Record<string, any>>>}
210
+ */
211
+ function unitAssembler(assembleUnits) {
212
+ if (typeof assembleUnits !== 'function') {
213
+ throw new Error('[context-units] unitAssembler(fn): fn must be (units, ctx) => units');
214
+ }
215
+ return async (msgs, ctx) => {
216
+ const units = toUnits(msgs);
217
+ const out = await assembleUnits(units, ctx);
218
+ // litectx returns { units, dropped, tokens }; a bare array is also accepted. Anything else → fail-open.
219
+ const view = Array.isArray(out) ? out : (out && Array.isArray(out.units) ? out.units : null);
220
+ if (!view) return msgs; // fail-open: unrecognised return → full context
221
+ return fromUnits(view);
222
+ };
223
+ }
224
+
225
+ module.exports = { toUnits, fromUnits, unitAssembler, approxTokens, pairingSeatbelt };
package/src/loop.d.ts CHANGED
@@ -20,6 +20,16 @@ export type LoopOptions = {
20
20
  onError?: Function | undefined;
21
21
  throwOnError?: boolean | undefined;
22
22
  policy?: Function | undefined;
23
+ /**
24
+ * - async (msgs, ctx) => msgs. Context-assembly chokepoint: shape the
25
+ * window sent to the provider each round (e.g. a context-engineering library). Returns a VIEW — the
26
+ * canonical transcript is never mutated. Fail-open (a thrown error degrades to full context); a
27
+ * thrown HaltError propagates. `ctx` is the per-run opaque blob (`run(msgs, tools, { ctx })`), the
28
+ * same object forwarded to `policy`; litectx reads `ctx.task` (intent) and `ctx.budget`. The
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.
31
+ */
32
+ assemble?: Function | undefined;
23
33
  onLlmResult?: Function | undefined;
24
34
  onToolResult?: Function | undefined;
25
35
  /**
@@ -49,6 +59,7 @@ export class Loop {
49
59
  throwOnError: boolean;
50
60
  store: import("../types").Store | null;
51
61
  policy: Function | null;
62
+ assemble: Function | null;
52
63
  onLlmResult: Function | null;
53
64
  onToolResult: Function | null;
54
65
  _stopped: boolean;
package/src/loop.js CHANGED
@@ -26,6 +26,13 @@ const { ToolError, HaltError } = require('./errors');
26
26
  * @property {Function} [onError]
27
27
  * @property {boolean} [throwOnError]
28
28
  * @property {Function} [policy]
29
+ * @property {Function} [assemble] - async (msgs, ctx) => msgs. Context-assembly chokepoint: shape the
30
+ * window sent to the provider each round (e.g. a context-engineering library). Returns a VIEW — the
31
+ * canonical transcript is never mutated. Fail-open (a thrown error degrades to full context); a
32
+ * thrown HaltError propagates. `ctx` is the per-run opaque blob (`run(msgs, tools, { ctx })`), the
33
+ * same object forwarded to `policy`; litectx reads `ctx.task` (intent) and `ctx.budget`. The
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.
29
36
  * @property {Function} [onLlmResult]
30
37
  * @property {Function} [onToolResult]
31
38
  * @property {number} [maxRounds] - Removed in v0.8; presence throws a migration error.
@@ -132,6 +139,10 @@ class Loop {
132
139
  throw new Error('[Loop] options.policy must be a function (toolName, args, ctx) => true | string');
133
140
  }
134
141
  this.policy = options.policy || null;
142
+ if (options.assemble != null && typeof options.assemble !== 'function') {
143
+ throw new Error('[Loop] options.assemble must be a function (msgs, info) => msgs');
144
+ }
145
+ this.assemble = options.assemble || null;
135
146
  if (options.onLlmResult != null && typeof options.onLlmResult !== 'function') {
136
147
  throw new Error('[Loop] options.onLlmResult must be a function');
137
148
  }
@@ -243,10 +254,29 @@ class Loop {
243
254
  for (let round = 0; round < HARD_ROUND_LIMIT; round++) {
244
255
  if (this._stopped) break;
245
256
 
257
+ // RT-1: context-assembly chokepoint. Let a caller (e.g. a context-engineering library) shape
258
+ // the window sent to the provider this round. Returns a VIEW — the canonical `msgs` transcript
259
+ // is never mutated, so result.msgs stays complete and correct. Fail-OPEN: an assembly error
260
+ // degrades to sending full context (a context-optimizer bug must not halt the agent); a thrown
261
+ // HaltError is a governance exit and propagates (same contract as onLlmResult).
262
+ let toSend = msgs;
263
+ if (this.assemble) {
264
+ try {
265
+ const view = await this.assemble(msgs, ctx);
266
+ if (Array.isArray(view)) {
267
+ toSend = view;
268
+ this._safeEmit({ type: 'loop:assemble', data: { round, before: msgs.length, after: toSend.length } });
269
+ }
270
+ } catch (err) {
271
+ if (err instanceof HaltError) throw err;
272
+ this._reportError('assemble', err, { round });
273
+ }
274
+ }
275
+
246
276
  let result;
247
277
  const llmStartedAt = Date.now();
248
278
  try {
249
- const generate = () => this.provider.generate(msgs, tools, options);
279
+ const generate = () => this.provider.generate(toSend, tools, options);
250
280
  result = this.retry ? await this.retry.call(generate) : await generate();
251
281
  } catch (err) {
252
282
  this._reportError('provider', err, { round });
@@ -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: {
package/src/tools.d.ts CHANGED
@@ -5,4 +5,5 @@ import { createSpawnTool } from "../tools/spawn";
5
5
  import { spawnChild } from "../tools/spawn";
6
6
  import { createDeferTool } from "../tools/defer";
7
7
  import { readQueue as readDeferQueue } from "../tools/defer";
8
- export { createBrowsingTools, createMobileTools, createShellTools, createSpawnTool, spawnChild, createDeferTool, readDeferQueue };
8
+ import { liteCtxMcpBridgeConfig } from "../tools/litectx-mcp";
9
+ export { createBrowsingTools, createMobileTools, createShellTools, createSpawnTool, spawnChild, createDeferTool, readDeferQueue, liteCtxMcpBridgeConfig };
package/src/tools.js CHANGED
@@ -5,6 +5,7 @@ const { createMobileTools } = require('../tools/mobile');
5
5
  const { createShellTools } = require('../tools/shell');
6
6
  const { createSpawnTool, spawnChild } = require('../tools/spawn');
7
7
  const { createDeferTool, readQueue: readDeferQueue } = require('../tools/defer');
8
+ const { liteCtxMcpBridgeConfig } = require('../tools/litectx-mcp');
8
9
 
9
10
  module.exports = {
10
11
  createBrowsingTools,
@@ -14,4 +15,5 @@ module.exports = {
14
15
  spawnChild,
15
16
  createDeferTool,
16
17
  readDeferQueue,
18
+ liteCtxMcpBridgeConfig,
17
19
  };