bare-agent 0.31.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.
@@ -0,0 +1,271 @@
1
+ 'use strict';
2
+
3
+ const { ProviderError } = require('./errors');
4
+
5
+ /** @typedef {import('../types').Message} Message */
6
+ /** @typedef {import('../types').ToolDef} ToolDef */
7
+
8
+ // CLIPipe tool-mode support (v0.32.0). A subscription CLI (`claude -p`, …) is a plain
9
+ // TURN-provider: it takes text and returns text, with no native channel for a caller's tools. This
10
+ // module adds Option C — SCHEMA-VALIDATED TOOL EMULATION: the caller's tools are described in the
11
+ // system prompt, the CLI is constrained to a JSON envelope, and the envelope is parsed back into
12
+ // normalized `toolCalls` so bareagent's own `Loop` keeps ownership of the agentic cycle (round
13
+ // accounting, spin guards, stop-reason classification all still apply — unlike an MCP-callback
14
+ // design where the CLI would own the turns). Proven end-to-end through a real Loop in
15
+ // `poc/clipipe-tools-03-through-loop.mjs` (sonnet, multi-round).
16
+ //
17
+ // Two layers, deliberately split so a SECOND CLI (codex/gemini) slots in behind the same seam
18
+ // without touching the generic provider (the "keep the claude-specific parts isolated" constraint):
19
+ // - PROTOCOL-AGNOSTIC (renderTranscript / buildToolSystemPrompt): produce the text a CLI is fed.
20
+ // - PROTOCOL-SPECIFIC (CLAUDE_TOOL_PROTOCOL): the claude-CLI flags, the envelope schema, the
21
+ // result parse, and the capability-probe assets. Claude-only for now, by design.
22
+
23
+ /**
24
+ * The JSON envelope the CLI is constrained to (claude `--json-schema`). `tool_call` carries the
25
+ * name + args; `final_answer` carries prose. A closed `action` enum is the discriminator.
26
+ * @type {string}
27
+ */
28
+ const ENVELOPE_SCHEMA = JSON.stringify({
29
+ type: 'object',
30
+ properties: {
31
+ action: { type: 'string', enum: ['tool_call', 'final_answer'] },
32
+ tool_name: { type: 'string' },
33
+ tool_arguments: { type: 'object' },
34
+ answer: { type: 'string' },
35
+ },
36
+ required: ['action'],
37
+ });
38
+
39
+ /**
40
+ * One tool's signature line for the manifest. Protocol-agnostic.
41
+ * @param {ToolDef} t
42
+ * @returns {string}
43
+ */
44
+ function toolLine(t) {
45
+ const props = (t.parameters && t.parameters.properties) || {};
46
+ const args = Object.keys(props).map((k) => `${k}: ${props[k].type || 'any'}`).join(', ');
47
+ return `- ${t.name}(${args})${t.description ? ` — ${t.description}` : ''}`;
48
+ }
49
+
50
+ /**
51
+ * Build the tool-mode system prompt: the caller's own system stance (if any) + the tool manifest +
52
+ * the envelope contract. The contract text is load-bearing — the step-2 probe showed a weak model
53
+ * that BELIEVES it must execute the tool itself answers in prose ("I attempted… I failed"), so the
54
+ * prompt states plainly that EMITTING the envelope IS the call and "attempting/failing" is not a
55
+ * thing it can do. Protocol-agnostic (the envelope vocabulary is shared across CLIs).
56
+ * @param {string|null} baseSystem - the caller's system message content, or null.
57
+ * @param {ToolDef[]} tools
58
+ * @returns {string}
59
+ */
60
+ function buildToolSystemPrompt(baseSystem, tools) {
61
+ const manifest = tools.length
62
+ ? ['You can use these tools:', ...tools.map(toolLine)].join('\n')
63
+ : 'You have no tools.';
64
+ return [
65
+ baseSystem ? String(baseSystem) : 'You are the reasoning half of a tool-using system.',
66
+ '',
67
+ manifest,
68
+ '',
69
+ 'An external runtime executes tools for you. Emitting a tool_call envelope IS how a tool runs —',
70
+ 'you never execute one yourself, and "attempting" or "failing" to call a tool is not something',
71
+ 'you can do. To use a tool: action="tool_call" with tool_name and tool_arguments. When the tool',
72
+ 'results you need are already present in the conversation, reply action="final_answer" with the',
73
+ 'answer. Reply ONLY with the JSON envelope.',
74
+ ].join('\n');
75
+ }
76
+
77
+ /**
78
+ * Render the Loop's OpenAI-shaped transcript into a plain-text conversation a CLI can read. The
79
+ * load-bearing part: assistant `tool_calls` and `role:'tool'` results must SURVIVE (the default
80
+ * `_formatPrompt` drops them), and each result must trace to the call that produced it (id → name).
81
+ * Protocol-agnostic. The system message is excluded here — it rides the CLI's system-prompt flag.
82
+ * @param {Message[]} messages
83
+ * @returns {string}
84
+ */
85
+ function renderTranscript(messages) {
86
+ /** @type {Map<string,string>} */
87
+ const idToName = new Map();
88
+ for (const m of messages) {
89
+ if (m.role === 'assistant' && Array.isArray(m.tool_calls)) {
90
+ for (const tc of m.tool_calls) {
91
+ if (!tc.id) continue;
92
+ const nm = (tc.function && tc.function.name) || tc.name || '?';
93
+ idToName.set(String(tc.id), String(nm));
94
+ }
95
+ }
96
+ }
97
+ const lines = [];
98
+ for (const m of messages) {
99
+ if (m.role === 'system') continue;
100
+ if (m.role === 'user') { lines.push(`User: ${m.content}`); continue; }
101
+ if (m.role === 'assistant') {
102
+ let s = m.content ? `Assistant: ${m.content}` : 'Assistant:';
103
+ if (Array.isArray(m.tool_calls)) {
104
+ for (const tc of m.tool_calls) {
105
+ const fn = tc.function || {};
106
+ const args = typeof fn.arguments === 'string' ? fn.arguments : JSON.stringify(fn.arguments || {});
107
+ s += `\n (you called ${fn.name || tc.name}(${args}))`;
108
+ }
109
+ }
110
+ lines.push(s);
111
+ continue;
112
+ }
113
+ if (m.role === 'tool') {
114
+ const name = idToName.get(m.tool_call_id || '') || '?';
115
+ lines.push(`Tool result from ${name}: ${m.content}`);
116
+ }
117
+ }
118
+ return lines.join('\n');
119
+ }
120
+
121
+ /**
122
+ * Map the claude `--output-format json` OUTER envelope's usage / model / cost onto neutral shapes.
123
+ * Shared by tool-mode {@link CLAUDE_TOOL_PROTOCOL.parseResult} and the plain-text `_parseClaudeJson`
124
+ * preset so the claude usage contract (token tiers, `modelUsage` first-key, `total_cost_usd`) lives
125
+ * in exactly ONE place — a future CLI format change touches this function, not two copies.
126
+ * @param {any} outer - the parsed outer CLI envelope (already validated non-null by the caller).
127
+ * @returns {{usage: import('../types').Usage, model: string|null, costUsd?: number}}
128
+ */
129
+ function mapClaudeMeta(outer) {
130
+ const u = (outer.usage && typeof outer.usage === 'object') ? outer.usage : {};
131
+ /** @type {import('../types').Usage} */
132
+ const usage = {
133
+ inputTokens: Number(u.input_tokens) || 0,
134
+ outputTokens: Number(u.output_tokens) || 0,
135
+ };
136
+ // Absent cache tiers mean the model didn't cache — omit rather than emit a synthetic 0 (per Usage docs).
137
+ if (Number.isFinite(u.cache_read_input_tokens)) usage.cacheReadTokens = u.cache_read_input_tokens;
138
+ if (Number.isFinite(u.cache_creation_input_tokens)) usage.cacheCreationTokens = u.cache_creation_input_tokens;
139
+ // `modelUsage` is an object keyed by model id (e.g. {"claude-opus-4-8[1m]": {...}}) — take the first key.
140
+ const model = (outer.modelUsage && typeof outer.modelUsage === 'object')
141
+ ? (Object.keys(outer.modelUsage)[0] ?? null)
142
+ : null;
143
+ /** @type {{usage: import('../types').Usage, model: string|null, costUsd?: number}} */
144
+ const meta = { usage, model };
145
+ // The CLI's own price is authoritative (a subscription run reports an equivalent cost even at $0
146
+ // marginal) — feeds bareguard's USD axis with no local rate table. Only a finite number counts.
147
+ if (Number.isFinite(outer.total_cost_usd)) meta.costUsd = outer.total_cost_usd;
148
+ return meta;
149
+ }
150
+
151
+ /**
152
+ * @typedef {object} ParsedEnvelope
153
+ * @property {'tool_call'|'final_answer'} action
154
+ * @property {string} [toolName]
155
+ * @property {Record<string, any>} [toolArguments]
156
+ * @property {string} [answer]
157
+ * @property {import('../types').Usage} usage
158
+ * @property {string|null} [model]
159
+ * @property {number} [costUsd]
160
+ */
161
+
162
+ /**
163
+ * The claude-CLI tool protocol. Everything CLI-shaped lives here; a second CLI is a sibling object
164
+ * implementing the same three members. Isolated so the generic provider never grows a claude branch.
165
+ */
166
+ const CLAUDE_TOOL_PROTOCOL = {
167
+ name: 'claude',
168
+
169
+ /**
170
+ * The extra CLI args for one tool-mode turn, appended after the caller's base args (`-p --model X`).
171
+ * `--tools ''` + `--strict-mcp-config` strip the CLI's own tools/MCP to a bare brain (step 1);
172
+ * `--setting-sources ''` suppresses cwd CLAUDE.md/memory/settings auto-discovery — a MEASURED
173
+ * ~18× cost drop (37,423 → 2,026 tokens/turn, step 2), load-bearing for a subscription strategy;
174
+ * `--system-prompt` REPLACES the CLI's prompt (vs --append); `--json-schema` + `--output-format
175
+ * json` force the parseable envelope.
176
+ * @param {string} systemPrompt
177
+ * @returns {string[]}
178
+ */
179
+ turnArgs(systemPrompt) {
180
+ return [
181
+ '--tools', '', '--strict-mcp-config', '--setting-sources', '',
182
+ '--system-prompt', systemPrompt,
183
+ '--json-schema', ENVELOPE_SCHEMA,
184
+ '--output-format', 'json',
185
+ ];
186
+ },
187
+
188
+ /**
189
+ * Parse claude's `--output-format json` stdout into a neutral {@link ParsedEnvelope}. A malformed
190
+ * or error envelope is a LOUD `ProviderError` — NEVER a silent fall-through to prose (the whole
191
+ * point is that a broken tool turn cannot masquerade as a final answer, the BA-6 failure shape).
192
+ * @param {string} stdout
193
+ * @returns {ParsedEnvelope}
194
+ */
195
+ parseResult(stdout) {
196
+ let outer;
197
+ try {
198
+ outer = JSON.parse(stdout);
199
+ } catch (_) {
200
+ const preview = stdout.length > 200 ? `${stdout.slice(0, 200)}…` : stdout;
201
+ throw new ProviderError(`[CLIPipeProvider] tool-mode expected JSON on stdout, got: ${preview}`, /** @type {any} */ ({ status: 0 }));
202
+ }
203
+ if (!outer || typeof outer !== 'object' || outer.is_error === true || outer.subtype !== 'success') {
204
+ const detail = outer && typeof outer.result === 'string' ? outer.result : JSON.stringify(outer && outer.subtype);
205
+ throw new ProviderError(`[CLIPipeProvider] claude CLI reported failure (subtype='${outer && outer.subtype}'): ${detail}`, /** @type {any} */ ({ status: 0 }));
206
+ }
207
+ let env;
208
+ try {
209
+ env = JSON.parse(outer.result);
210
+ } catch (_) {
211
+ throw new ProviderError(`[CLIPipeProvider] tool-mode envelope was not valid JSON: ${String(outer.result).slice(0, 200)}`, /** @type {any} */ ({ status: 0 }));
212
+ }
213
+ if (!env || (env.action !== 'tool_call' && env.action !== 'final_answer')) {
214
+ throw new ProviderError(`[CLIPipeProvider] tool-mode envelope missing a valid action (got ${env && JSON.stringify(env.action)})`, /** @type {any} */ ({ status: 0 }));
215
+ }
216
+ if (env.action === 'tool_call' && (typeof env.tool_name !== 'string' || !env.tool_name)) {
217
+ throw new ProviderError('[CLIPipeProvider] tool-mode tool_call envelope has no tool_name', /** @type {any} */ ({ status: 0 }));
218
+ }
219
+
220
+ const { usage, model, costUsd } = mapClaudeMeta(outer);
221
+ /** @type {ParsedEnvelope} */
222
+ const parsed = { action: env.action, usage, model };
223
+ if (costUsd !== undefined) parsed.costUsd = costUsd;
224
+ if (env.action === 'tool_call') {
225
+ parsed.toolName = env.tool_name;
226
+ parsed.toolArguments = (env.tool_arguments && typeof env.tool_arguments === 'object') ? env.tool_arguments : {};
227
+ } else {
228
+ parsed.answer = typeof env.answer === 'string' ? env.answer : '';
229
+ }
230
+ return parsed;
231
+ },
232
+
233
+ /**
234
+ * Capability-probe assets. The probe mirrors REAL-TASK shape — a question whose answer the model
235
+ * cannot know, with a tool that yields it, and NO "call the tool" instruction — because a trivial
236
+ * "call capability_ping" instruction is a FALSE POSITIVE: haiku emits it 4/4 yet fails real tasks
237
+ * 0/5 (proven in `poc/clipipe-tools-04-capability-probe.mjs`). The question shape sorts cleanly:
238
+ * sonnet CAPABLE 4/4, haiku INCAPABLE 0/4, stable. Capable iff it emits a `tool_call` for the tool.
239
+ */
240
+ probe: {
241
+ system: [
242
+ 'You are the reasoning half of a tool-using system. An external runtime executes tools for you.',
243
+ '',
244
+ 'Tool available:',
245
+ '- lookup_code(name: string) — returns the secret verification code for a given name.',
246
+ '',
247
+ 'You have NO knowledge of verification codes; the ONLY way to obtain one is to emit a tool_call.',
248
+ 'Emitting a tool_call envelope IS how a tool runs; you never execute one yourself, and you cannot',
249
+ '"attempt" or "fail" to call one. Reply ONLY with the JSON envelope.',
250
+ ].join('\n'),
251
+ user: 'What is the verification code for "orchard-42"?',
252
+ /** @param {ParsedEnvelope} parsed @returns {boolean} */
253
+ isCapable: (parsed) => parsed.action === 'tool_call' && parsed.toolName === 'lookup_code',
254
+ },
255
+ };
256
+
257
+ /** Resolve a `toolProtocol` option to an adapter. Claude-only for now; unknown names throw. */
258
+ function resolveToolProtocol(name) {
259
+ if (name === 'claude') return CLAUDE_TOOL_PROTOCOL;
260
+ throw new Error(`[CLIPipeProvider] unknown toolProtocol '${name}' — only 'claude' is supported`);
261
+ }
262
+
263
+ module.exports = {
264
+ ENVELOPE_SCHEMA,
265
+ toolLine,
266
+ buildToolSystemPrompt,
267
+ renderTranscript,
268
+ mapClaudeMeta,
269
+ CLAUDE_TOOL_PROTOCOL,
270
+ resolveToolProtocol,
271
+ };
@@ -34,6 +34,78 @@ export type CLIPipeOptions = {
34
34
  * - 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.
35
35
  */
36
36
  parse?: "claude-json" | ((stdout: string) => Partial<GenerateResult>) | undefined;
37
+ /**
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) 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.
107
+ */
108
+ probeCapability?: boolean | undefined;
37
109
  };
