bare-agent 0.32.0 → 0.33.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
@@ -121,7 +121,7 @@ console.log(result.count, result.matchedIds); // a code-derived count + the id
121
121
 
122
122
  **Govern — one gate over both axes.** `wireGate(gate)` routes every LLM + tool call through one bareguard policy + audit + budget. Denied tools never reach the model; halts (turn / budget / content caps) exit cleanly. A plain deny stays advisory (the model can pivot to an allowed tool), but the Loop short-circuits a *spin* — `maxConsecutiveDenials` consecutive denials of the same action (default 3) stop the run with `error:'denied:<tool>'` instead of burning the budget to the cap; under `recurse` that surfaces as `{ incomplete, blocker:'governance-deny' }`. The same bound covers a tool that keeps *failing*: `maxIdenticalToolErrors` (default 3) stops a model re-sending a byte-identical call that cannot succeed, with `error:'stuck:<tool>'`. `require('bare-agent/bareguard')`
123
123
 
124
- **Providers:** OpenAI-compatible (OpenAI, OpenRouter, Groq, vLLM, LM Studio), Anthropic, Gemini (native), Ollama, CLIPipe, Fallback — or bring your own (one `generate` method). All return the same shape; swap freely. Usage including prompt-cache tiers is normalized, so `result.metrics` reports honest cumulative tokens + cost — and `null`, never a silent `0`, for a model it couldn't price. A model that rejects a non-default `temperature` (e.g. `claude-sonnet-5`, OpenAI o1/gpt-5-class return a `400`) is handled gracefully — the provider drops the param and retries once rather than failing the call, surfacing `temperatureDropped` so a caller can report the effective value. **CLIPipe can drive a full agentic Loop — tools and turns — over a CLI *subscription* instead of the metered API** (`CLIPipeProvider({ command:'claude', args:['-p','--model','sonnet'], toolProtocol:'claude' })`): schema-validated tool emulation with the Loop keeping governance, and a capable model enforced upfront (v0.32.0, sonnet-class+ for tools; Claude CLI for now).
124
+ **Providers:** OpenAI-compatible (OpenAI, OpenRouter, Groq, vLLM, LM Studio), Anthropic, Gemini (native), Ollama, CLIPipe, Fallback — or bring your own (one `generate` method). All return the same shape; swap freely. Usage including prompt-cache tiers is normalized, so `result.metrics` reports honest cumulative tokens + cost — and `null`, never a silent `0`, for a model it couldn't price. A model that rejects a non-default `temperature` (e.g. `claude-sonnet-5`, OpenAI o1/gpt-5-class return a `400`) is handled gracefully — the provider drops the param and retries once rather than failing the call, surfacing `temperatureDropped` so a caller can report the effective value. **CLIPipe can drive a full agentic Loop — tools and turns — over a CLI *subscription* instead of the metered API.** Two modes, chosen by cost: `toolProtocol:'claude-mcp'` (v0.33.0, NATIVE — the CLI runs its own session and calls your tools over an MCP bridge; the gate, spin guards, and turn bound ride the provider, and it caches session-side — ~$0.006/turn) is the default for the Claude CLI, while `toolProtocol:'claude'` (v0.32.0, schema-validated emulation with the Loop keeping the cycle) stays right for a CLI with no MCP channel. A capable model is enforced upfront (sonnet-class+ for tools; Claude CLI for now).
125
125
 
126
126
  **The run tells you the truth about what happened on the wire.** Every provider reports **why** generation ended (`stopReason`, normalized across all of them and surfaced on **every** `Loop.run()` return), so a round the API **cut off at the token cap** can no longer masquerade as a finished answer: it returns `error: 'truncated:max_tokens'` with the partial text preserved, instead of a silent `error: null` (BA-6). Its tool calls are **refused, never executed** — a *complete* tool call always arrives tagged `tool_use`, so one riding a truncated round was cut off mid-generation with arguments missing, which is exactly how a truncated `shell_write` can zero a file. The same honesty now covers **every** non-clean terminal signal (BA-13): a safety `refusal` returns `error: 'refusal'` and a blown context window returns `error: 'context_exceeded'` (both were previously laundered into an empty success), while a resumable `pause_turn` makes the loop resume rather than terminate. `error` is the sole success signal; a bound firing preserves the model's work rather than discarding it (BA-5).
