/** * @verevoir/llm/claude-cli — held sessions: lifecycle (wave 2a of 5). * * This is HALF of what was originally proposed as one file in the * withdrawn #57 (itself wave 2/4 of the split replacing the withdrawn * omnibus #55). #57 was REJECTED by review on * `pull-requests-carry-what-why-tests` — 1,799 changed lines, and the * file's own header, plus its own test file's `describe` blocks, * already showed an unexercised seam: lifecycle (this file) vs. turn * execution (`runSessionTurn`, its watchdog, its abort handling, tool * arming — landing on top of this as wave 2b). See CHANGELOG's 0.26.8 * entry for the full account. * * See index.ts's file header (SESSION-HOLDING TRANSPORT section, * landing in wave 3 of this overall split) for the why; this header * covers the how. This file has no caller yet (nothing on `main` * imports it) — the same leaf-first pattern the embedded MCP bridge * landed in at 0.26.6 — its own test file exercises it directly by * calling `getOrCreateSession` (exported for exactly this reason; see * its own doc comment below). * * ONE PROCESS PER HANDLE, KEPT ALIVE ACROSS TURNS. `claude -p * --input-format stream-json --output-format stream-json` is spawned * once per session and left running, rather than the single-shot * path's spawn-per-call. `--disallowedTools "*"` / `--strict-mcp-config` * / `--no-session-persistence` / `--safe-mode` stay on exactly as the * single-shot path uses them (see index.ts's own file header for why * each is there, and its SECURITY CORRECTION paragraph for why this is * `--disallowedTools "*"` and not the old `--tools ""`); `--mcp-config` * names this session's own bridge (`mcp-bridge.ts`) when the session has * tools, and is omitted entirely for a tool-free session (plain `chat()` * with `session` set, held only for prompt-cache warmth). * * THE SYSTEM PROMPT, MODEL, AND TOOL SET ARE FIXED AT SPAWN. `-p`'s * `--system-prompt` and `--model` are startup flags, not something a * stream-json turn can change mid-process, and the MCP bridge's tool * declarations are baked into its shim script at generation time. A * later call reusing the same handle with a DIFFERENT systemPrompt, * model, or tool set is refused with a specific error naming the * mismatch (`requireCompatible`, below), rather than either silently * keeping the old binding or silently reconfiguring it — the same * "declares it rather than pretending" standard this whole adapter * holds itself to. Close the session and open a new one to change any * of those. * * DEAD-HANDLE SAFETY. `getOrCreateSession` never distinguishes "this id * was never used", "its process already exited", and "it was evicted" — * all three simply spawn fresh under the same id. A handle is therefore * ALWAYS safe to present: the truth arrives on use, the same shape as a * 401, and the caller's own `turns` stay the ground truth regardless of * whether the accelerator underneath survived. * * BOUNDED, SO AN UNRELEASED HANDLE CANNOT ACCUMULATE PROCESSES FOREVER. * Two independent bounds, both — a caller that only ever uses * `chatWithToolLoop()` without a `session` never hits either, since that * path closes its own throwaway session at the end of every call (see * index.ts, wave 3): * - {@link SESSION_IDLE_TIMEOUT_MS}: a session with no turn IN FLIGHT * and none started for this long is killed and evicted. (The * "no turn in flight" half of this is a wave-2b concept — see that * wave's own header addition — but the eviction MECHANISM itself * lives entirely here.) * - {@link SESSION_MAX_HELD}: the process-wide cap on LIVE sessions. * Creating one past the cap evicts the least-recently-used first * (touch order is turn order, not creation order). * * THE TTL IS A RESOURCE POLICY, NOT A CORRECTNESS MECHANISM — and this is * only true because of a property that holds everywhere else in this * package too: the session is a CACHE over a stateless truth. The * caller's own `turns` remain the ground truth (see index.ts's, wave 3, * `ClaudeCliChatOptions` doc comment — a held session only ever adds an * accelerator on top of that, the same rehydration guarantee every other * adapter in this package gives by resending its full `turns` array * every call). So the worst case of a session being reclaimed too early * is a cold rebuild — a fresh process, cache cold, tools re-declared — * never lost work: `getOrCreateSession`'s dead-handle path (below) makes * a reclaimed handle behave exactly like one that was never used. That * is what makes {@link SESSION_IDLE_TIMEOUT_MS} safe to tune freely in * either direction: get it wrong and someone pays in tokens re-warming a * cache, never in correctness. * * ══════════════════════════════════════════════════════════════════ * SECURITY CORRECTION — `--tools ""` NEVER DISABLED BUILT-IN TOOLS. * ══════════════════════════════════════════════════════════════════ * Confirmed directly against the CLI: `--tools` is a space-separated * ALLOWLIST, and an empty string reads as NOT SET, not as an empty * list — every session this file spawned fell through to the CLI's own * default built-in tool set the entire time. See index.ts's own SECURITY * CORRECTION paragraph for the full account (it applies here identically). * * THE FIX HERE IS NOT "ZERO TOOLS", UNLIKE THE SINGLE-SHOT PATH — the * operator's own framing: "it is unlikely there will ever be no tools * for a conversation". The requirement on THIS path is the caller's own * tools and NOTHING ELSE: built-ins denied, the MCP-declared ones (via * the embedded bridge) reachable. `buildSessionArgs` (below) passes * `--disallowedTools "*"` — and THIS RELIES, DELIBERATELY, ON A * CONFIRMED CLI BUG: the operator confirmed `--disallowedTools` has "a * known issue not filtering MCP server tools". For a session with NO * tools (bridge is `null`, no `--mcp-config` passed) that bug is * irrelevant — there is no MCP tool for it to fail to filter. For a * session WITH tools, that bug is exactly the mechanism this adapter * depends on: the deny-by-name rule reaches every built-in but never * reaches the caller's own MCP-declared tools. AN UPSTREAM FIX TO THAT * CLI BUG WOULD BREAK THIS ADAPTER'S TOOL-BEARING SESSIONS SILENTLY — * this dependency is recorded here, at the flag-building site, and in * CHANGELOG.md, precisely so that a future CLI upgrade regressing this * doesn't read as unrelated. * * GETTING A FLAG RIGHT ONCE IS NOT THE FIX — index.ts's `chat()` (wave 3) * verifies `--disallowedTools "*"` against the CLI's own `init` event * `tools` array on every call (see its own file header's TOOL-SAFETY * VERIFICATION paragraph) rather than trusting the flag by construction, * which is exactly the standard that would have caught the original * `--tools ""` defect on day one. THIS FILE DOES NOT YET DO THE SAME — * stated plainly, not left for a reader to discover by its silence; see * wave 2b's own header addition for the current state of that gap. * * Wire-shape verification history (three real operator-run probes * confirming multi-turn-on-one-process, `--disallowedTools "*"` * genuinely removing built-ins, and the terminal envelope's shape) is * recorded in full in wave 2b's header addition, next to * `runSessionTurn` and `ClaudeCliStreamResultEnvelope` — the pieces that * actually consume that confirmation. Nothing in THIS file depends on * the streaming wire shape being right; it only spawns and tracks * processes. */ import { type ChildProcessWithoutNullStreams } from 'node:child_process'; import type { ToolDef } from '../index.js'; import { type ToolBridge } from './mcp-bridge.js'; import type { BridgeToolResult } from './mcp-bridge.js'; import type { ToolExecutor, ToolUse } from '../index.js'; /** An opaque handle to a held `claude` session — see index.ts's file * header (wave 3) for the full contract. Carries nothing but an id: * every fact about the session it names lives in this module's own * internal map, never on the handle itself, so a handle is safe to * serialise, log, or hold past the session's own life without leaking * anything. */ export interface ClaudeCliSessionHandle { readonly id: string; } /** How long a session with NO TURN IN FLIGHT is kept alive before being * killed and evicted — the gap between turns, never a turn's own * length. Cancelled while a turn is pending; rescheduled once it * settles (both of those are wave 2b's `runSessionTurn`, since "a turn * is in flight" is a concept only it can observe). A resource policy, * freely tunable — getting it wrong costs a token-spending cold * rebuild, never correctness, because the caller's own turns remain the * ground truth. */ export declare const SESSION_IDLE_TIMEOUT_MS: number; /** The process-wide cap on LIVE held sessions. Creating one past the cap * evicts the least-recently-used first. */ export declare const SESSION_MAX_HELD = 4; /** One turn's pending state, shared with wave 2b's `runSessionTurn` * (which is the only thing that ever CREATES one — see that wave's * header) but read here too: `killHeld` must reject an in-flight turn * rather than leaving its promise to hang forever if the session dies * or is evicted mid-turn, and `handleStreamLine` (below) is what * resolves one when a terminal `result` event arrives. */ interface PendingTurn { resolve: (envelope: { raw: ClaudeCliStreamResultEnvelope; assistantEventCount: number; }) => void; reject: (err: unknown) => void; assistantEventCount: number; watchdog: ReturnType; /** Clears the watchdog and removes the abort listener * `runSessionTurn`'s promise executor registered. Must be called from * EVERY path that can settle a pending turn — including `killHeld` * (below), which is reached from OUTSIDE `runSessionTurn` via * `closeSession`, `evictOverCapacity`, and dead-session cleanup, and * so is itself a settlement path, not just an internal one. Idempotent * — safe to call more than once — so every path can call it * unconditionally regardless of who settles the turn first. See * CHANGELOG.md's 0.27.1 entry for the bug this fixes and its history. */ teardown: () => void; } interface HeldSession { id: string; child: ChildProcessWithoutNullStreams; bridge: ToolBridge | null; toolNames: string[]; systemPrompt: string; model: string | undefined; idleTimer: ReturnType; closed: boolean; lineBuffer: string; pending: PendingTurn | null; } /** The stream-json turn's terminal `{"type":"result",...}` line — see * wave 2b's header addition for the real-invocation confirmation this * shape rests on. Declared here (not in wave 2b) because `PendingTurn`, * above, needs it for `resolve`'s parameter type, and `handleStreamLine` * (below) is what actually parses a raw line into one. */ interface ClaudeCliStreamResultEnvelope { type?: string; result?: string; is_error?: boolean; subtype?: string; stop_reason?: string; /** The CLI's own session id for this turn — same field, same shape, as * index.ts's `ClaudeCliResultEvent.session_id`. Reused across EVERY * turn on a held session — never unique per call, so never used as a * provider request id (see `uuid`, below). */ session_id?: string; /** A real, per-turn-unique id on the terminal result event — confirmed * on a real invocation (this file's own `REAL_AUTH_FAILURE_ENVELOPE` * fixture in session.test.ts carries it, until now an unmodeled * field). Unlike `session_id`, this changes every turn, which is why * `SessionTurnResult.uuid` / `providerRequestId` is built from this, * not `session_id` (#73 review fix). */ uuid?: string; usage?: { input_tokens?: number; output_tokens?: number; cache_read_input_tokens?: number; cache_creation_input_tokens?: number; /** The extended-cache-TTL breakdown — same field, same shape, as * index.ts's `ClaudeCliResultEvent.usage.cache_creation`. */ cache_creation?: { ephemeral_5m_input_tokens?: number; ephemeral_1h_input_tokens?: number; }; }; modelUsage?: Record; /** See wave 2b's `PermissionDenial` doc comment — present on every * real envelope this repo has captured so far, always empty; its * shape when POPULATED is unconfirmed. Declared here only as a field * on the envelope; extraction into a typed `PermissionDenial[]` is * wave 2b's own concern, since only `runSessionTurn` reads it. */ permission_denials?: unknown[]; /** Same field, same shape, as index.ts's `ClaudeCliResultEvent.total_cost_usd`. */ total_cost_usd?: number; } /** A fresh, opaque session handle. Spawns nothing yet — the first * `getOrCreateSession` call against it (via wave 2b's * `runSessionTurn`) is what actually starts `claude`, so creating a * handle you never use costs nothing. */ export declare function createSession(): ClaudeCliSessionHandle; /** Release a held session explicitly: kill its process (if live), tear * down its bridge, and stop its idle timer. Idempotent — closing an * unknown or already-closed handle is a no-op, the same dead-handle * safety `getOrCreateSession` gives a fresh call. */ export declare function closeSession(handle: ClaudeCliSessionHandle): Promise; /** Test-only: kill and clear every held session so a test file starts * clean rather than inheriting a process left running by an earlier * test — mirrors index.ts's `resetClaudeCliVersionCacheForTests`. Never * called from production code. */ export declare function resetClaudeCliSessionsForTests(): void; /** Exported so wave 2b's `runSessionTurn` can call it once a turn * settles — idle eviction is scheduled here (at spawn) and rescheduled * there (after each turn), but the scheduling mechanism itself belongs * with the rest of this file's bounds machinery. */ export declare function scheduleIdleEviction(session: HeldSession): void; interface SessionBinding { tools: ToolDef[]; systemPrompt: string; model: string | undefined; } /** Get the live session behind `handle`, spawning one if none exists, * the existing one has died/been evicted, or the caller asked for an * incompatible binding — see this file's header for the dead-handle and * fixed-binding contracts this implements, and `INITIALIZING`'s own doc * comment for the concurrent-first-use race this function closes. * * EXPORTED — not merely internal — specifically so this PR's own test * file can exercise spawn/reuse/dead-handle/eviction directly, since * this file has no other caller yet (wave 2b's `runSessionTurn`, the * eventual production caller, is the next PR in this split). The same * "confirmed leaf, tested standalone" pattern the embedded MCP bridge * (0.26.6) shipped in. */ export declare function getOrCreateSession(handle: ClaudeCliSessionHandle, binding: SessionBinding): Promise; /** How long a single turn waits for a terminal `result` event before * the held process is killed and the turn refuses. A wire shape this * repository has not independently confirmed must fail loudly, not * hang — see this section's CONFIRMED bullets below for what real * operator-run invocations have actually shown. * * ══════════════════════════════════════════════════════════════════ * WIRE-SHAPE VERIFICATION HISTORY — three real operator-run probes. * ══════════════════════════════════════════════════════════════════ * * A SECOND PROBE (the first to run to completion, though it failed on * expired OAuth rather than reaching a tool call) CONFIRMED two of this * file's most load-bearing assumptions, by direct observation rather * than documentation: * * CONFIRMED: one process serves MULTIPLE turns. Two * `{"type":"result",...}` lines appeared on the same stdout stream, * sharing one `session_id`, distinguished only by `result_index: 0` * then `result_index: 1` — a single `claude` invocation, one exit * code for the whole run, no process exit between the two turns. * * CONFIRMED: the terminal `result` line's shape matches * {@link ClaudeCliStreamResultEnvelope} field-for-field — `type`, * `result`, `is_error`, `subtype`, `stop_reason`, `usage`, * `modelUsage` were all present, alongside several unmodeled fields * that this file correctly ignores rather than choking on. * * A SHARP EDGE THIS RUN SURFACED, ALREADY HANDLED CORRECTLY: the * observed envelope had `"subtype":"success"` sitting ALONGSIDE * `"is_error":true`. `subtype` describes how the request/response * CYCLE ended, not whether the CONTENT is an error — this file's own * error check (below, in `runSessionTurn`) already reads `is_error`, * never `subtype`. * * STILL GENUINELY OPEN from this run: the failure was authentication, * arriving IN-BAND on stdout's own `result` field, with stderr EMPTY — * a live illustration of why this file never trusts stderr or exit * code alone. No model call ever reached the point of deciding * whether to invoke a tool. * * A THIRD PROBE, authenticated and run to completion, with * `--disallowedTools "*"` and a turn that explicitly asked the model to * ACTUALLY INVOKE Bash and show the raw output — the test the first two * runs were missing. Three turns, one process, one `session_id`, * `result_index: 0, 1, 2` — the multi-turn claim holds a THIRD time. * * CONFIRMED: `--disallowedTools "*"` genuinely removes every built-in * tool. All three `init` events reported `"tools":[]` — the CLI's own * authoritative observable. Corroborating: asked to run `pwd` via * Bash and show the raw output, the model replied it had no tool * available and explicitly declined to invent one. Zero `tool_use` * content blocks appear anywhere in the transcript — the model never * attempted a call, not merely reported failing one. * * STILL OPEN, UNCHANGED: `"mcp_servers":[]` on all three `init` * lines again, and the probe's own MCP server never wrote its * startup line — the process was never spawned. `--safe-mode` * remains the leading, unconfirmed hypothesis (see CHANGELOG.md's * history) — this run neither confirms nor refutes it. * * GETTING A FLAG RIGHT ONCE IS NOT THE FIX — index.ts's `chat()` * (wave 3) verifies `--disallowedTools "*"` against the CLI's own * `init` event `tools` array on every call rather than trusting the * flag by construction. THIS FILE DOES NOT YET DO THE SAME — stated * plainly, not left for a reader to discover by its silence. Building * the real check means an `init` event fixture in very nearly every * existing test in `session.test.ts`, which is real work, sized and * left for its own pass rather than folded in unannounced. */ export declare const SESSION_TURN_TIMEOUT_MS: number; /** * One entry from the terminal envelope's `permission_denials` array — a * field CONFIRMED to exist (the operator's own channel to the CLI; also * observed, always empty, in every real envelope this repo has captured), * but whose shape when POPULATED is UNCONFIRMED: this repository has never * seen a non-empty one. Rather than assume a field name and silently * mis-tag an existing `toolUses`/`toolResults` entry, each denial is kept * as `raw` — exactly what the CLI sent — plus a best-effort `toolName` * extracted by duck-typing over the field names most likely to carry it. * A caller that learns the real shape can read `raw` directly rather than * waiting on this file to model it. * * READ THIS ALONGSIDE `toolUses`, NEVER ALONE — an empty array here does * NOT mean "nothing was denied": a model can decline a tool in plain text * without ever attempting a call, which also leaves `permission_denials` * empty. So: both `toolUses` and this empty means no attempt was ever * made; a non-empty `toolUses` with this empty means every attempt * reached the bridge; a non-empty entry here means at least one attempt * was denied BEFORE it ever reached the bridge/executor — it will not * also appear in `toolUses`, because the bridge never saw it in the * first place. */ export interface PermissionDenial { /** The denial entry exactly as the CLI reported it. */ raw: unknown; /** Best-effort extraction — see this interface's own doc comment. * `undefined` when none of the candidate keys held a non-empty string, * rather than a guessed value. */ toolName?: string; } /** Options for one turn on a held session — see `index.ts`'s `chat` * (session branch) / `chatWithToolLoop` (wave 3) for the two callers of * this. */ export interface SessionTurnOptions { session: ClaudeCliSessionHandle; systemPrompt: string; /** The flattened text of this round's ONE new message — never the * full conversation history (the session already holds that). */ message: string; tools: ToolDef[]; /** Required when `tools` is non-empty. */ executor?: ToolExecutor; maxToolCalls: number; model?: string; signal?: AbortSignal; } /** Result of one turn on a held session. */ export interface SessionTurnResult { text: string; stopReason: string; usage: ClaudeCliStreamResultEnvelope['usage']; modelUsage: ClaudeCliStreamResultEnvelope['modelUsage']; /** See {@link ClaudeCliStreamResultEnvelope.total_cost_usd}. */ totalCostUsd: ClaudeCliStreamResultEnvelope['total_cost_usd']; /** See {@link ClaudeCliStreamResultEnvelope.session_id}. Shared across * every turn on this held session — surfaced for correlation only, * never used as `providerRequestId` (see `uuid`, below). */ sessionId: ClaudeCliStreamResultEnvelope['session_id']; /** See {@link ClaudeCliStreamResultEnvelope.uuid} — the real per-turn- * unique id, when the CLI supplies one. This is what index.ts's * `shapeUsage()` uses for `providerRequestId` on the held-session * path; `undefined` when a particular turn's envelope doesn't carry * one, rather than falling back to the shared `sessionId`. */ uuid: ClaudeCliStreamResultEnvelope['uuid']; toolUses: ToolUse[]; toolResults: BridgeToolResult[]; /** See {@link PermissionDenial}'s own doc comment for what this is and * — just as importantly — what an EMPTY array here does and does not * tell a caller. Never throws on its own; is_error remains the only * failure signal this file reads. */ permissionDenials: PermissionDenial[]; /** Proxy for "how many model iterations happened" — an observed count * of `assistant` stream events, not a count of separate calls this * function made (it made exactly one). */ assistantEventCount: number; } /** * Run one turn on a held session — spawning or reusing the process * behind `options.session` (via `getOrCreateSession`, wave 2a), arming * the tool bridge (if `options.tools` is non-empty) for the caller's * executor, writing the turn, and resolving once a terminal `result` * event arrives (or the per-turn watchdog / an abort fires first). */ export declare function runSessionTurn(options: SessionTurnOptions): Promise; export {}; //# sourceMappingURL=session.d.ts.map