import http from "node:http"; import https from "node:https"; /** A parsed Anthropic SSE event's `data:` JSON payload (already JSON.parsed). */ export interface SseDataEvent { type?: string; [k: string]: unknown; } /** Gateway-controlled marker injected into the MAIN agent's appended system prompt. * The proxy tees only requests whose system carries it — Task sub-agents get Claude * Code's own system prompt (no sentinel) and are therefore suppressed. This is the * one main-vs-sub signal the gateway fully owns, so it cannot drift like a request * fingerprint does. It is an HTML comment so the model ignores it. */ export declare const MAIN_AGENT_SENTINEL = ""; /** Signature of `https.request`/`http.request` — the seam we inject in tests so * the proxy can target a local fake upstream instead of api.anthropic.com. */ type UpstreamRequestFn = (options: https.RequestOptions, cb: (res: http.IncomingMessage) => void) => http.ClientRequest; /** Snapshot of the proxy's in-flight upstream work, fired on every change. */ export interface UpstreamActivityInfo { activeStreams: number; /** Tool-bearing /v1/messages requests: main agent + Task subagents. */ activeAgents?: number; /** Background Bash monitors tracked by the interactive engine. */ activeMonitors?: number; lastActivityAt: number; } /** Test/override hooks. All optional; defaults reproduce production behavior * (https → api.anthropic.com:443 over this proxy's own keep-alive pool). */ export interface SsePtyProxyOpts { requestFn?: UpstreamRequestFn; upstream?: { hostname: string; port: number; protocol?: "http:" | "https:"; }; /** Agent for the FIRST attempt. Default: a keep-alive pool this proxy owns. */ primaryAgent?: https.Agent | http.Agent | false; /** Fired whenever the in-flight upstream request count changes (start AND every * terminal path: response end, upstream error, client-gone abort). Counts ALL * requests through the proxy — main agent, Task sub-agents, and background * tasks alike (independent of the tee/sentinel decision) — so the gateway can * tell "CLI still working" apart from "truly idle" after the Stop hook. */ onUpstreamActivity?: (info: UpstreamActivityInfo) => void; } /** * Per-PTY forward proxy. The genuine `claude` CLI is pointed at this proxy via * ANTHROPIC_BASE_URL; every request is forwarded UNCHANGED to api.anthropic.com * (same method/path/headers/body, subscription OAuth token preserved → still * cc_entrypoint=cli, subsidy-safe — verified in Item A) and the response is * streamed back to the client byte-for-byte. The ONLY mutation is stripping the * client's `accept-encoding` so the SSE body comes back as plaintext we can * parse; the (now-uncompressed) response headers are forwarded as-is. * * When the upstream response is text/event-stream we tee a parsed copy of each * SSE `data:` event to `onEvent` — this is the live streaming source for the web * chat pane (word-by-word text, tool markers in true order, live context tokens). * * Tee gate (main-agent only): besides the real conversation turn, Claude Code fires * extra requests through this same proxy — haiku topic/title detection and quota * checks (NO tools), plus Task sub-agents (which run in-process, so their nested * /v1/messages flow through here too). We tee to `onEvent` ONLY the main agent's * turns, identified by a gateway-controlled sentinel (MAIN_AGENT_SENTINEL) that the * gateway injects into the main agent's appended system prompt. Sub-agents get * Claude Code's own system prompt (no sentinel) and aux calls carry no tools, so * both are suppressed — their output never leaks into the transcript. * * Why a gateway-owned sentinel and not a request fingerprint: the main agent's own * requests do NOT share a stable signature (tool set and system drift across a turn * as MCP tools/instructions load and per-request reminders are injected), so every * fingerprint heuristic we tried either dropped real turns (broke streaming) or * leaked sub-agents. The sentinel is the one signal the gateway fully controls. */ export declare class SsePtyProxy { private readonly label; private readonly onEvent; private server; /** Resolved listening port (0 until start() completes). */ port: number; private readonly requestFn; private readonly upstreamHost; private readonly upstreamPort; private readonly primaryAgent; /** True when `primaryAgent` is the pool we created, so stop() must free it. */ private readonly ownsPool; private readonly onUpstreamActivity?; /** Upstream requests currently in flight (incremented at request start, * decremented exactly once per request on end/error/client-abort). */ activeStreams: number; /** Agent requests currently in flight. Auxiliary requests remain streams but * do not inflate this count. */ activeAgents: number; /** Epoch ms of the most recent upstream request start or completion. */ lastUpstreamActivityAt: number; constructor(label: string, onEvent: (e: SseDataEvent) => void, opts?: SsePtyProxyOpts); /** Bind to an ephemeral 127.0.0.1 port; resolves with the chosen port. */ start(): Promise; /** Tear down the proxy. Safe to call multiple times. */ stop(): void; /** Mark one upstream request started. Returns a ONCE-guarded `finish` that * decrements on whichever terminal path fires first (response end, upstream * error, client-gone abort) — later calls are no-ops, so overlapping terminal * events can never double-decrement. Both edges notify onUpstreamActivity. */ private streamStarted; private notifyActivity; private handle; /** Forward one buffered request upstream and stream the response back. A * connection that dies BEFORE any response bytes is retried on a * guaranteed-fresh socket (agent:false), up to MAX_UPSTREAM_ATTEMPTS with a * backoff between attempts: the upstream fault arrives in bursts, so a single * immediate retry lands inside the same burst and surfaced to the CLI as a bare * `502` — which the harness reads as stream death. Anything else (or any error * after streaming started) ends as 502 exactly as before. */ private sendUpstream; /** Only tool-bearing /v1/messages requests are agents. count_tokens and * no-tools calls remain auxiliary even when they carry similar request data. * The sentinel separates main from subagent only for the transcript tee. */ private classifyRequest; /** Consume complete SSE frames (separated by a blank line) from `buf`, JSON.parse * each event's `data:` payload, fire onEvent, and return the trailing incomplete * remainder for the next chunk. Only ever called for the main agent's stream. */ private parseSse; } export {}; //# sourceMappingURL=sse-pty-proxy.d.ts.map