llm-cli-gateway 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (48) hide show
  1. package/CHANGELOG.md +541 -0
  2. package/LICENSE +21 -0
  3. package/README.md +545 -0
  4. package/dist/approval-manager.d.ts +43 -0
  5. package/dist/approval-manager.js +156 -0
  6. package/dist/async-job-manager.d.ts +57 -0
  7. package/dist/async-job-manager.js +334 -0
  8. package/dist/claude-mcp-config.d.ts +8 -0
  9. package/dist/claude-mcp-config.js +161 -0
  10. package/dist/config.d.ts +35 -0
  11. package/dist/config.js +56 -0
  12. package/dist/db.d.ts +48 -0
  13. package/dist/db.js +170 -0
  14. package/dist/executor.d.ts +30 -0
  15. package/dist/executor.js +315 -0
  16. package/dist/health.d.ts +20 -0
  17. package/dist/health.js +32 -0
  18. package/dist/index.d.ts +67 -0
  19. package/dist/index.js +1503 -0
  20. package/dist/logger.d.ts +6 -0
  21. package/dist/logger.js +5 -0
  22. package/dist/metrics.d.ts +23 -0
  23. package/dist/metrics.js +57 -0
  24. package/dist/migrate-sessions.d.ts +12 -0
  25. package/dist/migrate-sessions.js +145 -0
  26. package/dist/migrate.d.ts +2 -0
  27. package/dist/migrate.js +100 -0
  28. package/dist/model-registry.d.ts +10 -0
  29. package/dist/model-registry.js +346 -0
  30. package/dist/optimizer.d.ts +3 -0
  31. package/dist/optimizer.js +183 -0
  32. package/dist/process-monitor.d.ts +54 -0
  33. package/dist/process-monitor.js +146 -0
  34. package/dist/request-helpers.d.ts +25 -0
  35. package/dist/request-helpers.js +32 -0
  36. package/dist/resources.d.ts +26 -0
  37. package/dist/resources.js +201 -0
  38. package/dist/retry.d.ts +72 -0
  39. package/dist/retry.js +146 -0
  40. package/dist/review-integrity.d.ts +50 -0
  41. package/dist/review-integrity.js +283 -0
  42. package/dist/session-manager-pg.d.ts +76 -0
  43. package/dist/session-manager-pg.js +383 -0
  44. package/dist/session-manager.d.ts +62 -0
  45. package/dist/session-manager.js +223 -0
  46. package/dist/stream-json-parser.d.ts +35 -0
  47. package/dist/stream-json-parser.js +94 -0
  48. package/package.json +90 -0
