@oh-my-pi/pi-coding-agent 15.7.6 → 15.8.0

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.
Files changed (95) hide show
  1. package/CHANGELOG.md +146 -198
  2. package/dist/types/async/job-manager.d.ts +3 -3
  3. package/dist/types/cli/args.d.ts +1 -0
  4. package/dist/types/cli/claude-trace-cli.d.ts +54 -0
  5. package/dist/types/cli/session-picker.d.ts +10 -3
  6. package/dist/types/cli/update-cli.d.ts +17 -0
  7. package/dist/types/commands/launch.d.ts +3 -0
  8. package/dist/types/config/keybindings.d.ts +5 -0
  9. package/dist/types/config/settings-schema.d.ts +2 -2
  10. package/dist/types/config/settings.d.ts +13 -0
  11. package/dist/types/eval/concurrency-bridge.d.ts +26 -0
  12. package/dist/types/eval/js/tool-bridge.d.ts +2 -1
  13. package/dist/types/extensibility/plugins/legacy-pi-compat.d.ts +11 -0
  14. package/dist/types/main.d.ts +5 -0
  15. package/dist/types/modes/components/custom-editor.d.ts +3 -1
  16. package/dist/types/modes/components/hook-selector.d.ts +3 -0
  17. package/dist/types/modes/components/session-selector.d.ts +32 -5
  18. package/dist/types/modes/components/tool-execution.d.ts +8 -0
  19. package/dist/types/modes/controllers/extension-ui-controller.d.ts +2 -2
  20. package/dist/types/modes/controllers/input-controller.d.ts +1 -0
  21. package/dist/types/modes/interactive-mode.d.ts +9 -2
  22. package/dist/types/modes/types.d.ts +4 -2
  23. package/dist/types/registry/agent-registry.d.ts +1 -1
  24. package/dist/types/sdk.d.ts +2 -2
  25. package/dist/types/session/agent-session.d.ts +4 -2
  26. package/dist/types/session/history-storage.d.ts +16 -1
  27. package/dist/types/session/session-manager.d.ts +4 -0
  28. package/dist/types/task/output-manager.d.ts +6 -15
  29. package/dist/types/tools/find.d.ts +0 -9
  30. package/dist/types/tools/index.d.ts +1 -1
  31. package/dist/types/tools/path-utils.d.ts +16 -0
  32. package/dist/types/tools/sqlite-reader.d.ts +25 -8
  33. package/dist/types/utils/clipboard.d.ts +4 -0
  34. package/dist/types/web/kagi.d.ts +76 -0
  35. package/dist/types/web/search/providers/exa.d.ts +7 -1
  36. package/dist/types/web/search/providers/kagi.d.ts +1 -0
  37. package/package.json +9 -9
  38. package/src/async/job-manager.ts +3 -3
  39. package/src/cli/args.ts +6 -2
  40. package/src/cli/claude-trace-cli.ts +783 -0
  41. package/src/cli/session-picker.ts +36 -10
  42. package/src/cli/update-cli.ts +35 -2
  43. package/src/commands/launch.ts +3 -0
  44. package/src/config/keybindings.ts +6 -0
  45. package/src/config/settings-schema.ts +2 -2
  46. package/src/config/settings.ts +23 -0
  47. package/src/discovery/claude-plugins.ts +7 -9
  48. package/src/eval/__tests__/agent-bridge.test.ts +58 -4
  49. package/src/eval/concurrency-bridge.ts +34 -0
  50. package/src/eval/js/shared/prelude.txt +20 -17
  51. package/src/eval/js/tool-bridge.ts +5 -0
  52. package/src/eval/py/prelude.py +23 -15
  53. package/src/extensibility/plugins/legacy-pi-compat.ts +115 -131
  54. package/src/extensibility/skills.ts +0 -1
  55. package/src/internal-urls/docs-index.generated.ts +11 -10
  56. package/src/main.ts +92 -24
  57. package/src/modes/acp/acp-event-mapper.ts +54 -4
  58. package/src/modes/components/custom-editor.ts +10 -0
  59. package/src/modes/components/hook-selector.ts +89 -31
  60. package/src/modes/components/oauth-selector.ts +12 -6
  61. package/src/modes/components/session-selector.ts +179 -24
  62. package/src/modes/components/tool-execution.ts +16 -3
  63. package/src/modes/controllers/command-controller.ts +2 -11
  64. package/src/modes/controllers/extension-ui-controller.ts +3 -2
  65. package/src/modes/controllers/input-controller.ts +19 -1
  66. package/src/modes/controllers/selector-controller.ts +61 -21
  67. package/src/modes/interactive-mode.ts +125 -15
  68. package/src/modes/types.ts +5 -2
  69. package/src/prompts/system/orchestrate-notice.md +5 -3
  70. package/src/prompts/system/workflow-notice.md +2 -2
  71. package/src/prompts/tools/eval.md +5 -5
  72. package/src/prompts/tools/find.md +1 -1
  73. package/src/prompts/tools/irc.md +6 -6
  74. package/src/prompts/tools/search.md +1 -1
  75. package/src/prompts/tools/task.md +1 -1
  76. package/src/registry/agent-registry.ts +1 -1
  77. package/src/sdk.ts +85 -31
  78. package/src/session/agent-session.ts +62 -46
  79. package/src/session/history-storage.ts +56 -12
  80. package/src/session/session-manager.ts +34 -0
  81. package/src/task/output-manager.ts +40 -48
  82. package/src/task/render.ts +3 -8
  83. package/src/tools/browser/tab-worker.ts +8 -5
  84. package/src/tools/find.ts +5 -29
  85. package/src/tools/index.ts +1 -1
  86. package/src/tools/path-utils.ts +144 -1
  87. package/src/tools/read.ts +47 -0
  88. package/src/tools/search.ts +2 -27
  89. package/src/tools/sqlite-reader.ts +92 -9
  90. package/src/utils/clipboard.ts +38 -1
  91. package/src/utils/open.ts +37 -2
  92. package/src/web/kagi.ts +168 -49
  93. package/src/web/search/providers/anthropic.ts +1 -1
  94. package/src/web/search/providers/exa.ts +20 -86
  95. package/src/web/search/providers/kagi.ts +4 -0
