bare-agent 0.32.0 → 0.33.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.
@@ -35,30 +35,86 @@ export type CLIPipeOptions = {
35
35
  */
36
36
  parse?: "claude-json" | ((stdout: string) => Partial<GenerateResult>) | undefined;
37
37
  /**
38
- * - Opt into TOOL MODE (v0.32.0). A subscription CLI is a plain turn-provider with no native tool channel; this enables schema-validated tool EMULATION so a caller's `tools` work over the CLI (letting a Claude/etc. subscription drive an agentic Loop without metered-API spend). When set, ANY `generate(msgs, tools)` call with a non-empty `tools` array auto-uses emulation (the caller's own system stance + a tool manifest + a JSON envelope, parsed back into normalized `toolCalls`); an empty `tools` array is unchanged plain-text. When NOT set, `tools` are IGNORED (plain-text mode, the long-standing behavior — a non-tool-calling CLI can legitimately sit in a Loop that has tools mounted) with a one-time `console.warn` for visibility. Claude-only for now (`'claude'`); the claude-specific flags/schema/parse live in `provider-clipipe-tools.js` so a second CLI slots in behind the same seam. NOTE: tool mode requires a capable model — weak models (e.g. haiku) answer in prose instead of calling tools; see `probeCapability`.
39
- */
40
- toolProtocol?: "claude" | undefined;
41
- /**
42
- * - (tool mode only) On the first tool-mode `generate`, run ONE cheap upfront probe that asks the model to obtain unknowable info via a tool. If it answers in prose instead of emitting a tool_call, throw a loud `ProviderError` naming the model — FAIL FAST rather than silently degrade mid-run (the weak-model failure mode). Behaviour-based, never a model name-list (a roster goes stale, BA-10). The verdict is cached per instance (one probe per provider, not per turn). Set `false` to skip when the caller already knows the model is capable.
38
+ * - Opt into TOOL MODE. Two modes, and the choice is
39
+ * about COST, not capability. `'claude-mcp'` (BA-16, NATIVE — prefer this on the claude CLI): one CLI
40
+ * session per call, the caller's `tools` exposed to it as a real MCP server whose handlers call back
41
+ * into your own in-process closures. The CLI owns the inner cycle and caches its transcript
42
+ * session-side. `'claude'` (v0.32.0, EMULATION): one CLI spawn per round with the whole transcript
43
+ * re-rendered and re-sent, parsed back through a JSON envelope. Emulation re-buys the full prefix
44
+ * every turn, which the adopter measured at **$0.25–0.55/round** against **~$0.006/turn** native — so
45
+ * it is the right instrument only for a CLI with NO MCP support, not a default. NOT claimed for
46
+ * native: better output quality (n=2 suggestive evidence exists and is deliberately unminted).
47
+ * Native mode sets {@link CLIPipeProvider#ownsCycle}, which makes the Loop REFUSE options it could
48
+ * never honor (`assemble`/`trim`/`cacheMessages`, and a Loop-level `policy`) instead of leaving them
49
+ * silently dead. See the native-only properties below.
50
+ */
51
+ toolProtocol?: "claude" | "claude-mcp" | undefined;
52
+ /**
53
+ * - (native mode) The gate, same contract as `Loop({policy})`: only `true`
54
+ * allows, a string is the deny reason fed back verbatim, a thrown `HaltError` is a clean governance
55
+ * exit. REQUIRED here rather than on the Loop, because in native mode no tool call ever reaches the
56
+ * Loop — a `Loop({policy})` would be a fence that is silently not there (the Loop throws instead).
57
+ * Wiring the same `wireGate(gate).policy` keeps audit rows byte-shape-identical, with zero gate changes.
58
+ */
59
+ policy?: ((tool: string, args: any, ctx?: any) => any) | undefined;
60
+ /**
61
+ * - (native mode) Called with `{model, provider, usage, costUsd, pricing,
62
+ * durationMs, ctx, kind}` for EACH completed CLI turn as it arrives (`kind:'turn'`, four cache tiers,
63
+ * `costUsd:null` — the CLI prices the session, not the turn), then once at session end
64
+ * (`kind:'session'`) carrying the authoritative total cost with zero usage. Streaming, never
65
+ * sum-at-end: a session that dies mid-run must already have surfaced every completed turn's spend or
66
+ * the gate loses all of it. The event shape mirrors `Loop({onLlmResult})`, so `wireGate(gate).onLlmResult`
67
+ * drops straight in — and when it is wired the Loop skips its own forward, so nothing is billed twice.
68
+ */
69
+ onTurn?: Function | undefined;
70
+ /**
71
+ * - (native mode) Bound on ASSISTANT/LLM TURNS — the same unit as the
72
+ * Loop path's turn bound, so a caller's `maxTurns` means one thing on both surfaces (BA-17). NOT a
73
+ * tool-call count: a single turn may issue a dozen parallel tool calls and still be one turn
74
+ * (measured: 12 calls across 2 turns, well inside `--max-turns 3`). Enforced twice on purpose —
75
+ * the CLI's own `--max-turns` stops the session cleanly at N and emits its result event (the only
76
+ * report of the session's real cost), and a parent-side counter kills it if a turn beyond N is
77
+ * ever observed, since that flag is undocumented in `claude --help` and a rename would otherwise
78
+ * silently unbound the session. Either way the stop is NAMED (`session.error:'max_turns'`,
79
+ * `stopReason:'max_turns'`) and carries the last turn's text forward, never a silent clean success
80
+ * and never an empty result.
81
+ */
82
+ maxTurns?: number | undefined;
83
+ /**
84
+ * - (native mode) BA-11 at the bridge: a single deny stays
85
+ * advisory so the model can pivot to an allowed tool; N in a row with no allowed call between ends the
86
+ * session with `denied:<tool>`. `0`/`Infinity` disables.
87
+ */
88
+ maxConsecutiveDenials?: number | undefined;
89
+ /**
90
+ * - (native mode) BA-12 at the bridge: only a BYTE-IDENTICAL
91
+ * repeat (name + JSON args) counts, so a model varying its args while recovering is never punished. N in
92
+ * a row ends the session with `stuck:<tool>`. `0`/`Infinity` disables.
93
+ */
94
+ maxIdenticalToolErrors?: number | undefined;
95
+ /**
96
+ * - (native mode) Wall-clock ceiling for one whole session. The
97
+ * 30s `timeout` default is for one-shot text and would kill an agentic session mid-run.
98
+ */
99
+ sessionTimeout?: number | undefined;
100
+ /**
101
+ * - (native mode) Ceiling for ONE tool-handler round-trip across the
102
+ * bridge. A hung handler becomes an error tool result rather than a hung session (default 120s).
103
+ *
104
+ * Either mode: a non-empty `tools` array on `generate()` routes to tool mode; an empty one stays
105
+ * plain text. With NO `toolProtocol`, `tools` are IGNORED (plain-text, the long-standing behavior —
106
+ * a non-tool-calling CLI legitimately sits in a Loop with tools mounted) plus a one-time `console.warn`.
107
+ * Emulation additionally requires a capable model (weak ones answer in prose; see `probeCapability`);
108
+ * native mode needs no such probe, because the CLI's own tool channel does not depend on the model
109
+ * agreeing to fill in a JSON questionnaire. The claude-specific parts of each live in
110
+ * `provider-clipipe-tools.js` / `provider-clipipe-mcp.js`, so a second CLI slots in behind the same seams.
111
+ */
112
+ bridgeTimeoutMs?: number | undefined;
113
+ /**
114
+ * - (EMULATION tool mode only) On the first tool-mode `generate`, run ONE cheap upfront probe that asks the model to obtain unknowable info via a tool. If it answers in prose instead of emitting a tool_call, throw a loud `ProviderError` naming the model — FAIL FAST rather than silently degrade mid-run (the weak-model failure mode). Behaviour-based, never a model name-list (a roster goes stale, BA-10). The verdict is cached per instance (one probe per provider, not per turn). Set `false` to skip when the caller already knows the model is capable.
43
115
  */
44
116
  probeCapability?: boolean | undefined;
45
117
  };
