@tetrixdev/ai-bridge 0.1.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/LICENSE +21 -0
- package/README.md +81 -0
- package/dist/cli.js +2686 -0
- package/dist/cli.js.map +1 -0
- package/package.json +49 -0
package/dist/cli.js
ADDED
|
@@ -0,0 +1,2686 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
// src/cli.ts
|
|
4
|
+
import { fileURLToPath } from "url";
|
|
5
|
+
import { Command } from "commander";
|
|
6
|
+
|
|
7
|
+
// src/bridge.ts
|
|
8
|
+
import { EventEmitter } from "events";
|
|
9
|
+
import crypto2 from "crypto";
|
|
10
|
+
import WebSocket from "ws";
|
|
11
|
+
|
|
12
|
+
// src/protocol/version.ts
|
|
13
|
+
var PROTOCOL_VERSION = "0.1";
|
|
14
|
+
var BRIDGE_VERSION = "0.1.0";
|
|
15
|
+
|
|
16
|
+
// src/tools/manager.ts
|
|
17
|
+
import fs from "fs";
|
|
18
|
+
import path from "path";
|
|
19
|
+
import os from "os";
|
|
20
|
+
|
|
21
|
+
// src/utils/logger.ts
|
|
22
|
+
var LEVEL_PRIORITY = {
|
|
23
|
+
debug: 0,
|
|
24
|
+
info: 1,
|
|
25
|
+
warn: 2,
|
|
26
|
+
error: 3
|
|
27
|
+
};
|
|
28
|
+
var LEVEL_LABEL = {
|
|
29
|
+
debug: "DBG",
|
|
30
|
+
info: "INF",
|
|
31
|
+
warn: "WRN",
|
|
32
|
+
error: "ERR"
|
|
33
|
+
};
|
|
34
|
+
var currentLevel = "info";
|
|
35
|
+
function setDebug(enabled) {
|
|
36
|
+
currentLevel = enabled ? "debug" : "info";
|
|
37
|
+
}
|
|
38
|
+
function isDebugEnabled() {
|
|
39
|
+
return currentLevel === "debug";
|
|
40
|
+
}
|
|
41
|
+
function formatTimestamp() {
|
|
42
|
+
return (/* @__PURE__ */ new Date()).toISOString();
|
|
43
|
+
}
|
|
44
|
+
function shouldLog(level) {
|
|
45
|
+
return LEVEL_PRIORITY[level] >= LEVEL_PRIORITY[currentLevel];
|
|
46
|
+
}
|
|
47
|
+
function formatMessage(level, component, message, meta) {
|
|
48
|
+
const ts = formatTimestamp();
|
|
49
|
+
const label = LEVEL_LABEL[level];
|
|
50
|
+
const metaStr = meta ? " " + JSON.stringify(meta) : "";
|
|
51
|
+
return `${ts} [${label}] [${component}] ${message}${metaStr}`;
|
|
52
|
+
}
|
|
53
|
+
function createLogger(component) {
|
|
54
|
+
return {
|
|
55
|
+
debug(message, meta) {
|
|
56
|
+
if (shouldLog("debug")) {
|
|
57
|
+
process.stderr.write(formatMessage("debug", component, message, meta) + "\n");
|
|
58
|
+
}
|
|
59
|
+
},
|
|
60
|
+
info(message, meta) {
|
|
61
|
+
if (shouldLog("info")) {
|
|
62
|
+
process.stderr.write(formatMessage("info", component, message, meta) + "\n");
|
|
63
|
+
}
|
|
64
|
+
},
|
|
65
|
+
warn(message, meta) {
|
|
66
|
+
if (shouldLog("warn")) {
|
|
67
|
+
process.stderr.write(formatMessage("warn", component, message, meta) + "\n");
|
|
68
|
+
}
|
|
69
|
+
},
|
|
70
|
+
error(message, meta) {
|
|
71
|
+
if (shouldLog("error")) {
|
|
72
|
+
process.stderr.write(formatMessage("error", component, message, meta) + "\n");
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
};
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
// src/tools/manager.ts
|
|
79
|
+
var log = createLogger("ToolManager");
|
|
80
|
+
var SAFE_TOOL_NAME_PATTERN = /^[a-zA-Z][a-zA-Z0-9_-]{0,63}$/;
|
|
81
|
+
var RESERVED_TOOL_NAMES = /* @__PURE__ */ new Set([
|
|
82
|
+
"curl",
|
|
83
|
+
"wget",
|
|
84
|
+
"node",
|
|
85
|
+
"npm",
|
|
86
|
+
"npx",
|
|
87
|
+
"bash",
|
|
88
|
+
"sh",
|
|
89
|
+
"zsh",
|
|
90
|
+
"python",
|
|
91
|
+
"python3",
|
|
92
|
+
"ruby",
|
|
93
|
+
"perl",
|
|
94
|
+
"git",
|
|
95
|
+
"ssh",
|
|
96
|
+
"scp",
|
|
97
|
+
"cat",
|
|
98
|
+
"ls",
|
|
99
|
+
"rm",
|
|
100
|
+
"cp",
|
|
101
|
+
"mv",
|
|
102
|
+
"chmod",
|
|
103
|
+
"chown",
|
|
104
|
+
"mkdir",
|
|
105
|
+
"kill",
|
|
106
|
+
"ps",
|
|
107
|
+
"env",
|
|
108
|
+
"sudo",
|
|
109
|
+
"su",
|
|
110
|
+
"tar",
|
|
111
|
+
"gzip",
|
|
112
|
+
"gunzip",
|
|
113
|
+
"openssl",
|
|
114
|
+
"nc",
|
|
115
|
+
"ncat",
|
|
116
|
+
"netcat",
|
|
117
|
+
"socat",
|
|
118
|
+
"find",
|
|
119
|
+
"grep",
|
|
120
|
+
"awk",
|
|
121
|
+
"sed",
|
|
122
|
+
"echo",
|
|
123
|
+
"printf",
|
|
124
|
+
"head",
|
|
125
|
+
"tail",
|
|
126
|
+
"wc",
|
|
127
|
+
"tee",
|
|
128
|
+
"test",
|
|
129
|
+
"true",
|
|
130
|
+
"false",
|
|
131
|
+
"xargs",
|
|
132
|
+
"sort",
|
|
133
|
+
"uniq",
|
|
134
|
+
"cut",
|
|
135
|
+
"tr",
|
|
136
|
+
"make"
|
|
137
|
+
]);
|
|
138
|
+
var ToolManager = class {
|
|
139
|
+
tools = /* @__PURE__ */ new Map();
|
|
140
|
+
scriptDir = null;
|
|
141
|
+
/** Names of tools most recently rejected by register(). */
|
|
142
|
+
rejectedTools = [];
|
|
143
|
+
/**
|
|
144
|
+
* Register tool definitions received from the server.
|
|
145
|
+
* Replaces any previously registered tools.
|
|
146
|
+
* Tool names are validated against a safe pattern and denylist.
|
|
147
|
+
*/
|
|
148
|
+
register(tools) {
|
|
149
|
+
this.tools.clear();
|
|
150
|
+
const rejectedTools = [];
|
|
151
|
+
for (const tool of tools) {
|
|
152
|
+
if (!SAFE_TOOL_NAME_PATTERN.test(tool.name)) {
|
|
153
|
+
log.error("Rejected tool with unsafe name", {
|
|
154
|
+
name: tool.name.substring(0, 100),
|
|
155
|
+
reason: "Tool names must start with a letter and be 1-64 chars of alphanumeric/underscore/hyphen"
|
|
156
|
+
});
|
|
157
|
+
rejectedTools.push(tool.name.substring(0, 100));
|
|
158
|
+
continue;
|
|
159
|
+
}
|
|
160
|
+
if (RESERVED_TOOL_NAMES.has(tool.name.toLowerCase())) {
|
|
161
|
+
log.error("Rejected tool with reserved name", {
|
|
162
|
+
name: tool.name,
|
|
163
|
+
reason: "Tool name conflicts with a system binary"
|
|
164
|
+
});
|
|
165
|
+
rejectedTools.push(tool.name);
|
|
166
|
+
continue;
|
|
167
|
+
}
|
|
168
|
+
this.tools.set(tool.name, tool);
|
|
169
|
+
}
|
|
170
|
+
this.rejectedTools = rejectedTools;
|
|
171
|
+
if (rejectedTools.length > 0) {
|
|
172
|
+
log.warn("Tools rejected by name validation", {
|
|
173
|
+
count: rejectedTools.length,
|
|
174
|
+
names: rejectedTools
|
|
175
|
+
});
|
|
176
|
+
}
|
|
177
|
+
log.info("Registered tools", { accepted: this.tools.size, total: tools.length, names: Array.from(this.tools.keys()) });
|
|
178
|
+
}
|
|
179
|
+
/**
|
|
180
|
+
* Return the list of tool names rejected during the last register() call.
|
|
181
|
+
*/
|
|
182
|
+
getRejectedToolNames() {
|
|
183
|
+
return this.rejectedTools;
|
|
184
|
+
}
|
|
185
|
+
/**
|
|
186
|
+
* Get a tool definition by name.
|
|
187
|
+
*/
|
|
188
|
+
get(name) {
|
|
189
|
+
return this.tools.get(name);
|
|
190
|
+
}
|
|
191
|
+
/**
|
|
192
|
+
* Get all registered tool definitions.
|
|
193
|
+
*/
|
|
194
|
+
getAll() {
|
|
195
|
+
return Array.from(this.tools.values());
|
|
196
|
+
}
|
|
197
|
+
/**
|
|
198
|
+
* Returns the number of registered tools.
|
|
199
|
+
*/
|
|
200
|
+
count() {
|
|
201
|
+
return this.tools.size;
|
|
202
|
+
}
|
|
203
|
+
/**
|
|
204
|
+
* Returns the set of registered tool names.
|
|
205
|
+
* Useful for passing to ToolCallbackServer for validation.
|
|
206
|
+
*/
|
|
207
|
+
getRegisteredNames() {
|
|
208
|
+
return new Set(this.tools.keys());
|
|
209
|
+
}
|
|
210
|
+
/**
|
|
211
|
+
* Generate temporary Bash wrapper scripts for all registered tools.
|
|
212
|
+
*
|
|
213
|
+
* Each script, when invoked by a CLI tool, will:
|
|
214
|
+
* 1. Collect arguments as JSON
|
|
215
|
+
* 2. Send them to the bridge process via a local HTTP callback
|
|
216
|
+
* 3. Wait for the result
|
|
217
|
+
* 4. Print the result to stdout
|
|
218
|
+
*
|
|
219
|
+
* @param callbackPort The local HTTP port the bridge is listening on
|
|
220
|
+
* for tool call callbacks from spawned scripts.
|
|
221
|
+
* @param secret Optional bearer token for callback server auth.
|
|
222
|
+
* @param timeoutMs HTTP timeout for the callback request in ms. Should
|
|
223
|
+
* match the server-configured request_timeout so the
|
|
224
|
+
* bash script does not outlive the bridge-side timeout.
|
|
225
|
+
* Defaults to 300 000 ms (5 min).
|
|
226
|
+
* @returns The path to the temporary directory containing the scripts.
|
|
227
|
+
*/
|
|
228
|
+
generateScripts(callbackPort, secret, timeoutMs = 3e5) {
|
|
229
|
+
this.cleanupScripts();
|
|
230
|
+
this.scriptDir = fs.mkdtempSync(path.join(os.tmpdir(), "ai-bridge-tools-"));
|
|
231
|
+
log.debug("Created tool script directory", { dir: this.scriptDir });
|
|
232
|
+
for (const tool of this.tools.values()) {
|
|
233
|
+
const scriptPath = path.join(this.scriptDir, tool.name);
|
|
234
|
+
const scriptContent = this.buildScript(tool, callbackPort, secret, timeoutMs);
|
|
235
|
+
fs.writeFileSync(scriptPath, scriptContent, { mode: 448 });
|
|
236
|
+
log.debug("Generated tool script", { tool: tool.name, path: scriptPath });
|
|
237
|
+
}
|
|
238
|
+
return this.scriptDir;
|
|
239
|
+
}
|
|
240
|
+
/**
|
|
241
|
+
* Get the directory containing generated tool scripts, or null if
|
|
242
|
+
* scripts have not been generated yet.
|
|
243
|
+
*/
|
|
244
|
+
getScriptDir() {
|
|
245
|
+
return this.scriptDir;
|
|
246
|
+
}
|
|
247
|
+
/**
|
|
248
|
+
* Remove all generated tool scripts and the temp directory.
|
|
249
|
+
*/
|
|
250
|
+
cleanupScripts() {
|
|
251
|
+
if (this.scriptDir) {
|
|
252
|
+
try {
|
|
253
|
+
fs.rmSync(this.scriptDir, { recursive: true, force: true });
|
|
254
|
+
log.debug("Cleaned up tool script directory", { dir: this.scriptDir });
|
|
255
|
+
} catch (err) {
|
|
256
|
+
log.warn("Failed to clean up tool scripts", {
|
|
257
|
+
dir: this.scriptDir,
|
|
258
|
+
error: err instanceof Error ? err.message : String(err)
|
|
259
|
+
});
|
|
260
|
+
}
|
|
261
|
+
this.scriptDir = null;
|
|
262
|
+
}
|
|
263
|
+
}
|
|
264
|
+
/**
|
|
265
|
+
* Build the Bash script content for a single tool.
|
|
266
|
+
*
|
|
267
|
+
* Embeds the bearer token for callback server authentication. Tool names
|
|
268
|
+
* are pre-validated by register() so they are safe to embed; tool
|
|
269
|
+
* descriptions are NOT included to prevent injection.
|
|
270
|
+
*/
|
|
271
|
+
buildScript(tool, callbackPort, secret, timeoutMs = 3e5) {
|
|
272
|
+
const secretArg = secret ?? "";
|
|
273
|
+
return `#!/usr/bin/env bash
|
|
274
|
+
# Auto-generated tool wrapper: ${tool.name}
|
|
275
|
+
# DO NOT EDIT \u2014 regenerated on each bridge session.
|
|
276
|
+
|
|
277
|
+
set -euo pipefail
|
|
278
|
+
|
|
279
|
+
# Read arguments from stdin (the standard way AI CLIs pass tool args).
|
|
280
|
+
STDIN_DATA=""
|
|
281
|
+
if [ ! -t 0 ]; then
|
|
282
|
+
STDIN_DATA=$(cat)
|
|
283
|
+
fi
|
|
284
|
+
|
|
285
|
+
# Single Node.js invocation for input parsing, payload building, HTTP call,
|
|
286
|
+
# and output extraction.
|
|
287
|
+
node -e '
|
|
288
|
+
const http = require("http");
|
|
289
|
+
const stdinData = process.argv[1];
|
|
290
|
+
const toolName = process.argv[2];
|
|
291
|
+
const toolCallId = process.argv[3];
|
|
292
|
+
const requestId = process.argv[4];
|
|
293
|
+
const secret = process.argv[5];
|
|
294
|
+
|
|
295
|
+
// Parse stdin as JSON, fall back to wrapping as {input: ...}
|
|
296
|
+
let args = {};
|
|
297
|
+
if (stdinData) {
|
|
298
|
+
try { args = JSON.parse(stdinData); } catch { args = { input: stdinData }; }
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
const payload = JSON.stringify({
|
|
302
|
+
tool_name: toolName,
|
|
303
|
+
tool_call_id: toolCallId,
|
|
304
|
+
arguments: args,
|
|
305
|
+
request_id: requestId
|
|
306
|
+
});
|
|
307
|
+
|
|
308
|
+
const headers = { "Content-Type": "application/json", "Content-Length": Buffer.byteLength(payload) };
|
|
309
|
+
if (secret) headers["Authorization"] = "Bearer " + secret;
|
|
310
|
+
|
|
311
|
+
const req = http.request({ hostname: "127.0.0.1", port: ${callbackPort}, path: "/tool-call", method: "POST", headers, timeout: ${timeoutMs} }, (res) => {
|
|
312
|
+
let body = "";
|
|
313
|
+
res.on("data", (c) => { body += c; });
|
|
314
|
+
res.on("end", () => {
|
|
315
|
+
try {
|
|
316
|
+
const r = JSON.parse(body);
|
|
317
|
+
if (r.error) { process.stderr.write(r.error + "\\n"); process.exit(1); }
|
|
318
|
+
process.stdout.write(r.result != null ? String(r.result) : "");
|
|
319
|
+
} catch {
|
|
320
|
+
process.stderr.write("Tool call failed: invalid response from bridge\\n");
|
|
321
|
+
process.exit(1);
|
|
322
|
+
}
|
|
323
|
+
});
|
|
324
|
+
});
|
|
325
|
+
req.on("error", (e) => { process.stderr.write("Tool call failed: " + e.message + "\\n"); process.exit(1); });
|
|
326
|
+
req.write(payload);
|
|
327
|
+
req.end();
|
|
328
|
+
// The $RANDOM-based ID here is discarded \u2014 the callback server overrides it
|
|
329
|
+
// with a cryptographically strong UUID before use.
|
|
330
|
+
' "$STDIN_DATA" "${tool.name}" "tc_\${RANDOM}\${RANDOM}" "\${AI_BRIDGE_REQUEST_ID:-}" "${secretArg}"
|
|
331
|
+
`;
|
|
332
|
+
}
|
|
333
|
+
};
|
|
334
|
+
|
|
335
|
+
// src/tools/resolver.ts
|
|
336
|
+
var log2 = createLogger("ToolResolver");
|
|
337
|
+
var ToolResolver = class {
|
|
338
|
+
pending = /* @__PURE__ */ new Map();
|
|
339
|
+
timeoutMs;
|
|
340
|
+
constructor(timeoutMs = 3e5) {
|
|
341
|
+
this.timeoutMs = timeoutMs;
|
|
342
|
+
}
|
|
343
|
+
/**
|
|
344
|
+
* Update the timeout duration (e.g. from the server's request_timeout config).
|
|
345
|
+
*/
|
|
346
|
+
setTimeoutMs(ms) {
|
|
347
|
+
this.timeoutMs = ms;
|
|
348
|
+
log2.debug("Tool resolver timeout updated", { timeoutMs: ms });
|
|
349
|
+
}
|
|
350
|
+
/**
|
|
351
|
+
* Initiate a tool call and wait for the server's response.
|
|
352
|
+
*
|
|
353
|
+
* @param sendFn Function to send the tool_call over WebSocket.
|
|
354
|
+
* @param requestId The parent AI request ID.
|
|
355
|
+
* @param toolCallId Unique ID for this tool invocation.
|
|
356
|
+
* @param toolName Name of the tool being called.
|
|
357
|
+
* @param args Tool arguments.
|
|
358
|
+
* @returns The tool result from the server.
|
|
359
|
+
* @throws If the server returns a tool_error or the call times out.
|
|
360
|
+
*/
|
|
361
|
+
call(sendFn, requestId, toolCallId, toolName, args) {
|
|
362
|
+
return new Promise((resolve, reject) => {
|
|
363
|
+
const timer = setTimeout(() => {
|
|
364
|
+
this.pending.delete(toolCallId);
|
|
365
|
+
const seconds = Math.round(this.timeoutMs / 1e3);
|
|
366
|
+
const minutes = Math.round(seconds / 60);
|
|
367
|
+
const humanDuration = seconds >= 60 ? `${minutes} ${minutes === 1 ? "minute" : "minutes"}` : `${seconds}s`;
|
|
368
|
+
reject(new Error(`Tool call ${toolName} (${toolCallId}) timed out after ${humanDuration}`));
|
|
369
|
+
}, this.timeoutMs);
|
|
370
|
+
this.pending.set(toolCallId, {
|
|
371
|
+
toolCallId,
|
|
372
|
+
toolName,
|
|
373
|
+
resolve,
|
|
374
|
+
reject,
|
|
375
|
+
timer
|
|
376
|
+
});
|
|
377
|
+
log2.debug("Sending tool call to server", { requestId, toolCallId, toolName });
|
|
378
|
+
sendFn(requestId, toolCallId, toolName, args);
|
|
379
|
+
});
|
|
380
|
+
}
|
|
381
|
+
/**
|
|
382
|
+
* Resolve a pending tool call with a successful result.
|
|
383
|
+
* Called by the Bridge when a `tool_resolve` message arrives.
|
|
384
|
+
*/
|
|
385
|
+
resolve(toolCallId, result) {
|
|
386
|
+
const pending = this.pending.get(toolCallId);
|
|
387
|
+
if (!pending) {
|
|
388
|
+
log2.warn("Received tool_resolve for unknown tool_call_id", { toolCallId });
|
|
389
|
+
return false;
|
|
390
|
+
}
|
|
391
|
+
clearTimeout(pending.timer);
|
|
392
|
+
this.pending.delete(toolCallId);
|
|
393
|
+
log2.debug("Tool call resolved", { toolCallId, toolName: pending.toolName });
|
|
394
|
+
pending.resolve(result);
|
|
395
|
+
return true;
|
|
396
|
+
}
|
|
397
|
+
/**
|
|
398
|
+
* Reject a pending tool call with an error.
|
|
399
|
+
* Called by the Bridge when a `tool_error` message arrives.
|
|
400
|
+
*/
|
|
401
|
+
reject(toolCallId, error) {
|
|
402
|
+
const pending = this.pending.get(toolCallId);
|
|
403
|
+
if (!pending) {
|
|
404
|
+
log2.warn("Received tool_error for unknown tool_call_id", { toolCallId });
|
|
405
|
+
return false;
|
|
406
|
+
}
|
|
407
|
+
clearTimeout(pending.timer);
|
|
408
|
+
this.pending.delete(toolCallId);
|
|
409
|
+
log2.debug("Tool call rejected", { toolCallId, toolName: pending.toolName, error });
|
|
410
|
+
pending.reject(new Error(`Tool error (${pending.toolName}): ${error}`));
|
|
411
|
+
return true;
|
|
412
|
+
}
|
|
413
|
+
/**
|
|
414
|
+
* Cancel all pending tool calls (e.g., on disconnect).
|
|
415
|
+
*/
|
|
416
|
+
cancelAll() {
|
|
417
|
+
for (const [id, pending] of this.pending) {
|
|
418
|
+
clearTimeout(pending.timer);
|
|
419
|
+
pending.reject(new Error("Tool call cancelled \u2014 bridge disconnected"));
|
|
420
|
+
}
|
|
421
|
+
const count = this.pending.size;
|
|
422
|
+
this.pending.clear();
|
|
423
|
+
if (count > 0) {
|
|
424
|
+
log2.info("Cancelled all pending tool calls", { count });
|
|
425
|
+
}
|
|
426
|
+
}
|
|
427
|
+
/**
|
|
428
|
+
* Returns the number of tool calls currently awaiting resolution.
|
|
429
|
+
*/
|
|
430
|
+
pendingCount() {
|
|
431
|
+
return this.pending.size;
|
|
432
|
+
}
|
|
433
|
+
};
|
|
434
|
+
|
|
435
|
+
// src/tools/callback-server.ts
|
|
436
|
+
import http from "http";
|
|
437
|
+
import crypto from "crypto";
|
|
438
|
+
var log3 = createLogger("ToolCallbackServer");
|
|
439
|
+
var MAX_BODY_SIZE = 1048576;
|
|
440
|
+
var ToolCallbackServer = class {
|
|
441
|
+
constructor(toolResolver, sendFn, registeredToolNames, secret) {
|
|
442
|
+
this.toolResolver = toolResolver;
|
|
443
|
+
this.sendFn = sendFn;
|
|
444
|
+
if (registeredToolNames) {
|
|
445
|
+
this.registeredToolNames = registeredToolNames;
|
|
446
|
+
}
|
|
447
|
+
this.secret = secret ?? null;
|
|
448
|
+
}
|
|
449
|
+
toolResolver;
|
|
450
|
+
sendFn;
|
|
451
|
+
server = null;
|
|
452
|
+
port = null;
|
|
453
|
+
/** Set of registered tool names for validation. */
|
|
454
|
+
registeredToolNames = null;
|
|
455
|
+
/** Shared secret for authenticating callback requests. */
|
|
456
|
+
secret;
|
|
457
|
+
/**
|
|
458
|
+
* Set the registered tool names for validation.
|
|
459
|
+
* Tool calls with names not in this set will be rejected.
|
|
460
|
+
*/
|
|
461
|
+
setRegisteredToolNames(names) {
|
|
462
|
+
this.registeredToolNames = names;
|
|
463
|
+
}
|
|
464
|
+
/**
|
|
465
|
+
* Start the local HTTP server on a random available port.
|
|
466
|
+
*/
|
|
467
|
+
async start() {
|
|
468
|
+
if (this.server) {
|
|
469
|
+
return this.port;
|
|
470
|
+
}
|
|
471
|
+
return new Promise((resolve, reject) => {
|
|
472
|
+
this.server = http.createServer((req, res) => {
|
|
473
|
+
this.handleRequest(req, res);
|
|
474
|
+
});
|
|
475
|
+
this.server.listen(0, "127.0.0.1", () => {
|
|
476
|
+
const addr = this.server.address();
|
|
477
|
+
if (addr && typeof addr === "object") {
|
|
478
|
+
this.port = addr.port;
|
|
479
|
+
log3.info("Tool callback server started", { port: this.port });
|
|
480
|
+
resolve(this.port);
|
|
481
|
+
} else {
|
|
482
|
+
reject(new Error("Failed to get server address"));
|
|
483
|
+
}
|
|
484
|
+
});
|
|
485
|
+
this.server.on("error", (err) => {
|
|
486
|
+
log3.error("Tool callback server error", { error: err.message });
|
|
487
|
+
reject(err);
|
|
488
|
+
});
|
|
489
|
+
});
|
|
490
|
+
}
|
|
491
|
+
/**
|
|
492
|
+
* Stop the callback server.
|
|
493
|
+
*/
|
|
494
|
+
async stop() {
|
|
495
|
+
if (!this.server) return;
|
|
496
|
+
return new Promise((resolve) => {
|
|
497
|
+
this.server.close(() => {
|
|
498
|
+
log3.info("Tool callback server stopped");
|
|
499
|
+
this.server = null;
|
|
500
|
+
this.port = null;
|
|
501
|
+
resolve();
|
|
502
|
+
});
|
|
503
|
+
});
|
|
504
|
+
}
|
|
505
|
+
/**
|
|
506
|
+
* Get the port the server is listening on, or null if not started.
|
|
507
|
+
*/
|
|
508
|
+
getPort() {
|
|
509
|
+
return this.port;
|
|
510
|
+
}
|
|
511
|
+
/**
|
|
512
|
+
* Handle incoming HTTP requests from tool wrapper scripts.
|
|
513
|
+
*/
|
|
514
|
+
handleRequest(req, res) {
|
|
515
|
+
if (req.method !== "POST" || req.url !== "/tool-call") {
|
|
516
|
+
res.writeHead(404, { "Content-Type": "application/json" });
|
|
517
|
+
res.end(JSON.stringify({ error: "Not found" }));
|
|
518
|
+
return;
|
|
519
|
+
}
|
|
520
|
+
if (this.secret) {
|
|
521
|
+
const authHeader = req.headers["authorization"];
|
|
522
|
+
const expected = `Bearer ${this.secret}`;
|
|
523
|
+
if (!authHeader || authHeader.length !== expected.length || !crypto.timingSafeEqual(Buffer.from(authHeader), Buffer.from(expected))) {
|
|
524
|
+
res.writeHead(401, { "Content-Type": "application/json" });
|
|
525
|
+
res.end(JSON.stringify({ error: "Unauthorized" }));
|
|
526
|
+
return;
|
|
527
|
+
}
|
|
528
|
+
}
|
|
529
|
+
let body = "";
|
|
530
|
+
let bodyLength = 0;
|
|
531
|
+
req.on("data", (chunk) => {
|
|
532
|
+
bodyLength += chunk.length;
|
|
533
|
+
if (bodyLength > MAX_BODY_SIZE) {
|
|
534
|
+
res.writeHead(413, { "Content-Type": "application/json" });
|
|
535
|
+
res.end(JSON.stringify({ error: "Request body too large" }));
|
|
536
|
+
req.destroy();
|
|
537
|
+
return;
|
|
538
|
+
}
|
|
539
|
+
body += chunk.toString();
|
|
540
|
+
});
|
|
541
|
+
req.on("end", () => {
|
|
542
|
+
if (bodyLength > MAX_BODY_SIZE) return;
|
|
543
|
+
this.processToolCall(body, res).catch((err) => {
|
|
544
|
+
log3.error("Failed to process tool call", {
|
|
545
|
+
error: err instanceof Error ? err.message : String(err)
|
|
546
|
+
});
|
|
547
|
+
res.writeHead(500, { "Content-Type": "application/json" });
|
|
548
|
+
res.end(JSON.stringify({ error: err instanceof Error ? err.message : String(err) }));
|
|
549
|
+
});
|
|
550
|
+
});
|
|
551
|
+
}
|
|
552
|
+
async processToolCall(body, res) {
|
|
553
|
+
let parsed;
|
|
554
|
+
try {
|
|
555
|
+
parsed = JSON.parse(body);
|
|
556
|
+
} catch {
|
|
557
|
+
res.writeHead(400, { "Content-Type": "application/json" });
|
|
558
|
+
res.end(JSON.stringify({ error: "Invalid JSON" }));
|
|
559
|
+
return;
|
|
560
|
+
}
|
|
561
|
+
const { tool_name, arguments: args } = parsed;
|
|
562
|
+
const requestId = parsed.request_id;
|
|
563
|
+
if (!requestId) {
|
|
564
|
+
res.writeHead(400, { "Content-Type": "application/json" });
|
|
565
|
+
res.end(JSON.stringify({ error: "Missing request_id \u2014 tool call cannot be routed" }));
|
|
566
|
+
return;
|
|
567
|
+
}
|
|
568
|
+
if (this.registeredToolNames && !this.registeredToolNames.has(tool_name)) {
|
|
569
|
+
res.writeHead(400, { "Content-Type": "application/json" });
|
|
570
|
+
res.end(JSON.stringify({ error: `Unknown tool: ${tool_name}` }));
|
|
571
|
+
return;
|
|
572
|
+
}
|
|
573
|
+
const toolCallId = `tc_${crypto.randomUUID().replace(/-/g, "").slice(0, 12)}`;
|
|
574
|
+
log3.debug("Tool call received via HTTP callback", { tool_name, toolCallId, requestId });
|
|
575
|
+
try {
|
|
576
|
+
const result = await this.toolResolver.call(
|
|
577
|
+
this.sendFn,
|
|
578
|
+
requestId,
|
|
579
|
+
toolCallId,
|
|
580
|
+
tool_name,
|
|
581
|
+
args ?? {}
|
|
582
|
+
);
|
|
583
|
+
res.writeHead(200, { "Content-Type": "application/json" });
|
|
584
|
+
res.end(JSON.stringify({ result: typeof result === "string" ? result : JSON.stringify(result) }));
|
|
585
|
+
} catch (err) {
|
|
586
|
+
res.writeHead(200, { "Content-Type": "application/json" });
|
|
587
|
+
res.end(JSON.stringify({ error: err instanceof Error ? err.message : String(err) }));
|
|
588
|
+
}
|
|
589
|
+
}
|
|
590
|
+
};
|
|
591
|
+
|
|
592
|
+
// src/session/store.ts
|
|
593
|
+
import fs2 from "fs";
|
|
594
|
+
import path2 from "path";
|
|
595
|
+
import os2 from "os";
|
|
596
|
+
var log4 = createLogger("SessionStore");
|
|
597
|
+
var DEFAULT_TTL_MS = 7 * 24 * 60 * 60 * 1e3;
|
|
598
|
+
var SessionStore = class {
|
|
599
|
+
dir;
|
|
600
|
+
filePath;
|
|
601
|
+
ttlMs;
|
|
602
|
+
data;
|
|
603
|
+
constructor(ttlMs = DEFAULT_TTL_MS) {
|
|
604
|
+
this.dir = path2.join(os2.homedir(), ".ai-bridge");
|
|
605
|
+
this.filePath = path2.join(this.dir, "sessions.json");
|
|
606
|
+
this.ttlMs = ttlMs;
|
|
607
|
+
this.data = {};
|
|
608
|
+
this.load();
|
|
609
|
+
}
|
|
610
|
+
// -------------------------------------------------------------------------
|
|
611
|
+
// Public API
|
|
612
|
+
// -------------------------------------------------------------------------
|
|
613
|
+
/**
|
|
614
|
+
* Look up the CLI session ID for a given conversation.
|
|
615
|
+
* Returns null if not found or expired.
|
|
616
|
+
*/
|
|
617
|
+
get(conversationId) {
|
|
618
|
+
const record = this.data[conversationId];
|
|
619
|
+
if (!record) return null;
|
|
620
|
+
if (this.isExpired(record)) {
|
|
621
|
+
delete this.data[conversationId];
|
|
622
|
+
this.persist();
|
|
623
|
+
return null;
|
|
624
|
+
}
|
|
625
|
+
record.last_used_at = (/* @__PURE__ */ new Date()).toISOString();
|
|
626
|
+
return record.cli_session_id;
|
|
627
|
+
}
|
|
628
|
+
/**
|
|
629
|
+
* Store a mapping from conversation_id to cli_session_id.
|
|
630
|
+
* @param systemPrompt The system prompt used for the first message in this
|
|
631
|
+
* conversation. Stored so session resets can restore it even when the
|
|
632
|
+
* server omits system_prompt from the session_reset message.
|
|
633
|
+
*/
|
|
634
|
+
set(conversationId, cliSessionId, provider, systemPrompt) {
|
|
635
|
+
const now = (/* @__PURE__ */ new Date()).toISOString();
|
|
636
|
+
const existing = this.data[conversationId];
|
|
637
|
+
this.data[conversationId] = {
|
|
638
|
+
cli_session_id: cliSessionId,
|
|
639
|
+
provider,
|
|
640
|
+
// Preserve the original created_at when updating an existing record —
|
|
641
|
+
// every resumed request calls set() again.
|
|
642
|
+
created_at: existing?.created_at ?? now,
|
|
643
|
+
last_used_at: now,
|
|
644
|
+
// Only overwrite system_prompt when a non-null value is supplied;
|
|
645
|
+
// follow-up requests carry no system_prompt and must not erase it.
|
|
646
|
+
system_prompt: systemPrompt ?? existing?.system_prompt ?? null
|
|
647
|
+
};
|
|
648
|
+
this.persist();
|
|
649
|
+
log4.debug("Session stored", { conversationId, cliSessionId, provider });
|
|
650
|
+
}
|
|
651
|
+
/**
|
|
652
|
+
* Retrieve the stored system prompt for a conversation.
|
|
653
|
+
* Returns null if not found or if no system_prompt was stored.
|
|
654
|
+
*/
|
|
655
|
+
getSystemPrompt(conversationId) {
|
|
656
|
+
return this.data[conversationId]?.system_prompt ?? null;
|
|
657
|
+
}
|
|
658
|
+
/**
|
|
659
|
+
* Remove a specific conversation mapping.
|
|
660
|
+
*/
|
|
661
|
+
delete(conversationId) {
|
|
662
|
+
if (this.data[conversationId]) {
|
|
663
|
+
delete this.data[conversationId];
|
|
664
|
+
this.persist();
|
|
665
|
+
log4.debug("Session deleted", { conversationId });
|
|
666
|
+
return true;
|
|
667
|
+
}
|
|
668
|
+
return false;
|
|
669
|
+
}
|
|
670
|
+
/**
|
|
671
|
+
* Flush the current in-memory state to disk immediately. Call on bridge
|
|
672
|
+
* shutdown to persist last_used_at updates made in memory via get(), which
|
|
673
|
+
* would otherwise be lost and could make active sessions appear expired.
|
|
674
|
+
*/
|
|
675
|
+
flush() {
|
|
676
|
+
this.persist();
|
|
677
|
+
log4.debug("Session store flushed to disk", { count: Object.keys(this.data).length });
|
|
678
|
+
}
|
|
679
|
+
/**
|
|
680
|
+
* Remove all expired sessions. Returns the number of pruned entries.
|
|
681
|
+
*/
|
|
682
|
+
prune() {
|
|
683
|
+
const now = Date.now();
|
|
684
|
+
let pruned = 0;
|
|
685
|
+
for (const [id, record] of Object.entries(this.data)) {
|
|
686
|
+
if (this.isExpired(record, now)) {
|
|
687
|
+
delete this.data[id];
|
|
688
|
+
pruned++;
|
|
689
|
+
}
|
|
690
|
+
}
|
|
691
|
+
if (pruned > 0) {
|
|
692
|
+
this.persist();
|
|
693
|
+
log4.info("Pruned expired sessions", { count: pruned });
|
|
694
|
+
}
|
|
695
|
+
return pruned;
|
|
696
|
+
}
|
|
697
|
+
/**
|
|
698
|
+
* Returns the number of active (non-expired) sessions.
|
|
699
|
+
*/
|
|
700
|
+
size() {
|
|
701
|
+
const now = Date.now();
|
|
702
|
+
return Object.values(this.data).filter((record) => !this.isExpired(record, now)).length;
|
|
703
|
+
}
|
|
704
|
+
// -------------------------------------------------------------------------
|
|
705
|
+
// Internals
|
|
706
|
+
// -------------------------------------------------------------------------
|
|
707
|
+
isExpired(record, now = Date.now()) {
|
|
708
|
+
const lastUsed = new Date(record.last_used_at).getTime();
|
|
709
|
+
return now - lastUsed > this.ttlMs;
|
|
710
|
+
}
|
|
711
|
+
load() {
|
|
712
|
+
try {
|
|
713
|
+
if (fs2.existsSync(this.filePath)) {
|
|
714
|
+
const raw = fs2.readFileSync(this.filePath, "utf-8");
|
|
715
|
+
const parsed = JSON.parse(raw);
|
|
716
|
+
if (parsed && typeof parsed === "object") {
|
|
717
|
+
if ("version" in parsed && "sessions" in parsed) {
|
|
718
|
+
const oldSessions = parsed.sessions;
|
|
719
|
+
let migrateSkipped = 0;
|
|
720
|
+
for (const [id, rec] of Object.entries(oldSessions)) {
|
|
721
|
+
if (!rec.cli_session_id || typeof rec.cli_session_id !== "string") {
|
|
722
|
+
log4.warn("Skipping migrated session record with missing cli_session_id", { id });
|
|
723
|
+
migrateSkipped++;
|
|
724
|
+
continue;
|
|
725
|
+
}
|
|
726
|
+
const rawLastUsed = typeof rec.last_used_at === "number" ? rec.last_used_at : new Date(rec.last_used_at ?? "").getTime();
|
|
727
|
+
if (Number.isNaN(rawLastUsed)) {
|
|
728
|
+
log4.warn("Skipping migrated session record with invalid last_used_at", { id, last_used_at: rec.last_used_at });
|
|
729
|
+
migrateSkipped++;
|
|
730
|
+
continue;
|
|
731
|
+
}
|
|
732
|
+
this.data[id] = {
|
|
733
|
+
cli_session_id: rec.cli_session_id,
|
|
734
|
+
provider: rec.provider ?? rec.provider_id ?? "unknown",
|
|
735
|
+
created_at: typeof rec.created_at === "number" ? new Date(rec.created_at).toISOString() : rec.created_at,
|
|
736
|
+
last_used_at: typeof rec.last_used_at === "number" ? new Date(rec.last_used_at).toISOString() : rec.last_used_at
|
|
737
|
+
};
|
|
738
|
+
}
|
|
739
|
+
if (migrateSkipped > 0) {
|
|
740
|
+
log4.warn("Skipped invalid session records during migration", { count: migrateSkipped });
|
|
741
|
+
}
|
|
742
|
+
log4.debug("Migrated sessions from old format", { count: Object.keys(this.data).length });
|
|
743
|
+
this.persist();
|
|
744
|
+
} else {
|
|
745
|
+
const rawSessions = parsed;
|
|
746
|
+
let skipped = 0;
|
|
747
|
+
for (const [id, rec] of Object.entries(rawSessions)) {
|
|
748
|
+
const r = rec;
|
|
749
|
+
if (!r.cli_session_id || typeof r.cli_session_id !== "string") {
|
|
750
|
+
log4.warn("Skipping session record with missing cli_session_id", { id });
|
|
751
|
+
skipped++;
|
|
752
|
+
continue;
|
|
753
|
+
}
|
|
754
|
+
const lastUsed = new Date(r.last_used_at ?? "").getTime();
|
|
755
|
+
if (Number.isNaN(lastUsed)) {
|
|
756
|
+
log4.warn("Skipping session record with invalid last_used_at", { id, last_used_at: r.last_used_at });
|
|
757
|
+
skipped++;
|
|
758
|
+
continue;
|
|
759
|
+
}
|
|
760
|
+
this.data[id] = r;
|
|
761
|
+
}
|
|
762
|
+
if (skipped > 0) {
|
|
763
|
+
log4.warn("Skipped invalid session records on load", { count: skipped });
|
|
764
|
+
}
|
|
765
|
+
log4.debug("Sessions loaded from disk", { count: Object.keys(this.data).length });
|
|
766
|
+
}
|
|
767
|
+
}
|
|
768
|
+
}
|
|
769
|
+
} catch (err) {
|
|
770
|
+
log4.error("Failed to load sessions file \u2014 starting with empty session store", {
|
|
771
|
+
error: err instanceof Error ? err.message : String(err)
|
|
772
|
+
});
|
|
773
|
+
try {
|
|
774
|
+
if (fs2.existsSync(this.filePath)) {
|
|
775
|
+
const bakPath = this.filePath + ".bak";
|
|
776
|
+
fs2.copyFileSync(this.filePath, bakPath);
|
|
777
|
+
log4.error("Corrupted sessions file saved as backup", { backupPath: bakPath });
|
|
778
|
+
}
|
|
779
|
+
} catch {
|
|
780
|
+
}
|
|
781
|
+
}
|
|
782
|
+
this.prune();
|
|
783
|
+
}
|
|
784
|
+
// persist() uses synchronous file I/O intentionally: it is called at most
|
|
785
|
+
// once per completed request, and a single SSD write is negligible next to
|
|
786
|
+
// the multi-second CLI invocations it bookends.
|
|
787
|
+
persist() {
|
|
788
|
+
try {
|
|
789
|
+
if (!fs2.existsSync(this.dir)) {
|
|
790
|
+
fs2.mkdirSync(this.dir, { recursive: true, mode: 448 });
|
|
791
|
+
}
|
|
792
|
+
fs2.writeFileSync(this.filePath, JSON.stringify(this.data, null, 2), { encoding: "utf-8", mode: 384 });
|
|
793
|
+
} catch (err) {
|
|
794
|
+
log4.error("Failed to persist sessions file", {
|
|
795
|
+
error: err instanceof Error ? err.message : String(err)
|
|
796
|
+
});
|
|
797
|
+
}
|
|
798
|
+
}
|
|
799
|
+
};
|
|
800
|
+
|
|
801
|
+
// src/utils/clamp.ts
|
|
802
|
+
var REQUEST_TIMEOUT_MIN_S = 10;
|
|
803
|
+
var REQUEST_TIMEOUT_MAX_S = 3600;
|
|
804
|
+
var HEARTBEAT_MIN_S = 5;
|
|
805
|
+
var HEARTBEAT_MAX_S = 300;
|
|
806
|
+
function clampRequestTimeout(raw) {
|
|
807
|
+
return Math.min(Math.max(raw, REQUEST_TIMEOUT_MIN_S), REQUEST_TIMEOUT_MAX_S);
|
|
808
|
+
}
|
|
809
|
+
function clampHeartbeat(raw) {
|
|
810
|
+
return Math.min(Math.max(raw, HEARTBEAT_MIN_S), HEARTBEAT_MAX_S);
|
|
811
|
+
}
|
|
812
|
+
|
|
813
|
+
// src/errors.ts
|
|
814
|
+
var FatalBridgeError = class extends Error {
|
|
815
|
+
constructor(message) {
|
|
816
|
+
super(message);
|
|
817
|
+
this.name = "FatalBridgeError";
|
|
818
|
+
}
|
|
819
|
+
};
|
|
820
|
+
|
|
821
|
+
// src/bridge.ts
|
|
822
|
+
var log5 = createLogger("Bridge");
|
|
823
|
+
var DEFAULT_HEARTBEAT_SECONDS = 30;
|
|
824
|
+
var DEFAULT_REQUEST_TIMEOUT_SECONDS = 300;
|
|
825
|
+
var MAX_RECONNECT_ATTEMPTS = 100;
|
|
826
|
+
var BASE_RECONNECT_DELAY_MS = 1e3;
|
|
827
|
+
var MAX_RECONNECT_DELAY_MS = 15e3;
|
|
828
|
+
function escapeXml(text) {
|
|
829
|
+
return text.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">");
|
|
830
|
+
}
|
|
831
|
+
function buildSessionResetRequest(originalMsg, currentSystemPrompt) {
|
|
832
|
+
const { request_id, conversation_id, provider, history, options } = originalMsg;
|
|
833
|
+
const validRoles = /* @__PURE__ */ new Set(["user", "assistant", "system"]);
|
|
834
|
+
const unexpectedRoles = history.map((h) => h.role).filter((role) => !validRoles.has(role));
|
|
835
|
+
if (unexpectedRoles.length > 0) {
|
|
836
|
+
log5.warn("Session reset history contains unexpected role values", {
|
|
837
|
+
unexpectedRoles: [...new Set(unexpectedRoles)]
|
|
838
|
+
});
|
|
839
|
+
}
|
|
840
|
+
const lastUserIdx = history.findLastIndex((h) => h.role === "user");
|
|
841
|
+
if (lastUserIdx === -1) {
|
|
842
|
+
return null;
|
|
843
|
+
}
|
|
844
|
+
const lastUserMessage = history[lastUserIdx];
|
|
845
|
+
const rawPriorHistory = history.slice(0, lastUserIdx);
|
|
846
|
+
const priorHistory = rawPriorHistory.filter((h) => validRoles.has(h.role));
|
|
847
|
+
let enhancedSystemPrompt;
|
|
848
|
+
if (priorHistory.length > 0) {
|
|
849
|
+
const historyXml = priorHistory.map((h) => `<message role="${escapeXml(h.role)}">${escapeXml(h.content)}</message>`).join("\n");
|
|
850
|
+
const historyBlock = `<conversation_history>
|
|
851
|
+
${historyXml}
|
|
852
|
+
</conversation_history>`;
|
|
853
|
+
enhancedSystemPrompt = currentSystemPrompt ? `${currentSystemPrompt}
|
|
854
|
+
|
|
855
|
+
${historyBlock}` : historyBlock;
|
|
856
|
+
} else {
|
|
857
|
+
enhancedSystemPrompt = currentSystemPrompt;
|
|
858
|
+
}
|
|
859
|
+
return {
|
|
860
|
+
type: "ai_request",
|
|
861
|
+
request_id,
|
|
862
|
+
conversation_id,
|
|
863
|
+
provider,
|
|
864
|
+
message: lastUserMessage.content,
|
|
865
|
+
system_prompt: enhancedSystemPrompt,
|
|
866
|
+
options
|
|
867
|
+
};
|
|
868
|
+
}
|
|
869
|
+
var Bridge = class extends EventEmitter {
|
|
870
|
+
ws = null;
|
|
871
|
+
serverUrl;
|
|
872
|
+
token;
|
|
873
|
+
providers;
|
|
874
|
+
adapters;
|
|
875
|
+
toolManager = new ToolManager();
|
|
876
|
+
toolResolver = new ToolResolver();
|
|
877
|
+
sessionStore = new SessionStore();
|
|
878
|
+
callbackServer;
|
|
879
|
+
testMode;
|
|
880
|
+
onTestRequest;
|
|
881
|
+
sessionId = null;
|
|
882
|
+
serverConfig = {
|
|
883
|
+
heartbeat_interval: DEFAULT_HEARTBEAT_SECONDS,
|
|
884
|
+
request_timeout: DEFAULT_REQUEST_TIMEOUT_SECONDS
|
|
885
|
+
};
|
|
886
|
+
/** Timer to detect a missing welcome message after hello is sent. */
|
|
887
|
+
welcomeTimeoutTimer = null;
|
|
888
|
+
heartbeatTimer = null;
|
|
889
|
+
pongTimeoutTimer = null;
|
|
890
|
+
awaitingPong = false;
|
|
891
|
+
reconnectAttempts = 0;
|
|
892
|
+
reconnectTimer = null;
|
|
893
|
+
isShuttingDown = false;
|
|
894
|
+
activeRequests = /* @__PURE__ */ new Map();
|
|
895
|
+
/**
|
|
896
|
+
* Request IDs aborted because the WebSocket dropped while they were in
|
|
897
|
+
* flight. No terminal event could be sent over the closed socket, so on the
|
|
898
|
+
* next welcome these are replayed as session_expired errors to release the
|
|
899
|
+
* browser's loading state.
|
|
900
|
+
*/
|
|
901
|
+
abortedRequestIds = [];
|
|
902
|
+
/** Random secret for authenticating tool callback HTTP requests. */
|
|
903
|
+
callbackSecret;
|
|
904
|
+
constructor(options) {
|
|
905
|
+
super();
|
|
906
|
+
this.serverUrl = options.serverUrl;
|
|
907
|
+
this.token = options.token;
|
|
908
|
+
this.providers = options.providers;
|
|
909
|
+
this.adapters = options.adapters;
|
|
910
|
+
this.testMode = options.testMode ?? false;
|
|
911
|
+
this.onTestRequest = options.onTestRequest;
|
|
912
|
+
this.callbackSecret = crypto2.randomBytes(32).toString("hex");
|
|
913
|
+
const sendFn = (reqId, tcId, tName, tArgs) => {
|
|
914
|
+
this.send({
|
|
915
|
+
type: "tool_call",
|
|
916
|
+
request_id: reqId,
|
|
917
|
+
tool_call_id: tcId,
|
|
918
|
+
tool_name: tName,
|
|
919
|
+
arguments: tArgs
|
|
920
|
+
});
|
|
921
|
+
};
|
|
922
|
+
this.callbackServer = new ToolCallbackServer(
|
|
923
|
+
this.toolResolver,
|
|
924
|
+
sendFn,
|
|
925
|
+
new Set(this.toolManager.getAll().map((t) => t.name)),
|
|
926
|
+
this.callbackSecret
|
|
927
|
+
);
|
|
928
|
+
}
|
|
929
|
+
// -------------------------------------------------------------------------
|
|
930
|
+
// Connection Lifecycle
|
|
931
|
+
// -------------------------------------------------------------------------
|
|
932
|
+
/**
|
|
933
|
+
* Initiate the WebSocket connection to the server.
|
|
934
|
+
*
|
|
935
|
+
* The token is sent as an Authorization: Bearer header so it does NOT appear
|
|
936
|
+
* in server/proxy access logs. It is also kept in the URL query parameter as
|
|
937
|
+
* a backward-compatible fallback for servers that have not yet adopted the
|
|
938
|
+
* header-based flow.
|
|
939
|
+
*
|
|
940
|
+
* NOTE: When passing --token on the command line the value is still visible
|
|
941
|
+
* in process listings (ps aux). Prefer the AI_BRIDGE_TOKEN environment
|
|
942
|
+
* variable to avoid this.
|
|
943
|
+
*/
|
|
944
|
+
connect() {
|
|
945
|
+
if (this.ws) {
|
|
946
|
+
log5.warn("connect() called while already connected \u2014 ignoring");
|
|
947
|
+
return;
|
|
948
|
+
}
|
|
949
|
+
const url = new URL(this.serverUrl);
|
|
950
|
+
url.searchParams.set("token", this.token);
|
|
951
|
+
const wsUrl = url.toString();
|
|
952
|
+
log5.info("Connecting to server", { url: this.serverUrl });
|
|
953
|
+
this.ws = new WebSocket(wsUrl, {
|
|
954
|
+
headers: {
|
|
955
|
+
"User-Agent": `ai-bridge/${BRIDGE_VERSION}`,
|
|
956
|
+
// Send token via Authorization header (not visible in proxy logs)
|
|
957
|
+
"Authorization": `Bearer ${this.token}`
|
|
958
|
+
},
|
|
959
|
+
// Limit incoming message size to 10MB to prevent memory exhaustion
|
|
960
|
+
maxPayload: 10 * 1024 * 1024
|
|
961
|
+
});
|
|
962
|
+
this.ws.on("open", this.onOpen.bind(this));
|
|
963
|
+
this.ws.on("message", this.onMessage.bind(this));
|
|
964
|
+
this.ws.on("close", this.onClose.bind(this));
|
|
965
|
+
this.ws.on("error", this.onError.bind(this));
|
|
966
|
+
}
|
|
967
|
+
/**
|
|
968
|
+
* Gracefully disconnect from the server.
|
|
969
|
+
*/
|
|
970
|
+
async disconnect() {
|
|
971
|
+
this.isShuttingDown = true;
|
|
972
|
+
this.stopHeartbeat();
|
|
973
|
+
this.clearReconnectTimer();
|
|
974
|
+
this.toolResolver.cancelAll();
|
|
975
|
+
this.toolManager.cleanupScripts();
|
|
976
|
+
await this.callbackServer.stop();
|
|
977
|
+
this.sessionStore.flush();
|
|
978
|
+
for (const [id, controller] of this.activeRequests) {
|
|
979
|
+
controller.abort();
|
|
980
|
+
log5.debug("Cancelled active request", { requestId: id });
|
|
981
|
+
}
|
|
982
|
+
this.activeRequests.clear();
|
|
983
|
+
if (this.ws) {
|
|
984
|
+
if (this.ws.readyState === WebSocket.OPEN) {
|
|
985
|
+
this.ws.close(1e3, "Bridge shutting down");
|
|
986
|
+
}
|
|
987
|
+
this.ws = null;
|
|
988
|
+
}
|
|
989
|
+
log5.info("Bridge disconnected");
|
|
990
|
+
}
|
|
991
|
+
/**
|
|
992
|
+
* Returns true if the WebSocket is currently open.
|
|
993
|
+
*/
|
|
994
|
+
isConnected() {
|
|
995
|
+
return this.ws?.readyState === WebSocket.OPEN;
|
|
996
|
+
}
|
|
997
|
+
// -------------------------------------------------------------------------
|
|
998
|
+
// WebSocket Event Handlers
|
|
999
|
+
// -------------------------------------------------------------------------
|
|
1000
|
+
onOpen() {
|
|
1001
|
+
log5.info("WebSocket connected");
|
|
1002
|
+
this.reconnectAttempts = 0;
|
|
1003
|
+
this.emit("connected");
|
|
1004
|
+
this.sendHello();
|
|
1005
|
+
}
|
|
1006
|
+
onMessage(data) {
|
|
1007
|
+
let message;
|
|
1008
|
+
try {
|
|
1009
|
+
message = JSON.parse(data.toString());
|
|
1010
|
+
} catch (err) {
|
|
1011
|
+
log5.error("Failed to parse server message", {
|
|
1012
|
+
error: err instanceof Error ? err.message : String(err)
|
|
1013
|
+
});
|
|
1014
|
+
return;
|
|
1015
|
+
}
|
|
1016
|
+
log5.debug("Received message", { type: message.type });
|
|
1017
|
+
switch (message.type) {
|
|
1018
|
+
case "welcome":
|
|
1019
|
+
this.handleWelcome(message);
|
|
1020
|
+
break;
|
|
1021
|
+
case "ai_request":
|
|
1022
|
+
this.handleAiRequest(message);
|
|
1023
|
+
break;
|
|
1024
|
+
case "session_reset":
|
|
1025
|
+
this.handleSessionReset(message);
|
|
1026
|
+
break;
|
|
1027
|
+
case "tool_resolve":
|
|
1028
|
+
this.toolResolver.resolve(message.tool_call_id, message.result);
|
|
1029
|
+
break;
|
|
1030
|
+
case "tool_error":
|
|
1031
|
+
this.toolResolver.reject(message.tool_call_id, message.error);
|
|
1032
|
+
break;
|
|
1033
|
+
case "pong":
|
|
1034
|
+
log5.debug("Pong received", { timestamp: message.timestamp });
|
|
1035
|
+
this.awaitingPong = false;
|
|
1036
|
+
if (this.pongTimeoutTimer) {
|
|
1037
|
+
clearTimeout(this.pongTimeoutTimer);
|
|
1038
|
+
this.pongTimeoutTimer = null;
|
|
1039
|
+
}
|
|
1040
|
+
break;
|
|
1041
|
+
case "error":
|
|
1042
|
+
this.handleServerError(message);
|
|
1043
|
+
break;
|
|
1044
|
+
default:
|
|
1045
|
+
log5.warn("Unknown message type received", { type: message.type });
|
|
1046
|
+
}
|
|
1047
|
+
}
|
|
1048
|
+
onClose(code, reason) {
|
|
1049
|
+
const reasonStr = reason.toString();
|
|
1050
|
+
log5.info("WebSocket closed", { code, reason: reasonStr });
|
|
1051
|
+
this.stopHeartbeat();
|
|
1052
|
+
if (this.welcomeTimeoutTimer) {
|
|
1053
|
+
clearTimeout(this.welcomeTimeoutTimer);
|
|
1054
|
+
this.welcomeTimeoutTimer = null;
|
|
1055
|
+
}
|
|
1056
|
+
this.ws = null;
|
|
1057
|
+
this.sessionId = null;
|
|
1058
|
+
this.toolResolver.cancelAll();
|
|
1059
|
+
if (!this.isShuttingDown) {
|
|
1060
|
+
for (const [id, controller] of this.activeRequests) {
|
|
1061
|
+
controller.abort();
|
|
1062
|
+
this.abortedRequestIds.push(id);
|
|
1063
|
+
log5.debug("Aborted active request on disconnect", { requestId: id });
|
|
1064
|
+
}
|
|
1065
|
+
this.activeRequests.clear();
|
|
1066
|
+
}
|
|
1067
|
+
this.emit("disconnected", code, reasonStr);
|
|
1068
|
+
if (!this.isShuttingDown) {
|
|
1069
|
+
if (code === 4001) {
|
|
1070
|
+
this.toolManager.cleanupScripts();
|
|
1071
|
+
this.callbackServer.stop().catch(() => {
|
|
1072
|
+
});
|
|
1073
|
+
this.isShuttingDown = true;
|
|
1074
|
+
this.emit(
|
|
1075
|
+
"error",
|
|
1076
|
+
new FatalBridgeError(
|
|
1077
|
+
"Connection rejected: invalid or expired token. Generate a new token from your application's dashboard and restart the bridge."
|
|
1078
|
+
)
|
|
1079
|
+
);
|
|
1080
|
+
return;
|
|
1081
|
+
}
|
|
1082
|
+
this.scheduleReconnect();
|
|
1083
|
+
}
|
|
1084
|
+
}
|
|
1085
|
+
onError(err) {
|
|
1086
|
+
log5.error("WebSocket error", { error: err.message });
|
|
1087
|
+
this.emit("error", err);
|
|
1088
|
+
}
|
|
1089
|
+
// -------------------------------------------------------------------------
|
|
1090
|
+
// Protocol Handlers
|
|
1091
|
+
// -------------------------------------------------------------------------
|
|
1092
|
+
/**
|
|
1093
|
+
* Send hello message per PROTOCOL.md:
|
|
1094
|
+
* { type: "hello", version, bridge_version, providers[] }
|
|
1095
|
+
* NO token field — token is in the URL query param.
|
|
1096
|
+
* NO id field on providers — just name.
|
|
1097
|
+
*/
|
|
1098
|
+
sendHello() {
|
|
1099
|
+
const hello = {
|
|
1100
|
+
type: "hello",
|
|
1101
|
+
version: PROTOCOL_VERSION,
|
|
1102
|
+
bridge_version: BRIDGE_VERSION,
|
|
1103
|
+
providers: this.providers
|
|
1104
|
+
};
|
|
1105
|
+
this.send(hello);
|
|
1106
|
+
log5.info("Hello sent", {
|
|
1107
|
+
protocol: PROTOCOL_VERSION,
|
|
1108
|
+
providers: this.providers.filter((p) => p.available).map((p) => p.name)
|
|
1109
|
+
});
|
|
1110
|
+
this.welcomeTimeoutTimer = setTimeout(() => {
|
|
1111
|
+
this.welcomeTimeoutTimer = null;
|
|
1112
|
+
if (!this.sessionId && !this.isShuttingDown) {
|
|
1113
|
+
log5.error("Welcome message not received within 15 seconds after hello \u2014 reconnecting");
|
|
1114
|
+
this.ws?.close(4e3, "Welcome timeout");
|
|
1115
|
+
}
|
|
1116
|
+
}, 15e3);
|
|
1117
|
+
}
|
|
1118
|
+
async handleWelcome(message) {
|
|
1119
|
+
if (this.welcomeTimeoutTimer) {
|
|
1120
|
+
clearTimeout(this.welcomeTimeoutTimer);
|
|
1121
|
+
this.welcomeTimeoutTimer = null;
|
|
1122
|
+
}
|
|
1123
|
+
this.sessionId = message.session_id;
|
|
1124
|
+
this.serverConfig = message.config;
|
|
1125
|
+
if (message.protocol_version) {
|
|
1126
|
+
const serverMajor = message.protocol_version.split(".")[0];
|
|
1127
|
+
const bridgeMajor = PROTOCOL_VERSION.split(".")[0];
|
|
1128
|
+
if (serverMajor !== bridgeMajor) {
|
|
1129
|
+
log5.warn("Protocol version mismatch \u2014 major versions differ", {
|
|
1130
|
+
server: message.protocol_version,
|
|
1131
|
+
bridge: PROTOCOL_VERSION
|
|
1132
|
+
});
|
|
1133
|
+
}
|
|
1134
|
+
}
|
|
1135
|
+
if (message.config.request_timeout) {
|
|
1136
|
+
const raw = message.config.request_timeout;
|
|
1137
|
+
const clamped = clampRequestTimeout(raw);
|
|
1138
|
+
if (clamped !== raw) {
|
|
1139
|
+
log5.warn("Server request_timeout is outside safe range \u2014 clamping", {
|
|
1140
|
+
received: raw,
|
|
1141
|
+
clamped
|
|
1142
|
+
});
|
|
1143
|
+
}
|
|
1144
|
+
this.toolResolver.setTimeoutMs(clamped * 1e3);
|
|
1145
|
+
this.serverConfig.request_timeout = clamped;
|
|
1146
|
+
}
|
|
1147
|
+
this.toolManager.register(message.tools);
|
|
1148
|
+
this.callbackServer.setRegisteredToolNames(this.toolManager.getRegisteredNames());
|
|
1149
|
+
const rejectedTools = this.toolManager.getRejectedToolNames();
|
|
1150
|
+
if (rejectedTools.length > 0) {
|
|
1151
|
+
this.send({
|
|
1152
|
+
type: "error",
|
|
1153
|
+
request_id: "setup",
|
|
1154
|
+
code: "tool_rejected",
|
|
1155
|
+
message: `The following tools were rejected by the bridge due to unsafe or reserved names and will be unavailable: ${rejectedTools.join(", ")}`,
|
|
1156
|
+
fatal: false
|
|
1157
|
+
});
|
|
1158
|
+
}
|
|
1159
|
+
if (message.tools.length > 0) {
|
|
1160
|
+
try {
|
|
1161
|
+
await this.callbackServer.start();
|
|
1162
|
+
const port = this.callbackServer.getPort();
|
|
1163
|
+
if (port) {
|
|
1164
|
+
this.toolManager.generateScripts(port, this.callbackSecret, this.serverConfig.request_timeout * 1e3);
|
|
1165
|
+
log5.info("Tool scripts generated", {
|
|
1166
|
+
count: message.tools.length,
|
|
1167
|
+
callbackPort: port,
|
|
1168
|
+
scriptDir: this.toolManager.getScriptDir()
|
|
1169
|
+
});
|
|
1170
|
+
}
|
|
1171
|
+
} catch (err) {
|
|
1172
|
+
log5.error("Failed to set up tool callback server", {
|
|
1173
|
+
error: err instanceof Error ? err.message : String(err)
|
|
1174
|
+
});
|
|
1175
|
+
this.send({
|
|
1176
|
+
type: "error",
|
|
1177
|
+
request_id: "setup",
|
|
1178
|
+
code: "tool_setup_failed",
|
|
1179
|
+
message: `Tool callback server failed to start \u2014 tool calls will not work for this session: ${err instanceof Error ? err.message : String(err)}`,
|
|
1180
|
+
fatal: false
|
|
1181
|
+
});
|
|
1182
|
+
}
|
|
1183
|
+
}
|
|
1184
|
+
const rawHeartbeat = message.config.heartbeat_interval;
|
|
1185
|
+
const clampedHeartbeat = clampHeartbeat(rawHeartbeat);
|
|
1186
|
+
if (clampedHeartbeat !== rawHeartbeat) {
|
|
1187
|
+
log5.warn("Server heartbeat_interval is outside safe range \u2014 clamping", {
|
|
1188
|
+
received: rawHeartbeat,
|
|
1189
|
+
clamped: clampedHeartbeat
|
|
1190
|
+
});
|
|
1191
|
+
}
|
|
1192
|
+
const intervalMs = clampedHeartbeat * 1e3;
|
|
1193
|
+
this.startHeartbeat(intervalMs);
|
|
1194
|
+
log5.info("Welcome received", {
|
|
1195
|
+
sessionId: this.sessionId,
|
|
1196
|
+
toolCount: message.tools.length,
|
|
1197
|
+
heartbeatSeconds: message.config.heartbeat_interval
|
|
1198
|
+
});
|
|
1199
|
+
this.emit("welcome", this.sessionId);
|
|
1200
|
+
if (this.abortedRequestIds.length > 0) {
|
|
1201
|
+
const replayed = this.abortedRequestIds;
|
|
1202
|
+
this.abortedRequestIds = [];
|
|
1203
|
+
for (const requestId of replayed) {
|
|
1204
|
+
log5.info("Replaying aborted request as session_expired error", { requestId });
|
|
1205
|
+
this.send({
|
|
1206
|
+
type: "error",
|
|
1207
|
+
request_id: requestId,
|
|
1208
|
+
code: "session_expired",
|
|
1209
|
+
message: "Request aborted: the bridge connection dropped while the response was streaming.",
|
|
1210
|
+
fatal: false
|
|
1211
|
+
});
|
|
1212
|
+
}
|
|
1213
|
+
}
|
|
1214
|
+
}
|
|
1215
|
+
/**
|
|
1216
|
+
* Handle an incoming ai_request: send ack, then execute.
|
|
1217
|
+
*/
|
|
1218
|
+
handleAiRequest(message) {
|
|
1219
|
+
const { request_id, provider, conversation_id } = message;
|
|
1220
|
+
const existingCliSessionId = conversation_id ? this.sessionStore.get(conversation_id) : null;
|
|
1221
|
+
if (conversation_id && !existingCliSessionId && !message.system_prompt) {
|
|
1222
|
+
log5.warn("Session not found for conversation \u2014 notifying server", {
|
|
1223
|
+
conversationId: conversation_id
|
|
1224
|
+
});
|
|
1225
|
+
this.send({
|
|
1226
|
+
type: "error",
|
|
1227
|
+
request_id,
|
|
1228
|
+
code: "session_expired",
|
|
1229
|
+
message: `No local session found for conversation ${conversation_id}`,
|
|
1230
|
+
fatal: false
|
|
1231
|
+
});
|
|
1232
|
+
return;
|
|
1233
|
+
}
|
|
1234
|
+
this.send({
|
|
1235
|
+
type: "ai_request_ack",
|
|
1236
|
+
request_id,
|
|
1237
|
+
cli_session_id: existingCliSessionId ?? null
|
|
1238
|
+
});
|
|
1239
|
+
this.executeAiRequestInternal(message, existingCliSessionId);
|
|
1240
|
+
}
|
|
1241
|
+
/**
|
|
1242
|
+
* Internal request execution logic shared by handleAiRequest and handleSessionReset.
|
|
1243
|
+
* Does NOT send ai_request_ack — the caller is responsible for that.
|
|
1244
|
+
*/
|
|
1245
|
+
executeAiRequestInternal(message, existingCliSessionId) {
|
|
1246
|
+
const { request_id, provider } = message;
|
|
1247
|
+
const cliSessionId = existingCliSessionId ?? null;
|
|
1248
|
+
if (this.testMode && this.onTestRequest) {
|
|
1249
|
+
this.emit("request_start", request_id, provider);
|
|
1250
|
+
const sendEvent = (event, data) => {
|
|
1251
|
+
this.sendStreamEvent(request_id, event, data);
|
|
1252
|
+
};
|
|
1253
|
+
this.onTestRequest(message, sendEvent).catch((err) => {
|
|
1254
|
+
log5.error("Test mode handler failed", {
|
|
1255
|
+
error: err instanceof Error ? err.message : String(err)
|
|
1256
|
+
});
|
|
1257
|
+
this.sendStreamEvent(request_id, "error", {
|
|
1258
|
+
code: "test_error",
|
|
1259
|
+
message: err instanceof Error ? err.message : String(err)
|
|
1260
|
+
});
|
|
1261
|
+
this.sendStreamEvent(request_id, "done", {});
|
|
1262
|
+
}).finally(() => {
|
|
1263
|
+
this.emit("request_end", request_id);
|
|
1264
|
+
});
|
|
1265
|
+
return;
|
|
1266
|
+
}
|
|
1267
|
+
const adapter = this.adapters.get(provider);
|
|
1268
|
+
if (!adapter) {
|
|
1269
|
+
log5.error("No adapter for requested provider", { provider });
|
|
1270
|
+
const installHints = {
|
|
1271
|
+
codex: " Install the Codex CLI (https://github.com/openai/codex) on the machine running the bridge and restart it.",
|
|
1272
|
+
claude: " Install the Claude CLI (https://claude.ai/download) on the machine running the bridge and restart it.",
|
|
1273
|
+
gemini: " Install the Gemini CLI (https://github.com/google-gemini/gemini-cli) on the machine running the bridge and restart it."
|
|
1274
|
+
};
|
|
1275
|
+
const hint = installHints[provider] ?? "";
|
|
1276
|
+
this.send({
|
|
1277
|
+
type: "error",
|
|
1278
|
+
request_id,
|
|
1279
|
+
code: "provider_unavailable",
|
|
1280
|
+
message: `Provider "${provider}" is not available on this bridge.${hint}`,
|
|
1281
|
+
fatal: true
|
|
1282
|
+
});
|
|
1283
|
+
return;
|
|
1284
|
+
}
|
|
1285
|
+
this.emit("request_start", request_id, provider);
|
|
1286
|
+
const controller = new AbortController();
|
|
1287
|
+
this.activeRequests.set(request_id, controller);
|
|
1288
|
+
this.executeRequest(adapter, message, cliSessionId, controller.signal).catch((err) => {
|
|
1289
|
+
log5.error("Request execution failed", {
|
|
1290
|
+
requestId: request_id,
|
|
1291
|
+
error: err instanceof Error ? err.message : String(err)
|
|
1292
|
+
});
|
|
1293
|
+
this.sendStreamEvent(request_id, "error", {
|
|
1294
|
+
code: "provider_error",
|
|
1295
|
+
message: err instanceof Error ? err.message : String(err)
|
|
1296
|
+
});
|
|
1297
|
+
this.sendStreamEvent(request_id, "done", {});
|
|
1298
|
+
}).finally(() => {
|
|
1299
|
+
this.activeRequests.delete(request_id);
|
|
1300
|
+
this.emit("request_end", request_id);
|
|
1301
|
+
});
|
|
1302
|
+
}
|
|
1303
|
+
async executeRequest(adapter, request, cliSessionId, signal) {
|
|
1304
|
+
const { request_id, conversation_id } = request;
|
|
1305
|
+
const context = {
|
|
1306
|
+
request,
|
|
1307
|
+
requestId: request_id,
|
|
1308
|
+
tools: this.toolManager.getAll(),
|
|
1309
|
+
toolScriptDir: this.toolManager.getScriptDir(),
|
|
1310
|
+
onToolCall: async (toolCallId, toolName, args) => {
|
|
1311
|
+
return this.toolResolver.call(
|
|
1312
|
+
(reqId, tcId, tName, tArgs) => {
|
|
1313
|
+
this.send({
|
|
1314
|
+
type: "tool_call",
|
|
1315
|
+
request_id: reqId,
|
|
1316
|
+
tool_call_id: tcId,
|
|
1317
|
+
tool_name: tName,
|
|
1318
|
+
arguments: tArgs
|
|
1319
|
+
});
|
|
1320
|
+
},
|
|
1321
|
+
request_id,
|
|
1322
|
+
toolCallId,
|
|
1323
|
+
toolName,
|
|
1324
|
+
args
|
|
1325
|
+
);
|
|
1326
|
+
},
|
|
1327
|
+
signal,
|
|
1328
|
+
cliSessionId
|
|
1329
|
+
};
|
|
1330
|
+
let newCliSessionId = null;
|
|
1331
|
+
try {
|
|
1332
|
+
newCliSessionId = await adapter.execute(context, (event) => {
|
|
1333
|
+
this.sendStreamEvent(request_id, event.event, event.data);
|
|
1334
|
+
});
|
|
1335
|
+
} finally {
|
|
1336
|
+
if (newCliSessionId && conversation_id) {
|
|
1337
|
+
this.sessionStore.set(
|
|
1338
|
+
conversation_id,
|
|
1339
|
+
newCliSessionId,
|
|
1340
|
+
adapter.providerName,
|
|
1341
|
+
request.system_prompt
|
|
1342
|
+
);
|
|
1343
|
+
}
|
|
1344
|
+
}
|
|
1345
|
+
}
|
|
1346
|
+
/**
|
|
1347
|
+
* Handle a session_reset message by constructing a synthetic ai_request
|
|
1348
|
+
* from the conversation history and executing it without sending an ack.
|
|
1349
|
+
*/
|
|
1350
|
+
handleSessionReset(message) {
|
|
1351
|
+
const { request_id, conversation_id } = message;
|
|
1352
|
+
const storedSystemPrompt = this.sessionStore.getSystemPrompt(conversation_id);
|
|
1353
|
+
const deleted = this.sessionStore.delete(conversation_id);
|
|
1354
|
+
log5.info("Session reset", { conversationId: conversation_id, found: deleted, historyLength: message.history.length });
|
|
1355
|
+
const effectiveSystemPrompt = message.system_prompt ?? storedSystemPrompt;
|
|
1356
|
+
if (!message.system_prompt && storedSystemPrompt) {
|
|
1357
|
+
log5.warn("session_reset has no system_prompt \u2014 using stored system prompt for this conversation", {
|
|
1358
|
+
conversationId: conversation_id
|
|
1359
|
+
});
|
|
1360
|
+
}
|
|
1361
|
+
const syntheticRequest = buildSessionResetRequest(message, effectiveSystemPrompt);
|
|
1362
|
+
if (!syntheticRequest) {
|
|
1363
|
+
log5.error("Session reset has no user message in history", { conversationId: conversation_id });
|
|
1364
|
+
this.send({
|
|
1365
|
+
type: "error",
|
|
1366
|
+
request_id,
|
|
1367
|
+
code: "session_reset_failed",
|
|
1368
|
+
message: "No user message found in conversation history",
|
|
1369
|
+
fatal: true
|
|
1370
|
+
});
|
|
1371
|
+
return;
|
|
1372
|
+
}
|
|
1373
|
+
log5.info("Re-processing session_reset as new ai_request", { requestId: request_id, provider: message.provider });
|
|
1374
|
+
this.executeAiRequestInternal(syntheticRequest);
|
|
1375
|
+
}
|
|
1376
|
+
handleServerError(message) {
|
|
1377
|
+
log5.error("Server error", { code: message.code, message: message.message, fatal: message.fatal });
|
|
1378
|
+
if (message.fatal) {
|
|
1379
|
+
log5.error("Fatal server error \u2014 disconnecting");
|
|
1380
|
+
this.isShuttingDown = true;
|
|
1381
|
+
this.toolManager.cleanupScripts();
|
|
1382
|
+
this.callbackServer.stop().catch(() => {
|
|
1383
|
+
});
|
|
1384
|
+
this.ws?.close(1e3, "Fatal server error");
|
|
1385
|
+
}
|
|
1386
|
+
}
|
|
1387
|
+
// -------------------------------------------------------------------------
|
|
1388
|
+
// Heartbeat
|
|
1389
|
+
// -------------------------------------------------------------------------
|
|
1390
|
+
startHeartbeat(intervalMs) {
|
|
1391
|
+
this.stopHeartbeat();
|
|
1392
|
+
this.heartbeatTimer = setInterval(() => {
|
|
1393
|
+
if (this.isConnected()) {
|
|
1394
|
+
this.send({ type: "ping", timestamp: Date.now() });
|
|
1395
|
+
log5.debug("Ping sent");
|
|
1396
|
+
this.awaitingPong = true;
|
|
1397
|
+
if (this.pongTimeoutTimer) {
|
|
1398
|
+
clearTimeout(this.pongTimeoutTimer);
|
|
1399
|
+
}
|
|
1400
|
+
this.pongTimeoutTimer = setTimeout(() => {
|
|
1401
|
+
if (this.awaitingPong && this.isConnected()) {
|
|
1402
|
+
log5.warn("Pong not received within 10s \u2014 connection presumed dead");
|
|
1403
|
+
this.ws?.close(4e3, "Pong timeout");
|
|
1404
|
+
}
|
|
1405
|
+
}, 1e4);
|
|
1406
|
+
}
|
|
1407
|
+
}, intervalMs);
|
|
1408
|
+
log5.debug("Heartbeat started", { intervalMs });
|
|
1409
|
+
}
|
|
1410
|
+
stopHeartbeat() {
|
|
1411
|
+
if (this.heartbeatTimer) {
|
|
1412
|
+
clearInterval(this.heartbeatTimer);
|
|
1413
|
+
this.heartbeatTimer = null;
|
|
1414
|
+
}
|
|
1415
|
+
if (this.pongTimeoutTimer) {
|
|
1416
|
+
clearTimeout(this.pongTimeoutTimer);
|
|
1417
|
+
this.pongTimeoutTimer = null;
|
|
1418
|
+
}
|
|
1419
|
+
this.awaitingPong = false;
|
|
1420
|
+
}
|
|
1421
|
+
// -------------------------------------------------------------------------
|
|
1422
|
+
// Reconnection with Exponential Backoff
|
|
1423
|
+
// -------------------------------------------------------------------------
|
|
1424
|
+
scheduleReconnect() {
|
|
1425
|
+
if (this.reconnectAttempts >= MAX_RECONNECT_ATTEMPTS) {
|
|
1426
|
+
log5.error("Maximum reconnection attempts reached \u2014 giving up", {
|
|
1427
|
+
attempts: this.reconnectAttempts,
|
|
1428
|
+
max: MAX_RECONNECT_ATTEMPTS
|
|
1429
|
+
});
|
|
1430
|
+
this.emit(
|
|
1431
|
+
"error",
|
|
1432
|
+
new FatalBridgeError(
|
|
1433
|
+
"Maximum reconnection attempts reached. Check that the server URL is correct and the server is reachable, then restart the bridge."
|
|
1434
|
+
)
|
|
1435
|
+
);
|
|
1436
|
+
return;
|
|
1437
|
+
}
|
|
1438
|
+
const delay2 = Math.min(
|
|
1439
|
+
BASE_RECONNECT_DELAY_MS * Math.pow(2, this.reconnectAttempts),
|
|
1440
|
+
MAX_RECONNECT_DELAY_MS
|
|
1441
|
+
);
|
|
1442
|
+
this.reconnectAttempts++;
|
|
1443
|
+
log5.info(`Reconnecting in ${delay2 / 1e3}s (attempt ${this.reconnectAttempts}/${MAX_RECONNECT_ATTEMPTS})`);
|
|
1444
|
+
this.reconnectTimer = setTimeout(() => {
|
|
1445
|
+
this.reconnectTimer = null;
|
|
1446
|
+
this.connect();
|
|
1447
|
+
}, delay2);
|
|
1448
|
+
}
|
|
1449
|
+
clearReconnectTimer() {
|
|
1450
|
+
if (this.reconnectTimer) {
|
|
1451
|
+
clearTimeout(this.reconnectTimer);
|
|
1452
|
+
this.reconnectTimer = null;
|
|
1453
|
+
}
|
|
1454
|
+
}
|
|
1455
|
+
// -------------------------------------------------------------------------
|
|
1456
|
+
// Message Sending
|
|
1457
|
+
// -------------------------------------------------------------------------
|
|
1458
|
+
send(message) {
|
|
1459
|
+
if (!this.ws || this.ws.readyState !== WebSocket.OPEN) {
|
|
1460
|
+
log5.warn("Cannot send message \u2014 WebSocket not open", { type: message.type });
|
|
1461
|
+
return;
|
|
1462
|
+
}
|
|
1463
|
+
const payload = JSON.stringify(message);
|
|
1464
|
+
this.ws.send(payload);
|
|
1465
|
+
log5.debug("Message sent", { type: message.type, bytes: payload.length });
|
|
1466
|
+
}
|
|
1467
|
+
/**
|
|
1468
|
+
* Send a stream event using the correct envelope format per PROTOCOL.md:
|
|
1469
|
+
* { type: "stream", request_id, event: "<event_type>", data: {...} }
|
|
1470
|
+
*/
|
|
1471
|
+
sendStreamEvent(requestId, event, data) {
|
|
1472
|
+
this.send({
|
|
1473
|
+
type: "stream",
|
|
1474
|
+
request_id: requestId,
|
|
1475
|
+
event,
|
|
1476
|
+
data
|
|
1477
|
+
});
|
|
1478
|
+
}
|
|
1479
|
+
};
|
|
1480
|
+
|
|
1481
|
+
// src/providers/detector.ts
|
|
1482
|
+
import { execFile } from "child_process";
|
|
1483
|
+
import { promisify } from "util";
|
|
1484
|
+
var log6 = createLogger("Detector");
|
|
1485
|
+
var execFileAsync = promisify(execFile);
|
|
1486
|
+
var CLI_PROBES = [
|
|
1487
|
+
{
|
|
1488
|
+
name: "codex",
|
|
1489
|
+
binary: "codex",
|
|
1490
|
+
versionArgs: ["--version"],
|
|
1491
|
+
parseVersion: (output) => extractVersion(output),
|
|
1492
|
+
supports_streaming: true,
|
|
1493
|
+
// Codex supports server-defined bridge tools via wrapper scripts on PATH.
|
|
1494
|
+
// See codex.ts for the full mechanism.
|
|
1495
|
+
supports_tools: true,
|
|
1496
|
+
supports_thinking: true,
|
|
1497
|
+
supports_session_resume: true
|
|
1498
|
+
},
|
|
1499
|
+
{
|
|
1500
|
+
name: "claude",
|
|
1501
|
+
binary: "claude",
|
|
1502
|
+
versionArgs: ["--version"],
|
|
1503
|
+
parseVersion: (output) => extractVersion(output),
|
|
1504
|
+
supports_streaming: true,
|
|
1505
|
+
supports_tools: true,
|
|
1506
|
+
supports_thinking: true,
|
|
1507
|
+
supports_session_resume: true
|
|
1508
|
+
},
|
|
1509
|
+
{
|
|
1510
|
+
name: "gemini",
|
|
1511
|
+
binary: "gemini",
|
|
1512
|
+
versionArgs: ["--version"],
|
|
1513
|
+
parseVersion: (output) => extractVersion(output),
|
|
1514
|
+
supports_streaming: true,
|
|
1515
|
+
supports_tools: true,
|
|
1516
|
+
supports_thinking: false,
|
|
1517
|
+
supports_session_resume: true
|
|
1518
|
+
}
|
|
1519
|
+
];
|
|
1520
|
+
function extractVersion(output) {
|
|
1521
|
+
const match = output.match(/v?(\d+\.\d+\.\d+[\w.-]*)/);
|
|
1522
|
+
return match ? match[1] : null;
|
|
1523
|
+
}
|
|
1524
|
+
async function probeOne(probe) {
|
|
1525
|
+
const capability = {
|
|
1526
|
+
name: probe.name,
|
|
1527
|
+
version: null,
|
|
1528
|
+
available: false,
|
|
1529
|
+
supports_streaming: probe.supports_streaming,
|
|
1530
|
+
supports_tools: probe.supports_tools,
|
|
1531
|
+
supports_thinking: probe.supports_thinking,
|
|
1532
|
+
supports_session_resume: probe.supports_session_resume
|
|
1533
|
+
};
|
|
1534
|
+
try {
|
|
1535
|
+
const probeEnv = { ...process.env };
|
|
1536
|
+
delete probeEnv["AI_BRIDGE_TOKEN"];
|
|
1537
|
+
delete probeEnv["AI_BRIDGE_SERVER"];
|
|
1538
|
+
const { stdout, stderr } = await execFileAsync(probe.binary, probe.versionArgs, {
|
|
1539
|
+
timeout: 5e3,
|
|
1540
|
+
env: probeEnv
|
|
1541
|
+
});
|
|
1542
|
+
const output = (stdout || "") + (stderr || "");
|
|
1543
|
+
capability.available = true;
|
|
1544
|
+
capability.version = probe.parseVersion(output.trim());
|
|
1545
|
+
log6.info(`Detected ${probe.name}`, { version: capability.version });
|
|
1546
|
+
} catch (err) {
|
|
1547
|
+
log6.debug(`${probe.name} not found or not executable`, {
|
|
1548
|
+
error: err instanceof Error ? err.message : String(err)
|
|
1549
|
+
});
|
|
1550
|
+
}
|
|
1551
|
+
return capability;
|
|
1552
|
+
}
|
|
1553
|
+
async function detectProviders() {
|
|
1554
|
+
log6.info("Detecting locally installed AI CLI tools...");
|
|
1555
|
+
const results = await Promise.all(CLI_PROBES.map(probeOne));
|
|
1556
|
+
const available = results.filter((r) => r.available);
|
|
1557
|
+
log6.info(`Detection complete: ${available.length}/${results.length} providers available`);
|
|
1558
|
+
return results;
|
|
1559
|
+
}
|
|
1560
|
+
|
|
1561
|
+
// src/providers/codex.ts
|
|
1562
|
+
import { spawn } from "child_process";
|
|
1563
|
+
import { readFile } from "fs/promises";
|
|
1564
|
+
import { createInterface } from "readline";
|
|
1565
|
+
import { homedir } from "os";
|
|
1566
|
+
import { join } from "path";
|
|
1567
|
+
|
|
1568
|
+
// src/providers/env.ts
|
|
1569
|
+
var MAX_STDERR_BYTES = 10 * 1024;
|
|
1570
|
+
function buildSpawnEnv(toolScriptDir, requestId) {
|
|
1571
|
+
const env = { ...process.env };
|
|
1572
|
+
if (toolScriptDir) {
|
|
1573
|
+
env["PATH"] = `${toolScriptDir}:${env["PATH"] ?? ""}`;
|
|
1574
|
+
}
|
|
1575
|
+
if (requestId) {
|
|
1576
|
+
env["AI_BRIDGE_REQUEST_ID"] = requestId;
|
|
1577
|
+
}
|
|
1578
|
+
delete env["AI_BRIDGE_TOKEN"];
|
|
1579
|
+
delete env["AI_BRIDGE_SERVER"];
|
|
1580
|
+
return env;
|
|
1581
|
+
}
|
|
1582
|
+
function buildCombinedPrompt(systemPrompt, userMessage) {
|
|
1583
|
+
return `${systemPrompt}
|
|
1584
|
+
|
|
1585
|
+
User request:
|
|
1586
|
+
${userMessage}`;
|
|
1587
|
+
}
|
|
1588
|
+
function appendStderr(buffer, chunk) {
|
|
1589
|
+
if (buffer.length >= MAX_STDERR_BYTES) {
|
|
1590
|
+
return buffer;
|
|
1591
|
+
}
|
|
1592
|
+
buffer += chunk;
|
|
1593
|
+
if (buffer.length > MAX_STDERR_BYTES) {
|
|
1594
|
+
buffer = buffer.slice(0, MAX_STDERR_BYTES);
|
|
1595
|
+
}
|
|
1596
|
+
return buffer;
|
|
1597
|
+
}
|
|
1598
|
+
function formatStderrMessage(provider, stderr, exitCode) {
|
|
1599
|
+
const clean = stderr.replace(/\x1b\[[0-9;]*[mGKHFJSTsuABCDhl]/g, "").trim();
|
|
1600
|
+
if (!clean) {
|
|
1601
|
+
return `${provider} CLI exited with code ${exitCode ?? "unknown"}`;
|
|
1602
|
+
}
|
|
1603
|
+
const lower = clean.toLowerCase();
|
|
1604
|
+
if (lower.includes("401") || lower.includes("403") || lower.includes("unauthorized") || lower.includes("unauthenticated") || lower.includes("auth") || lower.includes("login") || lower.includes("authenticate") || lower.includes("not logged in") || lower.includes("sign in") || lower.includes("credentials")) {
|
|
1605
|
+
const authCmd = provider === "codex" ? `${provider} login` : `${provider} auth login`;
|
|
1606
|
+
return `Authentication required \u2014 run \`${authCmd}\` to re-authenticate.`;
|
|
1607
|
+
}
|
|
1608
|
+
if (lower.includes("rate limit") || lower.includes("ratelimit") || lower.includes("too many requests") || lower.includes("429")) {
|
|
1609
|
+
return `Rate limit reached \u2014 please wait a moment and try again.`;
|
|
1610
|
+
}
|
|
1611
|
+
const firstLine = clean.split("\n").find((l) => l.trim()) ?? clean;
|
|
1612
|
+
return firstLine.substring(0, 500);
|
|
1613
|
+
}
|
|
1614
|
+
|
|
1615
|
+
// src/providers/base.ts
|
|
1616
|
+
function createFinalizer(opts) {
|
|
1617
|
+
let rlClosed = false;
|
|
1618
|
+
let childExitCode = null;
|
|
1619
|
+
let childExited = false;
|
|
1620
|
+
const tryFinalize = () => {
|
|
1621
|
+
if (!rlClosed || !childExited) return;
|
|
1622
|
+
opts.signal.removeEventListener("abort", opts.onAbort);
|
|
1623
|
+
if (opts.getSettled()) {
|
|
1624
|
+
opts.resolve(opts.getSessionId());
|
|
1625
|
+
return;
|
|
1626
|
+
}
|
|
1627
|
+
opts.setSettled();
|
|
1628
|
+
opts.onBeforeFinalize?.();
|
|
1629
|
+
if (childExitCode !== 0 && childExitCode !== null) {
|
|
1630
|
+
opts.onEvent({
|
|
1631
|
+
event: "error",
|
|
1632
|
+
data: {
|
|
1633
|
+
code: "provider_error",
|
|
1634
|
+
message: formatStderrMessage(opts.providerName, opts.getStderr(), childExitCode)
|
|
1635
|
+
}
|
|
1636
|
+
});
|
|
1637
|
+
opts.onEvent({ event: "done", data: {} });
|
|
1638
|
+
} else {
|
|
1639
|
+
opts.onEvent({
|
|
1640
|
+
event: "error",
|
|
1641
|
+
data: {
|
|
1642
|
+
code: "provider_empty_response",
|
|
1643
|
+
message: "The AI returned no response. Please try again."
|
|
1644
|
+
}
|
|
1645
|
+
});
|
|
1646
|
+
opts.onEvent({ event: "done", data: {} });
|
|
1647
|
+
}
|
|
1648
|
+
opts.resolve(opts.getSessionId());
|
|
1649
|
+
};
|
|
1650
|
+
return {
|
|
1651
|
+
onRlClose: () => {
|
|
1652
|
+
rlClosed = true;
|
|
1653
|
+
tryFinalize();
|
|
1654
|
+
},
|
|
1655
|
+
onChildClose: (code) => {
|
|
1656
|
+
childExitCode = code;
|
|
1657
|
+
childExited = true;
|
|
1658
|
+
tryFinalize();
|
|
1659
|
+
}
|
|
1660
|
+
};
|
|
1661
|
+
}
|
|
1662
|
+
var ProviderAdapter = class {
|
|
1663
|
+
};
|
|
1664
|
+
|
|
1665
|
+
// src/providers/codex.ts
|
|
1666
|
+
var log7 = createLogger("CodexAdapter");
|
|
1667
|
+
var DEFAULT_MODEL = "gpt-5.3-codex";
|
|
1668
|
+
function buildToolPromptNote(tools) {
|
|
1669
|
+
if (tools.length === 0) return "";
|
|
1670
|
+
const lines = tools.map((t) => `- \`${t.name}\`: ${t.description}`);
|
|
1671
|
+
return "\n\n---\nThe following bridge tools are available to you as shell commands. Run a tool by executing its command name in the shell (passing any arguments it documents); the command performs the action and prints the result. Use them when they help answer the request:\n" + lines.join("\n");
|
|
1672
|
+
}
|
|
1673
|
+
var CodexAdapter = class extends ProviderAdapter {
|
|
1674
|
+
providerName = "codex";
|
|
1675
|
+
async execute(context, onEvent) {
|
|
1676
|
+
const { request, signal, cliSessionId } = context;
|
|
1677
|
+
const requestId = request.request_id;
|
|
1678
|
+
const userMessage = request.message;
|
|
1679
|
+
log7.info("Executing Codex request", { requestId });
|
|
1680
|
+
let args;
|
|
1681
|
+
const model = request.options?.model ?? DEFAULT_MODEL;
|
|
1682
|
+
if (cliSessionId) {
|
|
1683
|
+
args = [
|
|
1684
|
+
"exec",
|
|
1685
|
+
"resume",
|
|
1686
|
+
cliSessionId,
|
|
1687
|
+
"--json"
|
|
1688
|
+
];
|
|
1689
|
+
if (request.options?.model) {
|
|
1690
|
+
args.push("-m", request.options.model);
|
|
1691
|
+
}
|
|
1692
|
+
log7.debug("Resuming session", { cliSessionId });
|
|
1693
|
+
} else {
|
|
1694
|
+
args = [
|
|
1695
|
+
"exec",
|
|
1696
|
+
"--json",
|
|
1697
|
+
"--skip-git-repo-check",
|
|
1698
|
+
"--ephemeral",
|
|
1699
|
+
"-m",
|
|
1700
|
+
model
|
|
1701
|
+
];
|
|
1702
|
+
}
|
|
1703
|
+
const hasTools = context.tools.length > 0;
|
|
1704
|
+
if (hasTools) {
|
|
1705
|
+
args.push(
|
|
1706
|
+
"-s",
|
|
1707
|
+
"workspace-write",
|
|
1708
|
+
"-c",
|
|
1709
|
+
"sandbox_workspace_write.network_access=true",
|
|
1710
|
+
"-c",
|
|
1711
|
+
"approval_policy=never"
|
|
1712
|
+
);
|
|
1713
|
+
}
|
|
1714
|
+
const toolNote = hasTools ? buildToolPromptNote(context.tools) : "";
|
|
1715
|
+
if (!cliSessionId && request.system_prompt) {
|
|
1716
|
+
args.push("--", buildCombinedPrompt(request.system_prompt, userMessage) + toolNote);
|
|
1717
|
+
} else if (toolNote) {
|
|
1718
|
+
args.push("--", userMessage + toolNote);
|
|
1719
|
+
} else {
|
|
1720
|
+
args.push(userMessage);
|
|
1721
|
+
}
|
|
1722
|
+
if (request.options?.max_tokens) {
|
|
1723
|
+
log7.warn("max_tokens option specified but Codex CLI does not support it directly \u2014 ignoring", {
|
|
1724
|
+
max_tokens: request.options.max_tokens
|
|
1725
|
+
});
|
|
1726
|
+
}
|
|
1727
|
+
if (isDebugEnabled()) {
|
|
1728
|
+
log7.debug("Spawning codex", { args: args.map((a) => a.length > 50 ? a.substring(0, 50) + "..." : a) });
|
|
1729
|
+
}
|
|
1730
|
+
return new Promise((resolve) => {
|
|
1731
|
+
let sessionId = null;
|
|
1732
|
+
let blockIndex = 0;
|
|
1733
|
+
let settled = false;
|
|
1734
|
+
if (hasTools) {
|
|
1735
|
+
log7.info("Server-defined tools enabled for Codex request", {
|
|
1736
|
+
toolCount: context.tools.length
|
|
1737
|
+
});
|
|
1738
|
+
}
|
|
1739
|
+
const env = buildSpawnEnv(hasTools ? context.toolScriptDir : null, context.requestId);
|
|
1740
|
+
const child = spawn("codex", args, {
|
|
1741
|
+
env,
|
|
1742
|
+
stdio: ["ignore", "pipe", "pipe"]
|
|
1743
|
+
});
|
|
1744
|
+
const onAbort = () => {
|
|
1745
|
+
log7.info("Request aborted \u2014 killing codex process", { requestId });
|
|
1746
|
+
child.kill("SIGTERM");
|
|
1747
|
+
};
|
|
1748
|
+
signal.addEventListener("abort", onAbort, { once: true });
|
|
1749
|
+
let stderrBuffer = "";
|
|
1750
|
+
const finalizer = createFinalizer({
|
|
1751
|
+
providerName: "codex",
|
|
1752
|
+
terminalEvent: "turn.completed",
|
|
1753
|
+
getSettled: () => settled,
|
|
1754
|
+
setSettled: () => {
|
|
1755
|
+
settled = true;
|
|
1756
|
+
},
|
|
1757
|
+
getSessionId: () => sessionId,
|
|
1758
|
+
getStderr: () => stderrBuffer,
|
|
1759
|
+
onEvent,
|
|
1760
|
+
resolve,
|
|
1761
|
+
signal,
|
|
1762
|
+
onAbort
|
|
1763
|
+
});
|
|
1764
|
+
const rl = createInterface({ input: child.stdout });
|
|
1765
|
+
rl.on("line", (line) => {
|
|
1766
|
+
if (!line.trim()) return;
|
|
1767
|
+
let parsed;
|
|
1768
|
+
try {
|
|
1769
|
+
parsed = JSON.parse(line);
|
|
1770
|
+
} catch {
|
|
1771
|
+
log7.debug("Skipping non-JSON line", { line: line.substring(0, 100) });
|
|
1772
|
+
return;
|
|
1773
|
+
}
|
|
1774
|
+
const type = parsed["type"];
|
|
1775
|
+
if (type === "thread.started") {
|
|
1776
|
+
sessionId = parsed["thread_id"] ?? null;
|
|
1777
|
+
log7.debug("Thread started", { sessionId });
|
|
1778
|
+
return;
|
|
1779
|
+
}
|
|
1780
|
+
if (type === "turn.started") {
|
|
1781
|
+
log7.debug("Turn started");
|
|
1782
|
+
return;
|
|
1783
|
+
}
|
|
1784
|
+
if (type === "item.completed") {
|
|
1785
|
+
const item = parsed["item"];
|
|
1786
|
+
if (!item) return;
|
|
1787
|
+
const itemType = item["type"];
|
|
1788
|
+
if (itemType === "agent_message") {
|
|
1789
|
+
const text = item["text"];
|
|
1790
|
+
if (!text) return;
|
|
1791
|
+
onEvent({
|
|
1792
|
+
event: "block_start",
|
|
1793
|
+
data: { block_index: blockIndex, block_type: "text" }
|
|
1794
|
+
});
|
|
1795
|
+
onEvent({
|
|
1796
|
+
event: "block_delta",
|
|
1797
|
+
data: { block_index: blockIndex, content: text }
|
|
1798
|
+
});
|
|
1799
|
+
onEvent({
|
|
1800
|
+
event: "block_stop",
|
|
1801
|
+
data: { block_index: blockIndex }
|
|
1802
|
+
});
|
|
1803
|
+
blockIndex++;
|
|
1804
|
+
} else if (itemType === "reasoning") {
|
|
1805
|
+
const text = item["text"] ?? "";
|
|
1806
|
+
if (!text) return;
|
|
1807
|
+
onEvent({
|
|
1808
|
+
event: "block_start",
|
|
1809
|
+
data: { block_index: blockIndex, block_type: "thinking" }
|
|
1810
|
+
});
|
|
1811
|
+
onEvent({
|
|
1812
|
+
event: "block_delta",
|
|
1813
|
+
data: { block_index: blockIndex, content: text }
|
|
1814
|
+
});
|
|
1815
|
+
onEvent({
|
|
1816
|
+
event: "block_stop",
|
|
1817
|
+
data: { block_index: blockIndex }
|
|
1818
|
+
});
|
|
1819
|
+
blockIndex++;
|
|
1820
|
+
} else if (itemType === "error") {
|
|
1821
|
+
const message = item["message"] ?? "Unknown Codex error";
|
|
1822
|
+
log7.warn("Codex error item", { message: message.substring(0, 200) });
|
|
1823
|
+
onEvent({
|
|
1824
|
+
event: "error",
|
|
1825
|
+
data: { code: "provider_error", message }
|
|
1826
|
+
});
|
|
1827
|
+
onEvent({ event: "done", data: {} });
|
|
1828
|
+
settled = true;
|
|
1829
|
+
}
|
|
1830
|
+
return;
|
|
1831
|
+
}
|
|
1832
|
+
if (type === "turn.completed") {
|
|
1833
|
+
if (settled) return;
|
|
1834
|
+
const usage = parsed["usage"];
|
|
1835
|
+
const inputTokens = usage ? usage["input_tokens"] ?? null : null;
|
|
1836
|
+
const outputTokens = usage ? usage["output_tokens"] ?? null : null;
|
|
1837
|
+
onEvent({
|
|
1838
|
+
event: "done",
|
|
1839
|
+
data: {
|
|
1840
|
+
usage: {
|
|
1841
|
+
input_tokens: inputTokens,
|
|
1842
|
+
output_tokens: outputTokens
|
|
1843
|
+
}
|
|
1844
|
+
}
|
|
1845
|
+
});
|
|
1846
|
+
settled = true;
|
|
1847
|
+
return;
|
|
1848
|
+
}
|
|
1849
|
+
if (type === "turn.failed") {
|
|
1850
|
+
const error = parsed["error"];
|
|
1851
|
+
const message = error?.["message"] ?? "Codex turn failed";
|
|
1852
|
+
log7.warn("Codex turn failed", { message: message.substring(0, 200) });
|
|
1853
|
+
onEvent({
|
|
1854
|
+
event: "error",
|
|
1855
|
+
data: { code: "provider_error", message }
|
|
1856
|
+
});
|
|
1857
|
+
onEvent({ event: "done", data: {} });
|
|
1858
|
+
settled = true;
|
|
1859
|
+
return;
|
|
1860
|
+
}
|
|
1861
|
+
if (type === "error") {
|
|
1862
|
+
const message = parsed["message"] ?? "Unknown Codex error";
|
|
1863
|
+
log7.warn("Codex error event", { message: message.substring(0, 200) });
|
|
1864
|
+
onEvent({
|
|
1865
|
+
event: "error",
|
|
1866
|
+
data: { code: "provider_error", message }
|
|
1867
|
+
});
|
|
1868
|
+
onEvent({ event: "done", data: {} });
|
|
1869
|
+
settled = true;
|
|
1870
|
+
return;
|
|
1871
|
+
}
|
|
1872
|
+
log7.debug("Unhandled Codex event type", { type });
|
|
1873
|
+
});
|
|
1874
|
+
rl.on("close", finalizer.onRlClose);
|
|
1875
|
+
child.stderr.on("data", (chunk) => {
|
|
1876
|
+
stderrBuffer = appendStderr(stderrBuffer, chunk.toString());
|
|
1877
|
+
});
|
|
1878
|
+
child.on("error", (err) => {
|
|
1879
|
+
log7.error("Failed to spawn codex", { error: err.message });
|
|
1880
|
+
const errorMessage = err.code === "ENOENT" ? "codex CLI not found. Install it or ensure it is on your PATH." : `Failed to spawn codex: ${err.message}`;
|
|
1881
|
+
signal.removeEventListener("abort", onAbort);
|
|
1882
|
+
if (!settled) {
|
|
1883
|
+
settled = true;
|
|
1884
|
+
onEvent({
|
|
1885
|
+
event: "error",
|
|
1886
|
+
data: {
|
|
1887
|
+
code: "provider_spawn_error",
|
|
1888
|
+
message: errorMessage
|
|
1889
|
+
}
|
|
1890
|
+
});
|
|
1891
|
+
onEvent({ event: "done", data: {} });
|
|
1892
|
+
resolve(null);
|
|
1893
|
+
}
|
|
1894
|
+
});
|
|
1895
|
+
child.on("close", (code) => {
|
|
1896
|
+
log7.debug("Codex process closed", { code, sessionId });
|
|
1897
|
+
finalizer.onChildClose(code);
|
|
1898
|
+
});
|
|
1899
|
+
});
|
|
1900
|
+
}
|
|
1901
|
+
async listModels() {
|
|
1902
|
+
try {
|
|
1903
|
+
const cachePath = join(homedir(), ".codex", "models_cache.json");
|
|
1904
|
+
const raw = await readFile(cachePath, "utf-8");
|
|
1905
|
+
const cache = JSON.parse(raw);
|
|
1906
|
+
if (!cache.models || !Array.isArray(cache.models)) {
|
|
1907
|
+
log7.warn("Codex models cache is empty or invalid");
|
|
1908
|
+
return [];
|
|
1909
|
+
}
|
|
1910
|
+
return cache.models.filter((m) => m.visibility !== "hide").map((m) => ({
|
|
1911
|
+
id: m.slug,
|
|
1912
|
+
name: m.display_name,
|
|
1913
|
+
description: m.description,
|
|
1914
|
+
is_default: m.slug === DEFAULT_MODEL
|
|
1915
|
+
}));
|
|
1916
|
+
} catch (err) {
|
|
1917
|
+
log7.warn("Failed to read Codex models cache. Run codex once to populate models cache. Showing default model only.", {
|
|
1918
|
+
error: err instanceof Error ? err.message : String(err)
|
|
1919
|
+
});
|
|
1920
|
+
return [
|
|
1921
|
+
{ id: DEFAULT_MODEL, name: DEFAULT_MODEL, is_default: true }
|
|
1922
|
+
];
|
|
1923
|
+
}
|
|
1924
|
+
}
|
|
1925
|
+
};
|
|
1926
|
+
|
|
1927
|
+
// src/providers/claude.ts
|
|
1928
|
+
import { spawn as spawn2 } from "child_process";
|
|
1929
|
+
import { createInterface as createInterface2 } from "readline";
|
|
1930
|
+
var CLAUDE_MODELS = [
|
|
1931
|
+
{ id: "sonnet", name: "Sonnet", description: "Best balance of speed and intelligence", is_default: true },
|
|
1932
|
+
{ id: "opus", name: "Opus", description: "Highest intelligence, slower", is_default: false },
|
|
1933
|
+
{ id: "haiku", name: "Haiku", description: "Fastest and most cost-efficient", is_default: false }
|
|
1934
|
+
];
|
|
1935
|
+
var log8 = createLogger("ClaudeAdapter");
|
|
1936
|
+
var ClaudeAdapter = class extends ProviderAdapter {
|
|
1937
|
+
providerName = "claude";
|
|
1938
|
+
async execute(context, onEvent) {
|
|
1939
|
+
const { request, signal, cliSessionId } = context;
|
|
1940
|
+
const requestId = request.request_id;
|
|
1941
|
+
const userMessage = request.message;
|
|
1942
|
+
log8.info("Executing Claude request", { requestId });
|
|
1943
|
+
const args = [
|
|
1944
|
+
"-p",
|
|
1945
|
+
// Print mode (non-interactive)
|
|
1946
|
+
"--output-format",
|
|
1947
|
+
"stream-json",
|
|
1948
|
+
// NDJSON streaming output
|
|
1949
|
+
"--verbose"
|
|
1950
|
+
// Required for stream-json in print mode
|
|
1951
|
+
];
|
|
1952
|
+
if (cliSessionId) {
|
|
1953
|
+
args.push("--session-id", cliSessionId);
|
|
1954
|
+
log8.debug("Resuming session", { cliSessionId });
|
|
1955
|
+
}
|
|
1956
|
+
if (request.system_prompt && !cliSessionId) {
|
|
1957
|
+
args.push("--system-prompt", request.system_prompt);
|
|
1958
|
+
}
|
|
1959
|
+
if (request.options?.model) {
|
|
1960
|
+
args.push("--model", request.options.model);
|
|
1961
|
+
}
|
|
1962
|
+
if (request.options?.max_tokens) {
|
|
1963
|
+
args.push("--max-tokens", String(request.options.max_tokens));
|
|
1964
|
+
}
|
|
1965
|
+
if (context.tools.length > 0 && context.toolScriptDir) {
|
|
1966
|
+
args.push("--allowedTools=bash");
|
|
1967
|
+
}
|
|
1968
|
+
args.push(userMessage);
|
|
1969
|
+
if (isDebugEnabled()) {
|
|
1970
|
+
log8.debug("Spawning claude", { args: args.map((a) => a.length > 50 ? a.substring(0, 50) + "..." : a) });
|
|
1971
|
+
}
|
|
1972
|
+
return new Promise((resolve, reject) => {
|
|
1973
|
+
let sessionId = null;
|
|
1974
|
+
let blockIndex = 0;
|
|
1975
|
+
let settled = false;
|
|
1976
|
+
const env = buildSpawnEnv(context.toolScriptDir, context.requestId);
|
|
1977
|
+
delete env["CLAUDECODE"];
|
|
1978
|
+
const child = spawn2("claude", args, {
|
|
1979
|
+
env,
|
|
1980
|
+
stdio: ["ignore", "pipe", "pipe"]
|
|
1981
|
+
// stdin must be 'ignore' — Claude CLI hangs if stdin is a pipe
|
|
1982
|
+
});
|
|
1983
|
+
const onAbort = () => {
|
|
1984
|
+
log8.info("Request aborted \u2014 killing claude process", { requestId });
|
|
1985
|
+
child.kill("SIGTERM");
|
|
1986
|
+
};
|
|
1987
|
+
signal.addEventListener("abort", onAbort, { once: true });
|
|
1988
|
+
let stderrBuffer = "";
|
|
1989
|
+
const finalizer = createFinalizer({
|
|
1990
|
+
providerName: "claude",
|
|
1991
|
+
terminalEvent: "result",
|
|
1992
|
+
getSettled: () => settled,
|
|
1993
|
+
setSettled: () => {
|
|
1994
|
+
settled = true;
|
|
1995
|
+
},
|
|
1996
|
+
getSessionId: () => sessionId,
|
|
1997
|
+
getStderr: () => stderrBuffer,
|
|
1998
|
+
onEvent,
|
|
1999
|
+
resolve,
|
|
2000
|
+
signal,
|
|
2001
|
+
onAbort
|
|
2002
|
+
});
|
|
2003
|
+
const rl = createInterface2({ input: child.stdout });
|
|
2004
|
+
rl.on("line", (line) => {
|
|
2005
|
+
if (!line.trim()) return;
|
|
2006
|
+
let parsed;
|
|
2007
|
+
try {
|
|
2008
|
+
parsed = JSON.parse(line);
|
|
2009
|
+
} catch {
|
|
2010
|
+
log8.debug("Skipping non-JSON line", { line: line.substring(0, 100) });
|
|
2011
|
+
return;
|
|
2012
|
+
}
|
|
2013
|
+
const type = parsed["type"];
|
|
2014
|
+
if (type === "system" && parsed["subtype"] === "init") {
|
|
2015
|
+
sessionId = parsed["session_id"] ?? null;
|
|
2016
|
+
log8.debug("Session init", { sessionId, model: parsed["model"] });
|
|
2017
|
+
return;
|
|
2018
|
+
}
|
|
2019
|
+
if (type === "assistant") {
|
|
2020
|
+
if (settled) {
|
|
2021
|
+
log8.debug("Assistant event received after stream settled \u2014 block events would be emitted post-done", {
|
|
2022
|
+
sessionId
|
|
2023
|
+
});
|
|
2024
|
+
}
|
|
2025
|
+
const message = parsed["message"];
|
|
2026
|
+
if (!message) return;
|
|
2027
|
+
const content = message["content"];
|
|
2028
|
+
if (!content || !Array.isArray(content)) return;
|
|
2029
|
+
for (const block of content) {
|
|
2030
|
+
const blockType = block["type"];
|
|
2031
|
+
if (blockType === "text") {
|
|
2032
|
+
const text = block["text"];
|
|
2033
|
+
if (!text) continue;
|
|
2034
|
+
onEvent({
|
|
2035
|
+
event: "block_start",
|
|
2036
|
+
data: {
|
|
2037
|
+
block_index: blockIndex,
|
|
2038
|
+
block_type: "text"
|
|
2039
|
+
}
|
|
2040
|
+
});
|
|
2041
|
+
onEvent({
|
|
2042
|
+
event: "block_delta",
|
|
2043
|
+
data: {
|
|
2044
|
+
block_index: blockIndex,
|
|
2045
|
+
content: text
|
|
2046
|
+
}
|
|
2047
|
+
});
|
|
2048
|
+
onEvent({
|
|
2049
|
+
event: "block_stop",
|
|
2050
|
+
data: {
|
|
2051
|
+
block_index: blockIndex
|
|
2052
|
+
}
|
|
2053
|
+
});
|
|
2054
|
+
blockIndex++;
|
|
2055
|
+
} else if (blockType === "thinking") {
|
|
2056
|
+
const thinking = block["thinking"];
|
|
2057
|
+
if (!thinking) continue;
|
|
2058
|
+
onEvent({
|
|
2059
|
+
event: "block_start",
|
|
2060
|
+
data: {
|
|
2061
|
+
block_index: blockIndex,
|
|
2062
|
+
block_type: "thinking"
|
|
2063
|
+
}
|
|
2064
|
+
});
|
|
2065
|
+
onEvent({
|
|
2066
|
+
event: "block_delta",
|
|
2067
|
+
data: {
|
|
2068
|
+
block_index: blockIndex,
|
|
2069
|
+
content: thinking
|
|
2070
|
+
}
|
|
2071
|
+
});
|
|
2072
|
+
onEvent({
|
|
2073
|
+
event: "block_stop",
|
|
2074
|
+
data: {
|
|
2075
|
+
block_index: blockIndex
|
|
2076
|
+
}
|
|
2077
|
+
});
|
|
2078
|
+
blockIndex++;
|
|
2079
|
+
} else if (blockType === "tool_use") {
|
|
2080
|
+
const toolName = block["name"];
|
|
2081
|
+
const toolId = block["id"];
|
|
2082
|
+
const toolInput = block["input"];
|
|
2083
|
+
if (!toolName || !toolId) continue;
|
|
2084
|
+
onEvent({
|
|
2085
|
+
event: "block_start",
|
|
2086
|
+
data: {
|
|
2087
|
+
block_index: blockIndex,
|
|
2088
|
+
block_type: "tool_call",
|
|
2089
|
+
tool_name: toolName,
|
|
2090
|
+
tool_call_id: toolId
|
|
2091
|
+
}
|
|
2092
|
+
});
|
|
2093
|
+
onEvent({
|
|
2094
|
+
event: "block_delta",
|
|
2095
|
+
data: {
|
|
2096
|
+
block_index: blockIndex,
|
|
2097
|
+
content: JSON.stringify(toolInput ?? {})
|
|
2098
|
+
}
|
|
2099
|
+
});
|
|
2100
|
+
onEvent({
|
|
2101
|
+
event: "block_stop",
|
|
2102
|
+
data: {
|
|
2103
|
+
block_index: blockIndex
|
|
2104
|
+
}
|
|
2105
|
+
});
|
|
2106
|
+
blockIndex++;
|
|
2107
|
+
}
|
|
2108
|
+
}
|
|
2109
|
+
return;
|
|
2110
|
+
}
|
|
2111
|
+
if (type === "result") {
|
|
2112
|
+
sessionId = parsed["session_id"] ?? sessionId;
|
|
2113
|
+
const usage = parsed["usage"];
|
|
2114
|
+
const inputTokens = usage ? usage["input_tokens"] ?? null : null;
|
|
2115
|
+
const outputTokens = usage ? usage["output_tokens"] ?? null : null;
|
|
2116
|
+
onEvent({
|
|
2117
|
+
event: "done",
|
|
2118
|
+
data: {
|
|
2119
|
+
usage: {
|
|
2120
|
+
input_tokens: inputTokens,
|
|
2121
|
+
output_tokens: outputTokens
|
|
2122
|
+
}
|
|
2123
|
+
}
|
|
2124
|
+
});
|
|
2125
|
+
settled = true;
|
|
2126
|
+
return;
|
|
2127
|
+
}
|
|
2128
|
+
if (type === "rate_limit_event") {
|
|
2129
|
+
log8.warn("Claude rate limit event", { type });
|
|
2130
|
+
onEvent({
|
|
2131
|
+
event: "error",
|
|
2132
|
+
data: {
|
|
2133
|
+
code: "rate_limited",
|
|
2134
|
+
message: "Claude is currently rate-limited. The request may retry automatically, or you may need to try again in a moment."
|
|
2135
|
+
}
|
|
2136
|
+
});
|
|
2137
|
+
return;
|
|
2138
|
+
}
|
|
2139
|
+
log8.debug("Unhandled Claude event type", { type });
|
|
2140
|
+
});
|
|
2141
|
+
rl.on("close", finalizer.onRlClose);
|
|
2142
|
+
child.stderr.on("data", (chunk) => {
|
|
2143
|
+
stderrBuffer = appendStderr(stderrBuffer, chunk.toString());
|
|
2144
|
+
});
|
|
2145
|
+
child.on("error", (err) => {
|
|
2146
|
+
log8.error("Failed to spawn claude", { error: err.message });
|
|
2147
|
+
const errorMessage = err.code === "ENOENT" ? "claude CLI not found. Install it or ensure it is on your PATH." : `Failed to spawn claude: ${err.message}`;
|
|
2148
|
+
signal.removeEventListener("abort", onAbort);
|
|
2149
|
+
if (!settled) {
|
|
2150
|
+
settled = true;
|
|
2151
|
+
onEvent({
|
|
2152
|
+
event: "error",
|
|
2153
|
+
data: {
|
|
2154
|
+
code: "provider_spawn_error",
|
|
2155
|
+
message: errorMessage
|
|
2156
|
+
}
|
|
2157
|
+
});
|
|
2158
|
+
onEvent({ event: "done", data: {} });
|
|
2159
|
+
resolve(null);
|
|
2160
|
+
}
|
|
2161
|
+
});
|
|
2162
|
+
child.on("close", (code) => {
|
|
2163
|
+
log8.debug("Claude process closed", { code, sessionId });
|
|
2164
|
+
finalizer.onChildClose(code);
|
|
2165
|
+
});
|
|
2166
|
+
});
|
|
2167
|
+
}
|
|
2168
|
+
async listModels() {
|
|
2169
|
+
return CLAUDE_MODELS;
|
|
2170
|
+
}
|
|
2171
|
+
};
|
|
2172
|
+
|
|
2173
|
+
// src/providers/gemini.ts
|
|
2174
|
+
import { spawn as spawn3 } from "child_process";
|
|
2175
|
+
import { createInterface as createInterface3 } from "readline";
|
|
2176
|
+
var GEMINI_MODELS = [
|
|
2177
|
+
{ id: "auto", name: "Auto", description: "Automatically selects the best model", is_default: true },
|
|
2178
|
+
{ id: "pro", name: "Pro", description: "Complex reasoning tasks (Gemini 2.5 Pro)", is_default: false },
|
|
2179
|
+
{ id: "flash", name: "Flash", description: "Fast and balanced (Gemini 2.5 Flash)", is_default: false },
|
|
2180
|
+
{ id: "flash-lite", name: "Flash Lite", description: "Fastest for simple tasks (Gemini 2.5 Flash Lite)", is_default: false }
|
|
2181
|
+
];
|
|
2182
|
+
var log9 = createLogger("GeminiAdapter");
|
|
2183
|
+
var GeminiAdapter = class extends ProviderAdapter {
|
|
2184
|
+
providerName = "gemini";
|
|
2185
|
+
async execute(context, onEvent) {
|
|
2186
|
+
const { request, signal, cliSessionId } = context;
|
|
2187
|
+
const requestId = request.request_id;
|
|
2188
|
+
const userMessage = request.message;
|
|
2189
|
+
log9.info("Executing Gemini request", { requestId });
|
|
2190
|
+
let prompt = userMessage;
|
|
2191
|
+
if (request.system_prompt && !cliSessionId) {
|
|
2192
|
+
prompt = buildCombinedPrompt(request.system_prompt, userMessage);
|
|
2193
|
+
}
|
|
2194
|
+
const args = [
|
|
2195
|
+
"--prompt",
|
|
2196
|
+
prompt,
|
|
2197
|
+
// Non-interactive mode with prompt
|
|
2198
|
+
"--output-format",
|
|
2199
|
+
"stream-json",
|
|
2200
|
+
// NDJSON streaming output
|
|
2201
|
+
"--skip-trust"
|
|
2202
|
+
// Required for headless/non-interactive mode
|
|
2203
|
+
];
|
|
2204
|
+
if (cliSessionId) {
|
|
2205
|
+
args.push("--resume", cliSessionId);
|
|
2206
|
+
log9.debug("Resuming session", { cliSessionId });
|
|
2207
|
+
}
|
|
2208
|
+
if (request.options?.model) {
|
|
2209
|
+
args.push("--model", request.options.model);
|
|
2210
|
+
}
|
|
2211
|
+
if (request.options?.max_tokens) {
|
|
2212
|
+
log9.warn("max_tokens option specified but Gemini CLI does not support it directly \u2014 ignoring", {
|
|
2213
|
+
max_tokens: request.options.max_tokens
|
|
2214
|
+
});
|
|
2215
|
+
}
|
|
2216
|
+
if (isDebugEnabled()) {
|
|
2217
|
+
log9.debug("Spawning gemini", { args: args.map((a) => a.length > 50 ? a.substring(0, 50) + "..." : a) });
|
|
2218
|
+
}
|
|
2219
|
+
return new Promise((resolve) => {
|
|
2220
|
+
let sessionId = null;
|
|
2221
|
+
let blockIndex = 0;
|
|
2222
|
+
let settled = false;
|
|
2223
|
+
let inTextBlock = false;
|
|
2224
|
+
const env = buildSpawnEnv(context.toolScriptDir, context.requestId);
|
|
2225
|
+
const child = spawn3("gemini", args, {
|
|
2226
|
+
env,
|
|
2227
|
+
stdio: ["ignore", "pipe", "pipe"]
|
|
2228
|
+
// stdin must be 'ignore' to prevent hanging
|
|
2229
|
+
});
|
|
2230
|
+
const onAbort = () => {
|
|
2231
|
+
log9.info("Request aborted \u2014 killing gemini process", { requestId });
|
|
2232
|
+
child.kill("SIGTERM");
|
|
2233
|
+
};
|
|
2234
|
+
signal.addEventListener("abort", onAbort, { once: true });
|
|
2235
|
+
let stderrBuffer = "";
|
|
2236
|
+
const finalizer = createFinalizer({
|
|
2237
|
+
providerName: "gemini",
|
|
2238
|
+
terminalEvent: "result",
|
|
2239
|
+
getSettled: () => settled,
|
|
2240
|
+
setSettled: () => {
|
|
2241
|
+
settled = true;
|
|
2242
|
+
},
|
|
2243
|
+
getSessionId: () => sessionId,
|
|
2244
|
+
getStderr: () => stderrBuffer,
|
|
2245
|
+
onEvent,
|
|
2246
|
+
resolve,
|
|
2247
|
+
signal,
|
|
2248
|
+
onAbort,
|
|
2249
|
+
// Gemini-specific: close any open text block before finalizing
|
|
2250
|
+
onBeforeFinalize: () => {
|
|
2251
|
+
if (inTextBlock) {
|
|
2252
|
+
onEvent({
|
|
2253
|
+
event: "block_stop",
|
|
2254
|
+
data: { block_index: blockIndex }
|
|
2255
|
+
});
|
|
2256
|
+
blockIndex++;
|
|
2257
|
+
inTextBlock = false;
|
|
2258
|
+
}
|
|
2259
|
+
}
|
|
2260
|
+
});
|
|
2261
|
+
const rl = createInterface3({ input: child.stdout });
|
|
2262
|
+
rl.on("line", (line) => {
|
|
2263
|
+
if (!line.trim()) return;
|
|
2264
|
+
let parsed;
|
|
2265
|
+
try {
|
|
2266
|
+
parsed = JSON.parse(line);
|
|
2267
|
+
} catch {
|
|
2268
|
+
log9.debug("Skipping non-JSON line", { line: line.substring(0, 100) });
|
|
2269
|
+
return;
|
|
2270
|
+
}
|
|
2271
|
+
const type = parsed["type"];
|
|
2272
|
+
if (type === "init") {
|
|
2273
|
+
sessionId = parsed["session_id"] ?? null;
|
|
2274
|
+
log9.debug("Session init", { sessionId, model: parsed["model"] });
|
|
2275
|
+
return;
|
|
2276
|
+
}
|
|
2277
|
+
if (type === "message") {
|
|
2278
|
+
const role = parsed["role"];
|
|
2279
|
+
if (role === "user") return;
|
|
2280
|
+
if (role === "assistant") {
|
|
2281
|
+
const content = parsed["content"];
|
|
2282
|
+
const isDelta = parsed["delta"];
|
|
2283
|
+
if (!content) return;
|
|
2284
|
+
if (isDelta) {
|
|
2285
|
+
if (!inTextBlock) {
|
|
2286
|
+
onEvent({
|
|
2287
|
+
event: "block_start",
|
|
2288
|
+
data: {
|
|
2289
|
+
block_index: blockIndex,
|
|
2290
|
+
block_type: "text"
|
|
2291
|
+
}
|
|
2292
|
+
});
|
|
2293
|
+
inTextBlock = true;
|
|
2294
|
+
}
|
|
2295
|
+
onEvent({
|
|
2296
|
+
event: "block_delta",
|
|
2297
|
+
data: {
|
|
2298
|
+
block_index: blockIndex,
|
|
2299
|
+
content
|
|
2300
|
+
}
|
|
2301
|
+
});
|
|
2302
|
+
} else {
|
|
2303
|
+
if (inTextBlock) {
|
|
2304
|
+
onEvent({
|
|
2305
|
+
event: "block_stop",
|
|
2306
|
+
data: { block_index: blockIndex }
|
|
2307
|
+
});
|
|
2308
|
+
blockIndex++;
|
|
2309
|
+
inTextBlock = false;
|
|
2310
|
+
}
|
|
2311
|
+
onEvent({
|
|
2312
|
+
event: "block_start",
|
|
2313
|
+
data: { block_index: blockIndex, block_type: "text" }
|
|
2314
|
+
});
|
|
2315
|
+
onEvent({
|
|
2316
|
+
event: "block_delta",
|
|
2317
|
+
data: { block_index: blockIndex, content }
|
|
2318
|
+
});
|
|
2319
|
+
onEvent({
|
|
2320
|
+
event: "block_stop",
|
|
2321
|
+
data: { block_index: blockIndex }
|
|
2322
|
+
});
|
|
2323
|
+
blockIndex++;
|
|
2324
|
+
}
|
|
2325
|
+
}
|
|
2326
|
+
return;
|
|
2327
|
+
}
|
|
2328
|
+
if (type === "tool_use") {
|
|
2329
|
+
if (inTextBlock) {
|
|
2330
|
+
onEvent({
|
|
2331
|
+
event: "block_stop",
|
|
2332
|
+
data: { block_index: blockIndex }
|
|
2333
|
+
});
|
|
2334
|
+
blockIndex++;
|
|
2335
|
+
inTextBlock = false;
|
|
2336
|
+
}
|
|
2337
|
+
onEvent({
|
|
2338
|
+
event: "block_start",
|
|
2339
|
+
data: {
|
|
2340
|
+
block_index: blockIndex,
|
|
2341
|
+
block_type: "tool_call",
|
|
2342
|
+
tool_name: parsed["tool_name"],
|
|
2343
|
+
tool_call_id: parsed["tool_id"]
|
|
2344
|
+
}
|
|
2345
|
+
});
|
|
2346
|
+
onEvent({
|
|
2347
|
+
event: "block_delta",
|
|
2348
|
+
data: {
|
|
2349
|
+
block_index: blockIndex,
|
|
2350
|
+
content: JSON.stringify(parsed["parameters"] ?? {})
|
|
2351
|
+
}
|
|
2352
|
+
});
|
|
2353
|
+
onEvent({
|
|
2354
|
+
event: "block_stop",
|
|
2355
|
+
data: { block_index: blockIndex }
|
|
2356
|
+
});
|
|
2357
|
+
blockIndex++;
|
|
2358
|
+
return;
|
|
2359
|
+
}
|
|
2360
|
+
if (type === "tool_result") {
|
|
2361
|
+
const toolId = parsed["tool_id"];
|
|
2362
|
+
const output = parsed["output"] ?? "";
|
|
2363
|
+
const status = parsed["status"];
|
|
2364
|
+
onEvent({
|
|
2365
|
+
event: "tool_result",
|
|
2366
|
+
data: {
|
|
2367
|
+
tool_call_id: toolId,
|
|
2368
|
+
result: status === "error" ? `Error: ${parsed["error"]?.["message"] ?? output}` : output
|
|
2369
|
+
}
|
|
2370
|
+
});
|
|
2371
|
+
return;
|
|
2372
|
+
}
|
|
2373
|
+
if (type === "error") {
|
|
2374
|
+
const severity = parsed["severity"];
|
|
2375
|
+
const message = parsed["message"];
|
|
2376
|
+
log9.warn("Gemini error event", { severity, message: message?.substring(0, 200) });
|
|
2377
|
+
if (severity === "error") {
|
|
2378
|
+
onEvent({
|
|
2379
|
+
event: "error",
|
|
2380
|
+
data: {
|
|
2381
|
+
code: "provider_error",
|
|
2382
|
+
message: message ?? "Unknown Gemini error"
|
|
2383
|
+
}
|
|
2384
|
+
});
|
|
2385
|
+
onEvent({ event: "done", data: {} });
|
|
2386
|
+
settled = true;
|
|
2387
|
+
} else if (severity === "warning") {
|
|
2388
|
+
const lowerMsg = (message ?? "").toLowerCase();
|
|
2389
|
+
const isRateLimit = lowerMsg.includes("rate limit") || lowerMsg.includes("ratelimit") || lowerMsg.includes("quota") || lowerMsg.includes("429") || lowerMsg.includes("too many requests");
|
|
2390
|
+
onEvent({
|
|
2391
|
+
event: "error",
|
|
2392
|
+
data: {
|
|
2393
|
+
code: isRateLimit ? "rate_limited" : "provider_warning",
|
|
2394
|
+
message: message ?? "Gemini warning"
|
|
2395
|
+
}
|
|
2396
|
+
});
|
|
2397
|
+
}
|
|
2398
|
+
return;
|
|
2399
|
+
}
|
|
2400
|
+
if (type === "result") {
|
|
2401
|
+
if (settled) return;
|
|
2402
|
+
if (inTextBlock) {
|
|
2403
|
+
onEvent({
|
|
2404
|
+
event: "block_stop",
|
|
2405
|
+
data: { block_index: blockIndex }
|
|
2406
|
+
});
|
|
2407
|
+
blockIndex++;
|
|
2408
|
+
inTextBlock = false;
|
|
2409
|
+
}
|
|
2410
|
+
const status = parsed["status"];
|
|
2411
|
+
if (status === "error") {
|
|
2412
|
+
const error = parsed["error"];
|
|
2413
|
+
const errorMessage = error?.["message"] ?? "Gemini request failed";
|
|
2414
|
+
log9.warn("Gemini result error", { type: error?.["type"], message: errorMessage.substring(0, 200) });
|
|
2415
|
+
onEvent({
|
|
2416
|
+
event: "error",
|
|
2417
|
+
data: {
|
|
2418
|
+
code: "provider_error",
|
|
2419
|
+
message: errorMessage
|
|
2420
|
+
}
|
|
2421
|
+
});
|
|
2422
|
+
}
|
|
2423
|
+
const stats = parsed["stats"];
|
|
2424
|
+
const inputTokens = stats ? stats["input_tokens"] ?? null : null;
|
|
2425
|
+
const outputTokens = stats ? stats["output_tokens"] ?? null : null;
|
|
2426
|
+
onEvent({
|
|
2427
|
+
event: "done",
|
|
2428
|
+
data: {
|
|
2429
|
+
usage: {
|
|
2430
|
+
input_tokens: inputTokens,
|
|
2431
|
+
output_tokens: outputTokens
|
|
2432
|
+
}
|
|
2433
|
+
}
|
|
2434
|
+
});
|
|
2435
|
+
settled = true;
|
|
2436
|
+
return;
|
|
2437
|
+
}
|
|
2438
|
+
log9.debug("Unhandled Gemini event type", { type });
|
|
2439
|
+
});
|
|
2440
|
+
rl.on("close", finalizer.onRlClose);
|
|
2441
|
+
child.stderr.on("data", (chunk) => {
|
|
2442
|
+
stderrBuffer = appendStderr(stderrBuffer, chunk.toString());
|
|
2443
|
+
});
|
|
2444
|
+
child.on("error", (err) => {
|
|
2445
|
+
log9.error("Failed to spawn gemini", { error: err.message });
|
|
2446
|
+
const errorMessage = err.code === "ENOENT" ? "gemini CLI not found. Install it or ensure it is on your PATH." : `Failed to spawn gemini: ${err.message}`;
|
|
2447
|
+
signal.removeEventListener("abort", onAbort);
|
|
2448
|
+
if (!settled) {
|
|
2449
|
+
settled = true;
|
|
2450
|
+
onEvent({
|
|
2451
|
+
event: "error",
|
|
2452
|
+
data: {
|
|
2453
|
+
code: "provider_spawn_error",
|
|
2454
|
+
message: errorMessage
|
|
2455
|
+
}
|
|
2456
|
+
});
|
|
2457
|
+
onEvent({ event: "done", data: {} });
|
|
2458
|
+
resolve(null);
|
|
2459
|
+
}
|
|
2460
|
+
});
|
|
2461
|
+
child.on("close", (code) => {
|
|
2462
|
+
log9.debug("Gemini process closed", { code, sessionId });
|
|
2463
|
+
finalizer.onChildClose(code);
|
|
2464
|
+
});
|
|
2465
|
+
});
|
|
2466
|
+
}
|
|
2467
|
+
async listModels() {
|
|
2468
|
+
return GEMINI_MODELS;
|
|
2469
|
+
}
|
|
2470
|
+
};
|
|
2471
|
+
|
|
2472
|
+
// src/test-mode.ts
|
|
2473
|
+
var log10 = createLogger("TestMode");
|
|
2474
|
+
function delay(ms) {
|
|
2475
|
+
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
2476
|
+
}
|
|
2477
|
+
async function handleTestRequest(request, sendEvent) {
|
|
2478
|
+
log10.info("Test mode: handling mock request", {
|
|
2479
|
+
requestId: request.request_id,
|
|
2480
|
+
provider: request.provider,
|
|
2481
|
+
message: request.message.slice(0, 80)
|
|
2482
|
+
});
|
|
2483
|
+
await delay(100);
|
|
2484
|
+
sendEvent("block_start", {
|
|
2485
|
+
block_index: 0,
|
|
2486
|
+
block_type: "thinking"
|
|
2487
|
+
});
|
|
2488
|
+
await delay(50);
|
|
2489
|
+
const thinkingChunks = [
|
|
2490
|
+
"Let me think about this request... ",
|
|
2491
|
+
`The user asked: "${request.message.slice(0, 50)}". `,
|
|
2492
|
+
"I will provide a helpful mock response."
|
|
2493
|
+
];
|
|
2494
|
+
for (const chunk of thinkingChunks) {
|
|
2495
|
+
sendEvent("block_delta", {
|
|
2496
|
+
block_index: 0,
|
|
2497
|
+
content: chunk
|
|
2498
|
+
});
|
|
2499
|
+
await delay(30);
|
|
2500
|
+
}
|
|
2501
|
+
sendEvent("block_stop", {
|
|
2502
|
+
block_index: 0
|
|
2503
|
+
});
|
|
2504
|
+
await delay(50);
|
|
2505
|
+
sendEvent("block_start", {
|
|
2506
|
+
block_index: 1,
|
|
2507
|
+
block_type: "text"
|
|
2508
|
+
});
|
|
2509
|
+
await delay(50);
|
|
2510
|
+
const textChunks = [
|
|
2511
|
+
"This is a **mock response** from ai-bridge test mode. ",
|
|
2512
|
+
"The bridge is connected and streaming is working correctly. ",
|
|
2513
|
+
`Your request was routed to the "${request.provider}" provider. `,
|
|
2514
|
+
"In production, this response would come from your local CLI tool. ",
|
|
2515
|
+
`
|
|
2516
|
+
|
|
2517
|
+
Original message: "${request.message.slice(0, 100)}"`
|
|
2518
|
+
];
|
|
2519
|
+
for (const chunk of textChunks) {
|
|
2520
|
+
sendEvent("block_delta", {
|
|
2521
|
+
block_index: 1,
|
|
2522
|
+
content: chunk
|
|
2523
|
+
});
|
|
2524
|
+
await delay(40);
|
|
2525
|
+
}
|
|
2526
|
+
sendEvent("block_stop", {
|
|
2527
|
+
block_index: 1
|
|
2528
|
+
});
|
|
2529
|
+
await delay(50);
|
|
2530
|
+
sendEvent("done", {
|
|
2531
|
+
usage: {
|
|
2532
|
+
input_tokens: 42,
|
|
2533
|
+
output_tokens: 108
|
|
2534
|
+
}
|
|
2535
|
+
});
|
|
2536
|
+
log10.info("Test mode: mock response complete", { requestId: request.request_id });
|
|
2537
|
+
}
|
|
2538
|
+
|
|
2539
|
+
// src/cli.ts
|
|
2540
|
+
var log11 = createLogger("CLI");
|
|
2541
|
+
var program = new Command();
|
|
2542
|
+
program.name("ai-bridge").description("Local CLI bridge for AI web apps \u2014 connects Codex, Claude, and Gemini to your web application via WebSocket").version(BRIDGE_VERSION).option(
|
|
2543
|
+
"-t, --token <token>",
|
|
2544
|
+
"Authentication token (or set AI_BRIDGE_TOKEN env var)",
|
|
2545
|
+
process.env["AI_BRIDGE_TOKEN"]
|
|
2546
|
+
).option(
|
|
2547
|
+
"-s, --server <url>",
|
|
2548
|
+
"WebSocket server URL (or set AI_BRIDGE_SERVER env var)",
|
|
2549
|
+
process.env["AI_BRIDGE_SERVER"]
|
|
2550
|
+
).option(
|
|
2551
|
+
"-d, --debug",
|
|
2552
|
+
"Enable verbose debug logging",
|
|
2553
|
+
false
|
|
2554
|
+
).option(
|
|
2555
|
+
"--test",
|
|
2556
|
+
"Test mode \u2014 respond to AI requests with mock streaming data (--server and --token still required for the WebSocket connection)",
|
|
2557
|
+
false
|
|
2558
|
+
).action(async (opts) => {
|
|
2559
|
+
if (opts.debug) {
|
|
2560
|
+
setDebug(true);
|
|
2561
|
+
}
|
|
2562
|
+
log11.info(`AI Bridge v${BRIDGE_VERSION} (protocol v${PROTOCOL_VERSION})`);
|
|
2563
|
+
if (opts.test) {
|
|
2564
|
+
log11.info("Running in TEST MODE \u2014 AI requests will receive mock responses");
|
|
2565
|
+
}
|
|
2566
|
+
const token = opts.token;
|
|
2567
|
+
const serverUrl = opts.server;
|
|
2568
|
+
if (!token) {
|
|
2569
|
+
log11.error("Authentication token is required. Use --token <token> or set AI_BRIDGE_TOKEN. Generate a token from your web application (see README for details).");
|
|
2570
|
+
process.exit(1);
|
|
2571
|
+
}
|
|
2572
|
+
if (!serverUrl) {
|
|
2573
|
+
log11.error("Server URL is required. Use --server <url> or set AI_BRIDGE_SERVER. Use the wss:// address provided by your web application (e.g. wss://your-app.com/api/ai-bridge/ws).");
|
|
2574
|
+
process.exit(1);
|
|
2575
|
+
}
|
|
2576
|
+
if (!serverUrl.startsWith("ws://") && !serverUrl.startsWith("wss://")) {
|
|
2577
|
+
log11.error("Server URL must start with ws:// or wss://");
|
|
2578
|
+
process.exit(1);
|
|
2579
|
+
}
|
|
2580
|
+
if (serverUrl.startsWith("ws://")) {
|
|
2581
|
+
log11.warn("Connecting over unencrypted ws://. Use wss:// in production.");
|
|
2582
|
+
}
|
|
2583
|
+
try {
|
|
2584
|
+
const parsedUrl = new URL(serverUrl);
|
|
2585
|
+
if (parsedUrl.username || parsedUrl.password) {
|
|
2586
|
+
log11.error("Server URL must not contain username or password components");
|
|
2587
|
+
process.exit(1);
|
|
2588
|
+
}
|
|
2589
|
+
} catch {
|
|
2590
|
+
log11.error("Server URL is not a valid URL");
|
|
2591
|
+
process.exit(1);
|
|
2592
|
+
}
|
|
2593
|
+
const providers = await detectProviders();
|
|
2594
|
+
const availableProviders = providers.filter((p) => p.available);
|
|
2595
|
+
if (availableProviders.length === 0 && !opts.test) {
|
|
2596
|
+
log11.warn("No AI CLI tools detected. The bridge will NOT be able to execute requests.");
|
|
2597
|
+
log11.warn("Install one of: codex (https://github.com/openai/codex), claude (https://claude.ai/download), gemini (https://github.com/google-gemini/gemini-cli)");
|
|
2598
|
+
log11.warn("Or use --test flag to run in test mode with mock responses.");
|
|
2599
|
+
log11.warn("AI requests will fail until a provider CLI is installed. See install links above.");
|
|
2600
|
+
log11.warn("Connecting anyway so the server knows a bridge is present...");
|
|
2601
|
+
} else if (availableProviders.length > 0) {
|
|
2602
|
+
log11.info(`Available providers: ${availableProviders.map((p) => `${p.name} (${p.version ?? "unknown version"})`).join(", ")}`);
|
|
2603
|
+
}
|
|
2604
|
+
const adapterInstances = [
|
|
2605
|
+
new CodexAdapter(),
|
|
2606
|
+
new ClaudeAdapter(),
|
|
2607
|
+
new GeminiAdapter()
|
|
2608
|
+
];
|
|
2609
|
+
const adapters = /* @__PURE__ */ new Map();
|
|
2610
|
+
const availableAdapters = adapterInstances.filter((adapter) => {
|
|
2611
|
+
const capability = providers.find((p) => p.name === adapter.providerName);
|
|
2612
|
+
return capability?.available === true;
|
|
2613
|
+
});
|
|
2614
|
+
await Promise.all(
|
|
2615
|
+
availableAdapters.map(async (adapter) => {
|
|
2616
|
+
const capability = providers.find((p) => p.name === adapter.providerName);
|
|
2617
|
+
adapters.set(adapter.providerName, adapter);
|
|
2618
|
+
try {
|
|
2619
|
+
const models = await adapter.listModels();
|
|
2620
|
+
capability.models = models;
|
|
2621
|
+
log11.info(`${adapter.providerName} models: ${models.map((m) => m.id).join(", ")}`);
|
|
2622
|
+
} catch (err) {
|
|
2623
|
+
log11.warn(`Failed to list models for ${adapter.providerName}`, {
|
|
2624
|
+
error: err instanceof Error ? err.message : String(err)
|
|
2625
|
+
});
|
|
2626
|
+
}
|
|
2627
|
+
log11.debug("Registered adapter", { name: adapter.providerName });
|
|
2628
|
+
})
|
|
2629
|
+
);
|
|
2630
|
+
const bridge = new Bridge({
|
|
2631
|
+
serverUrl,
|
|
2632
|
+
token,
|
|
2633
|
+
providers,
|
|
2634
|
+
adapters,
|
|
2635
|
+
testMode: opts.test,
|
|
2636
|
+
onTestRequest: opts.test ? handleTestRequest : void 0
|
|
2637
|
+
});
|
|
2638
|
+
bridge.on("connected", () => {
|
|
2639
|
+
log11.info("Connected to server");
|
|
2640
|
+
});
|
|
2641
|
+
bridge.on("welcome", (sessionId) => {
|
|
2642
|
+
log11.info(`Session established: ${sessionId}`);
|
|
2643
|
+
if (opts.test) {
|
|
2644
|
+
log11.info("Test mode active \u2014 waiting for ai_request messages...");
|
|
2645
|
+
}
|
|
2646
|
+
});
|
|
2647
|
+
bridge.on("disconnected", (code, reason) => {
|
|
2648
|
+
log11.warn(`Disconnected from server (code=${code}, reason="${reason}")`);
|
|
2649
|
+
});
|
|
2650
|
+
bridge.on("error", (err) => {
|
|
2651
|
+
log11.error("Bridge error", { error: err.message });
|
|
2652
|
+
if (err instanceof FatalBridgeError) {
|
|
2653
|
+
process.exit(1);
|
|
2654
|
+
}
|
|
2655
|
+
});
|
|
2656
|
+
bridge.on("request_start", (requestId, provider) => {
|
|
2657
|
+
log11.info(`Processing request ${requestId} with ${provider}${opts.test ? " (test mode)" : ""}`);
|
|
2658
|
+
});
|
|
2659
|
+
bridge.on("request_end", (requestId) => {
|
|
2660
|
+
log11.info(`Request ${requestId} completed`);
|
|
2661
|
+
});
|
|
2662
|
+
const shutdown = async (signal) => {
|
|
2663
|
+
log11.info(`Received ${signal} \u2014 shutting down gracefully`);
|
|
2664
|
+
await bridge.disconnect();
|
|
2665
|
+
process.exit(0);
|
|
2666
|
+
};
|
|
2667
|
+
process.on("SIGINT", () => shutdown("SIGINT"));
|
|
2668
|
+
process.on("SIGTERM", () => shutdown("SIGTERM"));
|
|
2669
|
+
process.on("unhandledRejection", (reason) => {
|
|
2670
|
+
log11.error("Unhandled rejection \u2014 bridge is in an unknown state, exiting (restart the bridge to recover)", {
|
|
2671
|
+
error: reason instanceof Error ? reason.message : String(reason)
|
|
2672
|
+
});
|
|
2673
|
+
bridge.disconnect().catch(() => {
|
|
2674
|
+
}).finally(() => {
|
|
2675
|
+
process.exit(1);
|
|
2676
|
+
});
|
|
2677
|
+
});
|
|
2678
|
+
bridge.connect();
|
|
2679
|
+
});
|
|
2680
|
+
if (process.argv[1] === fileURLToPath(import.meta.url)) {
|
|
2681
|
+
program.parseAsync(process.argv).catch((err) => {
|
|
2682
|
+
log11.error("Fatal error", { error: err instanceof Error ? err.message : String(err) });
|
|
2683
|
+
process.exit(1);
|
|
2684
|
+
});
|
|
2685
|
+
}
|
|
2686
|
+
//# sourceMappingURL=cli.js.map
|