@bakers-build/addon-claude 0.0.0-stage → 0.1.0-next.38

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.
@@ -0,0 +1,686 @@
1
+ /**
2
+ * agent-proxy — see what Claude Code actually sends the model.
3
+ *
4
+ * Based on Matt Pocock, https://gist.github.com/mattpocock/5b3d76ea21f5f698aefded47a9cea3b1
5
+ * revision e142f08fd2d1de0adc914355112d6f2b56386959 (updated 2026-09-23), fetched 2026-09-28.
6
+ *
7
+ * A zero-dependency logging proxy for Claude Code. It sits between the CLI and
8
+ * the Anthropic API, forwards every request untouched (auth header and all),
9
+ * streams the response straight back so the CLI is unaffected, and for each
10
+ * request writes a readable Markdown document — led by a ranked table of what
11
+ * is eating your context.
12
+ *
13
+ * Run: BB_TICKET_IDENTIFIER=BB-569 node measure/agent-proxy.mjs
14
+ * Point Claude Code at it:
15
+ * ANTHROPIC_BASE_URL=http://127.0.0.1:8787 claude
16
+ *
17
+ * Measurement only. Zero runtime dependencies — Node built-ins only. Requires Node 18+.
18
+ */
19
+
20
+ import { spawnSync } from 'node:child_process';
21
+ import { randomUUID } from 'node:crypto';
22
+ import fs from 'node:fs';
23
+ import http from 'node:http';
24
+ import https from 'node:https';
25
+ import path from 'node:path';
26
+ import { fileURLToPath, pathToFileURL } from 'node:url';
27
+
28
+ const DEFAULT_PORT = 8787;
29
+ const LISTEN_HOST = '127.0.0.1';
30
+ const UPSTREAM = {
31
+ hostname: 'api.anthropic.com',
32
+ port: 443,
33
+ protocol: 'https',
34
+ };
35
+ const TICKET_ID = /^[A-Z][A-Z0-9]*-\d+$/;
36
+ const USAGE_KEYS = [
37
+ 'input_tokens',
38
+ 'cache_read_input_tokens',
39
+ 'cache_creation_input_tokens',
40
+ ];
41
+ const REDACT = new Set(['authorization', 'x-api-key', 'api-key']);
42
+
43
+ const HERE = path.dirname(fileURLToPath(import.meta.url));
44
+
45
+ /** Rough token estimate for display. Real input tokens come from the response
46
+ * usage; this is only for ranking the request before the reply arrives. */
47
+ const estTokens = (bytes) => Math.round(bytes / 4);
48
+
49
+ /** count_tokens calls send content but get back only a number, never a reply.
50
+ * A single turn fires many as housekeeping — pure noise here, so skip them. */
51
+ const isTokenCount = (reqPath) => reqPath.includes('count_tokens');
52
+
53
+ /** Strip hop-by-hop and encoding headers so the captured response is readable,
54
+ * recompute content-length, and pass auth through untouched so the real request
55
+ * still authenticates. */
56
+ function forwardHeaders(headers, body) {
57
+ const out = { ...headers };
58
+ delete out.host;
59
+ delete out.connection;
60
+ delete out['accept-encoding']; // force identity so we can read the stream
61
+ delete out['transfer-encoding'];
62
+ delete out['content-length'];
63
+ if (body.length > 0) out['content-length'] = String(body.length);
64
+ return out;
65
+ }
66
+
67
+ export function createLogBasename() {
68
+ const stamp = new Date()
69
+ .toISOString()
70
+ .replace(/:/g, '-')
71
+ .replace('.', '-')
72
+ .replace('Z', '');
73
+ return `${stamp}_${randomUUID()}_anthropic`;
74
+ }
75
+
76
+ export function resolveLogDirectory({
77
+ cwd = process.cwd(),
78
+ ticketIdentifier,
79
+ subdir,
80
+ } = {}) {
81
+ const id = ticketIdentifier ?? process.env.BB_TICKET_IDENTIFIER;
82
+ if (typeof id !== 'string' || !TICKET_ID.test(id)) {
83
+ throw new Error(
84
+ '[agent-proxy] BB_TICKET_IDENTIFIER is required (ticket identifier such as BB-569, not a UUID)',
85
+ );
86
+ }
87
+ const root = path.resolve(cwd, 'assets-ai', id, 'node');
88
+ if (subdir == null || subdir === '') return root;
89
+ if (path.isAbsolute(subdir)) {
90
+ throw new Error(
91
+ '[agent-proxy] log subdir must be a relative path within the ticket node directory',
92
+ );
93
+ }
94
+ const resolved = path.resolve(root, subdir);
95
+ const rel = path.relative(root, resolved);
96
+ if (rel.startsWith('..') || path.isAbsolute(rel)) {
97
+ throw new Error(
98
+ '[agent-proxy] log subdir must stay within the ticket node directory',
99
+ );
100
+ }
101
+ return resolved;
102
+ }
103
+
104
+ function parsePort(value, { allowEphemeral = false } = {}) {
105
+ const port = typeof value === 'number' ? value : Number(value);
106
+ if (allowEphemeral && port === 0) return 0;
107
+ if (!Number.isInteger(port) || port < 1 || port > 65535) {
108
+ throw new Error(`[agent-proxy] invalid port: ${value}`);
109
+ }
110
+ return port;
111
+ }
112
+
113
+ function firstUserMessage(reqJson) {
114
+ if (!Array.isArray(reqJson?.messages)) return undefined;
115
+ return reqJson.messages.find((m) => m?.role === 'user');
116
+ }
117
+
118
+ function threeUsage(usage) {
119
+ if (!usage || typeof usage !== 'object') return null;
120
+ const present = USAGE_KEYS.some(
121
+ (key) => Object.hasOwn(usage, key) && usage[key] != null,
122
+ );
123
+ if (!present) return null;
124
+ const out = {};
125
+ for (const key of USAGE_KEYS) {
126
+ out[key] =
127
+ Object.hasOwn(usage, key) && usage[key] != null ? usage[key] : null;
128
+ }
129
+ return out;
130
+ }
131
+
132
+ function mergeUsage(current, incoming) {
133
+ if (!incoming) return current;
134
+ const next = { ...(current ?? {}) };
135
+ for (const key of USAGE_KEYS) {
136
+ if (Object.hasOwn(incoming, key) && incoming[key] != null) {
137
+ next[key] = incoming[key];
138
+ }
139
+ }
140
+ return next;
141
+ }
142
+
143
+ // ---------------------------------------------------------------------------
144
+ // The audit: rank what's in the request
145
+ // ---------------------------------------------------------------------------
146
+
147
+ /** Measure every removable region of the request and rank the tools by size.
148
+ * This is the whole point of the proxy — the numbers you cut against. */
149
+ export function auditRequest(reqJson, usage = null) {
150
+ const tools = Array.isArray(reqJson?.tools) ? reqJson.tools : [];
151
+ const toolRows = tools
152
+ .map((t) => {
153
+ const bytes = Buffer.byteLength(JSON.stringify(t));
154
+ return { name: t?.name ?? '(unnamed)', bytes, tokens: estTokens(bytes) };
155
+ })
156
+ .sort((a, b) => b.bytes - a.bytes);
157
+
158
+ const toolsBytes = toolRows.reduce((n, r) => n + r.bytes, 0);
159
+ const systemBytes = reqJson?.system
160
+ ? Buffer.byteLength(JSON.stringify(reqJson.system))
161
+ : 0;
162
+ const firstUser = firstUserMessage(reqJson);
163
+ const firstUserBytes = firstUser
164
+ ? Buffer.byteLength(JSON.stringify(firstUser))
165
+ : 0;
166
+ const totalBytes = Buffer.byteLength(JSON.stringify(reqJson ?? {}));
167
+
168
+ return {
169
+ toolRows,
170
+ toolCount: toolRows.length,
171
+ toolsBytes,
172
+ systemBytes,
173
+ firstUserBytes,
174
+ totalBytes,
175
+ usage: threeUsage(usage),
176
+ };
177
+ }
178
+
179
+ /** The ranked table, as Markdown. The hero of the whole document. */
180
+ function renderAudit(a) {
181
+ const pct = (b) =>
182
+ a.totalBytes ? ((b / a.totalBytes) * 100).toFixed(1) : '0.0';
183
+ const rows = a.toolRows
184
+ .map(
185
+ (r) =>
186
+ `| ${r.name} | ${r.bytes.toLocaleString()} | ~${r.tokens.toLocaleString()} | ${pct(r.bytes)}% |`,
187
+ )
188
+ .join('\n');
189
+ const usageLines = a.usage
190
+ ? [
191
+ `- **input_tokens**: ${a.usage.input_tokens}`,
192
+ `- **cache_read_input_tokens**: ${a.usage.cache_read_input_tokens}`,
193
+ `- **cache_creation_input_tokens**: ${a.usage.cache_creation_input_tokens}`,
194
+ ]
195
+ : ['- **usage**: unknown'];
196
+
197
+ return [
198
+ '<audit>',
199
+ '',
200
+ ...usageLines,
201
+ '',
202
+ `- **tools**: ${a.toolCount} definitions, ${a.toolsBytes.toLocaleString()} bytes (~${estTokens(a.toolsBytes).toLocaleString()} tokens)`,
203
+ `- **system prompt**: ${a.systemBytes.toLocaleString()} bytes (~${estTokens(a.systemBytes).toLocaleString()} tokens)`,
204
+ `- **first user message**: ${a.firstUserBytes.toLocaleString()} bytes (~${estTokens(a.firstUserBytes).toLocaleString()} tokens)`,
205
+ `- **total request**: ${a.totalBytes.toLocaleString()} bytes`,
206
+ '',
207
+ '**Tools, ranked by size — this is your cut list:**',
208
+ '',
209
+ '| tool | bytes | ~tokens | % of request |',
210
+ '| --- | --: | --: | --: |',
211
+ rows,
212
+ '',
213
+ '</audit>',
214
+ ].join('\n');
215
+ }
216
+
217
+ /** The same ranking, compact, for the terminal — so you see the bloat live. */
218
+ function printAudit(a, base, logDir) {
219
+ const top = a.toolRows.slice(0, 12);
220
+ const w = Math.max(4, ...top.map((r) => r.name.length));
221
+ const usage = a.usage
222
+ ? ` · in ${a.usage.input_tokens} / cache-read ${a.usage.cache_read_input_tokens} / cache-create ${a.usage.cache_creation_input_tokens}`
223
+ : '';
224
+ console.log(
225
+ `\n[agent-proxy] ${a.toolCount} tools · ${a.toolsBytes.toLocaleString()} tool bytes · first user ${a.firstUserBytes.toLocaleString()} B${usage}`,
226
+ );
227
+ for (const r of top) {
228
+ console.log(
229
+ ` ${r.name.padEnd(w)} ${String(r.bytes).padStart(7)} B ~${r.tokens} tok`,
230
+ );
231
+ }
232
+ if (a.toolRows.length > top.length)
233
+ console.log(` … ${a.toolRows.length - top.length} more`);
234
+ console.log(` ${path.join(logDir, `${base}.md`)}\n`);
235
+ }
236
+
237
+ // ---------------------------------------------------------------------------
238
+ // Readable Markdown render (Anthropic /messages only)
239
+ // ---------------------------------------------------------------------------
240
+
241
+ const fenceJson = (v) => `\`\`\`json\n${JSON.stringify(v, null, 2)}\n\`\`\``;
242
+ const fence = (t, lang = '') => `\`\`\`${lang}\n${t}\n\`\`\``;
243
+
244
+ function blockText(b) {
245
+ if (typeof b === 'string') return b;
246
+ if (b?.type === 'text' && typeof b.text === 'string') return b.text;
247
+ return '';
248
+ }
249
+
250
+ function renderSystem(system) {
251
+ if (typeof system === 'string') return system;
252
+ if (Array.isArray(system)) {
253
+ return system
254
+ .map(
255
+ (b) =>
256
+ blockText(b) +
257
+ (b?.cache_control ? '\n\n<!-- cache_control breakpoint -->' : ''),
258
+ )
259
+ .join('\n\n');
260
+ }
261
+ return fenceJson(system);
262
+ }
263
+
264
+ function renderTools(tools) {
265
+ const rendered = tools.map((t) => {
266
+ const lines = [`### ${t.name ?? '(unnamed tool)'}`, ''];
267
+ if (t.description) lines.push(t.description, '');
268
+ if (t.input_schema) lines.push(fenceJson(t.input_schema));
269
+ return lines.join('\n');
270
+ });
271
+ return ['<tools>', '', rendered.join('\n\n'), '', '</tools>'].join('\n');
272
+ }
273
+
274
+ function imagePlaceholder(b) {
275
+ const src = b.source ?? {};
276
+ const bytes = typeof src.data === 'string' ? src.data.length : 0;
277
+ return `\`[image: ${src.media_type ?? 'unknown'}, ${bytes} base64 chars — full data in .request.txt]\``;
278
+ }
279
+
280
+ function renderContent(content) {
281
+ if (typeof content === 'string') return content;
282
+ if (!Array.isArray(content)) return fenceJson(content);
283
+ return content
284
+ .map((b) => {
285
+ switch (b?.type) {
286
+ case 'text':
287
+ return b.text ?? '';
288
+ case 'tool_use':
289
+ return [
290
+ `<tool-use name="${b.name}" id="${b.id ?? ''}">`,
291
+ '',
292
+ fenceJson(b.input ?? {}),
293
+ '',
294
+ '</tool-use>',
295
+ ].join('\n');
296
+ case 'tool_result': {
297
+ const inner =
298
+ typeof b.content === 'string'
299
+ ? b.content
300
+ : Array.isArray(b.content)
301
+ ? b.content
302
+ .map((x) =>
303
+ x?.type === 'image'
304
+ ? imagePlaceholder(x)
305
+ : blockText(x) || fenceJson(x),
306
+ )
307
+ .join('\n\n')
308
+ : fenceJson(b.content);
309
+ return [
310
+ `<tool-result tool-use-id="${b.tool_use_id ?? ''}" is-error="${!!b.is_error}">`,
311
+ '',
312
+ inner,
313
+ '',
314
+ '</tool-result>',
315
+ ].join('\n');
316
+ }
317
+ case 'image':
318
+ return imagePlaceholder(b);
319
+ case 'thinking':
320
+ return ['<thinking>', '', b.thinking ?? '', '', '</thinking>'].join(
321
+ '\n',
322
+ );
323
+ default:
324
+ return fenceJson(b);
325
+ }
326
+ })
327
+ .join('\n\n');
328
+ }
329
+
330
+ function renderMessages(messages) {
331
+ if (!Array.isArray(messages)) return '<messages></messages>';
332
+ const rendered = messages.map((m, i) =>
333
+ [
334
+ `<message index="${i + 1}" role="${m.role ?? 'unknown'}">`,
335
+ '',
336
+ renderContent(m.content),
337
+ '',
338
+ '</message>',
339
+ ].join('\n'),
340
+ );
341
+ return ['<messages>', '', rendered.join('\n\n'), '', '</messages>'].join(
342
+ '\n',
343
+ );
344
+ }
345
+
346
+ function decodeSse(raw) {
347
+ const events = [];
348
+ for (const line of raw.split(/\r?\n/)) {
349
+ const m = line.match(/^data:\s?(.*)$/);
350
+ if (!m || m[1] === '[DONE]' || m[1].trim() === '') continue;
351
+ try {
352
+ events.push(JSON.parse(m[1]));
353
+ } catch {
354
+ /* skip */
355
+ }
356
+ }
357
+ const blocks = {};
358
+ let stopReason;
359
+ let usage;
360
+ for (const ev of events) {
361
+ if (ev.type === 'content_block_start') {
362
+ blocks[ev.index] = {
363
+ type: ev.content_block?.type ?? 'text',
364
+ text: '',
365
+ name: ev.content_block?.name,
366
+ id: ev.content_block?.id,
367
+ };
368
+ } else if (ev.type === 'content_block_delta' && blocks[ev.index]) {
369
+ const d = ev.delta ?? {};
370
+ blocks[ev.index].text += d.text ?? d.partial_json ?? d.thinking ?? '';
371
+ } else if (ev.type === 'message_start' && ev.message?.usage) {
372
+ usage = mergeUsage(usage, ev.message.usage);
373
+ } else if (ev.type === 'message_delta') {
374
+ if (ev.delta?.stop_reason) stopReason = ev.delta.stop_reason;
375
+ if (ev.usage) usage = mergeUsage(usage, ev.usage);
376
+ }
377
+ }
378
+ const parts = [];
379
+ if (stopReason) parts.push(`- **stop reason**: ${stopReason}`);
380
+ if (usage) {
381
+ const fields = threeUsage(usage);
382
+ if (fields) parts.push(`- **usage**: ${JSON.stringify(fields)}`, '');
383
+ }
384
+ for (const i of Object.keys(blocks)
385
+ .map(Number)
386
+ .sort((a, b) => a - b)) {
387
+ const b = blocks[i];
388
+ if (b.type === 'text')
389
+ parts.push(
390
+ ['<assistant-text>', '', b.text, '', '</assistant-text>'].join('\n'),
391
+ );
392
+ else if (b.type === 'thinking')
393
+ parts.push(['<thinking>', '', b.text, '', '</thinking>'].join('\n'));
394
+ else if (b.type === 'tool_use')
395
+ parts.push(
396
+ [
397
+ `<tool-use name="${b.name}" id="${b.id ?? ''}">`,
398
+ '',
399
+ fence(b.text || '{}', 'json'),
400
+ '',
401
+ '</tool-use>',
402
+ ].join('\n'),
403
+ );
404
+ }
405
+ return {
406
+ markdown: parts.length ? parts.join('\n\n') : fence(raw),
407
+ usage: threeUsage(usage),
408
+ };
409
+ }
410
+
411
+ function decodeJsonMessage(raw) {
412
+ const json = JSON.parse(raw.trim());
413
+ const text = Array.isArray(json.content)
414
+ ? json.content
415
+ .map((b) => (b?.type === 'text' ? (b.text ?? '') : ''))
416
+ .join('')
417
+ : '';
418
+ const markdown = text
419
+ ? ['<assistant-text>', '', text, '', '</assistant-text>'].join('\n')
420
+ : fenceJson(json);
421
+ return { markdown, usage: threeUsage(json.usage) };
422
+ }
423
+
424
+ /** Reassemble the streamed SSE response, or a non-streaming JSON body, and
425
+ * report the three usage fields independently. Missing usage stays unknown. */
426
+ export function decodeResponse(raw) {
427
+ const trimmed = String(raw ?? '').trim();
428
+ if (trimmed.startsWith('{')) {
429
+ try {
430
+ return decodeJsonMessage(trimmed);
431
+ } catch {
432
+ // fall through to SSE
433
+ }
434
+ }
435
+ return decodeSse(raw);
436
+ }
437
+
438
+ function renderMarkdown(c, audit, responseMd) {
439
+ const headers = Object.entries(c.headers).map(
440
+ ([k, v]) =>
441
+ `${k}: ${REDACT.has(k.toLowerCase()) ? '[REDACTED]' : Array.isArray(v) ? v.join(', ') : (v ?? '')}`,
442
+ );
443
+ const req = c.reqJson;
444
+ const parts = [
445
+ [
446
+ '<meta>',
447
+ '',
448
+ `- **timestamp**: ${c.timestamp}`,
449
+ `- **model**: ${req?.model ?? 'unknown'}`,
450
+ `- **endpoint**: ${c.method} ${c.path}`,
451
+ `- **upstream status**: ${c.statusCode}`,
452
+ '',
453
+ '</meta>',
454
+ ].join('\n'),
455
+ renderAudit(audit),
456
+ ['<headers>', '', '```', ...headers, '```', '', '</headers>'].join('\n'),
457
+ ];
458
+ if (req?.system != null)
459
+ parts.push(
460
+ [
461
+ '<system-prompt>',
462
+ '',
463
+ renderSystem(req.system),
464
+ '',
465
+ '</system-prompt>',
466
+ ].join('\n'),
467
+ );
468
+ if (Array.isArray(req?.tools) && req.tools.length)
469
+ parts.push(renderTools(req.tools));
470
+ parts.push(renderMessages(req?.messages));
471
+ parts.push(`<response>\n\n${responseMd}\n\n</response>`);
472
+ return `${parts.join('\n\n')}\n`;
473
+ }
474
+
475
+ function writeExclusiveLogs(logDir, requestText, markdown) {
476
+ fs.mkdirSync(logDir, { recursive: true });
477
+ // A UUID basename opened with `wx`: a collision throws instead of overwriting.
478
+ const base = createLogBasename();
479
+ const mdPath = path.join(logDir, `${base}.md`);
480
+ const mdFd = fs.openSync(mdPath, 'wx');
481
+ try {
482
+ fs.writeFileSync(path.join(logDir, `${base}.request.txt`), requestText, {
483
+ flag: 'wx',
484
+ });
485
+ fs.writeFileSync(mdFd, markdown);
486
+ } catch (err) {
487
+ fs.unlinkSync(mdPath);
488
+ throw err;
489
+ } finally {
490
+ fs.closeSync(mdFd);
491
+ }
492
+ return base;
493
+ }
494
+
495
+ /** Errors go to stderr, or — quiet, inside a terminal UI — to a file beside the logs. */
496
+ function report(options, logDir, message) {
497
+ if (!options.quiet) {
498
+ console.error(message);
499
+ return;
500
+ }
501
+ try {
502
+ fs.mkdirSync(logDir, { recursive: true });
503
+ fs.appendFileSync(
504
+ path.join(logDir, 'agent-proxy.errors.log'),
505
+ `${new Date().toISOString()} ${message}\n`,
506
+ );
507
+ } catch {
508
+ /* nowhere left to report */
509
+ }
510
+ }
511
+
512
+ /** Full conversations must never be committable: refuse a log root git would track. */
513
+ function assertIgnored(logDirectory, cwd) {
514
+ const probe = spawnSync(
515
+ 'git',
516
+ ['check-ignore', '-q', path.join(logDirectory, 'probe.md')],
517
+ { cwd },
518
+ );
519
+ if (probe.status !== 0) {
520
+ throw new Error(
521
+ `[agent-proxy] log directory is not ignored by git: ${logDirectory}`,
522
+ );
523
+ }
524
+ }
525
+
526
+ function handle(req, res, options) {
527
+ const reqPath = req.url ?? '/';
528
+ const chunks = [];
529
+ req.on('data', (c) => chunks.push(c));
530
+ req.on('end', () => {
531
+ const body = Buffer.concat(chunks);
532
+ const timestamp = new Date().toISOString();
533
+ const logDir = options.logDirectory;
534
+ const upstream = options.upstream ?? UPSTREAM;
535
+ const transport = upstream.protocol === 'http' ? http : https;
536
+
537
+ const upReq = transport.request(
538
+ {
539
+ hostname: upstream.hostname,
540
+ port: upstream.port,
541
+ path: reqPath,
542
+ method: req.method,
543
+ headers: forwardHeaders(req.headers, body),
544
+ },
545
+ (up) => {
546
+ res.writeHead(up.statusCode ?? 502, up.headers);
547
+ const respChunks = [];
548
+ let failed = false;
549
+ up.on('error', () => {
550
+ failed = true;
551
+ });
552
+ up.on('aborted', () => {
553
+ failed = true;
554
+ });
555
+ up.on('data', (c) => {
556
+ respChunks.push(c);
557
+ res.write(c);
558
+ });
559
+ // `close` fires after `end` and after an aborted stream, so the client
560
+ // response always ends; only a completed stream is logged.
561
+ let completed = false;
562
+ up.on('end', () => {
563
+ completed = !failed;
564
+ });
565
+ up.on('close', () => {
566
+ if (!res.writableEnded) res.end();
567
+ // Body-less requests (model lists, GETs) carry no payload to measure.
568
+ if (!completed || isTokenCount(reqPath) || body.length === 0) return;
569
+ try {
570
+ const reqJson = JSON.parse(body.toString('utf8'));
571
+ const decoded = decodeResponse(
572
+ Buffer.concat(respChunks).toString('utf8'),
573
+ );
574
+ const audit = auditRequest(reqJson, decoded.usage);
575
+ const base = writeExclusiveLogs(
576
+ logDir,
577
+ body.toString('utf8'),
578
+ renderMarkdown(
579
+ {
580
+ reqJson,
581
+ timestamp,
582
+ method: req.method ?? 'POST',
583
+ path: reqPath,
584
+ statusCode: up.statusCode ?? 0,
585
+ headers: req.headers,
586
+ },
587
+ audit,
588
+ decoded.markdown,
589
+ ),
590
+ );
591
+ if (!options.quiet) printAudit(audit, base, logDir);
592
+ } catch (err) {
593
+ report(
594
+ options,
595
+ logDir,
596
+ `[agent-proxy] log write failed: ${err.message}`,
597
+ );
598
+ }
599
+ });
600
+ },
601
+ );
602
+ res.on('close', () => {
603
+ if (!res.writableFinished) upReq.destroy();
604
+ });
605
+ upReq.on('error', (err) => {
606
+ report(options, logDir, `[agent-proxy] upstream error: ${err.message}`);
607
+ if (!res.headersSent)
608
+ res.writeHead(502, { 'content-type': 'application/json' });
609
+ if (!res.writableEnded)
610
+ res.end(
611
+ JSON.stringify({
612
+ error: `agent-proxy upstream error: ${err.message}`,
613
+ }),
614
+ );
615
+ });
616
+ if (body.length > 0) upReq.write(body);
617
+ upReq.end();
618
+ });
619
+ }
620
+
621
+ export function startServer(options = {}) {
622
+ return new Promise((resolve, reject) => {
623
+ let port;
624
+ let logDirectory;
625
+ try {
626
+ port = parsePort(options.port ?? process.env.PORT ?? DEFAULT_PORT, {
627
+ allowEphemeral: options.port === 0,
628
+ });
629
+ logDirectory =
630
+ options.logDirectory ??
631
+ resolveLogDirectory({
632
+ cwd: options.cwd,
633
+ ticketIdentifier: options.ticketIdentifier,
634
+ subdir: options.logSubdir ?? process.env.BB_PROXY_LOG_SUBDIR,
635
+ });
636
+ if (!options.logDirectory)
637
+ assertIgnored(logDirectory, options.cwd ?? process.cwd());
638
+ if (
639
+ path.resolve(logDirectory).startsWith(path.resolve(HERE) + path.sep) ||
640
+ path.resolve(logDirectory) === path.resolve(HERE)
641
+ ) {
642
+ throw new Error(
643
+ '[agent-proxy] refusing to log beside the installed script',
644
+ );
645
+ }
646
+ } catch (err) {
647
+ reject(err);
648
+ return;
649
+ }
650
+ const server = http.createServer((req, res) =>
651
+ handle(req, res, { ...options, logDirectory }),
652
+ );
653
+ server.on('error', reject);
654
+ server.listen(port, LISTEN_HOST, () => {
655
+ resolve({
656
+ server,
657
+ address: server.address(),
658
+ logDirectory,
659
+ close: () =>
660
+ new Promise((done, fail) => {
661
+ server.close((err) => (err ? fail(err) : done()));
662
+ server.closeAllConnections();
663
+ }),
664
+ });
665
+ });
666
+ });
667
+ }
668
+
669
+ const isMain =
670
+ process.argv[1] &&
671
+ import.meta.url === pathToFileURL(path.resolve(process.argv[1])).href;
672
+ if (isMain) {
673
+ startServer()
674
+ .then((started) => {
675
+ const { port } = started.address;
676
+ console.log(`[agent-proxy] listening on http://${LISTEN_HOST}:${port}`);
677
+ console.log(`[agent-proxy] logs: ${started.logDirectory}`);
678
+ console.log(
679
+ `[agent-proxy] point Claude Code at it: ANTHROPIC_BASE_URL=http://${LISTEN_HOST}:${port} claude`,
680
+ );
681
+ })
682
+ .catch((err) => {
683
+ console.error(err.message);
684
+ process.exit(1);
685
+ });
686
+ }