@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/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, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;");
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