export type Message = import("../types").Message; export type ToolDef = import("../types").ToolDef; export type GenerateResult = import("../types").GenerateResult; export type ParsedEnvelope = import("./provider-clipipe-tools.js").ParsedEnvelope; export type CLIPipeOptions = { /** * - CLI command to spawn (required). */ command?: string | undefined; /** * - Arguments to pass to the command. */ args?: string[] | undefined; /** * - Working directory for the child process. */ cwd?: string | undefined; /** * - Environment variables for the child process. */ env?: Record | undefined; /** * - Accepted ('on'|'off', else `ValidationError`) but CLIPipe has NO thinking switch: the call runs unchanged and the result carries `thinkingStatus:'unsupported'` (+ `thinkingApplied:false`; warns once per instance). Overridable per call; `null` suppresses an instance default. */ thinkingMode?: any; /** * - Timeout in milliseconds. */ timeout?: number | undefined; /** * - CLI flag for system prompt (e.g. '--system'). When set, system messages are extracted and passed via this flag instead of stdin. */ systemPromptFlag?: string | undefined; /** * - Called with each stdout chunk as it streams. */ onChunk?: ((chunk: string) => void) | undefined; /** * - 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. */ parse?: "claude-json" | ((stdout: string) => Partial) | undefined; /** * - Opt into TOOL MODE. Two modes, and the choice is * about COST, not capability. `'claude-mcp'` (BA-16, NATIVE — prefer this on the claude CLI): one CLI * session per call, the caller's `tools` exposed to it as a real MCP server whose handlers call back * into your own in-process closures. The CLI owns the inner cycle and caches its transcript * session-side. `'claude'` (v0.32.0, EMULATION): one CLI spawn per round with the whole transcript * re-rendered and re-sent, parsed back through a JSON envelope. Emulation re-buys the full prefix * every turn, which the adopter measured at **$0.25–0.55/round** against **~$0.006/turn** native — so * it is the right instrument only for a CLI with NO MCP support, not a default. NOT claimed for * native: better output quality (n=2 suggestive evidence exists and is deliberately unminted). * Native mode sets {@link CLIPipeProvider#ownsCycle}, which makes the Loop REFUSE options it could * never honor (`assemble`/`trim`/`cacheMessages`, and a Loop-level `policy`) instead of leaving them * silently dead. See the native-only properties below. */ toolProtocol?: "claude" | "claude-mcp" | undefined; /** * - (native mode) The gate, same contract as `Loop({policy})`: only `true` * allows, a string is the deny reason fed back verbatim, a thrown `HaltError` is a clean governance * exit. REQUIRED here rather than on the Loop, because in native mode no tool call ever reaches the * Loop — a `Loop({policy})` would be a fence that is silently not there (the Loop throws instead). * Wiring the same `wireGate(gate).policy` keeps audit rows byte-shape-identical, with zero gate changes. */ policy?: ((tool: string, args: any, ctx?: any) => any) | undefined; /** * - (native mode) Called with `{model, provider, usage, costUsd, pricing, * rateSource, durationMs, ctx, kind}` for EACH completed CLI turn as it arrives (`kind:'turn'`, four * cache tiers, `costUsd:null`/`rateSource:null` — the CLI prices the session, not the turn), then once * at session end (`kind:'session'`) carrying the authoritative total cost with zero usage and * `rateSource:'provider'` when that cost is finite (BA-22 — the CLI's own `total_cost_usd`, no rate * table; `null` cost → `rateSource:null`, never a spurious 'provider'). Streaming, never * sum-at-end: a session that dies mid-run must already have surfaced every completed turn's spend or * the gate loses all of it. The event shape mirrors `Loop({onLlmResult})`, so `wireGate(gate).onLlmResult` * drops straight in — and when it is wired the Loop skips its own forward, so nothing is billed twice. */ onTurn?: Function | undefined; /** * - (native mode) Bound on ASSISTANT/LLM TURNS — the same unit as the * Loop path's turn bound, so a caller's `maxTurns` means one thing on both surfaces (BA-17). NOT a * tool-call count: a single turn may issue a dozen parallel tool calls and still be one turn * (measured: 12 calls across 2 turns, well inside `--max-turns 3`). Enforced twice on purpose — * the CLI's own `--max-turns` stops the session cleanly at N and emits its result event (the only * report of the session's real cost), and a parent-side counter kills it if a turn beyond N is * ever observed, since that flag is undocumented in `claude --help` and a rename would otherwise * silently unbound the session. Either way the stop is NAMED (`session.error:'max_turns'`, * `stopReason:'max_turns'`) and carries the last turn's text forward, never a silent clean success * and never an empty result. */ maxTurns?: number | undefined; /** * - (native mode) BA-11 at the bridge: a single deny stays * advisory so the model can pivot to an allowed tool; N in a row with no allowed call between ends the * session with `denied:`. `0`/`Infinity` disables. */ maxConsecutiveDenials?: number | undefined; /** * - (native mode) BA-12 at the bridge: only a BYTE-IDENTICAL * repeat (name + JSON args) counts, so a model varying its args while recovering is never punished. N in * a row ends the session with `stuck:`. `0`/`Infinity` disables. */ maxIdenticalToolErrors?: number | undefined; /** * - (native mode) Wall-clock ceiling for one whole session. The * 30s `timeout` default is for one-shot text and would kill an agentic session mid-run. */ sessionTimeout?: number | undefined; /** * - (native mode) Ceiling for ONE tool-handler round-trip across the * bridge. A hung handler becomes an error tool result rather than a hung session (default 120s). * * Either mode: a non-empty `tools` array on `generate()` routes to tool mode; an empty one stays * plain text. With NO `toolProtocol`, `tools` are IGNORED (plain-text, the long-standing behavior — * a non-tool-calling CLI legitimately sits in a Loop with tools mounted) plus a one-time `console.warn`. * Emulation additionally requires a capable model (weak ones answer in prose; see `probeCapability`); * native mode needs no such probe, because the CLI's own tool channel does not depend on the model * agreeing to fill in a JSON questionnaire. The claude-specific parts of each live in * `provider-clipipe-tools.js` / `provider-clipipe-mcp.js`, so a second CLI slots in behind the same seams. */ bridgeTimeoutMs?: number | undefined; /** * - (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. */ probeCapability?: boolean | undefined; }; export class CLIPipeProvider { /** * Provider that pipes prompts to a CLI command via stdin and reads stdout. * @param {CLIPipeOptions} [options] * @throws {Error} `[CLIPipeProvider] requires command` — when options.command is missing. * @when you want to drive a CLI (e.g. the claude CLI) as the Loop's provider — native MCP tools or a JSON emulation envelope * @fails throws on a missing command; in native mode it owns its own cycle (ownsCycle), so per-round Loop seams are refused at construction rather than left silently dead. * @example * const provider = new CLIPipeProvider({ command: 'claude' }); */ constructor(options?: CLIPipeOptions); thinkingMode: "on" | "off" | null; command: string; args: string[]; cwd: string | undefined; env: Record | undefined; timeout: number; systemPromptFlag: string | null; onChunk: ((chunk: string) => void) | null; parse: "claude-json" | ((stdout: string) => Partial) | null; nativeTools: boolean; /** * Declares to the Loop that this provider runs its OWN turn cycle. The Loop reads it to refuse * options it could never honor (assemble/trim/cacheMessages) and to require the fence be wired * where it can actually run. Generic provider-contract flag; nothing here is claude-specific. */ ownsCycle: boolean; toolProtocol: { name: string; turnArgs(systemPrompt: string): string[]; parseResult(stdout: string): import("./provider-clipipe-tools").ParsedEnvelope; probe: { system: string; user: string; isCapable: (parsed: import("./provider-clipipe-tools").ParsedEnvelope) => boolean; }; } | null; probeCapability: boolean; /** @type {Promise|null} */ _toolCapability: Promise | null; policy: ((tool: string, args: any, ctx?: any) => any) | null | undefined; onTurn: Function | null | undefined; maxTurns: number | null | undefined; maxConsecutiveDenials: number | undefined; maxIdenticalToolErrors: number | undefined; sessionTimeout: number | undefined; bridgeTimeoutMs: number | null | undefined; /** * Generate a response by piping messages to the CLI command. With a `toolProtocol` configured and * a non-empty `tools` array, routes to schema-validated tool EMULATION (v0.32.0); otherwise the * plain-text path below (unchanged). Passing `tools` with no `toolProtocol` warns ONCE and ignores * them (a non-tool-calling CLI legitimately coexists in a Loop with tools mounted); the loud * failure for a genuinely tool-incapable model lives in the tool-mode capability probe. * @param {Message[]} messages - Conversation messages in OpenAI format. * @param {ToolDef[]} [tools=[]] - Caller tools. Honored only in tool mode (`toolProtocol` set). * @param {Record} [options={}] - Unused, except `thinkingMode` (validated; CLIPipe has no thinking switch, so the call runs unchanged with `thinkingStatus:'unsupported'`). * @returns {Promise} * @throws {Error} `[CLIPipeProvider] failed to spawn "cmd": ...` — when the command cannot be found or executed. * @throws {Error} `[CLIPipeProvider] process exited with code N: ...` — on non-zero exit. * @throws {Error} `[CLIPipeProvider] timed out after Nms` — when the process exceeds timeout. * @throws {Error} `[CLIPipeProvider] process produced no output` — when stdout is empty. */ generate(messages: Message[], tools?: ToolDef[], options?: Record): Promise; /** * The pre-thinkingMode generate body: routes to native / emulation / plain-text. * @param {Message[]} messages * @param {ToolDef[]} tools * @param {Record} options * @returns {Promise} */ _generateInner(messages: Message[], tools: ToolDef[], options: Record): Promise; _warnedNoProtocol: boolean | undefined; /** * Tool mode (v0.32.0) — one turn of schema-validated tool emulation. Renders the Loop's * OpenAI-shaped transcript to text, injects the caller's system stance + a tool manifest + the * envelope contract, spawns the CLI under the protocol's flags, and parses the envelope back into * normalized `toolCalls` (a `tool_call`) or `text` (a `final_answer`). The Loop drives the cycle. * @param {Message[]} messages * @param {ToolDef[]} tools * @returns {Promise} */ _generateWithTools(messages: Message[], tools: ToolDef[]): Promise; _toolCallSeq: any; /** * BA-16 native tool mode — run ONE whole CLI session and report it honestly as one call. * * The CLI owns the inner cycle here: it calls the caller's tools natively over an MCP bridge and * keeps going until it answers or hits a bound. So this returns `toolCalls: []` always (there is * nothing left for the Loop to execute) plus a `session` block describing what really happened — * the real turn count, the real tool-call count, and any terminal the Loop must surface as the * run's `error`. * * Ordering of terminals is deliberate. A governance halt outranks everything (it is a clean exit, * not a fault). A tripped guard outranks the CLI's own subtype, because the guard is why we killed * the session. And `bridgeDown` outranks a reported `success`, because a session whose tools were * all broken still ends `subtype:'success'` — measured, and the reason this block exists. * * @param {Message[]} messages * @param {ToolDef[]} tools * @param {Record} options - the Loop's run options (`ctx` is read from here). * @returns {Promise} */ _generateWithMcp(messages: Message[], tools: ToolDef[], options?: Record): Promise; /** * Run the upfront capability probe ONCE per instance (cached), unless `probeCapability` is off. * A model that answers the probe in prose instead of emitting a tool_call throws a loud * `ProviderError` — fail fast, never silently degrade to a no-tools run mid-conversation. * NOTE: the probe is a single internal CLI turn whose token usage/cost is NOT surfaced to the Loop * (it never flows to `onLlmResult`), so a wired budget gate does not see it — negligible for the * subscription use case this exists for (flat cost, one probe per instance), by design. * @returns {Promise} */ _ensureToolCapability(): Promise; /** Best-effort model id from `--model X` in the base args, for a clearer probe-failure message. */ _modelFromArgs(): string | null; /** * Map the `claude -p --output-format json` result envelope onto a normalized GenerateResult. * The caller explicitly opted into structured output, so a malformed or error envelope is a LOUD * ProviderError — never a silent fall-back to raw text. * @param {string} stdout - Trimmed stdout from the CLI. * @returns {GenerateResult} * @throws {ProviderError} On non-JSON stdout, or an error envelope (`is_error` / non-success subtype). */ _parseClaudeJson(stdout: string): GenerateResult; /** * Convert OpenAI-format messages to a plain text prompt. * @param {Message[]} messages * @returns {string} */ _formatPrompt(messages: Message[]): string; /** * Spawn the CLI process, pipe prompt to stdin, collect stdout. * @param {string} prompt * @param {string[]} [extraArgs=[]] - Additional args appended after this.args. * @returns {Promise} */ _spawn(prompt: string, extraArgs?: string[]): Promise; }