bare-agent 0.33.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.
@@ -1,7 +1,7 @@
1
1
  # bareagent — Integration Guide
2
2
 
3
3
  > For AI assistants and developers wiring bareagent into a project.
4
- > v0.33.0 | Node.js >= 18 | zero required deps (`bareguard >=0.9.0 <0.13.0` optional peer for governance) | Apache 2.0
4
+ > v0.33.1 | Node.js >= 18 | zero required deps (`bareguard >=0.9.0 <0.13.0` optional peer for governance) | Apache 2.0
5
5
  >
6
6
  > Full human guide with composition examples, design philosophy, and recipes: [Usage Guide](docs/02-features/usage-guide.md)
7
7
 
@@ -817,11 +817,11 @@ new CLIPipe({ command: 'claude', args: ['-p', '--model', 'sonnet'], toolProtocol
817
817
  **CLIPipe NATIVE tool mode (BA-16, `toolProtocol:'claude-mcp'`).** The claude CLI has a real tool channel; native mode uses it instead of emulating one. The CLI runs its OWN multi-turn session per `generate()` call and executes your `tools` natively over an MCP bridge that calls back into your in-process `execute` closures. Because the CLI owns the inner cycle, the Loop's per-round machinery cannot run — so the governance you'd wire on `Loop` moves to the **provider**, at the one seam every tool call crosses:
818
818
  - `policy` — the SAME `(tool, args, ctx) => true|string` chokepoint as `Loop({policy})`, so a wired `wireGate(gate).policy` writes **audit rows of identical shape, zero gate changes**. A deny is a tool result (advisory); the handler never runs. **Required here** — a `Loop({policy})` in native mode would be a fence that is silently not there, so the Loop throws.
819
819
  - `maxConsecutiveDenials` (3) / `maxIdenticalToolErrors` (3) — BA-11/BA-12 guards at the bridge, same narrowest triggers; end the session `denied:<tool>` / `stuck:<tool>`.
820
- - `maxTurns` — maps to the CLI's `--max-turns`; the bound stop is `error:'max_turns'`, never a silent success.
821
- - `onTurn` — streams each completed turn's usage (four cache tiers, `costUsd:null` the CLI prices the session) as it arrives, then one closing `kind:'session'` event with the authoritative total. Shape mirrors `onLlmResult`, so `wireGate(gate).onLlmResult` drops in; when wired the Loop skips its own forward (billed once, never starved).
820
+ - `maxTurns` — a bound on **assistant/LLM turns**, the SAME unit as the Loop path, so one number means one thing on both surfaces (BA-17). NOT a tool-call count: one turn can fire a dozen parallel tool calls and still be one turn (measured: 12 calls across 2 turns, inside `--max-turns 3`). Enforced twice — the CLI's own `--max-turns` stops cleanly at N (and emits the result event that carries the session's real cost), plus a parent-side counter that kills the session if a turn beyond N is ever seen, since that flag is undocumented in `claude --help`. The stop is `error:'max_turns'` + `stopReason:'max_turns'` and **carries the last turn's text forward** — the CLI reports `result:null` on a bounded session, so an unfixed build returned `text:''`.
821
+ - `onTurn` — fires once per **assistant turn** (BA-17: the CLI emits a separate stream event per content *block*, all repeating that message's usage — firing per event inflated a caller's turn axis ~5–7× and its token axis 5.04×), carrying four cache tiers and `costUsd:null` since the CLI prices the session, not the turn. Then one closing `kind:'session'` event carrying the authoritative cost **and the token residual** — a turn's `message.usage` is a snapshot taken at its first block and never revised (a turn that emitted ~816 output tokens reported 2), so the closing event makes the streamed tiers add up to exactly the CLI's own session total. Shape mirrors `onLlmResult`, so `wireGate(gate).onLlmResult` drops in; when wired the Loop skips its own forward (billed once, never starved).
822
822
  - `sessionTimeout` (600s) / `bridgeTimeoutMs` (120s) — whole-session and per-handler ceilings.
823
823
 
824
- `GenerateResult.session` (`{turns, toolCalls, error, usageReported}`) carries what really happened; `metrics.sessionTurns` reports the real turn count so a 14-turn session never reads as one round. A terminal the CLI detects inside the session (bound, guard, or a **broken tool bridge** — a dead bridge still ends `subtype:'success'`, so it is caught parent-side by attempted-vs-served tool calls) surfaces as the run's `error`, never a laundered clean finish. `assemble`/`trim`/`cacheMessages` and a Loop-level `policy` all THROW at construction in native mode (no silently-dead knobs — the CLI owns the transcript). The bridge is a unix socket (0600 in a 0700 dir), never a listening port. Claude-only for now; the CLI-specific parts live in `src/provider-clipipe-mcp.js` + `src/mcp-bridge-stub.js` behind the same seam as emulation.
824
+ `GenerateResult.session` (`{turns, toolCalls, error, usageReported}`) carries what really happened; `metrics.sessionTurns` reports the real turn count — assistant *messages*, not stream events — so a 14-turn session never reads as one round, and a 2-turn session never reads as 14. A terminal the CLI detects inside the session (bound, guard, or a **broken tool bridge** — a dead bridge still ends `subtype:'success'`, so it is caught parent-side by attempted-vs-served tool calls) surfaces as the run's `error`, never a laundered clean finish. `assemble`/`trim`/`cacheMessages` and a Loop-level `policy` all THROW at construction in native mode (no silently-dead knobs — the CLI owns the transcript). The bridge is a unix socket (0600 in a 0700 dir), never a listening port. Claude-only for now; the CLI-specific parts live in `src/provider-clipipe-mcp.js` + `src/mcp-bridge-stub.js` behind the same seam as emulation.
825
825
 
826
826
  All return `{ text, toolCalls, usage: { inputTokens, outputTokens }, model?, costUsd? }`. The optional `model` (v0.16.1+) is the id the response was produced by — Loop prefers it over `provider.model` for cost accounting. By default CLIPipe returns `toolCalls: []` and zero usage (CLI tools don't report tokens) and omits `model`. **Structured output (v0.26.0+):** set `parse: 'claude-json'` (a preset for `claude -p --output-format json`) — or a `(stdout) => Partial<GenerateResult>` function for any other CLI — and CLIPipe maps the CLI's JSON envelope onto real `usage`, `model`, and `costUsd`, throwing `ProviderError` on a malformed/error envelope (never a silent raw-text fall-back). `costUsd` (optional `GenerateResult` field) is an **authoritative** per-call price the provider reports itself; when finite the Loop prefers it over the internal rate-table `estimateCost`, so a CLI-piped run enforces a bareguard USD cap with no local pricing table (a `0` counts as priced, distinct from null/unpriced). `toolCalls` stays `[]` regardless (CLIPipe is tool-free).
827
827
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "bare-agent",
3
- "version": "0.33.0",
3
+ "version": "0.33.1",
4
4
  "files": [
5
5
  "index.js",
6
6
  "index.d.ts",
@@ -89,6 +89,21 @@ export function resolveSessionError(facts: {
89
89
  /**
90
90
  * Line-oriented processor for the CLI's `stream-json` stdout.
91
91
  *
92
+ * ONE TURN IS ONE ASSISTANT MESSAGE, NOT ONE EVENT (BA-17). Measured on the real wire: the CLI
93
+ * emits a SEPARATE `assistant` event per content BLOCK of the same message, and every one of them
94
+ * repeats that message's `usage` verbatim — one 13-block message arrived as 13 events carrying the
95
+ * same numbers. Treating each event as a turn is wrong on both axes a caller meters:
96
+ * - the TURN axis — a caller whose attempt bound is an LLM-turn count sees 14 "turns" for 2 real
97
+ * ones (measured 7×; 4.4× on the adopter's failing run), so its net guillotines
98
+ * the session at a fraction of the allowance it advertised;
99
+ * - the TOKEN axis — the same message's usage is added once per block (measured 5.04× inflated
100
+ * against the CLI's own session total), so a budget cap fires early on tokens
101
+ * that were never spent.
102
+ * So a RUN of consecutive events sharing `message.id` is ONE turn: usage is recorded once and one
103
+ * `onTurn` fires. Adjacent-run dedup, not a Set — an id that somehow recurred later must still count
104
+ * as a new turn (dropping a real turn is the failure that matters). An event with NO id degrades to
105
+ * one-turn-per-event: the pre-BA-17 behaviour, never a collapse of the whole session into one turn.
106
+ *
92
107
  * FRAMING IS SYNCHRONOUS. The obvious shape — an `async` stdout handler that `await`s `onTurn`
93
108
  * inside its parse loop — has a data-corruption bug: while the `await` is suspended, the next
94
109
  * `'data'` event re-enters the handler and mutates the SHARED line buffer, so the suspended parse
@@ -104,18 +119,32 @@ export function resolveSessionError(facts: {
104
119
  * @param {any} o.ctx
105
120
  * @param {number} o.startedAt
106
121
  * @param {(err: Error) => void} o.onHalt - called if a forwarded `onTurn` throws a HaltError.
122
+ * @param {number|null} [o.turnCap] - BA-17 backstop: fires `onLimit` the moment a turn BEYOND this
123
+ * many is observed. Deliberately not `>=`: `--max-turns` is passed to the CLI too, and when it
124
+ * works (measured: it does, and it counts assistant turns) the CLI ends the session ITSELF at the
125
+ * cap and emits its `result` event — which is the only place the authoritative session cost
126
+ * arrives. Killing the session at exactly N would throw that figure away on every bounded run. So
127
+ * this fires ONLY on an overrun, i.e. only if the flag ever stops working — it is undocumented in
128
+ * `claude --help`, so the guarantee cannot rest on it alone.
129
+ * @param {(() => void)} [o.onLimit] - called once when `turnCap` is exceeded.
107
130
  */
108
- export function createSessionStream({ onTurn, ctx, startedAt, onHalt }: {
131
+ export function createSessionStream({ onTurn, ctx, startedAt, onHalt, turnCap, onLimit }: {
109
132
  onTurn: Function | null;
110
133
  ctx: any;
111
134
  startedAt: number;
112
135
  onHalt: (err: Error) => void;
136
+ turnCap?: number | null | undefined;
137
+ onLimit?: (() => void) | undefined;
113
138
  }): {
114
139
  /** Feed a stdout chunk. Pure synchronous framing — never awaits. */
115
140
  feed(chunk: Buffer | string): void;
116
141
  /** Await every queued per-turn forward — call before resolving the session. */
117
142
  flush(): Promise<void>;
118
143
  readonly turns: import("../types").Usage[];
144
+ /** Assistant TURNS observed — including any that carried no usage. */
145
+ readonly turnCount: number;
146
+ /** The last turn's text. The work a bounded/guard-stopped session still did (BA-5). */
147
+ readonly lastText: string;
119
148
  readonly attempted: number;
120
149
  readonly final: any;
121
150
  };
@@ -257,6 +257,13 @@ const SUBTYPE_MAP = {
257
257
  error_during_execution: { stopReason: null, error: 'session_error' },
258
258
  };
259
259
 
260
+ /**
261
+ * Terminals WE impose that ARE stop reasons in the neutral vocabulary. A guard terminal
262
+ * (`denied:`/`stuck:`) is deliberately absent — those are faults, not stop reasons.
263
+ * @type {Record<string, string>}
264
+ */
265
+ const TERMINAL_STOP = { max_turns: 'max_turns' };
266
+
260
267
  /**
261
268
  * @param {string|null|undefined} subtype
262
269
  * @returns {{stopReason: string|null, error: string|null}}
@@ -291,7 +298,15 @@ function classifySubtype(subtype) {
291
298
  */
292
299
  function resolveSessionError(facts) {
293
300
  const fromSubtype = classifySubtype(facts.subtype);
294
- if (facts.terminal) return { stopReason: fromSubtype.stopReason, error: facts.terminal };
301
+ if (facts.terminal) {
302
+ // A terminal WE imposed kills the session before its `result` event, so there is no subtype to
303
+ // read a stop reason from. Where the terminal IS a stop reason in the neutral vocabulary, say so
304
+ // — a consumer branching on `stopReason` must not see `null` for a bound it asked for.
305
+ // Own-property only: same proto-key footgun as SUBTYPE_MAP.
306
+ const named = Object.prototype.hasOwnProperty.call(TERMINAL_STOP, facts.terminal)
307
+ ? TERMINAL_STOP[facts.terminal] : null;
308
+ return { stopReason: named || fromSubtype.stopReason, error: facts.terminal };
309
+ }
295
310
  const bridgeBroken = facts.bridgeDown
296
311
  || (Number.isFinite(facts.attempted) && Number.isFinite(facts.served) && facts.attempted > facts.served);
297
312
  if (bridgeBroken) return { stopReason: fromSubtype.stopReason, error: 'bridge-failed' };
@@ -302,6 +317,21 @@ function resolveSessionError(facts) {
302
317
  /**
303
318
  * Line-oriented processor for the CLI's `stream-json` stdout.
304
319
  *
320
+ * ONE TURN IS ONE ASSISTANT MESSAGE, NOT ONE EVENT (BA-17). Measured on the real wire: the CLI
321
+ * emits a SEPARATE `assistant` event per content BLOCK of the same message, and every one of them
322
+ * repeats that message's `usage` verbatim — one 13-block message arrived as 13 events carrying the
323
+ * same numbers. Treating each event as a turn is wrong on both axes a caller meters:
324
+ * - the TURN axis — a caller whose attempt bound is an LLM-turn count sees 14 "turns" for 2 real
325
+ * ones (measured 7×; 4.4× on the adopter's failing run), so its net guillotines
326
+ * the session at a fraction of the allowance it advertised;
327
+ * - the TOKEN axis — the same message's usage is added once per block (measured 5.04× inflated
328
+ * against the CLI's own session total), so a budget cap fires early on tokens
329
+ * that were never spent.
330
+ * So a RUN of consecutive events sharing `message.id` is ONE turn: usage is recorded once and one
331
+ * `onTurn` fires. Adjacent-run dedup, not a Set — an id that somehow recurred later must still count
332
+ * as a new turn (dropping a real turn is the failure that matters). An event with NO id degrades to
333
+ * one-turn-per-event: the pre-BA-17 behaviour, never a collapse of the whole session into one turn.
334
+ *
305
335
  * FRAMING IS SYNCHRONOUS. The obvious shape — an `async` stdout handler that `await`s `onTurn`
306
336
  * inside its parse loop — has a data-corruption bug: while the `await` is suspended, the next
307
337
  * `'data'` event re-enters the handler and mutates the SHARED line buffer, so the suspended parse
@@ -317,11 +347,28 @@ function resolveSessionError(facts) {
317
347
  * @param {any} o.ctx
318
348
  * @param {number} o.startedAt
319
349
  * @param {(err: Error) => void} o.onHalt - called if a forwarded `onTurn` throws a HaltError.
350
+ * @param {number|null} [o.turnCap] - BA-17 backstop: fires `onLimit` the moment a turn BEYOND this
351
+ * many is observed. Deliberately not `>=`: `--max-turns` is passed to the CLI too, and when it
352
+ * works (measured: it does, and it counts assistant turns) the CLI ends the session ITSELF at the
353
+ * cap and emits its `result` event — which is the only place the authoritative session cost
354
+ * arrives. Killing the session at exactly N would throw that figure away on every bounded run. So
355
+ * this fires ONLY on an overrun, i.e. only if the flag ever stops working — it is undocumented in
356
+ * `claude --help`, so the guarantee cannot rest on it alone.
357
+ * @param {(() => void)} [o.onLimit] - called once when `turnCap` is exceeded.
320
358
  */
321
- function createSessionStream({ onTurn, ctx, startedAt, onHalt }) {
359
+ function createSessionStream({ onTurn, ctx, startedAt, onHalt, turnCap = null, onLimit = () => {} }) {
322
360
  let buf = '';
323
361
  let attempted = 0;
324
362
  let final = null;
363
+ /** Adjacent-run dedup key: the `message.id` of the turn currently being emitted. */
364
+ let turnId = /** @type {string|null} */ (null);
365
+ /** Has the CURRENT turn already contributed its usage? Blocks of one message repeat it. */
366
+ let turnMetered = false;
367
+ let turnCount = 0;
368
+ let limitFired = false;
369
+ /** Text of the current turn, and the last turn that produced any — BA-5 work preservation. */
370
+ let curText = '';
371
+ let lastText = '';
325
372
  /** @type {import('../types').Usage[]} */ const turns = [];
326
373
  /** @type {any[]} */ const queue = [];
327
374
  /** @type {Promise<void>|null} */ let draining = null;
@@ -367,29 +414,59 @@ function createSessionStream({ onTurn, ctx, startedAt, onHalt }) {
367
414
  }
368
415
  }
369
416
 
370
- if (ev.type === 'assistant' && ev.message && ev.message.usage) {
371
- const u = ev.message.usage;
372
- /** @type {import('../types').Usage} */
373
- const usage = {
374
- inputTokens: Number(u.input_tokens) || 0,
375
- outputTokens: Number(u.output_tokens) || 0,
376
- };
377
- // Omit an absent tier rather than emit a synthetic 0 (per the Usage contract).
378
- if (Number.isFinite(u.cache_read_input_tokens)) usage.cacheReadTokens = u.cache_read_input_tokens;
379
- if (Number.isFinite(u.cache_creation_input_tokens)) usage.cacheCreationTokens = u.cache_creation_input_tokens;
380
- turns.push(usage);
381
- // Stream it: a session that dies mid-run must already have surfaced every completed turn's
382
- // spend, or the gate loses it. Queued (not awaited here) so framing cannot race the buffer.
383
- if (onTurn) queue.push({
384
- model: (ev.message && ev.message.model) || null,
385
- provider: 'clipipe',
386
- usage,
387
- costUsd: null, // the CLI prices the SESSION, not the turn explicitly unpriced, never a synthetic 0.
388
- pricing: 'unpriced',
389
- durationMs: Date.now() - startedAt,
390
- ctx, // what a wired gate records spend against same as the Loop's onLlmResult.
391
- kind: 'turn',
392
- });
417
+ if (ev.type === 'assistant' && ev.message) {
418
+ // Is this event the start of a NEW assistant turn, or another block of the current one?
419
+ // No id at all ⇒ its own turn (degrade to the pre-BA-17 shape, never collapse into one).
420
+ const id = (typeof ev.message.id === 'string' && ev.message.id) ? ev.message.id : null;
421
+ if (id === null || id !== turnId) {
422
+ turnId = id;
423
+ turnMetered = false;
424
+ curText = '';
425
+ turnCount++;
426
+ // The overrun backstop. `>` not `>=`, so a CLI that honours --max-turns keeps its clean
427
+ // exit (and with it the only report of the session's real cost).
428
+ if (!limitFired && Number.isFinite(turnCap) && Number(turnCap) > 0 && turnCount > Number(turnCap)) {
429
+ limitFired = true;
430
+ onLimit();
431
+ }
432
+ }
433
+
434
+ // BA-5: keep the turn's own words, so a bound/guard stop still returns the work done. The
435
+ // CLI reports `result: null` on a bounded session — measured — so this is the ONLY source.
436
+ for (const block of (ev.message.content || [])) {
437
+ if (block && block.type === 'text' && typeof block.text === 'string' && block.text) {
438
+ curText += block.text;
439
+ lastText = curText;
440
+ }
441
+ }
442
+
443
+ // Usage rides on EVERY block-event of the message; count it once per turn. Read on any
444
+ // event of the turn (not just the first) — the first block need not be the one carrying it.
445
+ if (ev.message.usage && !turnMetered) {
446
+ turnMetered = true;
447
+ const u = ev.message.usage;
448
+ /** @type {import('../types').Usage} */
449
+ const usage = {
450
+ inputTokens: Number(u.input_tokens) || 0,
451
+ outputTokens: Number(u.output_tokens) || 0,
452
+ };
453
+ // Omit an absent tier rather than emit a synthetic 0 (per the Usage contract).
454
+ if (Number.isFinite(u.cache_read_input_tokens)) usage.cacheReadTokens = u.cache_read_input_tokens;
455
+ if (Number.isFinite(u.cache_creation_input_tokens)) usage.cacheCreationTokens = u.cache_creation_input_tokens;
456
+ turns.push(usage);
457
+ // Stream it: a session that dies mid-run must already have surfaced every completed turn's
458
+ // spend, or the gate loses it. Queued (not awaited here) so framing cannot race the buffer.
459
+ if (onTurn) queue.push({
460
+ model: ev.message.model || null,
461
+ provider: 'clipipe',
462
+ usage,
463
+ costUsd: null, // the CLI prices the SESSION, not the turn — explicitly unpriced, never a synthetic 0.
464
+ pricing: 'unpriced',
465
+ durationMs: Date.now() - startedAt,
466
+ ctx, // what a wired gate records spend against — same as the Loop's onLlmResult.
467
+ kind: 'turn',
468
+ });
469
+ }
393
470
  }
394
471
 
395
472
  if (ev.type === 'result') final = ev;
@@ -399,6 +476,10 @@ function createSessionStream({ onTurn, ctx, startedAt, onHalt }) {
399
476
  /** Await every queued per-turn forward — call before resolving the session. */
400
477
  async flush() { if (draining) await draining; await drain(); },
401
478
  get turns() { return turns; },
479
+ /** Assistant TURNS observed — including any that carried no usage. */
480
+ get turnCount() { return turnCount; },
481
+ /** The last turn's text. The work a bounded/guard-stopped session still did (BA-5). */
482
+ get lastText() { return lastText; },
402
483
  get attempted() { return attempted; },
403
484
  get final() { return final; },
404
485
  };
@@ -452,11 +533,18 @@ function runSession(opts) {
452
533
  const child = spawn(command, args, { cwd, env, stdio: ['pipe', 'pipe', 'pipe'] });
453
534
  let stderr = '', settled = false;
454
535
  /** @type {Error|null} */ let turnHalt = null;
536
+ /** @type {string|null} A terminal WE imposed from the stream (today: the BA-17 turn backstop). */
537
+ let terminal = null;
455
538
  const started = Date.now();
456
539
 
457
540
  /** Kill the session now — a guard tripped or governance halted mid-flight. */
458
541
  const abort = () => { try { child.kill('SIGTERM'); } catch (_) { /* already gone */ } };
459
- const stream = createSessionStream({ onTurn, ctx, startedAt: started, onHalt: (err) => { turnHalt = err; abort(); } });
542
+ const stream = createSessionStream({
543
+ onTurn, ctx, startedAt: started,
544
+ onHalt: (err) => { turnHalt = err; abort(); },
545
+ turnCap: Number.isFinite(maxTurns) ? maxTurns : null,
546
+ onLimit: () => { terminal = 'max_turns'; abort(); },
547
+ });
460
548
 
461
549
  const done = async (extra = {}) => {
462
550
  if (settled) return;
@@ -465,7 +553,11 @@ function runSession(opts) {
465
553
  // Flush queued per-turn forwards before resolving — a session that ends while a forward is
466
554
  // pending must still surface that turn's spend to the gate (F12/F18).
467
555
  try { await stream.flush(); } catch (_) { /* onTurn failures are surfaced in-drain, never fatal */ }
468
- resolve({ turns: stream.turns, final: stream.final, stderr, ms: Date.now() - started, turnHalt, attempted: stream.attempted, ...extra });
556
+ resolve({
557
+ turns: stream.turns, turnCount: stream.turnCount, lastText: stream.lastText,
558
+ final: stream.final, stderr, ms: Date.now() - started, turnHalt, terminal,
559
+ attempted: stream.attempted, ...extra,
560
+ });
469
561
  };
470
562
 
471
563
  const timer = setTimeout(() => {
@@ -68,8 +68,16 @@ export type CLIPipeOptions = {
68
68
  */
69
69
  onTurn?: Function | undefined;
70
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.
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.
73
81
  */
74
82
  maxTurns?: number | undefined;
75
83
  /**
@@ -107,65 +115,6 @@ export type CLIPipeOptions = {
107
115
  */
108
116
  probeCapability?: boolean | undefined;
109
117
  };
110
- /** @typedef {import('../types').Message} Message */
111
- /** @typedef {import('../types').ToolDef} ToolDef */
112
- /** @typedef {import('../types').GenerateResult} GenerateResult */
113
- /**
114
- * @typedef {object} CLIPipeOptions
115
- * @property {string} [command] - CLI command to spawn (required).
116
- * @property {string[]} [args=[]] - Arguments to pass to the command.
117
- * @property {string} [cwd] - Working directory for the child process.
118
- * @property {Record<string, string>} [env] - Environment variables for the child process.
119
- * @property {number} [timeout=30000] - Timeout in milliseconds.
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.
121
- * @property {(chunk: string) => void} [onChunk] - Called with each stdout chunk as it streams.
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.
168
- */
169
118
  export class CLIPipeProvider {
170
119
  /**
171
120
  * Provider that pipes prompts to a CLI command via stdin and reads stdout.
@@ -43,8 +43,16 @@ const { createBridge, resolveSessionError, runSession } = require('./provider-cl
43
43
  * sum-at-end: a session that dies mid-run must already have surfaced every completed turn's spend or
44
44
  * the gate loses all of it. The event shape mirrors `Loop({onLlmResult})`, so `wireGate(gate).onLlmResult`
45
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.
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.
48
56
  * @property {number} [maxConsecutiveDenials=3] - (native mode) BA-11 at the bridge: a single deny stays
49
57
  * advisory so the model can pivot to an allowed tool; N in a row with no allowed call between ends the
50
58
  * session with `denied:<tool>`. `0`/`Infinity` disables.
@@ -66,6 +74,31 @@ const { createBridge, resolveSessionError, runSession } = require('./provider-cl
66
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.
67
75
  */
68
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
+
69
102
  class CLIPipeProvider {
70
103
  /**
71
104
  * Provider that pipes prompts to a CLI command via stdin and reads stdout.
@@ -289,17 +322,25 @@ class CLIPipeProvider {
289
322
  throw new ProviderError(`[CLIPipeProvider] failed to spawn "${this.command}": ${r.spawnError.message}`, /** @type {any} */ ({ status: 0 }));
290
323
  }
291
324
 
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
- }
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 });
300
340
 
301
341
  const { stopReason, error } = resolveSessionError({
302
- terminal: st.terminal,
342
+ // A bridge/guard terminal is more specific than the turn backstop, so it wins the tag.
343
+ terminal: st.terminal || r.terminal,
303
344
  bridgeDown: st.bridgeDown,
304
345
  attempted: r.attempted,
305
346
  served: st.toolCalls,
@@ -307,17 +348,25 @@ class CLIPipeProvider {
307
348
  subtype: r.final && r.final.subtype,
308
349
  });
309
350
 
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.
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);
315
364
  if (this.onTurn) {
316
365
  try {
317
366
  await this.onTurn({
318
- model: (r.final && r.final.model) || null,
367
+ model: (meta && meta.model) || null,
319
368
  provider: 'clipipe',
320
- usage: { inputTokens: 0, outputTokens: 0 },
369
+ usage: residual,
321
370
  costUsd,
322
371
  pricing: costUsd === null ? 'unpriced' : 'priced',
323
372
  durationMs: r.ms,
@@ -329,15 +378,23 @@ class CLIPipeProvider {
329
378
  }
330
379
  }
331
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
+
332
389
  /** @type {GenerateResult} */
333
390
  const result = {
334
- text: (r.final && typeof r.final.result === 'string') ? r.final.result : '',
391
+ text: finalText,
335
392
  toolCalls: [],
336
393
  usage,
337
- model: (r.final && r.final.model) || null,
394
+ model: (meta && meta.model) || null,
338
395
  stopReason,
339
396
  session: {
340
- turns: r.turns.length,
397
+ turns: r.turnCount,
341
398
  toolCalls: st.toolCalls,
342
399
  error,
343
400
  // Only true when we ACTUALLY streamed — unwired, the Loop must still forward the total or