/** * Fully automated Claude Code /v1/messages capture helper. * * Starts a local CONNECT proxy, MITMs TLS using a local self-signed debug * certificate, drives Claude Code through a headless PTY/xterm, and returns the * first completed /v1/messages request/response exchange. */ import * as net from "node:net"; import * as path from "node:path"; import * as tls from "node:tls"; import * as zlib from "node:zlib"; import { PtySession } from "@oh-my-pi/pi-natives"; import xterm from "@xterm/headless"; const DEFAULT_PROXY_HOST = "127.0.0.1"; const DEFAULT_PROXY_PORT = 8080; const DEFAULT_COMMAND = "claude"; const DEFAULT_MESSAGE = "hi"; const DEFAULT_TIMEOUT_MS = 120_000; const DEFAULT_INPUT_DELAY_MS = 1_000; const DEFAULT_COLS = 120; const DEFAULT_ROWS = 40; const DOUBLE_CRLF = Buffer.from("\r\n\r\n", "latin1"); const CRLF = Buffer.from("\r\n", "latin1"); const TEXT_DECODER = new TextDecoder(); // Debug-only local MITM certificate. Claude is launched with // NODE_TLS_REJECT_UNAUTHORIZED=0, so the certificate has no trust value; it only // lets Node's TLS stack complete the CONNECT tunnel handshake. export const CLAUDE_TRACE_DEBUG_CERT = `-----BEGIN CERTIFICATE----- MIIDFzCCAf+gAwIBAgIUAe9omAqLbydZc5ZYZGhwbbpMSF0wDQYJKoZIhvcNAQEL BQAwGzEZMBcGA1UEAwwQb21wLWNsYXVkZS10cmFjZTAeFw0yNjA2MDIwODA2MjFa Fw0zNjA1MzAwODA2MjFaMBsxGTAXBgNVBAMMEG9tcC1jbGF1ZGUtdHJhY2UwggEi MA0GCSqGSIb3DQEBAQUAA4IBDwAwggEKAoIBAQCmpGe5T8B0oA2L82Rn5JJdXOBS ZX0DyBjiIK+Tqe8T3oAr41XDLnweqtrMDSBDYbVqAoKjNbaTUSYYcxSm0MAVs63w 08SfJmShZM9pElfANqXqMiyhksFgji7JEyt/rbbId207a7s5KvRvm3g/sxN/wGtr C5LCLMlc2GWEGD8qrVIQbmLw884qvtXi70RFUPP3Wpy4wGMWSdE+9IA27R5cMJS5 oHsO4HGB6J8VzLY+HGY2yr4BJ9qrAyjd1UetFd9RdcjyWpsbAfX8nWP+uleTNOiT ExNz7dPt/k6OPLNmI1iT/ruRS0uUzHZTimPd67TPQR/70RaW7Bh5wArawGw9AgMB AAGjUzBRMB0GA1UdDgQWBBQa4Ir8P3GAolZoPiuB4V2cq3riAjAfBgNVHSMEGDAW gBQa4Ir8P3GAolZoPiuB4V2cq3riAjAPBgNVHRMBAf8EBTADAQH/MA0GCSqGSIb3 DQEBCwUAA4IBAQClPYki235gDEUu7eDm60qsAGWxbKVv4pSh+vB+xgNgzMk4aOuU mSfp8Y8covwklph8VfDoKTaEGqqX0Q5s74Ctl6Mwy7b0u8Zztk/g4GynLocI7TQD ftZMgZka49+FkEsjp+XZtQbO4vOL5UsccpsLhFQQQuhVyiJ4gNo/VzgvSDkBuf3Q Rz7xFiDKCqFEoMPty4+nKEw5832FJ5mDCOyMk6fGSO8Wbt/hmRQQFu2cSdoBs0OT AQQJETQjPkKeTDX4jdSAlOeKwfyjfdfgeQuMkzX8xafisJa66MLPzOVbIuGbvbWD QVCd76iYPcfNK+JZUhmAUvTHSuwgJMZ6+NgI -----END CERTIFICATE-----`; export const CLAUDE_TRACE_DEBUG_KEY = `-----BEGIN PRIVATE KEY----- MIIEvQIBADANBgkqhkiG9w0BAQEFAASCBKcwggSjAgEAAoIBAQCmpGe5T8B0oA2L 82Rn5JJdXOBSZX0DyBjiIK+Tqe8T3oAr41XDLnweqtrMDSBDYbVqAoKjNbaTUSYY cxSm0MAVs63w08SfJmShZM9pElfANqXqMiyhksFgji7JEyt/rbbId207a7s5KvRv m3g/sxN/wGtrC5LCLMlc2GWEGD8qrVIQbmLw884qvtXi70RFUPP3Wpy4wGMWSdE+ 9IA27R5cMJS5oHsO4HGB6J8VzLY+HGY2yr4BJ9qrAyjd1UetFd9RdcjyWpsbAfX8 nWP+uleTNOiTExNz7dPt/k6OPLNmI1iT/ruRS0uUzHZTimPd67TPQR/70RaW7Bh5 wArawGw9AgMBAAECggEADX2mhA3H0pPuj35J36X/5Me9xWM//AwOr6febwGalazg Ctg3EOZ01/VptzaiKQetAdhoLmxidooNn9HD7JQJKPid7q7w7m1+R26mN/xrLD2A WyBqv+iQoo+ANs5y1BMChuIxmVY/FwFk6UWDNlekuXqgzPln4okbrYTmBbaszniO Mu1SI/3fpnTA3iJ634FUSRVoUPP8r0WEEUtpW1wAhsJR701gvKRYw/+YcRglkhm7 T4l6TuBcgIVzUqAc3oZLHVIMKN0ZprZSeopSRozTcUANfYONakvK9Hx1qf+/rmTR qZHg2uOxlqvxyABnwdk8rmyFx8YqUeN9jaAbbXxtBQKBgQDQRjc0gVg4STqcFqUu FW35MZ88S7+xTuRd/EG1dpsu2lptx1yhSLTsF5GfxBQKXCQUfWrpkyuCmlV6s+wJ H0LSyAJQ4ffBsFterQz7dRKTlhRJNk5PYn8jjNCAuBYVSbQZqZ3yZgG3CT3G5+PZ 8Ln3tJHqTRfP5B8KTMcNYiLI0wKBgQDM0/gdb9Dvdz/32GIpxwNNIb52IxnNVVrm M69+4XNg6CqvctZFuaMQ03W2J5IKAdESaCGLz9pwHZRjfBORcw3BPKD2QkfN5NJg hWvLlfAsblCYiCCjTCB6rf1OJOQ5fHoNFh1wqDaQCk0flsb9nlZmQQR5ZUHaJhSC QqMmeKvMrwKBgQDDzp+sH0Z/dGlDwg59auw/caWRHG4WFmOg8L4eCmoO/H4z41B0 2VQu+mGQYNmue733/Yl8Gz62xL5EY88vLFK4tA1pWWiCknj0Y6Fm70QNuPVNd17c R2/cTlDgEzG/xdEqp0q1T62hFXEdBXoztZxBA2SDcQNIEeIU3uXs8SxevQKBgFp4 acf+wody4aNERR900sV32RtvJ49lWxAA1kwxone0NF5oV7JWa2scK4r4cW3QHZuG uQJ7HV2WAxvqCu6cpf+rGuGKpxKPNkkBxXoX0Qye8SReRCQ8lL/7J74jV1b43yP2 l6xR8D+w/R2tyFjvXfQuVZ6VFgAX/8kFS/DLLf7rAoGAcnFgCwyzcq6FWL8iW23J GnbZ0IQk6SPch87MzMmnOFlEXrCf5l832vwI65tNzOoB0yQoWVfBv5sb4Zy9zeFj FbkpRZC0Kfi9PLzDV4IawoIINYthOJxIKJg+yrmrUWCggXxwdzYIYKLRIskMXoYs mNMXfUstElEcKO7+DKiPi6U= -----END PRIVATE KEY-----`; export interface HeaderEntry { name: string; value: string; } export interface CapturedRequest { method: string; path: string; version: string; headers: HeaderEntry[]; body: string; } export interface CapturedResponse { statusCode: number | undefined; statusMessage: string; version: string; headers: HeaderEntry[]; body: string; } export interface CapturedMessagesExchange { target: string; request: CapturedRequest; response: CapturedResponse; } export interface ClaudeMessagesProxyOptions { host?: string; port?: number; upstreamTlsRejectUnauthorized?: boolean; } interface ParsedHttpMessage { startLine: string; method?: string; path?: string; version: string; statusCode?: number; statusMessage?: string; headers: HeaderEntry[]; body: Buffer; } interface ConnectTarget { host: string; port: number; display: string; } type BodyMode = { kind: "none" } | { kind: "fixed"; length: number } | { kind: "chunked" } | { kind: "until-end" }; interface ParserState { head: Omit; bodyMode: BodyMode; } interface ChunkedParseResult { complete: boolean; consumed: number; body: Buffer; } interface CaptureWaiter { resolve: (exchange: CapturedMessagesExchange) => void; reject: (error: Error) => void; timer: NodeJS.Timeout; } export interface ClaudeTraceCommandArgs { command?: string; message?: string; cwd?: string; host?: string; port?: number; timeoutMs?: number; inputDelayMs?: number; json?: boolean; upstreamTlsRejectUnauthorized?: boolean; } const XtermTerminal = xterm.Terminal; function headerValue(headers: readonly HeaderEntry[], name: string): string | undefined { for (const header of headers) { if (header.name.toLowerCase() === name) return header.value; } return undefined; } function hasChunkedTransfer(headers: readonly HeaderEntry[]): boolean { const value = headerValue(headers, "transfer-encoding"); return ( value ?.toLowerCase() .split(",") .some(part => part.trim() === "chunked") === true ); } function contentLength(headers: readonly HeaderEntry[]): number { const value = headerValue(headers, "content-length"); if (!value) return 0; const parsed = Number.parseInt(value, 10); if (!Number.isSafeInteger(parsed) || parsed < 0) { throw new Error(`Invalid Content-Length header: ${value}`); } return parsed; } interface PendingCapturedRequest { target: string; request: CapturedRequest; } function parseHeaders(headText: string): { startLine: string; headers: HeaderEntry[] } { const lines = headText.split("\r\n"); const startLine = lines[0] ?? ""; const headers: HeaderEntry[] = []; for (let i = 1; i < lines.length; i++) { const line = lines[i]!; const colon = line.indexOf(":"); if (colon <= 0) continue; headers.push({ name: line.slice(0, colon), value: line.slice(colon + 1).trim() }); } return { startLine, headers }; } function parseRequestStartLine(startLine: string): Pick { const parts = startLine.split(/\s+/u); return { method: parts[0] ?? "", path: parts[1] ?? "", version: parts[2] ?? "", }; } function parseResponseStartLine( startLine: string, ): Pick { const match = /^(HTTP\/\d(?:\.\d)?)\s+(\d{3})(?:\s+(.*))?$/u.exec(startLine); if (!match) return { version: "", statusCode: undefined, statusMessage: "" }; return { version: match[1]!, statusCode: Number.parseInt(match[2]!, 10), statusMessage: match[3] ?? "", }; } function responseHasNoBody(statusCode: number | undefined): boolean { if (statusCode === undefined) return false; return (statusCode >= 100 && statusCode < 200) || statusCode === 204 || statusCode === 304; } function parseChunkedBody(buffer: Buffer): ChunkedParseResult { let offset = 0; const chunks: Buffer[] = []; while (true) { const lineEnd = buffer.indexOf(CRLF, offset); if (lineEnd < 0) return { complete: false, consumed: 0, body: Buffer.alloc(0) }; const sizeLine = buffer.subarray(offset, lineEnd).toString("latin1"); const semicolon = sizeLine.indexOf(";"); const sizeText = (semicolon >= 0 ? sizeLine.slice(0, semicolon) : sizeLine).trim(); const size = Number.parseInt(sizeText, 16); if (!Number.isSafeInteger(size) || size < 0) { throw new Error(`Invalid chunk size: ${sizeLine}`); } const dataStart = lineEnd + CRLF.length; if (size === 0) { if (buffer.length < dataStart + CRLF.length) return { complete: false, consumed: 0, body: Buffer.alloc(0) }; if (buffer.subarray(dataStart, dataStart + CRLF.length).equals(CRLF)) { return { complete: true, consumed: dataStart + CRLF.length, body: Buffer.concat(chunks) }; } const trailerEnd = buffer.indexOf(DOUBLE_CRLF, dataStart); if (trailerEnd < 0) return { complete: false, consumed: 0, body: Buffer.alloc(0) }; return { complete: true, consumed: trailerEnd + DOUBLE_CRLF.length, body: Buffer.concat(chunks) }; } const chunkEnd = dataStart + size; if (buffer.length < chunkEnd + CRLF.length) return { complete: false, consumed: 0, body: Buffer.alloc(0) }; chunks.push(buffer.subarray(dataStart, chunkEnd)); offset = chunkEnd + CRLF.length; } } class HttpMessageParser { readonly #kind: "request" | "response"; #buffer = Buffer.alloc(0); #state: ParserState | null = null; constructor(kind: "request" | "response") { this.#kind = kind; } push(chunk: Buffer): ParsedHttpMessage[] { if (chunk.length > 0) { const data = Buffer.from(chunk); this.#buffer = this.#buffer.length === 0 ? data : Buffer.concat([this.#buffer, data]); } return this.#drain(false); } finish(): ParsedHttpMessage[] { return this.#drain(true); } #bodyMode(head: Omit): BodyMode { if (this.#kind === "response" && responseHasNoBody(head.statusCode)) return { kind: "none" }; if (hasChunkedTransfer(head.headers)) return { kind: "chunked" }; const length = contentLength(head.headers); if (length > 0) return { kind: "fixed", length }; if (this.#kind === "response") return { kind: "until-end" }; return { kind: "none" }; } #parseHead(): ParserState | null { const headerEnd = this.#buffer.indexOf(DOUBLE_CRLF); if (headerEnd < 0) return null; const headText = this.#buffer.subarray(0, headerEnd).toString("latin1"); const { startLine, headers } = parseHeaders(headText); this.#buffer = this.#buffer.subarray(headerEnd + DOUBLE_CRLF.length); if (this.#kind === "request") { const request = parseRequestStartLine(startLine); const head = { startLine, headers, ...request }; return { head, bodyMode: this.#bodyMode(head) }; } const response = parseResponseStartLine(startLine); const head = { startLine, headers, ...response }; return { head, bodyMode: this.#bodyMode(head) }; } #drain(final: boolean): ParsedHttpMessage[] { const messages: ParsedHttpMessage[] = []; while (true) { if (!this.#state) { const state = this.#parseHead(); if (!state) break; this.#state = state; } const state = this.#state; if (state.bodyMode.kind === "none") { messages.push({ ...state.head, body: Buffer.alloc(0) }); this.#state = null; continue; } if (state.bodyMode.kind === "fixed") { if (this.#buffer.length < state.bodyMode.length) break; const body = this.#buffer.subarray(0, state.bodyMode.length); this.#buffer = this.#buffer.subarray(state.bodyMode.length); messages.push({ ...state.head, body }); this.#state = null; continue; } if (state.bodyMode.kind === "chunked") { const result = parseChunkedBody(this.#buffer); if (!result.complete) break; this.#buffer = this.#buffer.subarray(result.consumed); messages.push({ ...state.head, body: result.body }); this.#state = null; continue; } if (!final) break; const body = this.#buffer; this.#buffer = Buffer.alloc(0); messages.push({ ...state.head, body }); this.#state = null; } return messages; } } function parseConnectTarget(raw: string): ConnectTarget | null { if (!raw) return null; if (raw.startsWith("[")) { const end = raw.indexOf("]"); if (end < 0) return null; const host = raw.slice(1, end); const portText = raw.startsWith(":", end + 1) ? raw.slice(end + 2) : "443"; const port = Number.parseInt(portText, 10); if (!Number.isSafeInteger(port) || port <= 0 || port > 65535) return null; return { host, port, display: `[${host}]:${port}` }; } const colon = raw.lastIndexOf(":"); const host = colon >= 0 ? raw.slice(0, colon) : raw; const portText = colon >= 0 ? raw.slice(colon + 1) : "443"; const port = Number.parseInt(portText, 10); if (!host || !Number.isSafeInteger(port) || port <= 0 || port > 65535) return null; return { host, port, display: `${host}:${port}` }; } function pathNameFromRequestTarget(requestTarget: string): string { if (requestTarget.startsWith("http://") || requestTarget.startsWith("https://")) { try { return new URL(requestTarget).pathname; } catch { return requestTarget; } } const query = requestTarget.indexOf("?"); return query >= 0 ? requestTarget.slice(0, query) : requestTarget; } function isMessagesRequest(message: ParsedHttpMessage): boolean { return pathNameFromRequestTarget(message.path ?? "") === "/v1/messages"; } // Claude Code fires a background warmup/classification call on its small fast // model (a haiku variant, ANTHROPIC_SMALL_FAST_MODEL) before sending the user's // real message. Skip it so the capture lands on the actual prompt. function isBackgroundModelRequest(message: ParsedHttpMessage): boolean { try { const parsed = JSON.parse(decodeBody(message.headers, message.body)) as { model?: unknown }; return typeof parsed.model === "string" && parsed.model.toLowerCase().includes("haiku"); } catch { return false; } } function decodeBody(headers: readonly HeaderEntry[], body: Buffer): string { const encoding = headerValue(headers, "content-encoding")?.toLowerCase().trim(); try { if (encoding === "gzip") return zlib.gunzipSync(body).toString("utf8"); if (encoding === "br") return zlib.brotliDecompressSync(body).toString("utf8"); if (encoding === "deflate") return zlib.inflateSync(body).toString("utf8"); } catch { return TEXT_DECODER.decode(body); } return TEXT_DECODER.decode(body); } function toCapturedRequest(message: ParsedHttpMessage): CapturedRequest { return { method: message.method ?? "", path: message.path ?? "", version: message.version, headers: message.headers, body: decodeBody(message.headers, message.body), }; } function toCapturedResponse(message: ParsedHttpMessage): CapturedResponse { return { statusCode: message.statusCode, statusMessage: message.statusMessage ?? "", version: message.version, headers: message.headers, body: decodeBody(message.headers, message.body), }; } function formatHeaders(headers: readonly HeaderEntry[]): string { return headers.map(header => `${header.name}: ${header.value}`).join("\n"); } export function formatCapturedMessagesExchange(exchange: CapturedMessagesExchange): string { const requestHeaders = formatHeaders(exchange.request.headers); const responseHeaders = formatHeaders(exchange.response.headers); const responseLine = `${exchange.response.version || "HTTP"} ${exchange.response.statusCode ?? ""} ${exchange.response.statusMessage}`.trim(); return [ `# /v1/messages capture (${exchange.target})`, "", "## Request", `${exchange.request.method} ${exchange.request.path} ${exchange.request.version}`.trim(), "", "### Headers", requestHeaders, "", "### Body", exchange.request.body, "", "## Response", responseLine, "", "### Headers", responseHeaders, "", "### Body", exchange.response.body, "", ].join("\n"); } export class ClaudeMessagesProxy { readonly #host: string; readonly #requestedPort: number; readonly #upstreamTlsRejectUnauthorized: boolean; #server: net.Server | null = null; #sockets = new Set(); #completed: CapturedMessagesExchange[] = []; #waiters: CaptureWaiter[] = []; #stopped = false; #port = 0; constructor(options: ClaudeMessagesProxyOptions = {}) { this.#host = options.host ?? DEFAULT_PROXY_HOST; this.#requestedPort = options.port ?? DEFAULT_PROXY_PORT; this.#upstreamTlsRejectUnauthorized = options.upstreamTlsRejectUnauthorized ?? true; } get host(): string { return this.#host; } get port(): number { return this.#port; } get url(): string { return `http://${this.#host}:${this.#port}`; } async start(): Promise { if (this.#server) return; const server = net.createServer(socket => this.#handleConnection(socket)); this.#server = server; const { promise, resolve, reject } = Promise.withResolvers(); const onError = (error: Error) => reject(error); server.once("error", onError); server.listen(this.#requestedPort, this.#host, () => { server.off("error", onError); const address = server.address(); if (!address || typeof address === "string") { reject(new Error("Proxy did not bind to a TCP address")); return; } this.#port = address.port; resolve(); }); await promise; } async stop(): Promise { this.#stopped = true; for (const waiter of this.#waiters.splice(0)) { clearTimeout(waiter.timer); waiter.reject(new Error("Proxy stopped before a /v1/messages response completed")); } for (const socket of this.#sockets) { socket.destroy(); } this.#sockets.clear(); const server = this.#server; this.#server = null; if (!server) return; const { promise, resolve, reject } = Promise.withResolvers(); server.close(error => { if (error) reject(error); else resolve(); }); await promise; } waitForCapture(timeoutMs: number): Promise { const existing = this.#completed.shift(); if (existing) return Promise.resolve(existing); if (this.#stopped) return Promise.reject(new Error("Proxy is stopped")); const { promise, resolve, reject } = Promise.withResolvers(); const timer = setTimeout(() => { const index = this.#waiters.findIndex(waiter => waiter.resolve === resolve); if (index >= 0) this.#waiters.splice(index, 1); reject(new Error("Timed out waiting for a completed /v1/messages response")); }, timeoutMs); this.#waiters.push({ resolve, reject, timer }); return promise; } #complete(exchange: CapturedMessagesExchange): void { const waiter = this.#waiters.shift(); if (waiter) { clearTimeout(waiter.timer); waiter.resolve(exchange); return; } this.#completed.push(exchange); } #track(socket: T): T { this.#sockets.add(socket); socket.once("close", () => this.#sockets.delete(socket)); return socket; } #handleConnection(socket: net.Socket): void { this.#track(socket); let buffer = Buffer.alloc(0); const onData = (chunk: Buffer) => { const data = Buffer.from(chunk); buffer = buffer.length === 0 ? data : Buffer.concat([buffer, data]); const headerEnd = buffer.indexOf(DOUBLE_CRLF); if (headerEnd < 0) return; socket.off("data", onData); const head = buffer.subarray(0, headerEnd).toString("latin1"); const rest = buffer.subarray(headerEnd + DOUBLE_CRLF.length); this.#handleProxyRequest(socket, head, rest); }; socket.on("data", onData); socket.on("error", () => socket.destroy()); } #handleProxyRequest(socket: net.Socket, head: string, rest: Buffer): void { const firstLine = head.split("\r\n", 1)[0] ?? ""; const parts = firstLine.split(/\s+/u); if (parts[0] !== "CONNECT") { socket.end("HTTP/1.1 501 Not Implemented\r\nConnection: close\r\n\r\n"); return; } const target = parseConnectTarget(parts[1] ?? ""); if (!target) { socket.end("HTTP/1.1 400 Bad Request\r\nConnection: close\r\n\r\n"); return; } socket.write("HTTP/1.1 200 Connection Established\r\n\r\n", () => this.#openMitmTunnel(socket, target, rest)); } #openMitmTunnel(socket: net.Socket, target: ConnectTarget, rest: Buffer): void { void this.#openMitmTunnelAsync(socket, target, rest).catch(() => socket.destroy()); } async #openMitmTunnelAsync(socket: net.Socket, target: ConnectTarget, rest: Buffer): Promise { const clientReady = Promise.withResolvers(); const tlsServer = tls.createServer( { cert: CLAUDE_TRACE_DEBUG_CERT, key: CLAUDE_TRACE_DEBUG_KEY, ALPNProtocols: ["http/1.1"] }, clientTls => { this.#track(clientTls); clientReady.resolve(clientTls); }, ); tlsServer.once("error", error => clientReady.reject(error)); const listening = Promise.withResolvers(); tlsServer.listen(0, DEFAULT_PROXY_HOST, () => listening.resolve()); await listening.promise; const address = tlsServer.address(); if (!address || typeof address === "string") { tlsServer.close(); throw new Error("Internal TLS bridge did not bind to a TCP address"); } const bridge = this.#track(net.connect({ host: DEFAULT_PROXY_HOST, port: address.port })); const connected = Promise.withResolvers(); bridge.once("connect", () => connected.resolve()); bridge.once("error", error => connected.reject(error)); await connected.promise; socket.pipe(bridge); bridge.pipe(socket); const closeInternalServer = () => tlsServer.close(); socket.once("close", closeInternalServer); bridge.once("close", closeInternalServer); if (rest.length > 0) bridge.write(rest); const clientTls = await clientReady.promise; const upstreamTls = this.#track( tls.connect({ host: target.host, port: target.port, servername: net.isIP(target.host) ? undefined : target.host, rejectUnauthorized: this.#upstreamTlsRejectUnauthorized, ALPNProtocols: ["http/1.1"], }), ); const requestParser = new HttpMessageParser("request"); const responseParser = new HttpMessageParser("response"); const responseQueue: Array = []; const flushResponses = (messages: ParsedHttpMessage[]) => { for (const message of messages) { const pending = responseQueue.shift(); if (!pending) continue; this.#complete({ ...pending, response: toCapturedResponse(message) }); } }; clientTls.on("data", chunk => { if (!Buffer.isBuffer(chunk)) return; const data = Buffer.from(chunk); upstreamTls.write(data); const messages = requestParser.push(data); for (const message of messages) { if (!isMessagesRequest(message) || isBackgroundModelRequest(message)) { responseQueue.push(null); continue; } responseQueue.push({ target: target.display, request: toCapturedRequest(message) }); } }); upstreamTls.on("data", chunk => { if (!Buffer.isBuffer(chunk)) return; const data = Buffer.from(chunk); clientTls.write(data); flushResponses(responseParser.push(data)); }); clientTls.on("end", () => { try { requestParser.finish(); } catch {} upstreamTls.end(); }); upstreamTls.on("end", () => { try { flushResponses(responseParser.finish()); } catch {} clientTls.end(); }); clientTls.on("error", () => upstreamTls.destroy()); upstreamTls.on("error", () => clientTls.destroy()); clientTls.once("close", closeInternalServer); } } function errorMessage(error: unknown): string { return error instanceof Error ? error.message : String(error); } async function shutdownPty(session: PtySession, runPromise: Promise): Promise { try { session.write("\x03"); } catch {} await Bun.sleep(100); try { session.kill(); } catch {} try { await runPromise; } catch {} } export async function runClaudeMessagesCapture(args: ClaudeTraceCommandArgs = {}): Promise { const proxy = new ClaudeMessagesProxy({ host: args.host ?? DEFAULT_PROXY_HOST, port: args.port ?? DEFAULT_PROXY_PORT, upstreamTlsRejectUnauthorized: args.upstreamTlsRejectUnauthorized, }); await proxy.start(); const session = new PtySession(); const terminal = new XtermTerminal({ cols: DEFAULT_COLS, rows: DEFAULT_ROWS, disableStdin: true, allowProposedApi: true, scrollback: 10_000, }); terminal.onData(data => { try { session.write(data); } catch {} }); const command = args.command ?? DEFAULT_COMMAND; const timeoutMs = args.timeoutMs ?? DEFAULT_TIMEOUT_MS; const message = args.message ?? DEFAULT_MESSAGE; const cwd = path.resolve(args.cwd ?? process.cwd()); const env = { HTTPS_PROXY: proxy.url, HTTP_PROXY: proxy.url, NODE_TLS_REJECT_UNAUTHORIZED: "0", TERM: "xterm-256color", }; let ptyOutput = ""; const runPromise = session.start( { command, cwd, timeoutMs, env, cols: DEFAULT_COLS, rows: DEFAULT_ROWS, }, (error, chunk) => { if (error || !chunk) return; ptyOutput += chunk; if (ptyOutput.length > 20_000) ptyOutput = ptyOutput.slice(-20_000); terminal.write(chunk); }, ); try { const outputSuffix = () => (ptyOutput.trim() ? `\n\nClaude output:\n${ptyOutput}` : ""); void (async () => { await Bun.sleep(args.inputDelayMs ?? DEFAULT_INPUT_DELAY_MS); try { session.write(`${message}\r`); } catch (error) { ptyOutput += `\n[omp input write failed: ${errorMessage(error)}]\n`; } })(); const captureRace = proxy.waitForCapture(timeoutMs).then( exchange => ({ kind: "capture" as const, exchange }), error => ({ kind: "capture-error" as const, error }), ); const ptyRace = runPromise.then( () => ({ kind: "pty-exit" as const }), error => ({ kind: "pty-error" as const, error }), ); const first = await Promise.race([captureRace, ptyRace]); if (first.kind === "capture") { await shutdownPty(session, runPromise); return first.exchange; } if (first.kind === "capture-error") { throw new Error(`${errorMessage(first.error)}${outputSuffix()}`); } const late = await Promise.race([captureRace, Bun.sleep(250).then(() => ({ kind: "late-timeout" as const }))]); if (late.kind === "capture") { await shutdownPty(session, runPromise); return late.exchange; } if (first.kind === "pty-error") { throw new Error( `Claude command failed before /v1/messages completed: ${errorMessage(first.error)}${outputSuffix()}`, ); } throw new Error(`Claude command exited before /v1/messages completed${outputSuffix()}`); } finally { terminal.dispose(); await proxy.stop(); } } export async function runClaudeTraceCommand(args: ClaudeTraceCommandArgs = {}): Promise { process.stderr.write( `Starting Claude trace proxy on ${args.host ?? DEFAULT_PROXY_HOST}:${args.port ?? DEFAULT_PROXY_PORT}\n`, ); const exchange = await runClaudeMessagesCapture(args); const output = args.json ? `${JSON.stringify(exchange, null, 2)}\n` : formatCapturedMessagesExchange(exchange); process.stdout.write(output.endsWith("\n") ? output : `${output}\n`); }