@@ -0,0 +1,783 @@
1
+ /**
2
+ * Fully automated Claude Code /v1/messages capture helper.
3
+ *
4
+ * Starts a local CONNECT proxy, MITMs TLS using a local self-signed debug
5
+ * certificate, drives Claude Code through a headless PTY/xterm, and returns the
6
+ * first completed /v1/messages request/response exchange.
7
+ */
8
+ import * as net from "node:net";
9
+ import * as path from "node:path";
10
+ import * as tls from "node:tls";
11
+ import * as zlib from "node:zlib";
12
+ import { PtySession } from "@oh-my-pi/pi-natives";
13
+ import xterm from "@xterm/headless";
14
+
15
+ const DEFAULT_PROXY_HOST = "127.0.0.1";
16
+ const DEFAULT_PROXY_PORT = 8080;
17
+ const DEFAULT_COMMAND = "claude";
18
+ const DEFAULT_MESSAGE = "hi";
19
+ const DEFAULT_TIMEOUT_MS = 120_000;
20
+ const DEFAULT_INPUT_DELAY_MS = 1_000;
21
+ const DEFAULT_COLS = 120;
22
+ const DEFAULT_ROWS = 40;
23
+ const DOUBLE_CRLF = Buffer.from("\r\n\r\n", "latin1");
24
+ const CRLF = Buffer.from("\r\n", "latin1");
25
+ const TEXT_DECODER = new TextDecoder();
26
+
27
+ // Debug-only local MITM certificate. Claude is launched with
28
+ // NODE_TLS_REJECT_UNAUTHORIZED=0, so the certificate has no trust value; it only
29
+ // lets Node's TLS stack complete the CONNECT tunnel handshake.
30
+ export const CLAUDE_TRACE_DEBUG_CERT = `-----BEGIN CERTIFICATE-----
31
+ MIIDFzCCAf+gAwIBAgIUAe9omAqLbydZc5ZYZGhwbbpMSF0wDQYJKoZIhvcNAQEL
32
+ BQAwGzEZMBcGA1UEAwwQb21wLWNsYXVkZS10cmFjZTAeFw0yNjA2MDIwODA2MjFa
33
+ Fw0zNjA1MzAwODA2MjFaMBsxGTAXBgNVBAMMEG9tcC1jbGF1ZGUtdHJhY2UwggEi
34
+ MA0GCSqGSIb3DQEBAQUAA4IBDwAwggEKAoIBAQCmpGe5T8B0oA2L82Rn5JJdXOBS
35
+ ZX0DyBjiIK+Tqe8T3oAr41XDLnweqtrMDSBDYbVqAoKjNbaTUSYYcxSm0MAVs63w
36
+ 08SfJmShZM9pElfANqXqMiyhksFgji7JEyt/rbbId207a7s5KvRvm3g/sxN/wGtr
37
+ C5LCLMlc2GWEGD8qrVIQbmLw884qvtXi70RFUPP3Wpy4wGMWSdE+9IA27R5cMJS5
38
+ oHsO4HGB6J8VzLY+HGY2yr4BJ9qrAyjd1UetFd9RdcjyWpsbAfX8nWP+uleTNOiT
39
+ ExNz7dPt/k6OPLNmI1iT/ruRS0uUzHZTimPd67TPQR/70RaW7Bh5wArawGw9AgMB
40
+ AAGjUzBRMB0GA1UdDgQWBBQa4Ir8P3GAolZoPiuB4V2cq3riAjAfBgNVHSMEGDAW
41
+ gBQa4Ir8P3GAolZoPiuB4V2cq3riAjAPBgNVHRMBAf8EBTADAQH/MA0GCSqGSIb3
42
+ DQEBCwUAA4IBAQClPYki235gDEUu7eDm60qsAGWxbKVv4pSh+vB+xgNgzMk4aOuU
43
+ mSfp8Y8covwklph8VfDoKTaEGqqX0Q5s74Ctl6Mwy7b0u8Zztk/g4GynLocI7TQD
44
+ ftZMgZka49+FkEsjp+XZtQbO4vOL5UsccpsLhFQQQuhVyiJ4gNo/VzgvSDkBuf3Q
45
+ Rz7xFiDKCqFEoMPty4+nKEw5832FJ5mDCOyMk6fGSO8Wbt/hmRQQFu2cSdoBs0OT
46
+ AQQJETQjPkKeTDX4jdSAlOeKwfyjfdfgeQuMkzX8xafisJa66MLPzOVbIuGbvbWD
47
+ QVCd76iYPcfNK+JZUhmAUvTHSuwgJMZ6+NgI
48
+ -----END CERTIFICATE-----`;
49
+
50
+ export const CLAUDE_TRACE_DEBUG_KEY = `-----BEGIN PRIVATE KEY-----
51
+ MIIEvQIBADANBgkqhkiG9w0BAQEFAASCBKcwggSjAgEAAoIBAQCmpGe5T8B0oA2L
52
+ 82Rn5JJdXOBSZX0DyBjiIK+Tqe8T3oAr41XDLnweqtrMDSBDYbVqAoKjNbaTUSYY
53
+ cxSm0MAVs63w08SfJmShZM9pElfANqXqMiyhksFgji7JEyt/rbbId207a7s5KvRv
54
+ m3g/sxN/wGtrC5LCLMlc2GWEGD8qrVIQbmLw884qvtXi70RFUPP3Wpy4wGMWSdE+
55
+ 9IA27R5cMJS5oHsO4HGB6J8VzLY+HGY2yr4BJ9qrAyjd1UetFd9RdcjyWpsbAfX8
56
+ nWP+uleTNOiTExNz7dPt/k6OPLNmI1iT/ruRS0uUzHZTimPd67TPQR/70RaW7Bh5
57
+ wArawGw9AgMBAAECggEADX2mhA3H0pPuj35J36X/5Me9xWM//AwOr6febwGalazg
58
+ Ctg3EOZ01/VptzaiKQetAdhoLmxidooNn9HD7JQJKPid7q7w7m1+R26mN/xrLD2A
59
+ WyBqv+iQoo+ANs5y1BMChuIxmVY/FwFk6UWDNlekuXqgzPln4okbrYTmBbaszniO
60
+ Mu1SI/3fpnTA3iJ634FUSRVoUPP8r0WEEUtpW1wAhsJR701gvKRYw/+YcRglkhm7
61
+ T4l6TuBcgIVzUqAc3oZLHVIMKN0ZprZSeopSRozTcUANfYONakvK9Hx1qf+/rmTR
62
+ qZHg2uOxlqvxyABnwdk8rmyFx8YqUeN9jaAbbXxtBQKBgQDQRjc0gVg4STqcFqUu
63
+ FW35MZ88S7+xTuRd/EG1dpsu2lptx1yhSLTsF5GfxBQKXCQUfWrpkyuCmlV6s+wJ
64
+ H0LSyAJQ4ffBsFterQz7dRKTlhRJNk5PYn8jjNCAuBYVSbQZqZ3yZgG3CT3G5+PZ
65
+ 8Ln3tJHqTRfP5B8KTMcNYiLI0wKBgQDM0/gdb9Dvdz/32GIpxwNNIb52IxnNVVrm
66
+ M69+4XNg6CqvctZFuaMQ03W2J5IKAdESaCGLz9pwHZRjfBORcw3BPKD2QkfN5NJg
67
+ hWvLlfAsblCYiCCjTCB6rf1OJOQ5fHoNFh1wqDaQCk0flsb9nlZmQQR5ZUHaJhSC
68
+ QqMmeKvMrwKBgQDDzp+sH0Z/dGlDwg59auw/caWRHG4WFmOg8L4eCmoO/H4z41B0
69
+ 2VQu+mGQYNmue733/Yl8Gz62xL5EY88vLFK4tA1pWWiCknj0Y6Fm70QNuPVNd17c
70
+ R2/cTlDgEzG/xdEqp0q1T62hFXEdBXoztZxBA2SDcQNIEeIU3uXs8SxevQKBgFp4
71
+ acf+wody4aNERR900sV32RtvJ49lWxAA1kwxone0NF5oV7JWa2scK4r4cW3QHZuG
72
+ uQJ7HV2WAxvqCu6cpf+rGuGKpxKPNkkBxXoX0Qye8SReRCQ8lL/7J74jV1b43yP2
73
+ l6xR8D+w/R2tyFjvXfQuVZ6VFgAX/8kFS/DLLf7rAoGAcnFgCwyzcq6FWL8iW23J
74
+ GnbZ0IQk6SPch87MzMmnOFlEXrCf5l832vwI65tNzOoB0yQoWVfBv5sb4Zy9zeFj
75
+ FbkpRZC0Kfi9PLzDV4IawoIINYthOJxIKJg+yrmrUWCggXxwdzYIYKLRIskMXoYs
76
+ mNMXfUstElEcKO7+DKiPi6U=
77
+ -----END PRIVATE KEY-----`;
78
+
79
+ export interface HeaderEntry {
80
+ name: string;
81
+ value: string;
82
+ }
83
+
84
+ export interface CapturedRequest {
85
+ method: string;
86
+ path: string;
87
+ version: string;
88
+ headers: HeaderEntry[];
89
+ body: string;
90
+ }
91
+
92
+ export interface CapturedResponse {
93
+ statusCode: number | undefined;
94
+ statusMessage: string;
95
+ version: string;
96
+ headers: HeaderEntry[];
97
+ body: string;
98
+ }
99
+
100
+ export interface CapturedMessagesExchange {
101
+ target: string;
102
+ request: CapturedRequest;
103
+ response: CapturedResponse;
104
+ }
105
+
106
+ export interface ClaudeMessagesProxyOptions {
107
+ host?: string;
108
+ port?: number;
109
+ upstreamTlsRejectUnauthorized?: boolean;
110
+ }
111
+
112
+ interface ParsedHttpMessage {
113
+ startLine: string;
114
+ method?: string;
115
+ path?: string;
116
+ version: string;
117
+ statusCode?: number;
118
+ statusMessage?: string;
119
+ headers: HeaderEntry[];
120
+ body: Buffer;
121
+ }
122
+
123
+ interface ConnectTarget {
124
+ host: string;
125
+ port: number;
126
+ display: string;
127
+ }
128
+
129
+ type BodyMode = { kind: "none" } | { kind: "fixed"; length: number } | { kind: "chunked" } | { kind: "until-end" };
130
+
131
+ interface ParserState {
132
+ head: Omit<ParsedHttpMessage, "body">;
133
+ bodyMode: BodyMode;
134
+ }
135
+
136
+ interface ChunkedParseResult {
137
+ complete: boolean;
138
+ consumed: number;
139
+ body: Buffer;
140
+ }
141
+
142
+ interface CaptureWaiter {
143
+ resolve: (exchange: CapturedMessagesExchange) => void;
144
+ reject: (error: Error) => void;
145
+ timer: NodeJS.Timeout;
146
+ }
147
+
148
+ export interface ClaudeTraceCommandArgs {
149
+ command?: string;
150
+ message?: string;
151
+ cwd?: string;
152
+ host?: string;
153
+ port?: number;
154
+ timeoutMs?: number;
155
+ inputDelayMs?: number;
156
+ json?: boolean;
157
+ upstreamTlsRejectUnauthorized?: boolean;
158
+ }
159
+
160
+ const XtermTerminal = xterm.Terminal;
161
+
162
+ function headerValue(headers: readonly HeaderEntry[], name: string): string | undefined {
163
+ for (const header of headers) {
164
+ if (header.name.toLowerCase() === name) return header.value;
165
+ }
166
+ return undefined;
167
+ }
168
+
169
+ function hasChunkedTransfer(headers: readonly HeaderEntry[]): boolean {
170
+ const value = headerValue(headers, "transfer-encoding");
171
+ return (
172
+ value
173
+ ?.toLowerCase()
174
+ .split(",")
175
+ .some(part => part.trim() === "chunked") === true
176
+ );
177
+ }
178
+
179
+ function contentLength(headers: readonly HeaderEntry[]): number {
180
+ const value = headerValue(headers, "content-length");
181
+ if (!value) return 0;
182
+ const parsed = Number.parseInt(value, 10);
183
+ if (!Number.isSafeInteger(parsed) || parsed < 0) {
184
+ throw new Error(`Invalid Content-Length header: ${value}`);
185
+ }
186
+ return parsed;
187
+ }
188
+ interface PendingCapturedRequest {
189
+ target: string;
190
+ request: CapturedRequest;
191
+ }
192
+
193
+ function parseHeaders(headText: string): { startLine: string; headers: HeaderEntry[] } {
194
+ const lines = headText.split("\r\n");
195
+ const startLine = lines[0] ?? "";
196
+ const headers: HeaderEntry[] = [];
197
+ for (let i = 1; i < lines.length; i++) {
198
+ const line = lines[i]!;
199
+ const colon = line.indexOf(":");
200
+ if (colon <= 0) continue;
201
+ headers.push({ name: line.slice(0, colon), value: line.slice(colon + 1).trim() });
202
+ }
203
+ return { startLine, headers };
204
+ }
205
+
206
+ function parseRequestStartLine(startLine: string): Pick<ParsedHttpMessage, "method" | "path" | "version"> {
207
+ const parts = startLine.split(/\s+/u);
208
+ return {
209
+ method: parts[0] ?? "",
210
+ path: parts[1] ?? "",
211
+ version: parts[2] ?? "",
212
+ };
213
+ }
214
+
215
+ function parseResponseStartLine(
216
+ startLine: string,
217
+ ): Pick<ParsedHttpMessage, "statusCode" | "statusMessage" | "version"> {
218
+ const match = /^(HTTP\/\d(?:\.\d)?)\s+(\d{3})(?:\s+(.*))?$/u.exec(startLine);
219
+ if (!match) return { version: "", statusCode: undefined, statusMessage: "" };
220
+ return {
221
+ version: match[1]!,
222
+ statusCode: Number.parseInt(match[2]!, 10),
223
+ statusMessage: match[3] ?? "",
224
+ };
225
+ }
226
+
227
+ function responseHasNoBody(statusCode: number | undefined): boolean {
228
+ if (statusCode === undefined) return false;
229
+ return (statusCode >= 100 && statusCode < 200) || statusCode === 204 || statusCode === 304;
230
+ }
231
+
232
+ function parseChunkedBody(buffer: Buffer): ChunkedParseResult {
233
+ let offset = 0;
234
+ const chunks: Buffer[] = [];
235
+ while (true) {
236
+ const lineEnd = buffer.indexOf(CRLF, offset);
237
+ if (lineEnd < 0) return { complete: false, consumed: 0, body: Buffer.alloc(0) };
238
+ const sizeLine = buffer.subarray(offset, lineEnd).toString("latin1");
239
+ const semicolon = sizeLine.indexOf(";");
240
+ const sizeText = (semicolon >= 0 ? sizeLine.slice(0, semicolon) : sizeLine).trim();
241
+ const size = Number.parseInt(sizeText, 16);
242
+ if (!Number.isSafeInteger(size) || size < 0) {
243
+ throw new Error(`Invalid chunk size: ${sizeLine}`);
244
+ }
245
+ const dataStart = lineEnd + CRLF.length;
246
+ if (size === 0) {
247
+ if (buffer.length < dataStart + CRLF.length) return { complete: false, consumed: 0, body: Buffer.alloc(0) };
248
+ if (buffer.subarray(dataStart, dataStart + CRLF.length).equals(CRLF)) {
249
+ return { complete: true, consumed: dataStart + CRLF.length, body: Buffer.concat(chunks) };
250
+ }
251
+ const trailerEnd = buffer.indexOf(DOUBLE_CRLF, dataStart);
252
+ if (trailerEnd < 0) return { complete: false, consumed: 0, body: Buffer.alloc(0) };
253
+ return { complete: true, consumed: trailerEnd + DOUBLE_CRLF.length, body: Buffer.concat(chunks) };
254
+ }
255
+ const chunkEnd = dataStart + size;
256
+ if (buffer.length < chunkEnd + CRLF.length) return { complete: false, consumed: 0, body: Buffer.alloc(0) };
257
+ chunks.push(buffer.subarray(dataStart, chunkEnd));
258
+ offset = chunkEnd + CRLF.length;
259
+ }
260
+ }
261
+
262
+ class HttpMessageParser {
263
+ readonly #kind: "request" | "response";
264
+ #buffer = Buffer.alloc(0);
265
+ #state: ParserState | null = null;
266
+
267
+ constructor(kind: "request" | "response") {
268
+ this.#kind = kind;
269
+ }
270
+
271
+ push(chunk: Buffer): ParsedHttpMessage[] {
272
+ if (chunk.length > 0) {
273
+ const data = Buffer.from(chunk);
274
+ this.#buffer = this.#buffer.length === 0 ? data : Buffer.concat([this.#buffer, data]);
275
+ }
276
+ return this.#drain(false);
277
+ }
278
+
279
+ finish(): ParsedHttpMessage[] {
280
+ return this.#drain(true);
281
+ }
282
+
283
+ #bodyMode(head: Omit<ParsedHttpMessage, "body">): BodyMode {
284
+ if (this.#kind === "response" && responseHasNoBody(head.statusCode)) return { kind: "none" };
285
+ if (hasChunkedTransfer(head.headers)) return { kind: "chunked" };
286
+ const length = contentLength(head.headers);
287
+ if (length > 0) return { kind: "fixed", length };
288
+ if (this.#kind === "response") return { kind: "until-end" };
289
+ return { kind: "none" };
290
+ }
291
+
292
+ #parseHead(): ParserState | null {
293
+ const headerEnd = this.#buffer.indexOf(DOUBLE_CRLF);
294
+ if (headerEnd < 0) return null;
295
+ const headText = this.#buffer.subarray(0, headerEnd).toString("latin1");
296
+ const { startLine, headers } = parseHeaders(headText);
297
+ this.#buffer = this.#buffer.subarray(headerEnd + DOUBLE_CRLF.length);
298
+ if (this.#kind === "request") {
299
+ const request = parseRequestStartLine(startLine);
300
+ const head = { startLine, headers, ...request };
301
+ return { head, bodyMode: this.#bodyMode(head) };
302
+ }
303
+ const response = parseResponseStartLine(startLine);
304
+ const head = { startLine, headers, ...response };
305
+ return { head, bodyMode: this.#bodyMode(head) };
306
+ }
307
+
308
+ #drain(final: boolean): ParsedHttpMessage[] {
309
+ const messages: ParsedHttpMessage[] = [];
310
+ while (true) {
311
+ if (!this.#state) {
312
+ const state = this.#parseHead();
313
+ if (!state) break;
314
+ this.#state = state;
315
+ }
316
+ const state = this.#state;
317
+ if (state.bodyMode.kind === "none") {
318
+ messages.push({ ...state.head, body: Buffer.alloc(0) });
319
+ this.#state = null;
320
+ continue;
321
+ }
322
+ if (state.bodyMode.kind === "fixed") {
323
+ if (this.#buffer.length < state.bodyMode.length) break;
324
+ const body = this.#buffer.subarray(0, state.bodyMode.length);
325
+ this.#buffer = this.#buffer.subarray(state.bodyMode.length);
326
+ messages.push({ ...state.head, body });
327
+ this.#state = null;
328
+ continue;
329
+ }
330
+ if (state.bodyMode.kind === "chunked") {
331
+ const result = parseChunkedBody(this.#buffer);
332
+ if (!result.complete) break;
333
+ this.#buffer = this.#buffer.subarray(result.consumed);
334
+ messages.push({ ...state.head, body: result.body });
335
+ this.#state = null;
336
+ continue;
337
+ }
338
+ if (!final) break;
339
+ const body = this.#buffer;
340
+ this.#buffer = Buffer.alloc(0);
341
+ messages.push({ ...state.head, body });
342
+ this.#state = null;
343
+ }
344
+ return messages;
345
+ }
346
+ }
347
+
348
+ function parseConnectTarget(raw: string): ConnectTarget | null {
349
+ if (!raw) return null;
350
+ if (raw.startsWith("[")) {
351
+ const end = raw.indexOf("]");
352
+ if (end < 0) return null;
353
+ const host = raw.slice(1, end);
354
+ const portText = raw.startsWith(":", end + 1) ? raw.slice(end + 2) : "443";
355
+ const port = Number.parseInt(portText, 10);
356
+ if (!Number.isSafeInteger(port) || port <= 0 || port > 65535) return null;
357
+ return { host, port, display: `[${host}]:${port}` };
358
+ }
359
+ const colon = raw.lastIndexOf(":");
360
+ const host = colon >= 0 ? raw.slice(0, colon) : raw;
361
+ const portText = colon >= 0 ? raw.slice(colon + 1) : "443";
362
+ const port = Number.parseInt(portText, 10);
363
+ if (!host || !Number.isSafeInteger(port) || port <= 0 || port > 65535) return null;
364
+ return { host, port, display: `${host}:${port}` };
365
+ }
366
+
367
+ function pathNameFromRequestTarget(requestTarget: string): string {
368
+ if (requestTarget.startsWith("http://") || requestTarget.startsWith("https://")) {
369
+ try {
370
+ return new URL(requestTarget).pathname;
371
+ } catch {
372
+ return requestTarget;
373
+ }
374
+ }
375
+ const query = requestTarget.indexOf("?");
376
+ return query >= 0 ? requestTarget.slice(0, query) : requestTarget;
377
+ }
378
+
379
+ function isMessagesRequest(message: ParsedHttpMessage): boolean {
380
+ return pathNameFromRequestTarget(message.path ?? "") === "/v1/messages";
381
+ }
382
+
383
+ function decodeBody(headers: readonly HeaderEntry[], body: Buffer): string {
384
+ const encoding = headerValue(headers, "content-encoding")?.toLowerCase().trim();
385
+ try {
386
+ if (encoding === "gzip") return zlib.gunzipSync(body).toString("utf8");
387
+ if (encoding === "br") return zlib.brotliDecompressSync(body).toString("utf8");
388
+ if (encoding === "deflate") return zlib.inflateSync(body).toString("utf8");
389
+ } catch {
390
+ return TEXT_DECODER.decode(body);
391
+ }
392
+ return TEXT_DECODER.decode(body);
393
+ }
394
+
395
+ function toCapturedRequest(message: ParsedHttpMessage): CapturedRequest {
396
+ return {
397
+ method: message.method ?? "",
398
+ path: message.path ?? "",
399
+ version: message.version,
400
+ headers: message.headers,
401
+ body: decodeBody(message.headers, message.body),
402
+ };
403
+ }
404
+
405
+ function toCapturedResponse(message: ParsedHttpMessage): CapturedResponse {
406
+ return {
407
+ statusCode: message.statusCode,
408
+ statusMessage: message.statusMessage ?? "",
409
+ version: message.version,
410
+ headers: message.headers,
411
+ body: decodeBody(message.headers, message.body),
412
+ };
413
+ }
414
+
415
+ function formatHeaders(headers: readonly HeaderEntry[]): string {
416
+ return headers.map(header => `${header.name}: ${header.value}`).join("\n");
417
+ }
418
+
419
+ export function formatCapturedMessagesExchange(exchange: CapturedMessagesExchange): string {
420
+ const requestHeaders = formatHeaders(exchange.request.headers);
421
+ const responseHeaders = formatHeaders(exchange.response.headers);
422
+ const responseLine =
423
+ `${exchange.response.version || "HTTP"} ${exchange.response.statusCode ?? ""} ${exchange.response.statusMessage}`.trim();
424
+ return [
425
+ `# /v1/messages capture (${exchange.target})`,
426
+ "",
427
+ "## Request",
428
+ `${exchange.request.method} ${exchange.request.path} ${exchange.request.version}`.trim(),
429
+ "",
430
+ "### Headers",
431
+ requestHeaders,
432
+ "",
433
+ "### Body",
434
+ exchange.request.body,
435
+ "",
436
+ "## Response",
437
+ responseLine,
438
+ "",
439
+ "### Headers",
440
+ responseHeaders,
441
+ "",
442
+ "### Body",
443
+ exchange.response.body,
444
+ "",
445
+ ].join("\n");
446
+ }
447
+
448
+ export class ClaudeMessagesProxy {
449
+ readonly #host: string;
450
+ readonly #requestedPort: number;
451
+ readonly #upstreamTlsRejectUnauthorized: boolean;
452
+ #server: net.Server | null = null;
453
+ #sockets = new Set<net.Socket | tls.TLSSocket>();
454
+ #completed: CapturedMessagesExchange[] = [];
455
+ #waiters: CaptureWaiter[] = [];
456
+ #stopped = false;
457
+ #port = 0;
458
+
459
+ constructor(options: ClaudeMessagesProxyOptions = {}) {
460
+ this.#host = options.host ?? DEFAULT_PROXY_HOST;
461
+ this.#requestedPort = options.port ?? DEFAULT_PROXY_PORT;
462
+ this.#upstreamTlsRejectUnauthorized = options.upstreamTlsRejectUnauthorized ?? true;
463
+ }
464
+
465
+ get host(): string {
466
+ return this.#host;
467
+ }
468
+
469
+ get port(): number {
470
+ return this.#port;
471
+ }
472
+
473
+ get url(): string {
474
+ return `http://${this.#host}:${this.#port}`;
475
+ }
476
+
477
+ async start(): Promise<void> {
478
+ if (this.#server) return;
479
+ const server = net.createServer(socket => this.#handleConnection(socket));
480
+ this.#server = server;
481
+ const { promise, resolve, reject } = Promise.withResolvers<void>();
482
+ const onError = (error: Error) => reject(error);
483
+ server.once("error", onError);
484
+ server.listen(this.#requestedPort, this.#host, () => {
485
+ server.off("error", onError);
486
+ const address = server.address();
487
+ if (!address || typeof address === "string") {
488
+ reject(new Error("Proxy did not bind to a TCP address"));
489
+ return;
490
+ }
491
+ this.#port = address.port;
492
+ resolve();
493
+ });
494
+ await promise;
495
+ }
496
+
497
+ async stop(): Promise<void> {
498
+ this.#stopped = true;
499
+ for (const waiter of this.#waiters.splice(0)) {
500
+ clearTimeout(waiter.timer);
501
+ waiter.reject(new Error("Proxy stopped before a /v1/messages response completed"));
502
+ }
503
+ for (const socket of this.#sockets) {
504
+ socket.destroy();
505
+ }
506
+ this.#sockets.clear();
507
+ const server = this.#server;
508
+ this.#server = null;
509
+ if (!server) return;
510
+ const { promise, resolve, reject } = Promise.withResolvers<void>();
511
+ server.close(error => {
512
+ if (error) reject(error);
513
+ else resolve();
514
+ });
515
+ await promise;
516
+ }
517
+
518
+ waitForCapture(timeoutMs: number): Promise<CapturedMessagesExchange> {
519
+ const existing = this.#completed.shift();
520
+ if (existing) return Promise.resolve(existing);
521
+ if (this.#stopped) return Promise.reject(new Error("Proxy is stopped"));
522
+ const { promise, resolve, reject } = Promise.withResolvers<CapturedMessagesExchange>();
523
+ const timer = setTimeout(() => {
524
+ const index = this.#waiters.findIndex(waiter => waiter.resolve === resolve);
525
+ if (index >= 0) this.#waiters.splice(index, 1);
526
+ reject(new Error("Timed out waiting for a completed /v1/messages response"));
527
+ }, timeoutMs);
528
+ this.#waiters.push({ resolve, reject, timer });
529
+ return promise;
530
+ }
531
+
532
+ #complete(exchange: CapturedMessagesExchange): void {
533
+ const waiter = this.#waiters.shift();
534
+ if (waiter) {
535
+ clearTimeout(waiter.timer);
536
+ waiter.resolve(exchange);
537
+ return;
538
+ }
539
+ this.#completed.push(exchange);
540
+ }
541
+
542
+ #track<T extends net.Socket | tls.TLSSocket>(socket: T): T {
543
+ this.#sockets.add(socket);
544
+ socket.once("close", () => this.#sockets.delete(socket));
545
+ return socket;
546
+ }
547
+
548
+ #handleConnection(socket: net.Socket): void {
549
+ this.#track(socket);
550
+ let buffer = Buffer.alloc(0);
551
+ const onData = (chunk: Buffer) => {
552
+ const data = Buffer.from(chunk);
553
+ buffer = buffer.length === 0 ? data : Buffer.concat([buffer, data]);
554
+ const headerEnd = buffer.indexOf(DOUBLE_CRLF);
555
+ if (headerEnd < 0) return;
556
+ socket.off("data", onData);
557
+ const head = buffer.subarray(0, headerEnd).toString("latin1");
558
+ const rest = buffer.subarray(headerEnd + DOUBLE_CRLF.length);
559
+ this.#handleProxyRequest(socket, head, rest);
560
+ };
561
+ socket.on("data", onData);
562
+ socket.on("error", () => socket.destroy());
563
+ }
564
+
565
+ #handleProxyRequest(socket: net.Socket, head: string, rest: Buffer): void {
566
+ const firstLine = head.split("\r\n", 1)[0] ?? "";
567
+ const parts = firstLine.split(/\s+/u);
568
+ if (parts[0] !== "CONNECT") {
569
+ socket.end("HTTP/1.1 501 Not Implemented\r\nConnection: close\r\n\r\n");
570
+ return;
571
+ }
572
+ const target = parseConnectTarget(parts[1] ?? "");
573
+ if (!target) {
574
+ socket.end("HTTP/1.1 400 Bad Request\r\nConnection: close\r\n\r\n");
575
+ return;
576
+ }
577
+ socket.write("HTTP/1.1 200 Connection Established\r\n\r\n", () => this.#openMitmTunnel(socket, target, rest));
578
+ }
579
+
580
+ #openMitmTunnel(socket: net.Socket, target: ConnectTarget, rest: Buffer): void {
581
+ void this.#openMitmTunnelAsync(socket, target, rest).catch(() => socket.destroy());
582
+ }
583
+
584
+ async #openMitmTunnelAsync(socket: net.Socket, target: ConnectTarget, rest: Buffer): Promise<void> {
585
+ const clientReady = Promise.withResolvers<tls.TLSSocket>();
586
+ const tlsServer = tls.createServer(
587
+ { cert: CLAUDE_TRACE_DEBUG_CERT, key: CLAUDE_TRACE_DEBUG_KEY, ALPNProtocols: ["http/1.1"] },
588
+ clientTls => {
589
+ this.#track(clientTls);
590
+ clientReady.resolve(clientTls);
591
+ },
592
+ );
593
+ tlsServer.once("error", error => clientReady.reject(error));
594
+ const listening = Promise.withResolvers<void>();
595
+ tlsServer.listen(0, DEFAULT_PROXY_HOST, () => listening.resolve());
596
+ await listening.promise;
597
+ const address = tlsServer.address();
598
+ if (!address || typeof address === "string") {
599
+ tlsServer.close();
600
+ throw new Error("Internal TLS bridge did not bind to a TCP address");
601
+ }
602
+ const bridge = this.#track(net.connect({ host: DEFAULT_PROXY_HOST, port: address.port }));
603
+ const connected = Promise.withResolvers<void>();
604
+ bridge.once("connect", () => connected.resolve());
605
+ bridge.once("error", error => connected.reject(error));
606
+ await connected.promise;
607
+ socket.pipe(bridge);
608
+ bridge.pipe(socket);
609
+ const closeInternalServer = () => tlsServer.close();
610
+ socket.once("close", closeInternalServer);
611
+ bridge.once("close", closeInternalServer);
612
+ if (rest.length > 0) bridge.write(rest);
613
+ const clientTls = await clientReady.promise;
614
+ const upstreamTls = this.#track(
615
+ tls.connect({
616
+ host: target.host,
617
+ port: target.port,
618
+ servername: net.isIP(target.host) ? undefined : target.host,
619
+ rejectUnauthorized: this.#upstreamTlsRejectUnauthorized,
620
+ ALPNProtocols: ["http/1.1"],
621
+ }),
622
+ );
623
+ const requestParser = new HttpMessageParser("request");
624
+ const responseParser = new HttpMessageParser("response");
625
+ const responseQueue: Array<PendingCapturedRequest | null> = [];
626
+ const flushResponses = (messages: ParsedHttpMessage[]) => {
627
+ for (const message of messages) {
628
+ const pending = responseQueue.shift();
629
+ if (!pending) continue;
630
+ this.#complete({ ...pending, response: toCapturedResponse(message) });
631
+ }
632
+ };
633
+ clientTls.on("data", chunk => {
634
+ if (!Buffer.isBuffer(chunk)) return;
635
+ const data = Buffer.from(chunk);
636
+ upstreamTls.write(data);
637
+ const messages = requestParser.push(data);
638
+ for (const message of messages) {
639
+ if (!isMessagesRequest(message)) {
640
+ responseQueue.push(null);
641
+ continue;
642
+ }
643
+ responseQueue.push({ target: target.display, request: toCapturedRequest(message) });
644
+ }
645
+ });
646
+ upstreamTls.on("data", chunk => {
647
+ if (!Buffer.isBuffer(chunk)) return;
648
+ const data = Buffer.from(chunk);
649
+ clientTls.write(data);
650
+ flushResponses(responseParser.push(data));
651
+ });
652
+ clientTls.on("end", () => {
653
+ try {
654
+ requestParser.finish();
655
+ } catch {}
656
+ upstreamTls.end();
657
+ });
658
+ upstreamTls.on("end", () => {
659
+ try {
660
+ flushResponses(responseParser.finish());
661
+ } catch {}
662
+ clientTls.end();
663
+ });
664
+ clientTls.on("error", () => upstreamTls.destroy());
665
+ upstreamTls.on("error", () => clientTls.destroy());
666
+ clientTls.once("close", closeInternalServer);
667
+ }
668
+ }
669
+ function errorMessage(error: unknown): string {
670
+ return error instanceof Error ? error.message : String(error);
671
+ }
672
+
673
+ async function shutdownPty(session: PtySession, runPromise: Promise<unknown>): Promise<void> {
674
+ try {
675
+ session.write("\x03");
676
+ } catch {}
677
+ await Bun.sleep(100);
678
+ try {
679
+ session.kill();
680
+ } catch {}
681
+ try {
682
+ await runPromise;
683
+ } catch {}
684
+ }
685
+
686
+ export async function runClaudeMessagesCapture(args: ClaudeTraceCommandArgs = {}): Promise<CapturedMessagesExchange> {
687
+ const proxy = new ClaudeMessagesProxy({
688
+ host: args.host ?? DEFAULT_PROXY_HOST,
689
+ port: args.port ?? DEFAULT_PROXY_PORT,
690
+ upstreamTlsRejectUnauthorized: args.upstreamTlsRejectUnauthorized,
691
+ });
692
+ await proxy.start();
693
+ const session = new PtySession();
694
+ const terminal = new XtermTerminal({
695
+ cols: DEFAULT_COLS,
696
+ rows: DEFAULT_ROWS,
697
+ disableStdin: true,
698
+ allowProposedApi: true,
699
+ scrollback: 10_000,
700
+ });
701
+ terminal.onData(data => {
702
+ try {
703
+ session.write(data);
704
+ } catch {}
705
+ });
706
+ const command = args.command ?? DEFAULT_COMMAND;
707
+ const timeoutMs = args.timeoutMs ?? DEFAULT_TIMEOUT_MS;
708
+ const message = args.message ?? DEFAULT_MESSAGE;
709
+ const cwd = path.resolve(args.cwd ?? process.cwd());
710
+ const env = {
711
+ HTTPS_PROXY: proxy.url,
712
+ HTTP_PROXY: proxy.url,
713
+ NODE_TLS_REJECT_UNAUTHORIZED: "0",
714
+ TERM: "xterm-256color",
715
+ };
716
+ let ptyOutput = "";
717
+ const runPromise = session.start(
718
+ {
719
+ command,
720
+ cwd,
721
+ timeoutMs,
722
+ env,
723
+ cols: DEFAULT_COLS,
724
+ rows: DEFAULT_ROWS,
725
+ },
726
+ (error, chunk) => {
727
+ if (error || !chunk) return;
728
+ ptyOutput += chunk;
729
+ if (ptyOutput.length > 20_000) ptyOutput = ptyOutput.slice(-20_000);
730
+ terminal.write(chunk);
731
+ },
732
+ );
733
+ try {
734
+ const outputSuffix = () => (ptyOutput.trim() ? `\n\nClaude output:\n${ptyOutput}` : "");
735
+ void (async () => {
736
+ await Bun.sleep(args.inputDelayMs ?? DEFAULT_INPUT_DELAY_MS);
737
+ try {
738
+ session.write(`${message}\r`);
739
+ } catch (error) {
740
+ ptyOutput += `\n[omp input write failed: ${errorMessage(error)}]\n`;
741
+ }
742
+ })();
743
+ const captureRace = proxy.waitForCapture(timeoutMs).then(
744
+ exchange => ({ kind: "capture" as const, exchange }),
745
+ error => ({ kind: "capture-error" as const, error }),
746
+ );
747
+ const ptyRace = runPromise.then(
748
+ () => ({ kind: "pty-exit" as const }),
749
+ error => ({ kind: "pty-error" as const, error }),
750
+ );
751
+ const first = await Promise.race([captureRace, ptyRace]);
752
+ if (first.kind === "capture") {
753
+ await shutdownPty(session, runPromise);
754
+ return first.exchange;
755
+ }
756
+ if (first.kind === "capture-error") {
757
+ throw new Error(`${errorMessage(first.error)}${outputSuffix()}`);
758
+ }
759
+ const late = await Promise.race([captureRace, Bun.sleep(250).then(() => ({ kind: "late-timeout" as const }))]);
760
+ if (late.kind === "capture") {
761
+ await shutdownPty(session, runPromise);
762
+ return late.exchange;
763
+ }
764
+ if (first.kind === "pty-error") {
765
+ throw new Error(
766
+ `Claude command failed before /v1/messages completed: ${errorMessage(first.error)}${outputSuffix()}`,
767
+ );
768
+ }
769
+ throw new Error(`Claude command exited before /v1/messages completed${outputSuffix()}`);
770
+ } finally {
771
+ terminal.dispose();
772
+ await proxy.stop();
773
+ }
774
+ }
775
+
776
+ export async function runClaudeTraceCommand(args: ClaudeTraceCommandArgs = {}): Promise<void> {
777
+ process.stderr.write(
778
+ `Starting Claude trace proxy on ${args.host ?? DEFAULT_PROXY_HOST}:${args.port ?? DEFAULT_PROXY_PORT}\n`,
779
+ );
780
+ const exchange = await runClaudeMessagesCapture(args);
781
+ const output = args.json ? `${JSON.stringify(exchange, null, 2)}\n` : formatCapturedMessagesExchange(exchange);
782
+ process.stdout.write(output.endsWith("\n") ? output : `${output}\n`);
783
+ }