46
- /** @typedef {import('../types').Message} Message */
47
- /** @typedef {import('../types').ToolDef} ToolDef */
48
- /** @typedef {import('../types').GenerateResult} GenerateResult */
49
- /**
50
- * @typedef {object} CLIPipeOptions
51
- * @property {string} [command] - CLI command to spawn (required).
52
- * @property {string[]} [args=[]] - Arguments to pass to the command.
53
- * @property {string} [cwd] - Working directory for the child process.
54
- * @property {Record<string, string>} [env] - Environment variables for the child process.
55
- * @property {number} [timeout=30000] - Timeout in milliseconds.
56
- * @property {string} [systemPromptFlag] - CLI flag for system prompt (e.g. '--system'). When set, system messages are extracted and passed via this flag instead of stdin.
57
- * @property {(chunk: string) => void} [onChunk] - Called with each stdout chunk as it streams.
58
- * @property {'claude-json'|((stdout: string) => Partial<GenerateResult>)} [parse] - Opt-in structured-output parser for stdout. Default (unset) returns stdout verbatim as `text` with zero usage (no behavior change). `'claude-json'` is a shipped preset for `claude -p --output-format json`: it maps the CLI's result envelope onto `GenerateResult` (text←`result`, usage←`usage.*`, model←first `modelUsage` key, costUsd←`total_cost_usd`) and throws `ProviderError` on malformed JSON or an error envelope (`is_error`/non-success subtype). A function is the CLI-agnostic escape hatch: it receives trimmed stdout and returns a partial `GenerateResult` (merged over defaults); throw to signal a parse failure.
59
- * @property {'claude'} [toolProtocol] - Opt into TOOL MODE (v0.32.0). A subscription CLI is a plain turn-provider with no native tool channel; this enables schema-validated tool EMULATION so a caller's `tools` work over the CLI (letting a Claude/etc. subscription drive an agentic Loop without metered-API spend). When set, ANY `generate(msgs, tools)` call with a non-empty `tools` array auto-uses emulation (the caller's own system stance + a tool manifest + a JSON envelope, parsed back into normalized `toolCalls`); an empty `tools` array is unchanged plain-text. When NOT set, `tools` are IGNORED (plain-text mode, the long-standing behavior — a non-tool-calling CLI can legitimately sit in a Loop that has tools mounted) with a one-time `console.warn` for visibility. Claude-only for now (`'claude'`); the claude-specific flags/schema/parse live in `provider-clipipe-tools.js` so a second CLI slots in behind the same seam. NOTE: tool mode requires a capable model — weak models (e.g. haiku) answer in prose instead of calling tools; see `probeCapability`.
60
- * @property {boolean} [probeCapability=true] - (tool mode only) On the first tool-mode `generate`, run ONE cheap upfront probe that asks the model to obtain unknowable info via a tool. If it answers in prose instead of emitting a tool_call, throw a loud `ProviderError` naming the model — FAIL FAST rather than silently degrade mid-run (the weak-model failure mode). Behaviour-based, never a model name-list (a roster goes stale, BA-10). The verdict is cached per instance (one probe per provider, not per turn). Set `false` to skip when the caller already knows the model is capable.
61
- */
62
118
  export class CLIPipeProvider {
63
119
  /**
64
120
  * Provider that pipes prompts to a CLI command via stdin and reads stdout.
@@ -74,6 +130,13 @@ export class CLIPipeProvider {
74
130
  systemPromptFlag: string | null;
75
131
  onChunk: ((chunk: string) => void) | null;
76
132
  parse: "claude-json" | ((stdout: string) => Partial<GenerateResult>) | null;
133
+ nativeTools: boolean;
134
+ /**
135
+ * Declares to the Loop that this provider runs its OWN turn cycle. The Loop reads it to refuse
136
+ * options it could never honor (assemble/trim/cacheMessages) and to require the fence be wired
137
+ * where it can actually run. Generic provider-contract flag; nothing here is claude-specific.
138
+ */
139
+ ownsCycle: boolean;
77
140
  toolProtocol: {
78
141
  name: string;
79
142
  turnArgs(systemPrompt: string): string[];
@@ -87,6 +150,13 @@ export class CLIPipeProvider {
87
150
  probeCapability: boolean;
88
151
  /** @type {Promise<void>|null} */
89
152
  _toolCapability: Promise<void> | null;
153
+ policy: ((tool: string, args: any, ctx?: any) => any) | null | undefined;
154
+ onTurn: Function | null | undefined;
155
+ maxTurns: number | null | undefined;
156
+ maxConsecutiveDenials: number | undefined;
157
+ maxIdenticalToolErrors: number | undefined;
158
+ sessionTimeout: number | undefined;
159
+ bridgeTimeoutMs: number | null | undefined;
90
160
  /**
91
161
  * Generate a response by piping messages to the CLI command. With a `toolProtocol` configured and
92
162
  * a non-empty `tools` array, routes to schema-validated tool EMULATION (v0.32.0); otherwise the
@@ -115,6 +185,26 @@ export class CLIPipeProvider {
115
185
  */
116
186
  _generateWithTools(messages: Message[], tools: ToolDef[]): Promise<GenerateResult>;
117
187
  _toolCallSeq: any;
188
+ /**
189
+ * BA-16 native tool mode — run ONE whole CLI session and report it honestly as one call.
190
+ *
191
+ * The CLI owns the inner cycle here: it calls the caller's tools natively over an MCP bridge and
192
+ * keeps going until it answers or hits a bound. So this returns `toolCalls: []` always (there is
193
+ * nothing left for the Loop to execute) plus a `session` block describing what really happened —
194
+ * the real turn count, the real tool-call count, and any terminal the Loop must surface as the
195
+ * run's `error`.
196
+ *
197
+ * Ordering of terminals is deliberate. A governance halt outranks everything (it is a clean exit,
198
+ * not a fault). A tripped guard outranks the CLI's own subtype, because the guard is why we killed
199
+ * the session. And `bridgeDown` outranks a reported `success`, because a session whose tools were
200
+ * all broken still ends `subtype:'success'` — measured, and the reason this block exists.
201
+ *
202
+ * @param {Message[]} messages
203
+ * @param {ToolDef[]} tools
204
+ * @param {Record<string, any>} options - the Loop's run options (`ctx` is read from here).
205
+ * @returns {Promise<GenerateResult>}
206
+ */
207
+ _generateWithMcp(messages: Message[], tools: ToolDef[], options?: Record<string, any>): Promise<GenerateResult>;
118
208
  /**
119
209
  * Run the upfront capability probe ONCE per instance (cached), unless `probeCapability` is off.
120
210
  * A model that answers the probe in prose instead of emitting a tool_call throws a loud
@@ -1,8 +1,9 @@
1
1
  'use strict';
2
2
 
3
3
  const { spawn } = require('child_process');
4
- const { ProviderError } = require('./errors');
4
+ const { ProviderError, HaltError } = require('./errors');
5
5
  const { buildToolSystemPrompt, renderTranscript, resolveToolProtocol, mapClaudeMeta } = require('./provider-clipipe-tools');
6
+ const { createBridge, resolveSessionError, runSession } = require('./provider-clipipe-mcp');
6
7
 
7
8
  /** @typedef {import('../types').Message} Message */
8
9
  /** @typedef {import('../types').ToolDef} ToolDef */
@@ -18,10 +19,86 @@ const { buildToolSystemPrompt, renderTranscript, resolveToolProtocol, mapClaudeM
18
19
  * @property {string} [systemPromptFlag] - CLI flag for system prompt (e.g. '--system'). When set, system messages are extracted and passed via this flag instead of stdin.
19
20
  * @property {(chunk: string) => void} [onChunk] - Called with each stdout chunk as it streams.
20
21
  * @property {'claude-json'|((stdout: string) => Partial<GenerateResult>)} [parse] - Opt-in structured-output parser for stdout. Default (unset) returns stdout verbatim as `text` with zero usage (no behavior change). `'claude-json'` is a shipped preset for `claude -p --output-format json`: it maps the CLI's result envelope onto `GenerateResult` (text←`result`, usage←`usage.*`, model←first `modelUsage` key, costUsd←`total_cost_usd`) and throws `ProviderError` on malformed JSON or an error envelope (`is_error`/non-success subtype). A function is the CLI-agnostic escape hatch: it receives trimmed stdout and returns a partial `GenerateResult` (merged over defaults); throw to signal a parse failure.
21
- * @property {'claude'} [toolProtocol] - Opt into TOOL MODE (v0.32.0). A subscription CLI is a plain turn-provider with no native tool channel; this enables schema-validated tool EMULATION so a caller's `tools` work over the CLI (letting a Claude/etc. subscription drive an agentic Loop without metered-API spend). When set, ANY `generate(msgs, tools)` call with a non-empty `tools` array auto-uses emulation (the caller's own system stance + a tool manifest + a JSON envelope, parsed back into normalized `toolCalls`); an empty `tools` array is unchanged plain-text. When NOT set, `tools` are IGNORED (plain-text mode, the long-standing behavior — a non-tool-calling CLI can legitimately sit in a Loop that has tools mounted) with a one-time `console.warn` for visibility. Claude-only for now (`'claude'`); the claude-specific flags/schema/parse live in `provider-clipipe-tools.js` so a second CLI slots in behind the same seam. NOTE: tool mode requires a capable model — weak models (e.g. haiku) answer in prose instead of calling tools; see `probeCapability`.
22
- * @property {boolean} [probeCapability=true] - (tool mode only) On the first tool-mode `generate`, run ONE cheap upfront probe that asks the model to obtain unknowable info via a tool. If it answers in prose instead of emitting a tool_call, throw a loud `ProviderError` naming the model FAIL FAST rather than silently degrade mid-run (the weak-model failure mode). Behaviour-based, never a model name-list (a roster goes stale, BA-10). The verdict is cached per instance (one probe per provider, not per turn). Set `false` to skip when the caller already knows the model is capable.
22
+ * @property {'claude'|'claude-mcp'} [toolProtocol] - Opt into TOOL MODE. Two modes, and the choice is
23
+ * about COST, not capability. `'claude-mcp'` (BA-16, NATIVEprefer this on the claude CLI): one CLI
24
+ * session per call, the caller's `tools` exposed to it as a real MCP server whose handlers call back
25
+ * into your own in-process closures. The CLI owns the inner cycle and caches its transcript
26
+ * session-side. `'claude'` (v0.32.0, EMULATION): one CLI spawn per round with the whole transcript
27
+ * re-rendered and re-sent, parsed back through a JSON envelope. Emulation re-buys the full prefix
28
+ * every turn, which the adopter measured at **$0.25–0.55/round** against **~$0.006/turn** native — so
29
+ * it is the right instrument only for a CLI with NO MCP support, not a default. NOT claimed for
30
+ * native: better output quality (n=2 suggestive evidence exists and is deliberately unminted).
31
+ * Native mode sets {@link CLIPipeProvider#ownsCycle}, which makes the Loop REFUSE options it could
32
+ * never honor (`assemble`/`trim`/`cacheMessages`, and a Loop-level `policy`) instead of leaving them
33
+ * silently dead. See the native-only properties below.
34
+ * @property {(tool: string, args: any, ctx?: any) => any} [policy] - (native mode) The gate, same contract as `Loop({policy})`: only `true`
35
+ * allows, a string is the deny reason fed back verbatim, a thrown `HaltError` is a clean governance
36
+ * exit. REQUIRED here rather than on the Loop, because in native mode no tool call ever reaches the
37
+ * Loop — a `Loop({policy})` would be a fence that is silently not there (the Loop throws instead).
38
+ * Wiring the same `wireGate(gate).policy` keeps audit rows byte-shape-identical, with zero gate changes.
39
+ * @property {Function} [onTurn] - (native mode) Called with `{model, provider, usage, costUsd, pricing,
40
+ * durationMs, ctx, kind}` for EACH completed CLI turn as it arrives (`kind:'turn'`, four cache tiers,
41
+ * `costUsd:null` — the CLI prices the session, not the turn), then once at session end
42
+ * (`kind:'session'`) carrying the authoritative total cost with zero usage. Streaming, never
43
+ * sum-at-end: a session that dies mid-run must already have surfaced every completed turn's spend or
44
+ * the gate loses all of it. The event shape mirrors `Loop({onLlmResult})`, so `wireGate(gate).onLlmResult`
45
+ * drops straight in — and when it is wired the Loop skips its own forward, so nothing is billed twice.
46
+ * @property {number} [maxTurns] - (native mode) Bound on ASSISTANT/LLM TURNS — the same unit as the
47
+ * Loop path's turn bound, so a caller's `maxTurns` means one thing on both surfaces (BA-17). NOT a
48
+ * tool-call count: a single turn may issue a dozen parallel tool calls and still be one turn
49
+ * (measured: 12 calls across 2 turns, well inside `--max-turns 3`). Enforced twice on purpose —
50
+ * the CLI's own `--max-turns` stops the session cleanly at N and emits its result event (the only
51
+ * report of the session's real cost), and a parent-side counter kills it if a turn beyond N is
52
+ * ever observed, since that flag is undocumented in `claude --help` and a rename would otherwise
53
+ * silently unbound the session. Either way the stop is NAMED (`session.error:'max_turns'`,
54
+ * `stopReason:'max_turns'`) and carries the last turn's text forward, never a silent clean success
55
+ * and never an empty result.
56
+ * @property {number} [maxConsecutiveDenials=3] - (native mode) BA-11 at the bridge: a single deny stays
57
+ * advisory so the model can pivot to an allowed tool; N in a row with no allowed call between ends the
58
+ * session with `denied:<tool>`. `0`/`Infinity` disables.
59
+ * @property {number} [maxIdenticalToolErrors=3] - (native mode) BA-12 at the bridge: only a BYTE-IDENTICAL
60
+ * repeat (name + JSON args) counts, so a model varying its args while recovering is never punished. N in
61
+ * a row ends the session with `stuck:<tool>`. `0`/`Infinity` disables.
62
+ * @property {number} [sessionTimeout=600000] - (native mode) Wall-clock ceiling for one whole session. The
63
+ * 30s `timeout` default is for one-shot text and would kill an agentic session mid-run.
64
+ * @property {number} [bridgeTimeoutMs] - (native mode) Ceiling for ONE tool-handler round-trip across the
65
+ * bridge. A hung handler becomes an error tool result rather than a hung session (default 120s).
66
+ *
67
+ * Either mode: a non-empty `tools` array on `generate()` routes to tool mode; an empty one stays
68
+ * plain text. With NO `toolProtocol`, `tools` are IGNORED (plain-text, the long-standing behavior —
69
+ * a non-tool-calling CLI legitimately sits in a Loop with tools mounted) plus a one-time `console.warn`.
70
+ * Emulation additionally requires a capable model (weak ones answer in prose; see `probeCapability`);
71
+ * native mode needs no such probe, because the CLI's own tool channel does not depend on the model
72
+ * agreeing to fill in a JSON questionnaire. The claude-specific parts of each live in
73
+ * `provider-clipipe-tools.js` / `provider-clipipe-mcp.js`, so a second CLI slots in behind the same seams.
74
+ * @property {boolean} [probeCapability=true] - (EMULATION tool mode only) On the first tool-mode `generate`, run ONE cheap upfront probe that asks the model to obtain unknowable info via a tool. If it answers in prose instead of emitting a tool_call, throw a loud `ProviderError` naming the model — FAIL FAST rather than silently degrade mid-run (the weak-model failure mode). Behaviour-based, never a model name-list (a roster goes stale, BA-10). The verdict is cached per instance (one probe per provider, not per turn). Set `false` to skip when the caller already knows the model is capable.
23
75
  */
24
76
 
77
+ /**
78
+ * Session total minus what the per-turn events already reported, per tier, floored at 0.
79
+ *
80
+ * Floored because a negative would be a CREDIT to a gate's running total — an under-count that
81
+ * silently widens a budget cap. If the streamed turns ever overshoot the session total, the honest
82
+ * report is "nothing further", never "give some back".
83
+ *
84
+ * @param {import('../types').Usage} total
85
+ * @param {import('../types').Usage[]} streamed
86
+ * @returns {import('../types').Usage}
87
+ */
88
+ function subtractUsage(total, streamed) {
89
+ const sum = (/** @type {keyof import('../types').Usage} */ k) =>
90
+ streamed.reduce((a, t) => a + (Number(t[k]) || 0), 0);
91
+ const at = (/** @type {keyof import('../types').Usage} */ k) =>
92
+ Math.max(0, (Number(total[k]) || 0) - sum(k));
93
+ /** @type {import('../types').Usage} */
94
+ const out = { inputTokens: at('inputTokens'), outputTokens: at('outputTokens') };
95
+ // Only report a cache tier the session actually had — an absent tier stays absent, never a
96
+ // synthetic 0 (the Usage contract).
97
+ if (total.cacheReadTokens !== undefined) out.cacheReadTokens = at('cacheReadTokens');
98
+ if (total.cacheCreationTokens !== undefined) out.cacheCreationTokens = at('cacheCreationTokens');
99
+ return out;
100
+ }
101
+
25
102
  class CLIPipeProvider {
26
103
  /**
27
104
  * Provider that pipes prompts to a CLI command via stdin and reads stdout.
@@ -44,10 +121,40 @@ class CLIPipeProvider {
44
121
  // Tool mode (v0.32.0). Resolve the protocol adapter eagerly so an unknown name fails at
45
122
  // construction, not mid-run. `_toolCapability` caches the upfront probe verdict per instance
46
123
  // (null = not yet probed; a Promise while in flight; true once confirmed capable).
47
- this.toolProtocol = options.toolProtocol ? resolveToolProtocol(options.toolProtocol) : null;
124
+ // BA-16 native tool mode. `claude-mcp` is NOT an envelope protocol — the CLI runs its own
125
+ // multi-turn session and executes the caller's tools natively over MCP — so it is resolved on a
126
+ // separate axis rather than being forced through `resolveToolProtocol`'s emulation shape.
127
+ this.nativeTools = options.toolProtocol === 'claude-mcp';
128
+ /**
129
+ * Declares to the Loop that this provider runs its OWN turn cycle. The Loop reads it to refuse
130
+ * options it could never honor (assemble/trim/cacheMessages) and to require the fence be wired
131
+ * where it can actually run. Generic provider-contract flag; nothing here is claude-specific.
132
+ */
133
+ this.ownsCycle = this.nativeTools;
134
+ this.toolProtocol = (options.toolProtocol && !this.nativeTools) ? resolveToolProtocol(options.toolProtocol) : null;
48
135
  this.probeCapability = options.probeCapability !== false;
49
136
  /** @type {Promise<void>|null} */
50
137
  this._toolCapability = null;
138
+
139
+ if (this.nativeTools) {
140
+ // The gate CANNOT ride on the Loop in native mode: no tool call ever reaches the Loop, so a
141
+ // `Loop({policy})` would be a fence that silently is not there. It must be wired HERE, at the
142
+ // bridge, which is the one seam every tool call crosses.
143
+ if (options.policy != null && typeof options.policy !== 'function') {
144
+ throw new Error('[CLIPipeProvider] options.policy must be a function (tool, args, ctx) => true|string');
145
+ }
146
+ this.policy = options.policy || null;
147
+ this.onTurn = options.onTurn || null;
148
+ if (this.onTurn != null && typeof this.onTurn !== 'function') {
149
+ throw new Error('[CLIPipeProvider] options.onTurn must be a function');
150
+ }
151
+ this.maxTurns = options.maxTurns ?? null;
152
+ this.maxConsecutiveDenials = options.maxConsecutiveDenials;
153
+ this.maxIdenticalToolErrors = options.maxIdenticalToolErrors;
154
+ // A session is a whole agentic run, not one prompt — the 30s one-shot default would kill it.
155
+ this.sessionTimeout = options.sessionTimeout ?? 600000;
156
+ this.bridgeTimeoutMs = options.bridgeTimeoutMs ?? null;
157
+ }
51
158
  }
52
159
 
53
160
  /**
@@ -67,6 +174,7 @@ class CLIPipeProvider {
67
174
  */
68
175
  async generate(messages, tools = [], options = {}) {
69
176
  if (Array.isArray(tools) && tools.length > 0) {
177
+ if (this.nativeTools) return this._generateWithMcp(messages, tools, options);
70
178
  if (this.toolProtocol) return this._generateWithTools(messages, tools);
71
179
  // No protocol configured → plain-text mode, tools IGNORED — the long-standing behavior, kept
72
180
  // for backward compatibility (a non-tool-calling CLI legitimately coexists in a Loop that has
@@ -147,6 +255,157 @@ class CLIPipeProvider {
147
255
  return result;
148
256
  }
149
257
 
258
+ /**
259
+ * BA-16 native tool mode — run ONE whole CLI session and report it honestly as one call.
260
+ *
261
+ * The CLI owns the inner cycle here: it calls the caller's tools natively over an MCP bridge and
262
+ * keeps going until it answers or hits a bound. So this returns `toolCalls: []` always (there is
263
+ * nothing left for the Loop to execute) plus a `session` block describing what really happened —
264
+ * the real turn count, the real tool-call count, and any terminal the Loop must surface as the
265
+ * run's `error`.
266
+ *
267
+ * Ordering of terminals is deliberate. A governance halt outranks everything (it is a clean exit,
268
+ * not a fault). A tripped guard outranks the CLI's own subtype, because the guard is why we killed
269
+ * the session. And `bridgeDown` outranks a reported `success`, because a session whose tools were
270
+ * all broken still ends `subtype:'success'` — measured, and the reason this block exists.
271
+ *
272
+ * @param {Message[]} messages
273
+ * @param {ToolDef[]} tools
274
+ * @param {Record<string, any>} options - the Loop's run options (`ctx` is read from here).
275
+ * @returns {Promise<GenerateResult>}
276
+ */
277
+ async _generateWithMcp(messages, tools, options = {}) {
278
+ if (options.cacheMessages) {
279
+ throw new Error(
280
+ '[CLIPipeProvider] cacheMessages cannot apply in native tool mode — the CLI owns the transcript '
281
+ + 'and caches it session-side, so there is no request body for a breakpoint to ride on. Remove the option.',
282
+ );
283
+ }
284
+ const sysMsg = messages.find((m) => m.role === 'system');
285
+ const systemPrompt = (sysMsg && typeof sysMsg.content === 'string' && sysMsg.content)
286
+ || 'You are an agent. Use the tools provided over MCP when they are needed.';
287
+
288
+ const bridge = await createBridge({
289
+ tools,
290
+ policy: this.policy,
291
+ ctx: options.ctx,
292
+ maxConsecutiveDenials: this.maxConsecutiveDenials,
293
+ maxIdenticalToolErrors: this.maxIdenticalToolErrors,
294
+ });
295
+
296
+ let r;
297
+ try {
298
+ r = await runSession({
299
+ command: this.command,
300
+ baseArgs: this.args,
301
+ systemPrompt,
302
+ task: renderTranscript(messages),
303
+ sockPath: bridge.sockPath,
304
+ maxTurns: this.maxTurns,
305
+ timeoutMs: this.sessionTimeout,
306
+ bridgeTimeoutMs: this.bridgeTimeoutMs,
307
+ onTurn: this.onTurn,
308
+ ctx: options.ctx,
309
+ cwd: this.cwd,
310
+ env: this.env,
311
+ });
312
+ } finally {
313
+ bridge.close();
314
+ }
315
+
316
+ const st = bridge.state;
317
+ // A governance halt is a CLEAN exit and must reach the Loop as a HaltError, not as a session
318
+ // error tag — the Loop is the thing that knows a halt seals the transcript rather than faulting.
319
+ if (st.halt) throw st.halt;
320
+ if (r.turnHalt) throw r.turnHalt;
321
+ if (r.spawnError) {
322
+ throw new ProviderError(`[CLIPipeProvider] failed to spawn "${this.command}": ${r.spawnError.message}`, /** @type {any} */ ({ status: 0 }));
323
+ }
324
+
325
+ // The result event carries the session's authoritative totals. It has no `model` key — the model
326
+ // id lives under `modelUsage` — which is exactly what `mapClaudeMeta` already unpacks for the
327
+ // emulation path, so the native path reuses it rather than re-deriving three fields by hand.
328
+ const meta = r.final ? mapClaudeMeta(r.final) : null;
329
+
330
+ // `result.usage` is the authoritative session total and is preferred when present: it also
331
+ // captures a turn the CLI billed but never emitted as an event (measured — a bounded session's
332
+ // cut-off turn). Summing the per-turn records is the fallback for a session we killed before its
333
+ // result event. Either way the arithmetic is per-TURN, never per block-event (BA-17).
334
+ const usage = (meta && r.final.usage) ? meta.usage : r.turns.reduce((/** @type {any} */ a, t) => ({
335
+ inputTokens: a.inputTokens + (t.inputTokens || 0),
336
+ outputTokens: a.outputTokens + (t.outputTokens || 0),
337
+ cacheReadTokens: a.cacheReadTokens + (t.cacheReadTokens || 0),
338
+ cacheCreationTokens: a.cacheCreationTokens + (t.cacheCreationTokens || 0),
339
+ }), { inputTokens: 0, outputTokens: 0, cacheReadTokens: 0, cacheCreationTokens: 0 });
340
+
341
+ const { stopReason, error } = resolveSessionError({
342
+ // A bridge/guard terminal is more specific than the turn backstop, so it wins the tag.
343
+ terminal: st.terminal || r.terminal,
344
+ bridgeDown: st.bridgeDown,
345
+ attempted: r.attempted,
346
+ served: st.toolCalls,
347
+ timedOut: Boolean(r.timedOut),
348
+ subtype: r.final && r.final.subtype,
349
+ });
350
+
351
+ const costUsd = (meta && Number.isFinite(meta.costUsd)) ? /** @type {number} */ (meta.costUsd) : null;
352
+
353
+ // The authoritative figures arrive only at session end — the CLI prices the SESSION, not the
354
+ // turn — so when per-turn streaming is wired, one closing event RECONCILES both axes.
355
+ //
356
+ // Money: the whole cost, which no turn reported.
357
+ // Tokens: the RESIDUAL, not zero and not the total. A turn's `message.usage` is a snapshot taken
358
+ // when its first block was emitted and never revised (measured: a turn that emitted ~816 output
359
+ // tokens reported 2, identically on all 13 of its block-events), so the streamed per-turn sum is
360
+ // real but SHORT of the session total. Sending the difference makes a gate's token axis add up
361
+ // to exactly what the CLI itself reports — where sending the total would double-count everything
362
+ // already streamed, and sending zero would leave the axis quietly under-fed.
363
+ const residual = subtractUsage(usage, r.turns);
364
+ if (this.onTurn) {
365
+ try {
366
+ await this.onTurn({
367
+ model: (meta && meta.model) || null,
368
+ provider: 'clipipe',
369
+ usage: residual,
370
+ costUsd,
371
+ pricing: costUsd === null ? 'unpriced' : 'priced',
372
+ durationMs: r.ms,
373
+ ctx: options.ctx,
374
+ kind: 'session',
375
+ });
376
+ } catch (err) {
377
+ if (err instanceof HaltError) throw err;
378
+ }
379
+ }
380
+
381
+ // BA-5 on the native path: a bound or a tripped guard is normal termination for a bounded
382
+ // attempt, and the text is the ONLY channel from this attempt to the next. The CLI reports
383
+ // `result: null` when it stops on its own bound (measured), and a session we killed never emits
384
+ // a result at all — so fall back to the last assistant turn's own words rather than ''.
385
+ const finalText = (r.final && typeof r.final.result === 'string' && r.final.result)
386
+ ? r.final.result
387
+ : (r.lastText || '');
388
+
389
+ /** @type {GenerateResult} */
390
+ const result = {
391
+ text: finalText,
392
+ toolCalls: [],
393
+ usage,
394
+ model: (meta && meta.model) || null,
395
+ stopReason,
396
+ session: {
397
+ turns: r.turnCount,
398
+ toolCalls: st.toolCalls,
399
+ error,
400
+ // Only true when we ACTUALLY streamed — unwired, the Loop must still forward the total or
401
+ // the gate would see this session as free.
402
+ usageReported: Boolean(this.onTurn),
403
+ },
404
+ };
405
+ if (costUsd !== null) result.costUsd = costUsd;
406
+ return result;
407
+ }
408
+
150
409
  /**
151
410
  * Run the upfront capability probe ONCE per instance (cached), unless `probeCapability` is off.
152
411
  * A model that answers the probe in prose instead of emitting a tool_call throws a loud
package/types/index.d.ts CHANGED
@@ -34,6 +34,13 @@ export interface RunMetrics {
34
34
  turns: number;
35
35
  /** Total tool calls the model made (every invocation, including denied/unknown). */
36
36
  toolCalls: number;
37
+ /**
38
+ * BA-16 — turns that happened INSIDE a cycle-owning provider's own session (CLIPipe native tool
39
+ * mode). Such a session is ONE Loop round however many turns it really took, so `turns` alone
40
+ * would report a 14-turn session as 1 — a round count that reads far cheaper and shorter than the
41
+ * run actually was. 0 for every provider whose cycle the Loop drives.
42
+ */
43
+ sessionTurns: number;
37
44
  /** Per-tool invocation counts, keyed by tool name. */
38
45
  byTool: Record<string, number>;
39
46
  /** Cumulative token spend across all rounds (incl. summarize calls), by tier. */
@@ -97,6 +104,26 @@ export interface GenerateResult {
97
104
  * pre-BA-6 behavior exactly, so an unmapped provider degrades to the status quo.
98
105
  */
99
106
  stopReason?: string | null;
107
+ /**
108
+ * BA-16 — present ONLY when the provider ran its own multi-turn session for this single call
109
+ * (`Provider.ownsCycle`), e.g. CLIPipe native tool mode, where the CLI executes the caller's tools
110
+ * natively over MCP and keeps going until it answers or hits a bound.
111
+ *
112
+ * It exists so the Loop can stay honest about a call that was not one turn:
113
+ * - `turns` / `toolCalls` — what really happened, so `metrics` cannot report a 14-turn session as 1.
114
+ * - `error` — a terminal the provider detected INSIDE the session (a turn bound, a deny/stuck
115
+ * streak, a broken tool bridge). The Loop surfaces it as the run's `error`, never merely as a
116
+ * field: every downstream consumer branches on `error` as the sole success signal, so surfacing
117
+ * alone would let a session in which no tool call ever succeeded propagate as converged.
118
+ * - `usageReported` — the provider ALREADY forwarded this call's usage per internal turn, so the
119
+ * Loop must not forward the summed total again and bill the gate twice.
120
+ */
121
+ session?: {
122
+ turns: number;
123
+ toolCalls: number;
124
+ error: string | null;
125
+ usageReported: boolean;
126
+ };
100
127
  /**
101
128
  * True when the requested `temperature` was rejected by the model (400, unsupported/deprecated) and
102
129
  * the request was retried without it (BA-10). The response was produced at the model's DEFAULT
@@ -165,6 +192,19 @@ export interface Provider {
165
192
  model?: string | null;
166
193
  /** Provider name, surfaced in onLlmResult. */
167
194
  name?: string | null;
195
+ /**
196
+ * BA-16 — true when the provider runs its OWN multi-turn cycle inside a single `generate()` call
197
+ * (CLIPipe native tool mode), executing the caller's tools itself rather than returning
198
+ * `toolCalls` for the Loop to run.
199
+ *
200
+ * The Loop reads this to REFUSE options it could never honor rather than accept them and leave
201
+ * them silently dead: `assemble`/`trim` (the provider owns the transcript) and, most importantly,
202
+ * a Loop-level `policy` — no tool call reaches the Loop, so that fence would simply not be there
203
+ * while the run still looked governed. Such a provider must carry its own `policy`.
204
+ */
205
+ ownsCycle?: boolean;
206
+ /** Gate chokepoint for a cycle-owning provider — same contract as `Loop({policy})`. */
207
+ policy?: ((tool: string, args: any, ctx?: any) => any) | null;
168
208
  generate(
169
209
  messages: Message[],
170
210
  tools?: ToolDef[],