karajan-code 1.58.1 → 1.59.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.
- package/package.json +1 -1
- package/src/agents/aider-agent.js +7 -0
- package/src/agents/claude-agent.js +3 -0
- package/src/agents/codex-agent.js +4 -0
- package/src/agents/gemini-agent.js +4 -0
- package/src/agents/opencode-agent.js +4 -0
- package/src/bootstrap.js +20 -9
- package/src/commands/doctor.js +19 -0
- package/src/commands/report.js +13 -0
- package/src/commands/status.js +7 -0
- package/src/config.js +35 -0
- package/src/git/automation.js +32 -4
- package/src/guards/policy-guard.js +90 -0
- package/src/mcp/handlers/run-handler.js +20 -3
- package/src/mcp/shared-helpers.js +4 -2
- package/src/orchestrator.js +67 -20
- package/src/prompts/coder.js +5 -4
- package/src/prompts/reviewer.js +5 -4
- package/src/prompts/rtk-snippet.js +12 -0
- package/src/proxy/adapters/anthropic.js +131 -0
- package/src/proxy/adapters/gemini.js +106 -0
- package/src/proxy/adapters/index.js +51 -0
- package/src/proxy/adapters/openai.js +71 -0
- package/src/proxy/compression/ai-compressor.js +209 -0
- package/src/proxy/compression/dedup-cache.js +100 -0
- package/src/proxy/compression/deterministic/bash-build.js +60 -0
- package/src/proxy/compression/deterministic/bash-git.js +101 -0
- package/src/proxy/compression/deterministic/bash-infra.js +64 -0
- package/src/proxy/compression/deterministic/bash-misc.js +53 -0
- package/src/proxy/compression/deterministic/bash-pkg.js +49 -0
- package/src/proxy/compression/deterministic/bash-test.js +61 -0
- package/src/proxy/compression/deterministic/glob.js +26 -0
- package/src/proxy/compression/deterministic/grep.js +53 -0
- package/src/proxy/compression/deterministic/index.js +58 -0
- package/src/proxy/compression/deterministic/read.js +22 -0
- package/src/proxy/compression/deterministic/utils.js +33 -0
- package/src/proxy/compression/pipeline.js +270 -0
- package/src/proxy/middleware/prompt-injector.js +165 -0
- package/src/proxy/middleware/response-interceptor.js +223 -0
- package/src/proxy/progress-monitor.js +98 -0
- package/src/proxy/proxy-lifecycle.js +198 -0
- package/src/proxy/proxy-server.js +239 -0
- package/src/proxy/security.js +45 -0
- package/src/review/diff-generator.js +10 -8
- package/src/roles/coder-role.js +1 -0
- package/src/roles/reviewer-role.js +6 -4
- package/src/session-cleanup.js +39 -15
- package/src/sonar/cloud-scanner.js +6 -3
- package/src/sonar/scanner.js +12 -7
- package/src/utils/display.js +44 -4
- package/src/utils/git.js +5 -0
|
@@ -0,0 +1,223 @@
|
|
|
1
|
+
import { EventEmitter } from "node:events";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Create a response interceptor middleware that extracts tool calls, usage data,
|
|
5
|
+
* and text content from AI provider responses without buffering.
|
|
6
|
+
*
|
|
7
|
+
* For SSE (text/event-stream) responses, it parses each "data: " line inline.
|
|
8
|
+
* For non-streaming JSON responses, it parses the completed body.
|
|
9
|
+
*
|
|
10
|
+
* Events emitted on the provided EventEmitter:
|
|
11
|
+
* - tool_call {name, id}
|
|
12
|
+
* - tool_call_complete {id}
|
|
13
|
+
* - text_delta {text}
|
|
14
|
+
* - usage {input_tokens, output_tokens}
|
|
15
|
+
* - message_complete {}
|
|
16
|
+
*
|
|
17
|
+
* @param {object} [config]
|
|
18
|
+
* @param {EventEmitter} [config.emitter] - EventEmitter to emit events on (created if not provided)
|
|
19
|
+
* @returns {{ middleware: (ctx: object, next: () => Promise<void>) => Promise<void>, emitter: EventEmitter }}
|
|
20
|
+
*/
|
|
21
|
+
export function createResponseInterceptor(config = {}) {
|
|
22
|
+
const emitter = config.emitter || new EventEmitter();
|
|
23
|
+
|
|
24
|
+
// Track accumulated tool input per tool id
|
|
25
|
+
const toolInputBuffers = new Map();
|
|
26
|
+
|
|
27
|
+
function handleSSELine(line) {
|
|
28
|
+
if (!line.startsWith("data: ")) return;
|
|
29
|
+
const payload = line.slice(6).trim();
|
|
30
|
+
if (payload === "[DONE]") return;
|
|
31
|
+
|
|
32
|
+
let event;
|
|
33
|
+
try {
|
|
34
|
+
event = JSON.parse(payload);
|
|
35
|
+
} catch {
|
|
36
|
+
return; // Malformed JSON, skip
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
processAnthropicEvent(event);
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function processAnthropicEvent(event) {
|
|
43
|
+
const type = event.type;
|
|
44
|
+
|
|
45
|
+
if (type === "content_block_start") {
|
|
46
|
+
const block = event.content_block;
|
|
47
|
+
if (block && block.type === "tool_use") {
|
|
48
|
+
toolInputBuffers.set(block.id, "");
|
|
49
|
+
emitter.emit("tool_call", { name: block.name, id: block.id });
|
|
50
|
+
}
|
|
51
|
+
} else if (type === "content_block_delta") {
|
|
52
|
+
const delta = event.delta;
|
|
53
|
+
if (delta && delta.type === "input_json_delta") {
|
|
54
|
+
const id = findActiveToolId(event);
|
|
55
|
+
if (id && toolInputBuffers.has(id)) {
|
|
56
|
+
toolInputBuffers.set(id, toolInputBuffers.get(id) + (delta.partial_json || ""));
|
|
57
|
+
}
|
|
58
|
+
} else if (delta && delta.type === "text_delta") {
|
|
59
|
+
emitter.emit("text_delta", { text: delta.text || "" });
|
|
60
|
+
}
|
|
61
|
+
} else if (type === "content_block_stop") {
|
|
62
|
+
const id = findActiveToolId(event);
|
|
63
|
+
if (id && toolInputBuffers.has(id)) {
|
|
64
|
+
toolInputBuffers.delete(id);
|
|
65
|
+
emitter.emit("tool_call_complete", { id });
|
|
66
|
+
}
|
|
67
|
+
} else if (type === "message_delta") {
|
|
68
|
+
if (event.usage) {
|
|
69
|
+
emitter.emit("usage", {
|
|
70
|
+
input_tokens: event.usage.input_tokens,
|
|
71
|
+
output_tokens: event.usage.output_tokens,
|
|
72
|
+
});
|
|
73
|
+
}
|
|
74
|
+
} else if (type === "message_start") {
|
|
75
|
+
if (event.message && event.message.usage) {
|
|
76
|
+
emitter.emit("usage", {
|
|
77
|
+
input_tokens: event.message.usage.input_tokens,
|
|
78
|
+
output_tokens: event.message.usage.output_tokens,
|
|
79
|
+
});
|
|
80
|
+
}
|
|
81
|
+
} else if (type === "message_stop") {
|
|
82
|
+
emitter.emit("message_complete", {});
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* Resolve the tool ID from the event. Anthropic events use `index` to refer
|
|
88
|
+
* to the content block, but we track by id. For events with an index, we map
|
|
89
|
+
* it to the id stored at creation time. To keep things simple and avoid
|
|
90
|
+
* maintaining an index->id map, we accept an explicit `id` field if present
|
|
91
|
+
* or fall back to the first (and often only) active tool.
|
|
92
|
+
*/
|
|
93
|
+
function findActiveToolId(event) {
|
|
94
|
+
if (event.id) return event.id;
|
|
95
|
+
// Fall back to the first active tool (works for single-tool scenarios)
|
|
96
|
+
const keys = [...toolInputBuffers.keys()];
|
|
97
|
+
return keys.length > 0 ? keys[keys.length - 1] : null;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* Process a non-streaming JSON response body.
|
|
102
|
+
*/
|
|
103
|
+
function processJsonBody(bodyStr) {
|
|
104
|
+
let body;
|
|
105
|
+
try {
|
|
106
|
+
body = JSON.parse(bodyStr);
|
|
107
|
+
} catch {
|
|
108
|
+
return;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
// Extract tool_use blocks from content array
|
|
112
|
+
if (Array.isArray(body.content)) {
|
|
113
|
+
for (const block of body.content) {
|
|
114
|
+
if (block.type === "tool_use") {
|
|
115
|
+
emitter.emit("tool_call", { name: block.name, id: block.id });
|
|
116
|
+
emitter.emit("tool_call_complete", { id: block.id });
|
|
117
|
+
} else if (block.type === "text") {
|
|
118
|
+
emitter.emit("text_delta", { text: block.text || "" });
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
// Extract usage
|
|
124
|
+
if (body.usage) {
|
|
125
|
+
emitter.emit("usage", {
|
|
126
|
+
input_tokens: body.usage.input_tokens,
|
|
127
|
+
output_tokens: body.usage.output_tokens,
|
|
128
|
+
});
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
emitter.emit("message_complete", {});
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/**
|
|
135
|
+
* Middleware function compatible with the proxy server's Koa-style middleware.
|
|
136
|
+
* Wraps ctx.res.write and ctx.res.end to tap into the response stream.
|
|
137
|
+
*/
|
|
138
|
+
async function middleware(ctx, next) {
|
|
139
|
+
const origWrite = ctx.res.write.bind(ctx.res);
|
|
140
|
+
const origEnd = ctx.res.end.bind(ctx.res);
|
|
141
|
+
const origWriteHead = ctx.res.writeHead.bind(ctx.res);
|
|
142
|
+
|
|
143
|
+
let isSSE = false;
|
|
144
|
+
let isJSON = false;
|
|
145
|
+
let lineBuf = ""; // Buffer for partial SSE lines across chunks
|
|
146
|
+
const jsonChunks = []; // Accumulate non-streaming JSON body
|
|
147
|
+
|
|
148
|
+
function processSSEChunk(text) {
|
|
149
|
+
lineBuf += text;
|
|
150
|
+
const lines = lineBuf.split("\n");
|
|
151
|
+
// Keep the last element as it may be incomplete
|
|
152
|
+
lineBuf = lines.pop() || "";
|
|
153
|
+
for (const line of lines) {
|
|
154
|
+
const trimmed = line.trim();
|
|
155
|
+
if (trimmed) {
|
|
156
|
+
handleSSELine(trimmed);
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
// Intercept writeHead to detect content-type
|
|
162
|
+
ctx.res.writeHead = function interceptWriteHead(statusCode, ...args) {
|
|
163
|
+
// headers can be in args[0] (object) or args[1] with reasonPhrase in args[0]
|
|
164
|
+
const headers = typeof args[0] === "object" ? args[0] : args[1];
|
|
165
|
+
const ct = findHeader(headers, "content-type") || "";
|
|
166
|
+
isSSE = ct.includes("text/event-stream");
|
|
167
|
+
isJSON = ct.includes("application/json");
|
|
168
|
+
return origWriteHead(statusCode, ...args);
|
|
169
|
+
};
|
|
170
|
+
|
|
171
|
+
// Intercept write to parse chunks inline
|
|
172
|
+
ctx.res.write = function interceptWrite(chunk, encoding, callback) {
|
|
173
|
+
if (isSSE) {
|
|
174
|
+
processSSEChunk(typeof chunk === "string" ? chunk : chunk.toString());
|
|
175
|
+
} else if (isJSON) {
|
|
176
|
+
jsonChunks.push(typeof chunk === "string" ? chunk : chunk.toString());
|
|
177
|
+
}
|
|
178
|
+
return origWrite(chunk, encoding, callback);
|
|
179
|
+
};
|
|
180
|
+
|
|
181
|
+
// Intercept end to handle final chunk and JSON body
|
|
182
|
+
ctx.res.end = function interceptEnd(chunk, encoding, callback) {
|
|
183
|
+
if (chunk) {
|
|
184
|
+
if (isSSE) {
|
|
185
|
+
processSSEChunk(typeof chunk === "string" ? chunk : chunk.toString());
|
|
186
|
+
} else if (isJSON) {
|
|
187
|
+
jsonChunks.push(typeof chunk === "string" ? chunk : chunk.toString());
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
// Flush remaining SSE buffer
|
|
191
|
+
if (isSSE && lineBuf.trim()) {
|
|
192
|
+
handleSSELine(lineBuf.trim());
|
|
193
|
+
lineBuf = "";
|
|
194
|
+
}
|
|
195
|
+
// Process complete JSON body
|
|
196
|
+
if (isJSON && jsonChunks.length > 0) {
|
|
197
|
+
processJsonBody(jsonChunks.join(""));
|
|
198
|
+
}
|
|
199
|
+
return origEnd(chunk, encoding, callback);
|
|
200
|
+
};
|
|
201
|
+
|
|
202
|
+
await next();
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
function findHeader(headers, name) {
|
|
206
|
+
if (!headers) return undefined;
|
|
207
|
+
// Headers might be an object or an array of [key, value] pairs
|
|
208
|
+
if (Array.isArray(headers)) {
|
|
209
|
+
for (let i = 0; i < headers.length; i += 2) {
|
|
210
|
+
if (typeof headers[i] === "string" && headers[i].toLowerCase() === name) {
|
|
211
|
+
return headers[i + 1];
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
return undefined;
|
|
215
|
+
}
|
|
216
|
+
for (const key of Object.keys(headers)) {
|
|
217
|
+
if (key.toLowerCase() === name) return headers[key];
|
|
218
|
+
}
|
|
219
|
+
return undefined;
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
return { middleware, emitter };
|
|
223
|
+
}
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Progress monitor that bridges the response interceptor's events
|
|
3
|
+
* to KJ's structured progress event system.
|
|
4
|
+
*
|
|
5
|
+
* Subscribes to interceptorEmitter events (tool_call, usage, message_complete)
|
|
6
|
+
* and re-emits structured, timestamped progress events on the main emitter.
|
|
7
|
+
*
|
|
8
|
+
* @module proxy/progress-monitor
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Truncate a string to maxLen characters, appending "..." if truncated.
|
|
13
|
+
* @param {string} str
|
|
14
|
+
* @param {number} maxLen
|
|
15
|
+
* @returns {string}
|
|
16
|
+
*/
|
|
17
|
+
function truncate(str, maxLen) {
|
|
18
|
+
if (typeof str !== "string") return "";
|
|
19
|
+
if (str.length <= maxLen) return str;
|
|
20
|
+
return str.slice(0, maxLen) + "...";
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Create a progress monitor that subscribes to interceptor events
|
|
25
|
+
* and re-emits structured progress events on the main emitter.
|
|
26
|
+
*
|
|
27
|
+
* @param {object} config
|
|
28
|
+
* @param {import("node:events").EventEmitter} config.emitter - Main event emitter to emit structured events on
|
|
29
|
+
* @param {import("node:events").EventEmitter} config.interceptorEmitter - Interceptor emitter to subscribe to
|
|
30
|
+
* @param {string} config.stage - Pipeline stage name (e.g. "coder", "reviewer")
|
|
31
|
+
* @param {string} config.provider - AI provider name (e.g. "anthropic", "openai")
|
|
32
|
+
* @returns {{ getStats: () => object, stop: () => void }}
|
|
33
|
+
*/
|
|
34
|
+
export function createProgressMonitor({
|
|
35
|
+
emitter,
|
|
36
|
+
interceptorEmitter,
|
|
37
|
+
stage,
|
|
38
|
+
provider,
|
|
39
|
+
}) {
|
|
40
|
+
const stats = {
|
|
41
|
+
toolCalls: 0,
|
|
42
|
+
totalInputTokens: 0,
|
|
43
|
+
totalOutputTokens: 0,
|
|
44
|
+
};
|
|
45
|
+
|
|
46
|
+
function onToolCall({ name, id }) {
|
|
47
|
+
stats.toolCalls++;
|
|
48
|
+
const input = id ? truncate(String(id), 100) : "";
|
|
49
|
+
emitter.emit("progress", {
|
|
50
|
+
type: "proxy:tool_call",
|
|
51
|
+
stage,
|
|
52
|
+
provider,
|
|
53
|
+
tool: name,
|
|
54
|
+
input,
|
|
55
|
+
timestamp: Date.now(),
|
|
56
|
+
});
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function onUsage({ input_tokens, output_tokens }) {
|
|
60
|
+
const inputTokens = input_tokens || 0;
|
|
61
|
+
const outputTokens = output_tokens || 0;
|
|
62
|
+
stats.totalInputTokens += inputTokens;
|
|
63
|
+
stats.totalOutputTokens += outputTokens;
|
|
64
|
+
emitter.emit("progress", {
|
|
65
|
+
type: "proxy:usage",
|
|
66
|
+
stage,
|
|
67
|
+
provider,
|
|
68
|
+
input_tokens: inputTokens,
|
|
69
|
+
output_tokens: outputTokens,
|
|
70
|
+
timestamp: Date.now(),
|
|
71
|
+
});
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
function onMessageComplete() {
|
|
75
|
+
emitter.emit("progress", {
|
|
76
|
+
type: "proxy:message_complete",
|
|
77
|
+
stage,
|
|
78
|
+
provider,
|
|
79
|
+
timestamp: Date.now(),
|
|
80
|
+
});
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
interceptorEmitter.on("tool_call", onToolCall);
|
|
84
|
+
interceptorEmitter.on("usage", onUsage);
|
|
85
|
+
interceptorEmitter.on("message_complete", onMessageComplete);
|
|
86
|
+
|
|
87
|
+
function getStats() {
|
|
88
|
+
return { ...stats };
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
function stop() {
|
|
92
|
+
interceptorEmitter.off("tool_call", onToolCall);
|
|
93
|
+
interceptorEmitter.off("usage", onUsage);
|
|
94
|
+
interceptorEmitter.off("message_complete", onMessageComplete);
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
return { getStats, stop };
|
|
98
|
+
}
|
|
@@ -0,0 +1,198 @@
|
|
|
1
|
+
import net from "node:net";
|
|
2
|
+
import http from "node:http";
|
|
3
|
+
import { createProxyServer } from "./proxy-server.js";
|
|
4
|
+
|
|
5
|
+
/** @type {import('./proxy-server.js').ProxyServer | null} */
|
|
6
|
+
let proxyInstance = null;
|
|
7
|
+
let proxyPort = 0;
|
|
8
|
+
let orphanCheckInterval = null;
|
|
9
|
+
|
|
10
|
+
const DEFAULT_TARGET_HOSTS = {
|
|
11
|
+
"api.anthropic.com": "anthropic",
|
|
12
|
+
"api.openai.com": "openai",
|
|
13
|
+
"generativelanguage.googleapis.com": "gemini",
|
|
14
|
+
};
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Find a free port on localhost by briefly binding a server to port 0.
|
|
18
|
+
* @returns {Promise<number>}
|
|
19
|
+
*/
|
|
20
|
+
async function findFreePort() {
|
|
21
|
+
return new Promise((resolve, reject) => {
|
|
22
|
+
const srv = net.createServer();
|
|
23
|
+
srv.on("error", reject);
|
|
24
|
+
srv.listen(0, "127.0.0.1", () => {
|
|
25
|
+
const { port } = srv.address();
|
|
26
|
+
srv.close(() => resolve(port));
|
|
27
|
+
});
|
|
28
|
+
});
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Wait until the proxy health endpoint responds with 200.
|
|
33
|
+
* @param {number} port
|
|
34
|
+
* @param {number} timeoutMs
|
|
35
|
+
* @returns {Promise<void>}
|
|
36
|
+
*/
|
|
37
|
+
function waitForHealth(port, timeoutMs = 5000) {
|
|
38
|
+
const deadline = Date.now() + timeoutMs;
|
|
39
|
+
return new Promise((resolve, reject) => {
|
|
40
|
+
function attempt() {
|
|
41
|
+
if (Date.now() > deadline) {
|
|
42
|
+
return reject(new Error("Proxy health check timed out"));
|
|
43
|
+
}
|
|
44
|
+
const req = http.get(`http://127.0.0.1:${port}/_kj/health`, (res) => {
|
|
45
|
+
if (res.statusCode === 200) {
|
|
46
|
+
// Consume response body to free socket
|
|
47
|
+
res.resume();
|
|
48
|
+
resolve();
|
|
49
|
+
} else {
|
|
50
|
+
res.resume();
|
|
51
|
+
setTimeout(attempt, 50);
|
|
52
|
+
}
|
|
53
|
+
});
|
|
54
|
+
req.on("error", () => setTimeout(attempt, 50));
|
|
55
|
+
}
|
|
56
|
+
attempt();
|
|
57
|
+
});
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* Start orphan prevention: periodically check if the parent process is still alive.
|
|
62
|
+
* If the parent exits, stop the proxy to avoid orphan servers.
|
|
63
|
+
*/
|
|
64
|
+
function startOrphanPrevention() {
|
|
65
|
+
const parentPid = process.ppid;
|
|
66
|
+
orphanCheckInterval = setInterval(() => {
|
|
67
|
+
try {
|
|
68
|
+
// process.kill(pid, 0) throws if process doesn't exist
|
|
69
|
+
process.kill(parentPid, 0);
|
|
70
|
+
} catch {
|
|
71
|
+
// Parent is gone — self-terminate
|
|
72
|
+
stopProxy().catch(() => {});
|
|
73
|
+
clearInterval(orphanCheckInterval);
|
|
74
|
+
orphanCheckInterval = null;
|
|
75
|
+
}
|
|
76
|
+
}, 5000);
|
|
77
|
+
|
|
78
|
+
// Don't keep the event loop alive just for orphan checks
|
|
79
|
+
if (orphanCheckInterval.unref) {
|
|
80
|
+
orphanCheckInterval.unref();
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* Start the proxy server in-process.
|
|
86
|
+
*
|
|
87
|
+
* @param {object} options
|
|
88
|
+
* @param {object} [options.config] - Optional config overrides
|
|
89
|
+
* @param {Record<string, string>} [options.config.targetHosts] - Host-to-provider map
|
|
90
|
+
* @param {string} [options.sessionId] - Session identifier (for logging)
|
|
91
|
+
* @returns {Promise<{port: number, baseUrls: {anthropic: string, openai: string, gemini: string}}>}
|
|
92
|
+
*/
|
|
93
|
+
export async function startProxy({ config = {}, sessionId } = {}) {
|
|
94
|
+
if (proxyInstance) {
|
|
95
|
+
// Already running — return current info
|
|
96
|
+
return buildResult(proxyPort);
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
const port = await findFreePort();
|
|
100
|
+
const targetHosts = config.targetHosts || DEFAULT_TARGET_HOSTS;
|
|
101
|
+
|
|
102
|
+
proxyInstance = createProxyServer({ port, targetHosts });
|
|
103
|
+
await proxyInstance.listen();
|
|
104
|
+
proxyPort = proxyInstance.port;
|
|
105
|
+
|
|
106
|
+
await waitForHealth(proxyPort);
|
|
107
|
+
startOrphanPrevention();
|
|
108
|
+
|
|
109
|
+
return buildResult(proxyPort);
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/**
|
|
113
|
+
* Graceful shutdown with 5s timeout, then force cleanup.
|
|
114
|
+
* @returns {Promise<void>}
|
|
115
|
+
*/
|
|
116
|
+
export async function stopProxy() {
|
|
117
|
+
if (orphanCheckInterval) {
|
|
118
|
+
clearInterval(orphanCheckInterval);
|
|
119
|
+
orphanCheckInterval = null;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
if (!proxyInstance) return;
|
|
123
|
+
|
|
124
|
+
const instance = proxyInstance;
|
|
125
|
+
proxyInstance = null;
|
|
126
|
+
proxyPort = 0;
|
|
127
|
+
|
|
128
|
+
// Race: graceful close vs 5s timeout
|
|
129
|
+
const graceful = instance.close();
|
|
130
|
+
const timeout = new Promise((resolve) => {
|
|
131
|
+
const timer = setTimeout(resolve, 5000);
|
|
132
|
+
if (timer.unref) timer.unref();
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
await Promise.race([graceful, timeout]);
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/**
|
|
139
|
+
* Returns env vars to inject into agent subprocesses so they route through the proxy.
|
|
140
|
+
* @returns {Record<string, string> | null} env vars, or null if proxy is not running
|
|
141
|
+
*/
|
|
142
|
+
export function getProxyEnv() {
|
|
143
|
+
if (!proxyInstance || !proxyPort) return null;
|
|
144
|
+
|
|
145
|
+
const base = `http://127.0.0.1:${proxyPort}`;
|
|
146
|
+
return {
|
|
147
|
+
ANTHROPIC_BASE_URL: base,
|
|
148
|
+
OPENAI_BASE_URL: base,
|
|
149
|
+
GEMINI_API_BASE: base,
|
|
150
|
+
};
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
/**
|
|
154
|
+
* Return proxy stats if running, or null otherwise.
|
|
155
|
+
* @returns {{ port: number, requests: number, bytes_in: number, bytes_out: number } | null}
|
|
156
|
+
*/
|
|
157
|
+
export function getProxyStats() {
|
|
158
|
+
if (!proxyInstance || !proxyPort) return null;
|
|
159
|
+
return { port: proxyPort, ...proxyInstance.stats };
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
/**
|
|
163
|
+
* Check if the proxy is running by hitting the health endpoint.
|
|
164
|
+
* @returns {Promise<boolean>}
|
|
165
|
+
*/
|
|
166
|
+
export async function isProxyRunning() {
|
|
167
|
+
if (!proxyInstance || !proxyPort) return false;
|
|
168
|
+
|
|
169
|
+
return new Promise((resolve) => {
|
|
170
|
+
const req = http.get(`http://127.0.0.1:${proxyPort}/_kj/health`, (res) => {
|
|
171
|
+
res.resume();
|
|
172
|
+
resolve(res.statusCode === 200);
|
|
173
|
+
});
|
|
174
|
+
req.on("error", () => resolve(false));
|
|
175
|
+
// Tight timeout — should be instant for localhost
|
|
176
|
+
req.setTimeout(2000, () => {
|
|
177
|
+
req.destroy();
|
|
178
|
+
resolve(false);
|
|
179
|
+
});
|
|
180
|
+
});
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
/**
|
|
184
|
+
* Build the result object for startProxy.
|
|
185
|
+
* @param {number} port
|
|
186
|
+
* @returns {{port: number, baseUrls: {anthropic: string, openai: string, gemini: string}}}
|
|
187
|
+
*/
|
|
188
|
+
function buildResult(port) {
|
|
189
|
+
const base = `http://127.0.0.1:${port}`;
|
|
190
|
+
return {
|
|
191
|
+
port,
|
|
192
|
+
baseUrls: {
|
|
193
|
+
anthropic: base,
|
|
194
|
+
openai: base,
|
|
195
|
+
gemini: base,
|
|
196
|
+
},
|
|
197
|
+
};
|
|
198
|
+
}
|