127
127
 
@@ -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.32.0 | Node.js >= 18 | zero required deps (`bareguard >=0.9.0 <0.13.0` optional peer for governance) | Apache 2.0
4
+ > v0.33.0 | Node.js >= 18 | zero required deps (`bareguard >=0.9.0 <0.13.0` optional peer for governance) | 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
 
@@ -60,7 +60,8 @@ Eight entry points:
60
60
  | Cache identical planner calls | Planner({ cacheTTL: 60000 }) |
61
61
  | Stream CLIPipe output in real-time | CLIPipeProvider({ onChunk: fn }) |
62
62
  | Get real usage + cost from a CLI provider | CLIPipeProvider({ parse: 'claude-json' }) |
63
- | Drive tools over a CLI subscription (no metered API) | CLIPipeProvider({ toolProtocol: 'claude' }) |
63
+ | Drive tools over a CLI subscription — NATIVE, cheapest (no metered API) | CLIPipeProvider({ toolProtocol: 'claude-mcp', policy }) |
64
+ | Drive tools over a CLI with no MCP support (emulation) | CLIPipeProvider({ toolProtocol: 'claude' }) |
64
65
  | Browse the web (inline snapshots) | createBrowsingTools + Loop |
65
66
  | Browse the web (token-efficient, disk-based) | `barebrowse` CLI session — snapshots to `.barebrowse/*.yml` |
66
67
  | Assess website privacy risk | createBrowsingTools + Loop (requires `npm install wearehere`) |