@@ -0,0 +1,315 @@
1
+ import { spawn } from "child_process";
2
+ import { homedir } from "os";
3
+ import { join, dirname } from "path";
4
+ import { readdirSync, existsSync } from "fs";
5
+ import { createCircuitBreaker, withRetry } from "./retry.js";
6
+ const MAX_OUTPUT_SIZE = 50 * 1024 * 1024;
7
+ const circuitBreakers = new Map();
8
+ let cachedNvmPath;
9
+ function getCircuitBreaker(command) {
10
+ const existing = circuitBreakers.get(command);
11
+ if (existing) {
12
+ return existing;
13
+ }
14
+ const circuitBreaker = createCircuitBreaker();
15
+ circuitBreakers.set(command, circuitBreaker);
16
+ return circuitBreaker;
17
+ }
18
+ function getNvmPath() {
19
+ if (cachedNvmPath !== undefined) {
20
+ return cachedNvmPath;
21
+ }
22
+ const home = homedir();
23
+ const nvmVersionsDir = join(home, ".nvm/versions/node");
24
+ if (!existsSync(nvmVersionsDir)) {
25
+ cachedNvmPath = null;
26
+ return cachedNvmPath;
27
+ }
28
+ try {
29
+ const versions = readdirSync(nvmVersionsDir);
30
+ cachedNvmPath = versions.length
31
+ ? versions.map((version) => join(nvmVersionsDir, version, "bin")).join(":")
32
+ : null;
33
+ }
34
+ catch {
35
+ cachedNvmPath = null;
36
+ }
37
+ return cachedNvmPath;
38
+ }
39
+ // Extend PATH to include common locations for CLI tools
40
+ export function getExtendedPath() {
41
+ const home = homedir();
42
+ const additionalPaths = [
43
+ join(home, ".local/bin"),
44
+ dirname(process.execPath), // Current node's bin directory
45
+ "/usr/local/bin",
46
+ "/usr/bin"
47
+ ];
48
+ // Add all nvm node version bin directories
49
+ const nvmPath = getNvmPath();
50
+ if (nvmPath) {
51
+ additionalPaths.push(nvmPath);
52
+ }
53
+ const currentPath = process.env.PATH || "";
54
+ return [...additionalPaths, currentPath].join(":");
55
+ }
56
+ /** Registry of active detached process groups for shutdown cleanup. */
57
+ const activeProcessGroups = new Set();
58
+ export function registerProcessGroup(pid) {
59
+ activeProcessGroups.add(pid);
60
+ }
61
+ export function unregisterProcessGroup(pid) {
62
+ activeProcessGroups.delete(pid);
63
+ }
64
+ /**
65
+ * Kill all active process groups. Called on gateway shutdown.
66
+ * Sends SIGTERM to all groups, waits 3s, then SIGKILL survivors.
67
+ * Returns a Promise that resolves after SIGKILL escalation completes.
68
+ * The returned Promise keeps the event loop alive (no .unref()),
69
+ * ensuring the process does NOT exit before SIGKILL fires.
70
+ */
71
+ export function killAllProcessGroups() {
72
+ if (activeProcessGroups.size === 0)
73
+ return Promise.resolve();
74
+ for (const pid of activeProcessGroups) {
75
+ try {
76
+ process.kill(-pid, "SIGTERM");
77
+ }
78
+ catch { /* ESRCH ok */ }
79
+ }
80
+ return new Promise(resolve => {
81
+ setTimeout(() => {
82
+ for (const pid of activeProcessGroups) {
83
+ try {
84
+ process.kill(-pid, "SIGKILL");
85
+ }
86
+ catch { /* ESRCH ok */ }
87
+ }
88
+ activeProcessGroups.clear();
89
+ resolve();
90
+ }, 3000); // No .unref() — keeps event loop alive through escalation
91
+ });
92
+ }
93
+ /**
94
+ * Kill an entire process group. Falls back to killing just the process
95
+ * if the group kill fails (e.g., pid not yet assigned).
96
+ */
97
+ export function killProcessGroup(proc, signal) {
98
+ if (proc.pid) {
99
+ try {
100
+ process.kill(-proc.pid, signal);
101
+ return true;
102
+ }
103
+ catch (err) {
104
+ // ESRCH = process/group already dead — not an error
105
+ if (err.code !== "ESRCH") {
106
+ try {
107
+ return proc.kill(signal);
108
+ }
109
+ catch {
110
+ return false;
111
+ }
112
+ }
113
+ return false;
114
+ }
115
+ }
116
+ try {
117
+ return proc.kill(signal);
118
+ }
119
+ catch {
120
+ return false;
121
+ }
122
+ }
123
+ export async function executeCli(command, args, options = {}) {
124
+ const { timeout, idleTimeout, cwd } = options;
125
+ const extendedPath = getExtendedPath();
126
+ const circuitBreaker = getCircuitBreaker(command);
127
+ const runOnce = () => new Promise((resolve, reject) => {
128
+ const proc = spawn(command, args, {
129
+ cwd,
130
+ detached: true,
131
+ stdio: ["ignore", "pipe", "pipe"],
132
+ env: { ...process.env, PATH: extendedPath }
133
+ });
134
+ if (proc.pid)
135
+ registerProcessGroup(proc.pid);
136
+ // Prevent detached process from keeping parent alive when not needed
137
+ proc.unref();
138
+ let stdout = "";
139
+ let stderr = "";
140
+ let timedOut = false;
141
+ let idledOut = false;
142
+ let overflowed = false;
143
+ let exited = false;
144
+ let outputSize = 0;
145
+ let settled = false;
146
+ // Single cleanup flag to prevent double-unregister
147
+ let groupCleaned = false;
148
+ const cleanupProcessGroup = () => {
149
+ if (groupCleaned)
150
+ return;
151
+ groupCleaned = true;
152
+ if (proc.pid)
153
+ unregisterProcessGroup(proc.pid);
154
+ };
155
+ const timeoutMs = typeof timeout === "number" && Number.isFinite(timeout) && timeout > 0 ? timeout : undefined;
156
+ const timeoutId = timeoutMs
157
+ ? setTimeout(() => {
158
+ timedOut = true;
159
+ killProcessGroup(proc, "SIGTERM");
160
+ setTimeout(() => {
161
+ if (!exited)
162
+ killProcessGroup(proc, "SIGKILL");
163
+ cleanupProcessGroup();
164
+ }, 5000);
165
+ }, timeoutMs)
166
+ : undefined;
167
+ // Idle timeout: kill process if no stdout/stderr activity for idleMs
168
+ const idleMs = typeof idleTimeout === "number" && Number.isFinite(idleTimeout) && idleTimeout > 0
169
+ ? idleTimeout : undefined;
170
+ let idleTimerId;
171
+ const resetIdleTimer = () => {
172
+ if (!idleMs)
173
+ return;
174
+ if (idleTimerId)
175
+ clearTimeout(idleTimerId);
176
+ idleTimerId = setTimeout(() => {
177
+ idledOut = true;
178
+ if (timeoutId)
179
+ clearTimeout(timeoutId);
180
+ killProcessGroup(proc, "SIGTERM");
181
+ setTimeout(() => {
182
+ if (!exited)
183
+ killProcessGroup(proc, "SIGKILL");
184
+ cleanupProcessGroup();
185
+ }, 5000);
186
+ }, idleMs);
187
+ };
188
+ // Start idle timer immediately (covers case where process never outputs)
189
+ resetIdleTimer();
190
+ const finalizeReject = (error) => {
191
+ if (settled) {
192
+ return;
193
+ }
194
+ settled = true;
195
+ if (timeoutId) {
196
+ clearTimeout(timeoutId);
197
+ }
198
+ if (idleTimerId) {
199
+ clearTimeout(idleTimerId);
200
+ }
201
+ reject(error);
202
+ };
203
+ const handleOutputChunk = (data, stream) => {
204
+ outputSize += data.length;
205
+ if (outputSize > MAX_OUTPUT_SIZE) {
206
+ overflowed = true;
207
+ killProcessGroup(proc, "SIGTERM");
208
+ setTimeout(() => {
209
+ if (!exited)
210
+ killProcessGroup(proc, "SIGKILL");
211
+ cleanupProcessGroup();
212
+ }, 5000);
213
+ finalizeReject(new Error("Output exceeded maximum size (50MB)"));
214
+ return;
215
+ }
216
+ resetIdleTimer();
217
+ const text = data.toString();
218
+ if (stream === "stdout") {
219
+ stdout += text;
220
+ }
221
+ else {
222
+ stderr += text;
223
+ }
224
+ };
225
+ proc.stdout.on("data", (data) => {
226
+ if (settled) {
227
+ return;
228
+ }
229
+ handleOutputChunk(data, "stdout");
230
+ });
231
+ proc.stderr.on("data", (data) => {
232
+ if (settled) {
233
+ return;
234
+ }
235
+ handleOutputChunk(data, "stderr");
236
+ });
237
+ proc.on("close", (code) => {
238
+ exited = true;
239
+ if (idleTimerId) {
240
+ clearTimeout(idleTimerId);
241
+ }
242
+ // Unregister process group on clean exit (no kill was issued)
243
+ if (!timedOut && !idledOut && !overflowed) {
244
+ cleanupProcessGroup();
245
+ }
246
+ if (settled) {
247
+ return;
248
+ }
249
+ if (timeoutId) {
250
+ clearTimeout(timeoutId);
251
+ }
252
+ if (timedOut) {
253
+ const result = {
254
+ stdout,
255
+ stderr: stderr + `\nProcess timed out after ${timeoutMs}ms`,
256
+ code: 124 // Standard timeout exit code
257
+ };
258
+ const error = new Error(result.stderr);
259
+ error.code = 124;
260
+ error.result = result;
261
+ reject(error);
262
+ return;
263
+ }
264
+ if (idledOut) {
265
+ const result = {
266
+ stdout,
267
+ stderr: stderr + `\nProcess killed after ${idleMs}ms of inactivity`,
268
+ code: 125
269
+ };
270
+ const error = new Error(result.stderr);
271
+ error.code = 125;
272
+ error.result = result;
273
+ reject(error);
274
+ return;
275
+ }
276
+ const result = { stdout, stderr, code: code ?? 0 };
277
+ if (result.code !== 0) {
278
+ const error = new Error(`Process exited with code ${result.code}`);
279
+ error.code = result.code;
280
+ error.result = result;
281
+ reject(error);
282
+ return;
283
+ }
284
+ resolve(result);
285
+ });
286
+ proc.on("error", (err) => {
287
+ exited = true;
288
+ if (idleTimerId) {
289
+ clearTimeout(idleTimerId);
290
+ }
291
+ cleanupProcessGroup();
292
+ if (settled) {
293
+ return;
294
+ }
295
+ if (timeoutId) {
296
+ clearTimeout(timeoutId);
297
+ }
298
+ settled = true;
299
+ reject(err);
300
+ });
301
+ });
302
+ try {
303
+ return await withRetry(runOnce, circuitBreaker, undefined, options.logger);
304
+ }
305
+ catch (error) {
306
+ if (error?.cause?.message === "Output exceeded maximum size (50MB)") {
307
+ throw error.cause;
308
+ }
309
+ const result = error?.result ?? error?.cause?.result;
310
+ if (result) {
311
+ return result;
312
+ }
313
+ throw error;
314
+ }
315
+ }
@@ -0,0 +1,20 @@
1
+ import { DatabaseConnection } from "./db.js";
2
+ export interface HealthStatus {
3
+ status: "healthy" | "degraded" | "unhealthy";
4
+ postgres: {
5
+ status: "up" | "down";
6
+ latency: number;
7
+ };
8
+ redis: {
9
+ status: "up" | "down";
10
+ latency: number;
11
+ };
12
+ timestamp: string;
13
+ }
14
+ /**
15
+ * Check health status of PostgreSQL and Redis
16
+ * - Both up → healthy
17
+ * - Only PostgreSQL up → degraded (Redis down but DB works)
18
+ * - PostgreSQL down → unhealthy (critical failure)
19
+ */
20
+ export declare function checkHealth(db: DatabaseConnection): Promise<HealthStatus>;
package/dist/health.js ADDED
@@ -0,0 +1,32 @@
1
+ /**
2
+ * Check health status of PostgreSQL and Redis
3
+ * - Both up → healthy
4
+ * - Only PostgreSQL up → degraded (Redis down but DB works)
5
+ * - PostgreSQL down → unhealthy (critical failure)
6
+ */
7
+ export async function checkHealth(db) {
8
+ const result = await db.healthCheck();
9
+ const health = {
10
+ status: "unhealthy",
11
+ postgres: {
12
+ status: result.postgres.connected ? "up" : "down",
13
+ latency: result.postgres.latency
14
+ },
15
+ redis: {
16
+ status: result.redis.connected ? "up" : "down",
17
+ latency: result.redis.latency
18
+ },
19
+ timestamp: new Date().toISOString()
20
+ };
21
+ // Determine overall health status
22
+ if (result.postgres.connected && result.redis.connected) {
23
+ health.status = "healthy";
24
+ }
25
+ else if (result.postgres.connected && !result.redis.connected) {
26
+ health.status = "degraded";
27
+ }
28
+ else {
29
+ health.status = "unhealthy";
30
+ }
31
+ return health;
32
+ }
@@ -0,0 +1,67 @@
1
+ #!/usr/bin/env node
2
+ import { ISessionManager } from "./session-manager.js";
3
+ import { AsyncJobManager } from "./async-job-manager.js";
4
+ import { ApprovalRecord } from "./approval-manager.js";
5
+ import { ReviewIntegrityResult } from "./review-integrity.js";
6
+ import { ClaudeMcpServerName } from "./claude-mcp-config.js";
7
+ type ExtendedToolResponse = {
8
+ content: {
9
+ type: "text";
10
+ text: string;
11
+ }[];
12
+ isError?: boolean;
13
+ sessionId?: string;
14
+ resumable?: boolean;
15
+ approval?: ApprovalRecord | null;
16
+ mcpServers?: {
17
+ requested: ClaudeMcpServerName[];
18
+ enabled?: ClaudeMcpServerName[];
19
+ missing?: ClaudeMcpServerName[];
20
+ };
21
+ reviewIntegrity?: ReviewIntegrityResult;
22
+ };
23
+ export interface GeminiRequestParams {
24
+ prompt: string;
25
+ model?: string;
26
+ sessionId?: string;
27
+ resumeLatest: boolean;
28
+ createNewSession: boolean;
29
+ approvalMode?: string;
30
+ approvalStrategy: "legacy" | "mcp_managed";
31
+ approvalPolicy?: string;
32
+ mcpServers?: ClaudeMcpServerName[];
33
+ allowedTools?: string[];
34
+ includeDirs?: string[];
35
+ correlationId?: string;
36
+ optimizePrompt: boolean;
37
+ optimizeResponse?: boolean;
38
+ idleTimeoutMs?: number;
39
+ }
40
+ export interface HandlerDeps {
41
+ sessionManager: ISessionManager;
42
+ logger: {
43
+ info: (...args: any[]) => void;
44
+ error: (...args: any[]) => void;
45
+ debug: (...args: any[]) => void;
46
+ };
47
+ }
48
+ export interface AsyncHandlerDeps extends HandlerDeps {
49
+ asyncJobManager: AsyncJobManager;
50
+ }
51
+ export declare function handleGeminiRequest(deps: HandlerDeps, params: GeminiRequestParams): Promise<ExtendedToolResponse>;
52
+ export declare function handleGeminiRequestAsync(deps: AsyncHandlerDeps, params: Omit<GeminiRequestParams, "optimizeResponse">): Promise<ExtendedToolResponse>;
53
+ export declare function handleCodexRequestAsync(deps: AsyncHandlerDeps, params: {
54
+ prompt: string;
55
+ model?: string;
56
+ fullAuto: boolean;
57
+ dangerouslyBypassApprovalsAndSandbox: boolean;
58
+ approvalStrategy: "legacy" | "mcp_managed";
59
+ approvalPolicy?: string;
60
+ mcpServers?: ClaudeMcpServerName[];
61
+ sessionId?: string;
62
+ createNewSession: boolean;
63
+ correlationId?: string;
64
+ optimizePrompt: boolean;
65
+ idleTimeoutMs?: number;
66
+ }): Promise<ExtendedToolResponse>;
67
+ export {};