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.
@@ -35,11 +35,75 @@ 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`.
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.
39
50
  */
40
- toolProtocol?: "claude" | undefined;
51
+ toolProtocol?: "claude" | "claude-mcp" | undefined;
41
52
  /**
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.
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) Maps to the CLI's `--max-turns`. The bound stop is NAMED
72
+ * (`error_max_turns` → `session.error:'max_turns'`), never a silent clean success.
73
+ */
74
+ maxTurns?: number | undefined;
75
+ /**
76
+ * - (native mode) BA-11 at the bridge: a single deny stays
77
+ * advisory so the model can pivot to an allowed tool; N in a row with no allowed call between ends the
78
+ * session with `denied:<tool>`. `0`/`Infinity` disables.
79
+ */
80
+ maxConsecutiveDenials?: number | undefined;
81
+ /**
82
+ * - (native mode) BA-12 at the bridge: only a BYTE-IDENTICAL
83
+ * repeat (name + JSON args) counts, so a model varying its args while recovering is never punished. N in
84
+ * a row ends the session with `stuck:<tool>`. `0`/`Infinity` disables.
85
+ */
86
+ maxIdenticalToolErrors?: number | undefined;
87
+ /**
88
+ * - (native mode) Wall-clock ceiling for one whole session. The
89
+ * 30s `timeout` default is for one-shot text and would kill an agentic session mid-run.
90
+ */
91
+ sessionTimeout?: number | undefined;
92
+ /**
93
+ * - (native mode) Ceiling for ONE tool-handler round-trip across the
94
+ * bridge. A hung handler becomes an error tool result rather than a hung session (default 120s).
95
+ *
96
+ * Either mode: a non-empty `tools` array on `generate()` routes to tool mode; an empty one stays
97
+ * plain text. With NO `toolProtocol`, `tools` are IGNORED (plain-text, the long-standing behavior —
98
+ * a non-tool-calling CLI legitimately sits in a Loop with tools mounted) plus a one-time `console.warn`.
99
+ * Emulation additionally requires a capable model (weak ones answer in prose; see `probeCapability`);
100
+ * native mode needs no such probe, because the CLI's own tool channel does not depend on the model
101
+ * agreeing to fill in a JSON questionnaire. The claude-specific parts of each live in
102
+ * `provider-clipipe-tools.js` / `provider-clipipe-mcp.js`, so a second CLI slots in behind the same seams.
103
+ */
104
+ bridgeTimeoutMs?: number | undefined;
105
+ /**
106
+ * - (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
107
  */
44
108
  probeCapability?: boolean | undefined;
45
109
  };
@@ -56,8 +120,51 @@ export type CLIPipeOptions = {
56
120
  * @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
121
  * @property {(chunk: string) => void} [onChunk] - Called with each stdout chunk as it streams.
58
122
  * @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.
123
+ * @property {'claude'|'claude-mcp'} [toolProtocol] - Opt into TOOL MODE. Two modes, and the choice is
124
+ * about COST, not capability. `'claude-mcp'` (BA-16, NATIVEprefer this on the claude CLI): one CLI
125
+ * session per call, the caller's `tools` exposed to it as a real MCP server whose handlers call back
126
+ * into your own in-process closures. The CLI owns the inner cycle and caches its transcript
127
+ * session-side. `'claude'` (v0.32.0, EMULATION): one CLI spawn per round with the whole transcript
128
+ * re-rendered and re-sent, parsed back through a JSON envelope. Emulation re-buys the full prefix
129
+ * every turn, which the adopter measured at **$0.25–0.55/round** against **~$0.006/turn** native — so
130
+ * it is the right instrument only for a CLI with NO MCP support, not a default. NOT claimed for
131
+ * native: better output quality (n=2 suggestive evidence exists and is deliberately unminted).
132
+ * Native mode sets {@link CLIPipeProvider#ownsCycle}, which makes the Loop REFUSE options it could
133
+ * never honor (`assemble`/`trim`/`cacheMessages`, and a Loop-level `policy`) instead of leaving them
134
+ * silently dead. See the native-only properties below.
135
+ * @property {(tool: string, args: any, ctx?: any) => any} [policy] - (native mode) The gate, same contract as `Loop({policy})`: only `true`
136
+ * allows, a string is the deny reason fed back verbatim, a thrown `HaltError` is a clean governance
137
+ * exit. REQUIRED here rather than on the Loop, because in native mode no tool call ever reaches the
138
+ * Loop — a `Loop({policy})` would be a fence that is silently not there (the Loop throws instead).
139
+ * Wiring the same `wireGate(gate).policy` keeps audit rows byte-shape-identical, with zero gate changes.
140
+ * @property {Function} [onTurn] - (native mode) Called with `{model, provider, usage, costUsd, pricing,
141
+ * durationMs, ctx, kind}` for EACH completed CLI turn as it arrives (`kind:'turn'`, four cache tiers,
142
+ * `costUsd:null` — the CLI prices the session, not the turn), then once at session end
143
+ * (`kind:'session'`) carrying the authoritative total cost with zero usage. Streaming, never
144
+ * sum-at-end: a session that dies mid-run must already have surfaced every completed turn's spend or
145
+ * the gate loses all of it. The event shape mirrors `Loop({onLlmResult})`, so `wireGate(gate).onLlmResult`
146
+ * drops straight in — and when it is wired the Loop skips its own forward, so nothing is billed twice.
147
+ * @property {number} [maxTurns] - (native mode) Maps to the CLI's `--max-turns`. The bound stop is NAMED
148
+ * (`error_max_turns` → `session.error:'max_turns'`), never a silent clean success.
149
+ * @property {number} [maxConsecutiveDenials=3] - (native mode) BA-11 at the bridge: a single deny stays
150
+ * advisory so the model can pivot to an allowed tool; N in a row with no allowed call between ends the
151
+ * session with `denied:<tool>`. `0`/`Infinity` disables.
152
+ * @property {number} [maxIdenticalToolErrors=3] - (native mode) BA-12 at the bridge: only a BYTE-IDENTICAL
153
+ * repeat (name + JSON args) counts, so a model varying its args while recovering is never punished. N in
154
+ * a row ends the session with `stuck:<tool>`. `0`/`Infinity` disables.
155
+ * @property {number} [sessionTimeout=600000] - (native mode) Wall-clock ceiling for one whole session. The
156
+ * 30s `timeout` default is for one-shot text and would kill an agentic session mid-run.
157
+ * @property {number} [bridgeTimeoutMs] - (native mode) Ceiling for ONE tool-handler round-trip across the
158
+ * bridge. A hung handler becomes an error tool result rather than a hung session (default 120s).
159
+ *
160
+ * Either mode: a non-empty `tools` array on `generate()` routes to tool mode; an empty one stays
161
+ * plain text. With NO `toolProtocol`, `tools` are IGNORED (plain-text, the long-standing behavior —
162
+ * a non-tool-calling CLI legitimately sits in a Loop with tools mounted) plus a one-time `console.warn`.
163
+ * Emulation additionally requires a capable model (weak ones answer in prose; see `probeCapability`);
164
+ * native mode needs no such probe, because the CLI's own tool channel does not depend on the model
165
+ * agreeing to fill in a JSON questionnaire. The claude-specific parts of each live in
166
+ * `provider-clipipe-tools.js` / `provider-clipipe-mcp.js`, so a second CLI slots in behind the same seams.
167
+ * @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.
61
168
  */
62
169
  export class CLIPipeProvider {
63
170
  /**
@@ -74,6 +181,13 @@ export class CLIPipeProvider {
74
181
  systemPromptFlag: string | null;
75
182
  onChunk: ((chunk: string) => void) | null;
76
183
  parse: "claude-json" | ((stdout: string) => Partial<GenerateResult>) | null;
184
+ nativeTools: boolean;
185
+ /**
186
+ * Declares to the Loop that this provider runs its OWN turn cycle. The Loop reads it to refuse
187
+ * options it could never honor (assemble/trim/cacheMessages) and to require the fence be wired
188
+ * where it can actually run. Generic provider-contract flag; nothing here is claude-specific.
189
+ */
190
+ ownsCycle: boolean;
77
191
  toolProtocol: {
78
192
  name: string;
79
193
  turnArgs(systemPrompt: string): string[];
@@ -87,6 +201,13 @@ export class CLIPipeProvider {
87
201
  probeCapability: boolean;
88
202
  /** @type {Promise<void>|null} */
89
203
  _toolCapability: Promise<void> | null;
204
+ policy: ((tool: string, args: any, ctx?: any) => any) | null | undefined;
205
+ onTurn: Function | null | undefined;
206
+ maxTurns: number | null | undefined;
207
+ maxConsecutiveDenials: number | undefined;
208
+ maxIdenticalToolErrors: number | undefined;
209
+ sessionTimeout: number | undefined;
210
+ bridgeTimeoutMs: number | null | undefined;
90
211
  /**
91
212
  * Generate a response by piping messages to the CLI command. With a `toolProtocol` configured and
92
213
  * a non-empty `tools` array, routes to schema-validated tool EMULATION (v0.32.0); otherwise the
@@ -115,6 +236,26 @@ export class CLIPipeProvider {
115
236
  */
116
237
  _generateWithTools(messages: Message[], tools: ToolDef[]): Promise<GenerateResult>;
117
238
  _toolCallSeq: any;
239
+ /**
240
+ * BA-16 native tool mode — run ONE whole CLI session and report it honestly as one call.
241
+ *
242
+ * The CLI owns the inner cycle here: it calls the caller's tools natively over an MCP bridge and
243
+ * keeps going until it answers or hits a bound. So this returns `toolCalls: []` always (there is
244
+ * nothing left for the Loop to execute) plus a `session` block describing what really happened —
245
+ * the real turn count, the real tool-call count, and any terminal the Loop must surface as the
246
+ * run's `error`.
247
+ *
248
+ * Ordering of terminals is deliberate. A governance halt outranks everything (it is a clean exit,
249
+ * not a fault). A tripped guard outranks the CLI's own subtype, because the guard is why we killed
250
+ * the session. And `bridgeDown` outranks a reported `success`, because a session whose tools were
251
+ * all broken still ends `subtype:'success'` — measured, and the reason this block exists.
252
+ *
253
+ * @param {Message[]} messages
254
+ * @param {ToolDef[]} tools
255
+ * @param {Record<string, any>} options - the Loop's run options (`ctx` is read from here).
256
+ * @returns {Promise<GenerateResult>}
257
+ */
258
+ _generateWithMcp(messages: Message[], tools: ToolDef[], options?: Record<string, any>): Promise<GenerateResult>;
118
259
  /**
119
260
  * Run the upfront capability probe ONCE per instance (cached), unless `probeCapability` is off.
120
261
  * 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,8 +19,51 @@ 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) Maps to the CLI's `--max-turns`. The bound stop is NAMED
47
+ * (`error_max_turns` → `session.error:'max_turns'`), never a silent clean success.
48
+ * @property {number} [maxConsecutiveDenials=3] - (native mode) BA-11 at the bridge: a single deny stays
49
+ * advisory so the model can pivot to an allowed tool; N in a row with no allowed call between ends the
50
+ * session with `denied:<tool>`. `0`/`Infinity` disables.
51
+ * @property {number} [maxIdenticalToolErrors=3] - (native mode) BA-12 at the bridge: only a BYTE-IDENTICAL
52
+ * repeat (name + JSON args) counts, so a model varying its args while recovering is never punished. N in
53
+ * a row ends the session with `stuck:<tool>`. `0`/`Infinity` disables.
54
+ * @property {number} [sessionTimeout=600000] - (native mode) Wall-clock ceiling for one whole session. The
55
+ * 30s `timeout` default is for one-shot text and would kill an agentic session mid-run.
56
+ * @property {number} [bridgeTimeoutMs] - (native mode) Ceiling for ONE tool-handler round-trip across the
57
+ * bridge. A hung handler becomes an error tool result rather than a hung session (default 120s).
58
+ *
59
+ * Either mode: a non-empty `tools` array on `generate()` routes to tool mode; an empty one stays
60
+ * plain text. With NO `toolProtocol`, `tools` are IGNORED (plain-text, the long-standing behavior —
61
+ * a non-tool-calling CLI legitimately sits in a Loop with tools mounted) plus a one-time `console.warn`.
62
+ * Emulation additionally requires a capable model (weak ones answer in prose; see `probeCapability`);
63
+ * native mode needs no such probe, because the CLI's own tool channel does not depend on the model
64
+ * agreeing to fill in a JSON questionnaire. The claude-specific parts of each live in
65
+ * `provider-clipipe-tools.js` / `provider-clipipe-mcp.js`, so a second CLI slots in behind the same seams.
66
+ * @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
67
  */
24
68
 
25
69
  class CLIPipeProvider {
@@ -44,10 +88,40 @@ class CLIPipeProvider {
44
88
  // Tool mode (v0.32.0). Resolve the protocol adapter eagerly so an unknown name fails at
45
89
  // construction, not mid-run. `_toolCapability` caches the upfront probe verdict per instance
46
90
  // (null = not yet probed; a Promise while in flight; true once confirmed capable).
47
- this.toolProtocol = options.toolProtocol ? resolveToolProtocol(options.toolProtocol) : null;
91
+ // BA-16 native tool mode. `claude-mcp` is NOT an envelope protocol — the CLI runs its own
92
+ // multi-turn session and executes the caller's tools natively over MCP — so it is resolved on a
93
+ // separate axis rather than being forced through `resolveToolProtocol`'s emulation shape.
94
+ this.nativeTools = options.toolProtocol === 'claude-mcp';
95
+ /**
96
+ * Declares to the Loop that this provider runs its OWN turn cycle. The Loop reads it to refuse
97
+ * options it could never honor (assemble/trim/cacheMessages) and to require the fence be wired
98
+ * where it can actually run. Generic provider-contract flag; nothing here is claude-specific.
99
+ */
100
+ this.ownsCycle = this.nativeTools;
101
+ this.toolProtocol = (options.toolProtocol && !this.nativeTools) ? resolveToolProtocol(options.toolProtocol) : null;
48
102
  this.probeCapability = options.probeCapability !== false;
49
103
  /** @type {Promise<void>|null} */
50
104
  this._toolCapability = null;
105
+
106
+ if (this.nativeTools) {
107
+ // The gate CANNOT ride on the Loop in native mode: no tool call ever reaches the Loop, so a
108
+ // `Loop({policy})` would be a fence that silently is not there. It must be wired HERE, at the
109
+ // bridge, which is the one seam every tool call crosses.
110
+ if (options.policy != null && typeof options.policy !== 'function') {
111
+ throw new Error('[CLIPipeProvider] options.policy must be a function (tool, args, ctx) => true|string');
112
+ }
113
+ this.policy = options.policy || null;
114
+ this.onTurn = options.onTurn || null;
115
+ if (this.onTurn != null && typeof this.onTurn !== 'function') {
116
+ throw new Error('[CLIPipeProvider] options.onTurn must be a function');
117
+ }
118
+ this.maxTurns = options.maxTurns ?? null;
119
+ this.maxConsecutiveDenials = options.maxConsecutiveDenials;
120
+ this.maxIdenticalToolErrors = options.maxIdenticalToolErrors;
121
+ // A session is a whole agentic run, not one prompt — the 30s one-shot default would kill it.
122
+ this.sessionTimeout = options.sessionTimeout ?? 600000;
123
+ this.bridgeTimeoutMs = options.bridgeTimeoutMs ?? null;
124
+ }
51
125
  }
52
126
 
53
127
  /**
@@ -67,6 +141,7 @@ class CLIPipeProvider {
67
141
  */
68
142
  async generate(messages, tools = [], options = {}) {
69
143
  if (Array.isArray(tools) && tools.length > 0) {
144
+ if (this.nativeTools) return this._generateWithMcp(messages, tools, options);
70
145
  if (this.toolProtocol) return this._generateWithTools(messages, tools);
71
146
  // No protocol configured → plain-text mode, tools IGNORED — the long-standing behavior, kept
72
147
  // for backward compatibility (a non-tool-calling CLI legitimately coexists in a Loop that has
@@ -147,6 +222,133 @@ class CLIPipeProvider {
147
222
  return result;
148
223
  }
149
224
 
225
+ /**
226
+ * BA-16 native tool mode — run ONE whole CLI session and report it honestly as one call.
227
+ *
228
+ * The CLI owns the inner cycle here: it calls the caller's tools natively over an MCP bridge and
229
+ * keeps going until it answers or hits a bound. So this returns `toolCalls: []` always (there is
230
+ * nothing left for the Loop to execute) plus a `session` block describing what really happened —
231
+ * the real turn count, the real tool-call count, and any terminal the Loop must surface as the
232
+ * run's `error`.
233
+ *
234
+ * Ordering of terminals is deliberate. A governance halt outranks everything (it is a clean exit,
235
+ * not a fault). A tripped guard outranks the CLI's own subtype, because the guard is why we killed
236
+ * the session. And `bridgeDown` outranks a reported `success`, because a session whose tools were
237
+ * all broken still ends `subtype:'success'` — measured, and the reason this block exists.
238
+ *
239
+ * @param {Message[]} messages
240
+ * @param {ToolDef[]} tools
241
+ * @param {Record<string, any>} options - the Loop's run options (`ctx` is read from here).
242
+ * @returns {Promise<GenerateResult>}
243
+ */
244
+ async _generateWithMcp(messages, tools, options = {}) {
245
+ if (options.cacheMessages) {
246
+ throw new Error(
247
+ '[CLIPipeProvider] cacheMessages cannot apply in native tool mode — the CLI owns the transcript '
248
+ + 'and caches it session-side, so there is no request body for a breakpoint to ride on. Remove the option.',
249
+ );
250
+ }
251
+ const sysMsg = messages.find((m) => m.role === 'system');
252
+ const systemPrompt = (sysMsg && typeof sysMsg.content === 'string' && sysMsg.content)
253
+ || 'You are an agent. Use the tools provided over MCP when they are needed.';
254
+
255
+ const bridge = await createBridge({
256
+ tools,
257
+ policy: this.policy,
258
+ ctx: options.ctx,
259
+ maxConsecutiveDenials: this.maxConsecutiveDenials,
260
+ maxIdenticalToolErrors: this.maxIdenticalToolErrors,
261
+ });
262
+
263
+ let r;
264
+ try {
265
+ r = await runSession({
266
+ command: this.command,
267
+ baseArgs: this.args,
268
+ systemPrompt,
269
+ task: renderTranscript(messages),
270
+ sockPath: bridge.sockPath,
271
+ maxTurns: this.maxTurns,
272
+ timeoutMs: this.sessionTimeout,
273
+ bridgeTimeoutMs: this.bridgeTimeoutMs,
274
+ onTurn: this.onTurn,
275
+ ctx: options.ctx,
276
+ cwd: this.cwd,
277
+ env: this.env,
278
+ });
279
+ } finally {
280
+ bridge.close();
281
+ }
282
+
283
+ const st = bridge.state;
284
+ // A governance halt is a CLEAN exit and must reach the Loop as a HaltError, not as a session
285
+ // error tag — the Loop is the thing that knows a halt seals the transcript rather than faulting.
286
+ if (st.halt) throw st.halt;
287
+ if (r.turnHalt) throw r.turnHalt;
288
+ if (r.spawnError) {
289
+ throw new ProviderError(`[CLIPipeProvider] failed to spawn "${this.command}": ${r.spawnError.message}`, /** @type {any} */ ({ status: 0 }));
290
+ }
291
+
292
+ /** @type {import('../types').Usage} */
293
+ const usage = { inputTokens: 0, outputTokens: 0, cacheReadTokens: 0, cacheCreationTokens: 0 };
294
+ for (const t of r.turns) {
295
+ usage.inputTokens += t.inputTokens || 0;
296
+ usage.outputTokens += t.outputTokens || 0;
297
+ usage.cacheReadTokens += t.cacheReadTokens || 0;
298
+ usage.cacheCreationTokens += t.cacheCreationTokens || 0;
299
+ }
300
+
301
+ const { stopReason, error } = resolveSessionError({
302
+ terminal: st.terminal,
303
+ bridgeDown: st.bridgeDown,
304
+ attempted: r.attempted,
305
+ served: st.toolCalls,
306
+ timedOut: Boolean(r.timedOut),
307
+ subtype: r.final && r.final.subtype,
308
+ });
309
+
310
+ const costUsd = (r.final && Number.isFinite(r.final.total_cost_usd)) ? r.final.total_cost_usd : null;
311
+
312
+ // The authoritative price arrives only at session end (the CLI prices the SESSION, not the turn),
313
+ // so when per-turn streaming is wired it gets one closing event carrying the cost with zero
314
+ // usage — the tokens were already streamed, and double-counting either axis would be a lie.
315
+ if (this.onTurn) {
316
+ try {
317
+ await this.onTurn({
318
+ model: (r.final && r.final.model) || null,
319
+ provider: 'clipipe',
320
+ usage: { inputTokens: 0, outputTokens: 0 },
321
+ costUsd,
322
+ pricing: costUsd === null ? 'unpriced' : 'priced',
323
+ durationMs: r.ms,
324
+ ctx: options.ctx,
325
+ kind: 'session',
326
+ });
327
+ } catch (err) {
328
+ if (err instanceof HaltError) throw err;
329
+ }
330
+ }
331
+
332
+ /** @type {GenerateResult} */
333
+ const result = {
334
+ text: (r.final && typeof r.final.result === 'string') ? r.final.result : '',
335
+ toolCalls: [],
336
+ usage,
337
+ model: (r.final && r.final.model) || null,
338
+ stopReason,
339
+ session: {
340
+ turns: r.turns.length,
341
+ toolCalls: st.toolCalls,
342
+ error,
343
+ // Only true when we ACTUALLY streamed — unwired, the Loop must still forward the total or
344
+ // the gate would see this session as free.
345
+ usageReported: Boolean(this.onTurn),
346
+ },
347
+ };
348
+ if (costUsd !== null) result.costUsd = costUsd;
349
+ return result;
350
+ }
351
+
150
352
  /**
151
353
  * Run the upfront capability probe ONCE per instance (cached), unless `probeCapability` is off.
152
354
  * 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[],