@@ -801,14 +802,27 @@ new CLIPipe({ command: 'claude', args: ['--print'], systemPromptFlag: '--system-
801
802
  new CLIPipe({ command: 'ollama', args: ['run', 'llama3.2'] })
802
803
  // CLIPipe structured output (v0.26.0+) — map a CLI's JSON envelope to real usage + cost
803
804
  new CLIPipe({ command: 'claude', args: ['-p', '--output-format', 'json'], parse: 'claude-json' })
804
- // CLIPipe TOOL MODE (v0.32.0+) — drive a Loop's tools over a CLI SUBSCRIPTION (no metered API).
805
- // toolProtocol enables schema-validated tool emulation; the Loop keeps governance/round-accounting.
806
- // Tools are auto-used when passed; a weak model (e.g. haiku) is rejected UPFRONT by a capability
807
- // probe (needs sonnet-class+ for tools; haiku is fine for plain text). setting-sources '' is applied
808
- // internally for an ~18x cost drop. Claude-only for now.
805
+ // CLIPipe TOOL MODE — drive a Loop's tools over a CLI SUBSCRIPTION (no metered API). TWO modes:
806
+ // 'claude-mcp' (BA-16, NATIVE prefer on the claude CLI) vs 'claude' (v0.32.0, EMULATION). The
807
+ // difference is COST, not capability. Emulation re-sends the whole transcript every round
808
+ // ($0.25-0.55/round measured); native runs one CLI session that caches session-side (~$0.006/turn).
809
+ // Native: caller tools ride an MCP bridge back to your in-process closures; the CLI owns the cycle.
810
+ // Gate + BA-11/BA-12 guards + turn bound live on the PROVIDER (not the Loop) — see below.
811
+ new CLIPipe({ command: 'claude', args: ['-p', '--model', 'sonnet'], toolProtocol: 'claude-mcp', policy, onTurn, maxTurns: 20 })
812
+ // Emulation (v0.32.0) — still right for a CLI with NO MCP support. Weak models rejected upfront by a
813
+ // capability probe (needs sonnet-class+; haiku fine for plain text). setting-sources '' cuts ~18x cost.
809
814
  new CLIPipe({ command: 'claude', args: ['-p', '--model', 'sonnet'], toolProtocol: 'claude' })
810
815
  ```
811
816
 
817
+ **CLIPipe NATIVE tool mode (BA-16, `toolProtocol:'claude-mcp'`).** The claude CLI has a real tool channel; native mode uses it instead of emulating one. The CLI runs its OWN multi-turn session per `generate()` call and executes your `tools` natively over an MCP bridge that calls back into your in-process `execute` closures. Because the CLI owns the inner cycle, the Loop's per-round machinery cannot run — so the governance you'd wire on `Loop` moves to the **provider**, at the one seam every tool call crosses:
818
+ - `policy` — the SAME `(tool, args, ctx) => true|string` chokepoint as `Loop({policy})`, so a wired `wireGate(gate).policy` writes **audit rows of identical shape, zero gate changes**. A deny is a tool result (advisory); the handler never runs. **Required here** — a `Loop({policy})` in native mode would be a fence that is silently not there, so the Loop throws.
819
+ - `maxConsecutiveDenials` (3) / `maxIdenticalToolErrors` (3) — BA-11/BA-12 guards at the bridge, same narrowest triggers; end the session `denied:<tool>` / `stuck:<tool>`.
820
+ - `maxTurns` — maps to the CLI's `--max-turns`; the bound stop is `error:'max_turns'`, never a silent success.
821
+ - `onTurn` — streams each completed turn's usage (four cache tiers, `costUsd:null` — the CLI prices the session) as it arrives, then one closing `kind:'session'` event with the authoritative total. Shape mirrors `onLlmResult`, so `wireGate(gate).onLlmResult` drops in; when wired the Loop skips its own forward (billed once, never starved).
822
+ - `sessionTimeout` (600s) / `bridgeTimeoutMs` (120s) — whole-session and per-handler ceilings.
823
+
824
+ `GenerateResult.session` (`{turns, toolCalls, error, usageReported}`) carries what really happened; `metrics.sessionTurns` reports the real turn count so a 14-turn session never reads as one round. A terminal the CLI detects inside the session (bound, guard, or a **broken tool bridge** — a dead bridge still ends `subtype:'success'`, so it is caught parent-side by attempted-vs-served tool calls) surfaces as the run's `error`, never a laundered clean finish. `assemble`/`trim`/`cacheMessages` and a Loop-level `policy` all THROW at construction in native mode (no silently-dead knobs — the CLI owns the transcript). The bridge is a unix socket (0600 in a 0700 dir), never a listening port. Claude-only for now; the CLI-specific parts live in `src/provider-clipipe-mcp.js` + `src/mcp-bridge-stub.js` behind the same seam as emulation.
825
+
812
826
  All return `{ text, toolCalls, usage: { inputTokens, outputTokens }, model?, costUsd? }`. The optional `model` (v0.16.1+) is the id the response was produced by — Loop prefers it over `provider.model` for cost accounting. By default CLIPipe returns `toolCalls: []` and zero usage (CLI tools don't report tokens) and omits `model`. **Structured output (v0.26.0+):** set `parse: 'claude-json'` (a preset for `claude -p --output-format json`) — or a `(stdout) => Partial<GenerateResult>` function for any other CLI — and CLIPipe maps the CLI's JSON envelope onto real `usage`, `model`, and `costUsd`, throwing `ProviderError` on a malformed/error envelope (never a silent raw-text fall-back). `costUsd` (optional `GenerateResult` field) is an **authoritative** per-call price the provider reports itself; when finite the Loop prefers it over the internal rate-table `estimateCost`, so a CLI-piped run enforces a bareguard USD cap with no local pricing table (a `0` counts as priced, distinct from null/unpriced). `toolCalls` stays `[]` regardless (CLIPipe is tool-free).
813
827
 
814
828
  **Temperature graceful degradation (BA-10).** Newer models reject ANY non-default `temperature` with a `400` (`claude-sonnet-5`: `` `temperature` is deprecated for this model. ``; OpenAI o1/gpt-5-class: `Unsupported value: 'temperature' … Only the default (1) …`). All four providers detect that specific 400 (message names `temperature` as unsupported/deprecated AND a temperature was sent), **drop the param, warn once per instance, and retry once** — so a call that would otherwise throw succeeds at the model's default temperature. Keyed off the API error text, not a model list. A genuine out-of-range 400 is NOT degraded (it re-throws — dropping it would mask a caller bug). When a drop happens the result carries `temperatureDropped: true` (an optional `GenerateResult`/`Loop.run` field) so a caller can report the effective temperature — `recurse`'s `refineLeaf` uses it for an honest receipt. Dormant on models that accept temperature (byte-identical to before).
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "bare-agent",
3
- "version": "0.32.0",
3
+ "version": "0.33.0",
4
4
  "files": [
5
5
  "index.js",
6
6
  "index.d.ts",
package/src/loop.js CHANGED
@@ -277,6 +277,30 @@ class Loop {
277
277
  throw new Error('[Loop] options.trim must be a function (msgs, ctx) => msgs (e.g. unitTrimmer({ trim, onHarvest, policy }))');
278
278
  }
279
279
  this.trim = options.trim || null;
280
+ // BA-16: a CYCLE-OWNING provider (one that runs its own multi-turn session internally, e.g.
281
+ // CLIPipe native tool mode) has no per-round seam for the Loop to hand a transcript to — the
282
+ // transcript lives inside the provider's session. `assemble`/`trim` would therefore be accepted
283
+ // and then silently never called, which is precisely the "silently-dead knob" shape this repo
284
+ // keeps paying for. Fail at CONSTRUCTION instead: an unhonorable option is a configuration
285
+ // error, not a no-op.
286
+ if (this.provider && this.provider.ownsCycle && (this.assemble || this.trim)) {
287
+ const named = [this.assemble && 'assemble', this.trim && 'trim'].filter(Boolean).join(' and ');
288
+ throw new Error(
289
+ `[Loop] provider '${this.provider.name || 'unknown'}' owns its own turn cycle, so ${named} can never run `
290
+ + '(the provider owns the transcript, not the Loop). Remove the option, or use a provider whose cycle the Loop drives.',
291
+ );
292
+ }
293
+ // The same rule, applied to the option where being silently dead is WORST. With a cycle-owning
294
+ // provider no tool call ever reaches the Loop, so a `Loop({policy})` is a fence that is simply
295
+ // not there — the run looks governed and is not. Refuse it: the gate must be wired on the
296
+ // provider, where every tool call actually crosses.
297
+ if (this.provider && this.provider.ownsCycle && this.policy && !this.provider.policy) {
298
+ throw new Error(
299
+ `[Loop] provider '${this.provider.name || 'unknown'}' owns its own turn cycle, so Loop-level policy would `
300
+ + 'NEVER run — no tool call reaches the Loop. Wire the gate on the provider instead '
301
+ + '(e.g. new CLIPipeProvider({ toolProtocol: \'claude-mcp\', policy })).',
302
+ );
303
+ }
280
304
  if (options.onLlmResult != null && typeof options.onLlmResult !== 'function') {
281
305
  throw new Error('[Loop] options.onLlmResult must be a function');
282
306
  }
@@ -491,6 +515,12 @@ class Loop {
491
515
  const metrics = {
492
516
  turns: 0,
493
517
  toolCalls: 0,
518
+ // BA-16 — turns that happened INSIDE a cycle-owning provider's own session (CLIPipe native tool
519
+ // mode). Such a session is ONE Loop round no matter how many turns it really took, so `turns`
520
+ // alone would report a 14-turn session as 1 — a round count that reads far cheaper and far
521
+ // shorter than the run actually was. Reporting the real number keeps the meter honest; 0 for
522
+ // every provider whose cycle the Loop drives.
523
+ sessionTurns: 0,
494
524
  /** @type {Record<string, number>} */
495
525
  byTool: {},
496
526
  tokens: { input: 0, output: 0, cacheCreation: 0, cacheRead: 0 },
@@ -525,6 +555,7 @@ class Loop {
525
555
  /** Snapshot the meter for a return — finalizes the run-scoped fields. @returns {RunMetrics} */
526
556
  const finalizeMetrics = () => ({
527
557
  turns: metrics.turns,
558
+ sessionTurns: metrics.sessionTurns,
528
559
  toolCalls: metrics.toolCalls,
529
560
  byTool: metrics.byTool,
530
561
  tokens: { ...metrics.tokens },
@@ -742,11 +773,22 @@ class Loop {
742
773
  metrics.turns++;
743
774
  addUsage(result.usage);
744
775
  if (roundCost === null) metrics.unpricedRounds++; else pricedAny = true;
776
+ // BA-16: a cycle-owning provider reports what really happened inside its session.
777
+ const session = (result.session && typeof result.session === 'object') ? result.session : null;
778
+ if (session) {
779
+ if (Number.isFinite(session.turns)) metrics.sessionTurns += session.turns;
780
+ if (Number.isFinite(session.toolCalls)) metrics.toolCalls += session.toolCalls;
781
+ }
745
782
 
746
783
  // BA1: forward LLM usage to gate.record (via wireGate) so budget.maxCostUsd
747
784
  // covers token-heavy / tool-light workloads. Callback errors route through
748
785
  // _reportError but never kill the loop — governance failure ≠ run failure.
749
- if (this.onLlmResult) {
786
+ // BA-16: when a cycle-owning provider has ALREADY streamed this call's usage per internal turn
787
+ // (so a session that dies mid-run has surfaced every completed turn's spend rather than losing
788
+ // all of it), forwarding the summed total here too would bill the gate twice for one session.
789
+ // The provider only sets this when it genuinely reported; unwired, it stays false and the
790
+ // normal forward below runs — so the gate is never silently starved either.
791
+ if (this.onLlmResult && !(session && session.usageReported === true)) {
750
792
  try {
751
793
  await this.onLlmResult({
752
794
  model,
@@ -766,6 +808,20 @@ class Loop {
766
808
  }
767
809
  }
768
810
 
811
+ // BA-16: a cycle-owning provider detected a terminal INSIDE its own session — a turn bound, a
812
+ // guard streak, or a broken tool bridge. It is reported as the run's `error` (never merely
813
+ // surfaced) because every downstream consumer — `recurse`, the bareloop adopter — branches on
814
+ // `error` as the SOLE success signal. Surfacing alone would let a session in which no tool call
815
+ // ever succeeded propagate as converged. `lastText` (BA-5) preserves the partial work: a bound
816
+ // firing is normal termination for a bounded attempt, and that text is the only channel from
817
+ // attempt N to N+1.
818
+ if (session && typeof session.error === 'string' && session.error) {
819
+ sealDanglingToolCalls(msgs, `[halted:${session.error}]`);
820
+ this._reportError('session', new Error(`provider session terminated: ${session.error}`), { rule: session.error, sessionTurns: session.turns ?? null });
821
+ this._safeEmit({ type: 'loop:done', data: { text: lastText, rule: session.error, sessionTurns: session.turns ?? null, cost: totalCost } });
822
+ return { text: lastText, toolCalls: [], usage: lastUsage, cost: totalCost, error: session.error, stopReason: lastStopReason, msgs, metrics: finalizeMetrics(), ...(temperatureDropped && { temperatureDropped: true }) };
823
+ }
824
+
769
825
  // BA-13: classify this round's terminal signal against the neutral stop-reason vocabulary. BA-6
770
826
  // short-circuited exactly one non-clean reason (max_tokens); this gate is the general form. It sits
771
827
  // AFTER metering (the tokens were really spent — the gate must see them) and BEFORE tool execution,
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,107 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * BA-16 — the MCP stdio server the CLI spawns in native tool mode (`toolProtocol:'claude-mcp'`).
5
+ *
6
+ * This file owns NO tool logic. bare-agent's tools are `execute` CLOSURES living in the caller's
7
+ * process, but `--mcp-config` can only point the CLI at a COMMAND — so the tool surface has to cross
8
+ * a process boundary that the closures cannot. This stub is the bridge for exactly that hop:
9
+ *
10
+ * claude CLI <--stdio JSON-RPC--> THIS FILE <--unix socket--> parent (caller's closures)
11
+ *
12
+ * It is deliberately dumb. Everything that can decide anything — the manifest, the gate, the spin
13
+ * guards, redaction — lives parent-side, so the stub can never become a second place where policy
14
+ * is enforced (and never a second place where a secret can be logged: it writes no files at all).
15
+ *
16
+ * The one rule it does enforce is the BA-15 principle at the process boundary: EVERY failure of the
17
+ * hop is returned as a tool RESULT, never as a crash and never as a hang. A dead parent, a malformed
18
+ * frame and a slow handler all become `isError` results the model can read and react to — validated
19
+ * live before this shipped (probe 6: parent killed mid-call, session ended in 5.9s, no hang).
20
+ *
21
+ * Not a package export and never `require`d by the library — it is spawned as `node <this file>`.
22
+ */
23
+
24
+ const { createInterface } = require('readline');
25
+ const net = require('net');
26
+
27
+ const SOCK = process.env.BAREAGENT_BRIDGE_SOCK;
28
+ const CALL_TIMEOUT_MS = Number(process.env.BAREAGENT_BRIDGE_TIMEOUT_MS) || 120000;
29
+
30
+ const send = (obj) => process.stdout.write(JSON.stringify(obj) + '\n');
31
+ const errResult = (text) => ({ content: [{ type: 'text', text }], isError: true });
32
+
33
+ /**
34
+ * One request/response over the unix socket.
35
+ *
36
+ * A FRESH connection per call is deliberate: a pooled socket opened while the parent was alive
37
+ * hides a parent that died later, turning "the bridge is gone" into a silent hang. Connecting per
38
+ * call makes a dead parent surface immediately, as an error result.
39
+ *
40
+ * @param {object} payload
41
+ * @returns {Promise<any>} always resolves — never rejects, so no failure can escape as a crash.
42
+ */
43
+ function bridge(payload) {
44
+ return new Promise((resolve) => {
45
+ let settled = false;
46
+ const done = (v) => { if (!settled) { settled = true; resolve(v); } };
47
+ if (!SOCK) return done({ error: 'bridge not configured' });
48
+
49
+ const sock = net.createConnection(SOCK);
50
+ let buf = '';
51
+
52
+ // A hung parent handler must not hang the CLI session. Bounded, always.
53
+ const timer = setTimeout(() => {
54
+ try { sock.destroy(); } catch (_) { /* already gone */ }
55
+ done({ error: `bridge timeout after ${CALL_TIMEOUT_MS}ms` });
56
+ }, CALL_TIMEOUT_MS);
57
+
58
+ const finish = (v) => {
59
+ clearTimeout(timer);
60
+ try { sock.end(); } catch (_) { /* already gone */ }
61
+ done(v);
62
+ };
63
+
64
+ sock.on('connect', () => sock.write(JSON.stringify(payload) + '\n'));
65
+ sock.on('data', (d) => {
66
+ buf += d;
67
+ const nl = buf.indexOf('\n');
68
+ if (nl === -1) return; // frame incomplete — wait for the rest
69
+ try { finish(JSON.parse(buf.slice(0, nl))); }
70
+ catch (err) { finish({ error: `bridge sent malformed JSON: ${/** @type {Error} */ (err).message}` }); }
71
+ });
72
+ sock.on('error', (err) => finish({ error: `bridge unreachable: ${/** @type {any} */ (err).code || /** @type {Error} */ (err).message}` }));
73
+ sock.on('close', () => finish({ error: 'bridge closed before responding' }));
74
+ });
75
+ }
76
+
77
+ const rl = createInterface({ input: process.stdin });
78
+ rl.on('line', async (line) => {
79
+ if (!line.trim()) return;
80
+ let msg;
81
+ try { msg = JSON.parse(line); } catch (_) { return; } // not JSON — not ours to answer
82
+ const { id, method, params } = msg;
83
+
84
+ if (method === 'initialize') {
85
+ return send({ jsonrpc: '2.0', id, result: {
86
+ protocolVersion: (params && params.protocolVersion) || '2025-06-18',
87
+ capabilities: { tools: {} },
88
+ serverInfo: { name: 'bareagent', version: '1' },
89
+ } });
90
+ }
91
+
92
+ if (method === 'tools/list') {
93
+ // The PARENT owns the manifest — a tool definition is never duplicated here.
94
+ const res = await bridge({ op: 'list' });
95
+ return send({ jsonrpc: '2.0', id, result: { tools: (res && res.tools) || [] } });
96
+ }
97
+
98
+ if (method === 'tools/call') {
99
+ const res = await bridge({ op: 'call', name: params && params.name, args: (params && params.arguments) || {} });
100
+ if (res && res.error) return send({ jsonrpc: '2.0', id, result: errResult(`TOOL BRIDGE ERROR: ${res.error}`) });
101
+ if (res && res.isError) return send({ jsonrpc: '2.0', id, result: errResult(String(res.text)) });
102
+ return send({ jsonrpc: '2.0', id, result: { content: [{ type: 'text', text: String((res && res.text) ?? '') }] } });
103
+ }
104
+
105
+ if (method === 'ping') return send({ jsonrpc: '2.0', id, result: {} });
106
+ if (id !== undefined) send({ jsonrpc: '2.0', id, error: { code: -32601, message: `method not found: ${method}` } });
107
+ });
@@ -0,0 +1,128 @@
1
+ /** Path to the stdio stub the CLI spawns. Resolved once — it ships inside the package. */
2
+ export const STUB_PATH: string;
3
+ /** The MCP server name the CLI sees; tool names reach the model as `mcp__bareagent__<tool>`. */
4
+ export const SERVER_NAME: "bareagent";
5
+ /** Mirrors the Loop's own BA-11 / BA-12 defaults so native mode is not quietly laxer. */
6
+ export const DEFAULT_MAX_CONSECUTIVE_DENIALS: 3;
7
+ export const DEFAULT_MAX_IDENTICAL_TOOL_ERRORS: 3;
8
+ /**
9
+ * Stable key for BA-12's identical-tool-error guard. NARROWEST trigger by design: only a
10
+ * BYTE-IDENTICAL repeat counts. Counting any consecutive failure was measured to punish the
11
+ * recovery it exists to protect (a model varying args while working through an ENOENT).
12
+ * @param {string} name
13
+ * @param {any} args
14
+ * @returns {string}
15
+ */
16
+ export function toolErrorKey(name: string, args: any): string;
17
+ /**
18
+ * Clamp a diagnostic built from a thrown value of unknown shape before it can ride into a
19
+ * `receipts`/audit string. A non-Error throw serialized wholesale has previously pushed a secret
20
+ * into a plaintext audit log (the F16/BA-1 capture class), and an append-only log that captures a
21
+ * key captures it forever. Conventional fields only, hard length cap, never the whole object.
22
+ * @param {any} err
23
+ * @returns {string}
24
+ */
25
+ export function safeErrorText(err: any): string;
26
+ /**
27
+ * The parent-side bridge: a unix-socket server backed by the caller's in-process tool closures,
28
+ * with the gate and both spin guards wired at the one seam that sees every call.
29
+ *
30
+ * Unix socket, not loopback TCP: a library must not open a listening network port as a side effect
31
+ * of running a tool loop. The socket lives in a 0700 directory and is itself chmod 0600.
32
+ *
33
+ * @param {object} opts
34
+ * @param {import('../types').ToolDef[]} opts.tools - the caller's tools (with live `execute` closures).
35
+ * @param {Function|null} [opts.policy] - the SAME contract as `Loop({policy})`: only `true` allows;
36
+ * a string is the deny reason fed back verbatim.
37
+ * @param {any} [opts.ctx] - opaque governance ctx, forwarded to `policy` unchanged.
38
+ * @param {number} [opts.maxConsecutiveDenials]
39
+ * @param {number} [opts.maxIdenticalToolErrors]
40
+ * @returns {Promise<{sockPath: string, state: any, close: () => void}>}
41
+ */
42
+ export function createBridge({ tools, policy, ctx, maxConsecutiveDenials, maxIdenticalToolErrors }: {
43
+ tools: import("../types").ToolDef[];
44
+ policy?: Function | null | undefined;
45
+ ctx?: any;
46
+ maxConsecutiveDenials?: number | undefined;
47
+ maxIdenticalToolErrors?: number | undefined;
48
+ }): Promise<{
49
+ sockPath: string;
50
+ state: any;
51
+ close: () => void;
52
+ }>;
53
+ /**
54
+ * @param {string|null|undefined} subtype
55
+ * @returns {{stopReason: string|null, error: string|null}}
56
+ */
57
+ export function classifySubtype(subtype: string | null | undefined): {
58
+ stopReason: string | null;
59
+ error: string | null;
60
+ };
61
+ /**
62
+ * Decide the session's terminal tag. Pure, so the precedence is testable without a live CLI.
63
+ *
64
+ * The ordering is the whole point:
65
+ * 1. `terminal` — a guard tripped and WE killed the session; most specific, and it explains why
66
+ * the CLI's own subtype is missing or odd.
67
+ * 2. bridge broken — a tool call the CLI ATTEMPTED but the bridge never SERVED never reached the
68
+ * caller's closure. This must outrank a reported `success`, because a session
69
+ * whose tools were all broken still ends `subtype:'success'` — the model writes
70
+ * a tidy final answer explaining that its tools failed, and mapping that onto
71
+ * `error:null` reports a run in which nothing worked as converged.
72
+ * 3. `timedOut` — we killed it on the wall clock.
73
+ * 4. the subtype — what the CLI itself said.
74
+ *
75
+ * @param {{terminal: string|null, bridgeDown: boolean, attempted: number, served: number, timedOut: boolean, subtype: string|null|undefined}} facts
76
+ * @returns {{stopReason: string|null, error: string|null}}
77
+ */
78
+ export function resolveSessionError(facts: {
79
+ terminal: string | null;
80
+ bridgeDown: boolean;
81
+ attempted: number;
82
+ served: number;
83
+ timedOut: boolean;
84
+ subtype: string | null | undefined;
85
+ }): {
86
+ stopReason: string | null;
87
+ error: string | null;
88
+ };
89
+ /**
90
+ * Line-oriented processor for the CLI's `stream-json` stdout.
91
+ *
92
+ * FRAMING IS SYNCHRONOUS. The obvious shape — an `async` stdout handler that `await`s `onTurn`
93
+ * inside its parse loop — has a data-corruption bug: while the `await` is suspended, the next
94
+ * `'data'` event re-enters the handler and mutates the SHARED line buffer, so the suspended parse
95
+ * resumes against a buffer that moved under it (dropped/duplicated/mangled lines). It is invisible
96
+ * with a synchronous `onTurn` (the await resolves on the microtask queue before the next macrotask
97
+ * `'data'` event) and REACHABLE the moment `onTurn` does real async work — e.g. a wired gate's
98
+ * `gate.record`, which is exactly the intended consumer. So framing never awaits: it parses lines
99
+ * synchronously and pushes per-turn forwards onto a queue that a SERIAL async drainer empties in
100
+ * arrival order. Extracted from `runSession` so this can be unit-tested without spawning the CLI.
101
+ *
102
+ * @param {object} o
103
+ * @param {Function|null} o.onTurn
104
+ * @param {any} o.ctx
105
+ * @param {number} o.startedAt
106
+ * @param {(err: Error) => void} o.onHalt - called if a forwarded `onTurn` throws a HaltError.
107
+ */
108
+ export function createSessionStream({ onTurn, ctx, startedAt, onHalt }: {
109
+ onTurn: Function | null;
110
+ ctx: any;
111
+ startedAt: number;
112
+ onHalt: (err: Error) => void;
113
+ }): {
114
+ /** Feed a stdout chunk. Pure synchronous framing — never awaits. */
115
+ feed(chunk: Buffer | string): void;
116
+ /** Await every queued per-turn forward — call before resolving the session. */
117
+ flush(): Promise<void>;
118
+ readonly turns: import("../types").Usage[];
119
+ readonly attempted: number;
120
+ readonly final: any;
121
+ };
122
+ /**
123
+ * Run ONE native CLI session to completion, streaming per-turn usage as it arrives.
124
+ *
125
+ * @param {object} opts
126
+ * @returns {Promise<any>}
127
+ */
128
+ export function runSession(opts: object): Promise<any>;