38
110
  /** @typedef {import('../types').Message} Message */
39
111
  /** @typedef {import('../types').ToolDef} ToolDef */
@@ -48,6 +120,51 @@ export type CLIPipeOptions = {
48
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.
49
121
  * @property {(chunk: string) => void} [onChunk] - Called with each stdout chunk as it streams.
50
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.
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, NATIVE — prefer 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.
51
168
  */
52
169
  export class CLIPipeProvider {
53
170
  /**
@@ -64,10 +181,41 @@ export class CLIPipeProvider {
64
181
  systemPromptFlag: string | null;
65
182
  onChunk: ((chunk: string) => void) | null;
66
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;
191
+ toolProtocol: {
192
+ name: string;
193
+ turnArgs(systemPrompt: string): string[];
194
+ parseResult(stdout: string): ParsedEnvelope;
195
+ probe: {
196
+ system: string;
197
+ user: string;
198
+ isCapable: (parsed: ParsedEnvelope) => boolean;
199
+ };
200
+ } | null;
201
+ probeCapability: boolean;
202
+ /** @type {Promise<void>|null} */
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;
67
211
  /**
68
- * Generate a response by piping messages to the CLI command.
212
+ * Generate a response by piping messages to the CLI command. With a `toolProtocol` configured and
213
+ * a non-empty `tools` array, routes to schema-validated tool EMULATION (v0.32.0); otherwise the
214
+ * plain-text path below (unchanged). Passing `tools` with no `toolProtocol` warns ONCE and ignores
215
+ * them (a non-tool-calling CLI legitimately coexists in a Loop with tools mounted); the loud
216
+ * failure for a genuinely tool-incapable model lives in the tool-mode capability probe.
69
217
  * @param {Message[]} messages - Conversation messages in OpenAI format.
70
- * @param {ToolDef[]} [tools=[]] - Unused (CLI commands don't support tools).
218
+ * @param {ToolDef[]} [tools=[]] - Caller tools. Honored only in tool mode (`toolProtocol` set).
71
219
  * @param {Record<string, any>} [options={}] - Unused.
72
220
  * @returns {Promise<GenerateResult>}
73
221
  * @throws {Error} `[CLIPipeProvider] failed to spawn "cmd": ...` — when the command cannot be found or executed.
@@ -76,6 +224,50 @@ export class CLIPipeProvider {
76
224
  * @throws {Error} `[CLIPipeProvider] process produced no output` — when stdout is empty.
77
225
  */
78
226
  generate(messages: Message[], tools?: ToolDef[], options?: Record<string, any>): Promise<GenerateResult>;
227
+ _warnedNoProtocol: boolean | undefined;
228
+ /**
229
+ * Tool mode (v0.32.0) — one turn of schema-validated tool emulation. Renders the Loop's
230
+ * OpenAI-shaped transcript to text, injects the caller's system stance + a tool manifest + the
231
+ * envelope contract, spawns the CLI under the protocol's flags, and parses the envelope back into
232
+ * normalized `toolCalls` (a `tool_call`) or `text` (a `final_answer`). The Loop drives the cycle.
233
+ * @param {Message[]} messages
234
+ * @param {ToolDef[]} tools
235
+ * @returns {Promise<GenerateResult>}
236
+ */
237
+ _generateWithTools(messages: Message[], tools: ToolDef[]): Promise<GenerateResult>;
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>;
259
+ /**
260
+ * Run the upfront capability probe ONCE per instance (cached), unless `probeCapability` is off.
261
+ * A model that answers the probe in prose instead of emitting a tool_call throws a loud
262
+ * `ProviderError` — fail fast, never silently degrade to a no-tools run mid-conversation.
263
+ * NOTE: the probe is a single internal CLI turn whose token usage/cost is NOT surfaced to the Loop
264
+ * (it never flows to `onLlmResult`), so a wired budget gate does not see it — negligible for the
265
+ * subscription use case this exists for (flat cost, one probe per instance), by design.
266
+ * @returns {Promise<void>}
267
+ */
268
+ _ensureToolCapability(): Promise<void>;
269
+ /** Best-effort model id from `--model X` in the base args, for a clearer probe-failure message. */
270
+ _modelFromArgs(): string | null;
79
271
  /**
80
272
  * Map the `claude -p --output-format json` result envelope onto a normalized GenerateResult.
81
273
  * The caller explicitly opted into structured output, so a malformed or error envelope is a LOUD