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.
- package/CHANGELOG.md +541 -0
- package/LICENSE +21 -0
- package/README.md +545 -0
- package/dist/approval-manager.d.ts +43 -0
- package/dist/approval-manager.js +156 -0
- package/dist/async-job-manager.d.ts +57 -0
- package/dist/async-job-manager.js +334 -0
- package/dist/claude-mcp-config.d.ts +8 -0
- package/dist/claude-mcp-config.js +161 -0
- package/dist/config.d.ts +35 -0
- package/dist/config.js +56 -0
- package/dist/db.d.ts +48 -0
- package/dist/db.js +170 -0
- package/dist/executor.d.ts +30 -0
- package/dist/executor.js +315 -0
- package/dist/health.d.ts +20 -0
- package/dist/health.js +32 -0
- package/dist/index.d.ts +67 -0
- package/dist/index.js +1503 -0
- package/dist/logger.d.ts +6 -0
- package/dist/logger.js +5 -0
- package/dist/metrics.d.ts +23 -0
- package/dist/metrics.js +57 -0
- package/dist/migrate-sessions.d.ts +12 -0
- package/dist/migrate-sessions.js +145 -0
- package/dist/migrate.d.ts +2 -0
- package/dist/migrate.js +100 -0
- package/dist/model-registry.d.ts +10 -0
- package/dist/model-registry.js +346 -0
- package/dist/optimizer.d.ts +3 -0
- package/dist/optimizer.js +183 -0
- package/dist/process-monitor.d.ts +54 -0
- package/dist/process-monitor.js +146 -0
- package/dist/request-helpers.d.ts +25 -0
- package/dist/request-helpers.js +32 -0
- package/dist/resources.d.ts +26 -0
- package/dist/resources.js +201 -0
- package/dist/retry.d.ts +72 -0
- package/dist/retry.js +146 -0
- package/dist/review-integrity.d.ts +50 -0
- package/dist/review-integrity.js +283 -0
- package/dist/session-manager-pg.d.ts +76 -0
- package/dist/session-manager-pg.js +383 -0
- package/dist/session-manager.d.ts +62 -0
- package/dist/session-manager.js +223 -0
- package/dist/stream-json-parser.d.ts +35 -0
- package/dist/stream-json-parser.js +94 -0
- package/package.json +90 -0
|
@@ -0,0 +1,383 @@
|
|
|
1
|
+
import { randomUUID } from "crypto";
|
|
2
|
+
const DEFAULT_SESSION_DESCRIPTIONS = {
|
|
3
|
+
claude: "Claude Session",
|
|
4
|
+
codex: "Codex Session",
|
|
5
|
+
gemini: "Gemini Session"
|
|
6
|
+
};
|
|
7
|
+
/**
|
|
8
|
+
* PostgreSQL-backed session manager with Redis caching
|
|
9
|
+
*/
|
|
10
|
+
export class PostgreSQLSessionManager {
|
|
11
|
+
pool;
|
|
12
|
+
redis;
|
|
13
|
+
cacheTtl;
|
|
14
|
+
logger;
|
|
15
|
+
constructor(pool, redis, cacheTtl, logger) {
|
|
16
|
+
this.pool = pool;
|
|
17
|
+
this.redis = redis;
|
|
18
|
+
this.cacheTtl = cacheTtl;
|
|
19
|
+
this.logger = logger;
|
|
20
|
+
}
|
|
21
|
+
/**
|
|
22
|
+
* Acquire distributed lock using Redis SET NX EX
|
|
23
|
+
* Returns [success, lockValue] tuple
|
|
24
|
+
*/
|
|
25
|
+
async acquireLock(key, ttlSeconds) {
|
|
26
|
+
const lockKey = `lock:${key}`;
|
|
27
|
+
const lockValue = randomUUID();
|
|
28
|
+
// SET NX EX atomic operation
|
|
29
|
+
const result = await this.redis.set(lockKey, lockValue, "EX", ttlSeconds, "NX");
|
|
30
|
+
return [result === "OK", lockValue];
|
|
31
|
+
}
|
|
32
|
+
async sleep(ms) {
|
|
33
|
+
await new Promise(resolve => setTimeout(resolve, ms));
|
|
34
|
+
}
|
|
35
|
+
/**
|
|
36
|
+
* Acquire a distributed lock with bounded retries to smooth contention spikes.
|
|
37
|
+
*/
|
|
38
|
+
async acquireLockWithRetry(key, ttlSeconds, errorLabel, maxWaitMs = 6000) {
|
|
39
|
+
const deadline = Date.now() + maxWaitMs;
|
|
40
|
+
while (true) {
|
|
41
|
+
const [lockAcquired, lockValue] = await this.acquireLock(key, ttlSeconds);
|
|
42
|
+
if (lockAcquired) {
|
|
43
|
+
return lockValue;
|
|
44
|
+
}
|
|
45
|
+
if (Date.now() >= deadline) {
|
|
46
|
+
throw new Error(`Failed to acquire lock for ${errorLabel}`);
|
|
47
|
+
}
|
|
48
|
+
// Small jitter avoids lock-step retries from concurrent callers.
|
|
49
|
+
await this.sleep(25 + Math.floor(Math.random() * 25));
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
/**
|
|
53
|
+
* Release distributed lock using Lua script for atomic compare-and-delete
|
|
54
|
+
* Only releases if lockValue matches (prevents releasing another process's lock)
|
|
55
|
+
*/
|
|
56
|
+
async releaseLock(key, lockValue) {
|
|
57
|
+
const lockKey = `lock:${key}`;
|
|
58
|
+
// Lua script for atomic compare-and-delete
|
|
59
|
+
const script = `
|
|
60
|
+
if redis.call("get", KEYS[1]) == ARGV[1] then
|
|
61
|
+
return redis.call("del", KEYS[1])
|
|
62
|
+
else
|
|
63
|
+
return 0
|
|
64
|
+
end
|
|
65
|
+
`;
|
|
66
|
+
await this.redis.eval(script, 1, lockKey, lockValue);
|
|
67
|
+
}
|
|
68
|
+
/**
|
|
69
|
+
* Invalidate session cache
|
|
70
|
+
*/
|
|
71
|
+
async invalidateCache(sessionId) {
|
|
72
|
+
try {
|
|
73
|
+
await this.redis.del(`session:${sessionId}`);
|
|
74
|
+
}
|
|
75
|
+
catch (error) {
|
|
76
|
+
// Graceful degradation - log but don't fail
|
|
77
|
+
this.logger.error(`Cache invalidation failed for session ${sessionId}`, { error, sessionId });
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
/**
|
|
81
|
+
* Invalidate session list cache using SCAN (non-blocking)
|
|
82
|
+
*/
|
|
83
|
+
async invalidateListCache(cli) {
|
|
84
|
+
try {
|
|
85
|
+
if (cli) {
|
|
86
|
+
await this.redis.del(`session_list:${cli}`);
|
|
87
|
+
}
|
|
88
|
+
else {
|
|
89
|
+
// Use SCAN instead of KEYS to avoid blocking Redis
|
|
90
|
+
const keys = [];
|
|
91
|
+
let cursor = "0";
|
|
92
|
+
do {
|
|
93
|
+
const [nextCursor, matchedKeys] = await this.redis.scan(cursor, "MATCH", "session_list:*", "COUNT", 100);
|
|
94
|
+
cursor = nextCursor;
|
|
95
|
+
keys.push(...matchedKeys);
|
|
96
|
+
} while (cursor !== "0");
|
|
97
|
+
// Delete in batches to avoid overwhelming Redis
|
|
98
|
+
if (keys.length > 0) {
|
|
99
|
+
await this.redis.del(...keys);
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
catch (error) {
|
|
104
|
+
this.logger.error("List cache invalidation failed", { error });
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
/**
|
|
108
|
+
* Create a new session
|
|
109
|
+
*/
|
|
110
|
+
async createSession(cli, description, sessionId) {
|
|
111
|
+
const id = sessionId || randomUUID();
|
|
112
|
+
const sessionDescription = description ?? DEFAULT_SESSION_DESCRIPTIONS[cli];
|
|
113
|
+
const now = new Date().toISOString();
|
|
114
|
+
const client = await this.pool.connect();
|
|
115
|
+
try {
|
|
116
|
+
await client.query("BEGIN");
|
|
117
|
+
// Insert session
|
|
118
|
+
await client.query(`INSERT INTO sessions (id, cli, description, created_at, last_used_at)
|
|
119
|
+
VALUES ($1, $2, $3, $4, $5)`, [id, cli, sessionDescription, now, now]);
|
|
120
|
+
// Set as active if none exists
|
|
121
|
+
await client.query(`INSERT INTO active_sessions (cli, session_id, updated_at)
|
|
122
|
+
VALUES ($1, $2, $3)
|
|
123
|
+
ON CONFLICT (cli) DO NOTHING`, [cli, id, now]);
|
|
124
|
+
await client.query("COMMIT");
|
|
125
|
+
const session = {
|
|
126
|
+
id,
|
|
127
|
+
cli,
|
|
128
|
+
createdAt: now,
|
|
129
|
+
lastUsedAt: now,
|
|
130
|
+
description: sessionDescription
|
|
131
|
+
};
|
|
132
|
+
// Write-through to cache
|
|
133
|
+
try {
|
|
134
|
+
await this.redis.setex(`session:${id}`, this.cacheTtl.session, JSON.stringify(session));
|
|
135
|
+
}
|
|
136
|
+
catch (error) {
|
|
137
|
+
// Graceful degradation
|
|
138
|
+
this.logger.error("Cache write failed", { error });
|
|
139
|
+
}
|
|
140
|
+
// Invalidate list cache
|
|
141
|
+
await this.invalidateListCache(cli);
|
|
142
|
+
return session;
|
|
143
|
+
}
|
|
144
|
+
catch (error) {
|
|
145
|
+
await client.query("ROLLBACK");
|
|
146
|
+
throw error;
|
|
147
|
+
}
|
|
148
|
+
finally {
|
|
149
|
+
client.release();
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
/**
|
|
153
|
+
* Get session by ID (cache-aside pattern)
|
|
154
|
+
*/
|
|
155
|
+
async getSession(sessionId) {
|
|
156
|
+
// Try cache first
|
|
157
|
+
try {
|
|
158
|
+
const cached = await this.redis.get(`session:${sessionId}`);
|
|
159
|
+
if (cached) {
|
|
160
|
+
return JSON.parse(cached);
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
catch (error) {
|
|
164
|
+
// Graceful degradation - fallback to DB
|
|
165
|
+
this.logger.error("Cache read failed", { error });
|
|
166
|
+
}
|
|
167
|
+
// Cache miss - query database
|
|
168
|
+
const result = await this.pool.query(`SELECT id, cli, description, metadata, created_at AS "createdAt", last_used_at AS "lastUsedAt"
|
|
169
|
+
FROM sessions
|
|
170
|
+
WHERE id = $1`, [sessionId]);
|
|
171
|
+
if (result.rows.length === 0) {
|
|
172
|
+
return null;
|
|
173
|
+
}
|
|
174
|
+
const session = result.rows[0];
|
|
175
|
+
// Populate cache
|
|
176
|
+
try {
|
|
177
|
+
await this.redis.setex(`session:${sessionId}`, this.cacheTtl.session, JSON.stringify(session));
|
|
178
|
+
}
|
|
179
|
+
catch (error) {
|
|
180
|
+
this.logger.error("Cache write failed", { error });
|
|
181
|
+
}
|
|
182
|
+
return session;
|
|
183
|
+
}
|
|
184
|
+
/**
|
|
185
|
+
* List all sessions, optionally filtered by CLI
|
|
186
|
+
*/
|
|
187
|
+
async listSessions(cli) {
|
|
188
|
+
// Try cache for CLI-specific lists
|
|
189
|
+
const cacheKey = cli ? `session_list:${cli}` : null;
|
|
190
|
+
if (cacheKey) {
|
|
191
|
+
try {
|
|
192
|
+
const cached = await this.redis.get(cacheKey);
|
|
193
|
+
if (cached) {
|
|
194
|
+
return JSON.parse(cached);
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
catch (error) {
|
|
198
|
+
this.logger.error("Cache read failed", { error });
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
// Query database
|
|
202
|
+
const query = cli
|
|
203
|
+
? `SELECT id, cli, description, metadata, created_at AS "createdAt", last_used_at AS "lastUsedAt"
|
|
204
|
+
FROM sessions
|
|
205
|
+
WHERE cli = $1
|
|
206
|
+
ORDER BY last_used_at DESC`
|
|
207
|
+
: `SELECT id, cli, description, metadata, created_at AS "createdAt", last_used_at AS "lastUsedAt"
|
|
208
|
+
FROM sessions
|
|
209
|
+
ORDER BY last_used_at DESC`;
|
|
210
|
+
const result = cli ? await this.pool.query(query, [cli]) : await this.pool.query(query);
|
|
211
|
+
const sessions = result.rows;
|
|
212
|
+
// Cache CLI-specific lists
|
|
213
|
+
if (cacheKey) {
|
|
214
|
+
try {
|
|
215
|
+
await this.redis.setex(cacheKey, this.cacheTtl.sessionList, JSON.stringify(sessions));
|
|
216
|
+
}
|
|
217
|
+
catch (error) {
|
|
218
|
+
this.logger.error("Cache write failed", { error });
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
return sessions;
|
|
222
|
+
}
|
|
223
|
+
/**
|
|
224
|
+
* Delete a session
|
|
225
|
+
*/
|
|
226
|
+
async deleteSession(sessionId) {
|
|
227
|
+
// Get session to find CLI type
|
|
228
|
+
const session = await this.getSession(sessionId);
|
|
229
|
+
if (!session) {
|
|
230
|
+
return false;
|
|
231
|
+
}
|
|
232
|
+
// Delete from database (CASCADE will handle active_sessions)
|
|
233
|
+
const result = await this.pool.query("DELETE FROM sessions WHERE id = $1", [sessionId]);
|
|
234
|
+
if (result.rowCount === 0) {
|
|
235
|
+
return false;
|
|
236
|
+
}
|
|
237
|
+
// Invalidate caches (session, active session for this CLI, and list)
|
|
238
|
+
await this.invalidateCache(sessionId);
|
|
239
|
+
try {
|
|
240
|
+
await this.redis.del(`active_session:${session.cli}`);
|
|
241
|
+
}
|
|
242
|
+
catch (error) {
|
|
243
|
+
this.logger.error(`Failed to invalidate active session cache for ${session.cli}`, { error });
|
|
244
|
+
}
|
|
245
|
+
await this.invalidateListCache(session.cli);
|
|
246
|
+
return true;
|
|
247
|
+
}
|
|
248
|
+
/**
|
|
249
|
+
* Set active session for a CLI (with distributed locking)
|
|
250
|
+
*/
|
|
251
|
+
async setActiveSession(cli, sessionId) {
|
|
252
|
+
// Validate session exists if not null
|
|
253
|
+
if (sessionId !== null) {
|
|
254
|
+
const session = await this.getSession(sessionId);
|
|
255
|
+
if (!session || session.cli !== cli) {
|
|
256
|
+
return false;
|
|
257
|
+
}
|
|
258
|
+
}
|
|
259
|
+
// Acquire lock with bounded retries to avoid failing benign concurrent updates.
|
|
260
|
+
const lockValue = await this.acquireLockWithRetry(`active_session:${cli}`, 5, `active session ${cli}`);
|
|
261
|
+
try {
|
|
262
|
+
// UPSERT active session
|
|
263
|
+
const now = new Date().toISOString();
|
|
264
|
+
await this.pool.query(`INSERT INTO active_sessions (cli, session_id, updated_at)
|
|
265
|
+
VALUES ($1, $2, $3)
|
|
266
|
+
ON CONFLICT (cli) DO UPDATE SET session_id = $2, updated_at = $3`, [cli, sessionId, now]);
|
|
267
|
+
// Update cache
|
|
268
|
+
try {
|
|
269
|
+
if (sessionId) {
|
|
270
|
+
await this.redis.setex(`active_session:${cli}`, this.cacheTtl.activeSession, sessionId);
|
|
271
|
+
}
|
|
272
|
+
else {
|
|
273
|
+
await this.redis.del(`active_session:${cli}`);
|
|
274
|
+
}
|
|
275
|
+
}
|
|
276
|
+
catch (error) {
|
|
277
|
+
this.logger.error("Cache update failed", { error });
|
|
278
|
+
}
|
|
279
|
+
return true;
|
|
280
|
+
}
|
|
281
|
+
finally {
|
|
282
|
+
// Release lock with ownership verification
|
|
283
|
+
try {
|
|
284
|
+
await this.releaseLock(`active_session:${cli}`, lockValue);
|
|
285
|
+
}
|
|
286
|
+
catch (error) {
|
|
287
|
+
this.logger.error(`Failed to release lock for active session ${cli}`, { error, cli });
|
|
288
|
+
}
|
|
289
|
+
}
|
|
290
|
+
}
|
|
291
|
+
/**
|
|
292
|
+
* Get active session for a CLI
|
|
293
|
+
*/
|
|
294
|
+
async getActiveSession(cli) {
|
|
295
|
+
// Try cache first
|
|
296
|
+
try {
|
|
297
|
+
const cachedId = await this.redis.get(`active_session:${cli}`);
|
|
298
|
+
if (cachedId) {
|
|
299
|
+
return await this.getSession(cachedId);
|
|
300
|
+
}
|
|
301
|
+
}
|
|
302
|
+
catch (error) {
|
|
303
|
+
this.logger.error("Cache read failed", { error });
|
|
304
|
+
}
|
|
305
|
+
// Query database
|
|
306
|
+
const result = await this.pool.query("SELECT session_id FROM active_sessions WHERE cli = $1", [cli]);
|
|
307
|
+
if (result.rows.length === 0 || !result.rows[0].session_id) {
|
|
308
|
+
return null;
|
|
309
|
+
}
|
|
310
|
+
const sessionId = result.rows[0].session_id;
|
|
311
|
+
// Populate cache
|
|
312
|
+
try {
|
|
313
|
+
await this.redis.setex(`active_session:${cli}`, this.cacheTtl.activeSession, sessionId);
|
|
314
|
+
}
|
|
315
|
+
catch (error) {
|
|
316
|
+
this.logger.error("Cache write failed", { error });
|
|
317
|
+
}
|
|
318
|
+
return await this.getSession(sessionId);
|
|
319
|
+
}
|
|
320
|
+
/**
|
|
321
|
+
* Update session usage timestamp
|
|
322
|
+
*/
|
|
323
|
+
async updateSessionUsage(sessionId) {
|
|
324
|
+
const now = new Date().toISOString();
|
|
325
|
+
await this.pool.query("UPDATE sessions SET last_used_at = $1 WHERE id = $2", [now, sessionId]);
|
|
326
|
+
// Invalidate cache to force refresh
|
|
327
|
+
await this.invalidateCache(sessionId);
|
|
328
|
+
}
|
|
329
|
+
/**
|
|
330
|
+
* Update session metadata (atomic JSONB merge)
|
|
331
|
+
*/
|
|
332
|
+
async updateSessionMetadata(sessionId, metadata) {
|
|
333
|
+
// Use PostgreSQL JSONB || operator for atomic merge (prevents race conditions)
|
|
334
|
+
const result = await this.pool.query(`UPDATE sessions
|
|
335
|
+
SET metadata = COALESCE(metadata, '{}'::jsonb) || $1::jsonb
|
|
336
|
+
WHERE id = $2
|
|
337
|
+
RETURNING id`, [JSON.stringify(metadata), sessionId]);
|
|
338
|
+
if (result.rowCount === 0) {
|
|
339
|
+
return false;
|
|
340
|
+
}
|
|
341
|
+
// Invalidate cache
|
|
342
|
+
await this.invalidateCache(sessionId);
|
|
343
|
+
return true;
|
|
344
|
+
}
|
|
345
|
+
/**
|
|
346
|
+
* Clear all sessions, optionally filtered by CLI
|
|
347
|
+
* Invalidates all related caches (session, active, list)
|
|
348
|
+
*/
|
|
349
|
+
async clearAllSessions(cli) {
|
|
350
|
+
// First get all sessions to invalidate their caches
|
|
351
|
+
const sessions = await this.listSessions(cli);
|
|
352
|
+
// Delete from database
|
|
353
|
+
const query = cli ? "DELETE FROM sessions WHERE cli = $1" : "DELETE FROM sessions";
|
|
354
|
+
const result = cli ? await this.pool.query(query, [cli]) : await this.pool.query(query);
|
|
355
|
+
// Invalidate individual session caches (concurrent — each has its own try/catch)
|
|
356
|
+
await Promise.all(sessions.map(session => this.invalidateCache(session.id)));
|
|
357
|
+
// Invalidate active session caches
|
|
358
|
+
if (cli) {
|
|
359
|
+
try {
|
|
360
|
+
await this.redis.del(`active_session:${cli}`);
|
|
361
|
+
}
|
|
362
|
+
catch (error) {
|
|
363
|
+
this.logger.error(`Failed to invalidate active session cache for ${cli}`, { error, cli });
|
|
364
|
+
}
|
|
365
|
+
}
|
|
366
|
+
else {
|
|
367
|
+
// Invalidate all active session caches
|
|
368
|
+
try {
|
|
369
|
+
await Promise.all([
|
|
370
|
+
this.redis.del("active_session:claude"),
|
|
371
|
+
this.redis.del("active_session:codex"),
|
|
372
|
+
this.redis.del("active_session:gemini")
|
|
373
|
+
]);
|
|
374
|
+
}
|
|
375
|
+
catch (error) {
|
|
376
|
+
this.logger.error("Failed to invalidate active session caches", { error });
|
|
377
|
+
}
|
|
378
|
+
}
|
|
379
|
+
// Invalidate list caches
|
|
380
|
+
await this.invalidateListCache(cli);
|
|
381
|
+
return result.rowCount || 0;
|
|
382
|
+
}
|
|
383
|
+
}
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
import type { Config } from "./config.js";
|
|
2
|
+
import type { DatabaseConnection } from "./db.js";
|
|
3
|
+
import type { Logger } from "./logger.js";
|
|
4
|
+
export declare const CLI_TYPES: readonly ["claude", "codex", "gemini"];
|
|
5
|
+
export type CliType = (typeof CLI_TYPES)[number];
|
|
6
|
+
export interface Session {
|
|
7
|
+
id: string;
|
|
8
|
+
cli: CliType;
|
|
9
|
+
createdAt: string;
|
|
10
|
+
lastUsedAt: string;
|
|
11
|
+
description?: string;
|
|
12
|
+
metadata?: Record<string, any>;
|
|
13
|
+
}
|
|
14
|
+
export interface SessionStorage {
|
|
15
|
+
sessions: Record<string, Session>;
|
|
16
|
+
activeSession: Record<CliType, string | null>;
|
|
17
|
+
}
|
|
18
|
+
export declare class FileSessionManager {
|
|
19
|
+
private storagePath;
|
|
20
|
+
private storage;
|
|
21
|
+
private readonly sessionTtlMs;
|
|
22
|
+
constructor(customPath?: string, sessionTtlMs?: number);
|
|
23
|
+
private isExpired;
|
|
24
|
+
private evictExpiredSessions;
|
|
25
|
+
private ensureStorageDirectory;
|
|
26
|
+
private loadStorage;
|
|
27
|
+
private saveStorage;
|
|
28
|
+
createSession(cli: CliType, description?: string, sessionId?: string): Session;
|
|
29
|
+
getSession(sessionId: string): Session | null;
|
|
30
|
+
listSessions(cli?: CliType): Session[];
|
|
31
|
+
deleteSession(sessionId: string): boolean;
|
|
32
|
+
setActiveSession(cli: CliType, sessionId: string | null): boolean;
|
|
33
|
+
getActiveSession(cli: CliType): Session | null;
|
|
34
|
+
updateSessionUsage(sessionId: string): void;
|
|
35
|
+
updateSessionMetadata(sessionId: string, metadata: Record<string, any>): boolean;
|
|
36
|
+
clearAllSessions(cli?: CliType): number;
|
|
37
|
+
}
|
|
38
|
+
export declare const SessionManager: typeof FileSessionManager;
|
|
39
|
+
/**
|
|
40
|
+
* Session manager interface supporting both sync (file) and async (PostgreSQL) backends.
|
|
41
|
+
* Methods return T | Promise<T> so both backends satisfy the contract.
|
|
42
|
+
* Callers must always use `await` for uniform handling.
|
|
43
|
+
*/
|
|
44
|
+
export interface ISessionManager {
|
|
45
|
+
createSession(cli: CliType, description?: string, sessionId?: string): Session | Promise<Session>;
|
|
46
|
+
getSession(sessionId: string): Session | null | Promise<Session | null>;
|
|
47
|
+
listSessions(cli?: CliType): Session[] | Promise<Session[]>;
|
|
48
|
+
deleteSession(sessionId: string): boolean | Promise<boolean>;
|
|
49
|
+
setActiveSession(cli: CliType, sessionId: string | null): boolean | Promise<boolean>;
|
|
50
|
+
getActiveSession(cli: CliType): Session | null | Promise<Session | null>;
|
|
51
|
+
updateSessionUsage(sessionId: string): void | Promise<void>;
|
|
52
|
+
updateSessionMetadata(sessionId: string, metadata: Record<string, any>): boolean | Promise<boolean>;
|
|
53
|
+
clearAllSessions(cli?: CliType): number | Promise<number>;
|
|
54
|
+
}
|
|
55
|
+
/**
|
|
56
|
+
* Factory function to create session manager
|
|
57
|
+
* Returns PostgreSQLSessionManager if config present, otherwise FileSessionManager
|
|
58
|
+
* @param config - Configuration object
|
|
59
|
+
* @param db - Optional pre-existing DatabaseConnection (avoids creating duplicate connections)
|
|
60
|
+
* @param logger - Logger instance for structured logging
|
|
61
|
+
*/
|
|
62
|
+
export declare function createSessionManager(config?: Config, db?: DatabaseConnection, logger?: Logger): Promise<ISessionManager>;
|
|
@@ -0,0 +1,223 @@
|
|
|
1
|
+
import { randomUUID } from "crypto";
|
|
2
|
+
import { homedir } from "os";
|
|
3
|
+
import { join, dirname } from "path";
|
|
4
|
+
import { existsSync, mkdirSync, readFileSync, writeFileSync, renameSync, openSync, fsyncSync, closeSync, chmodSync } from "fs";
|
|
5
|
+
import { DEFAULT_SESSION_TTL_SECONDS } from "./config.js";
|
|
6
|
+
import { noopLogger } from "./logger.js";
|
|
7
|
+
export const CLI_TYPES = ["claude", "codex", "gemini"];
|
|
8
|
+
const createEmptyActiveSessions = () => Object.fromEntries(CLI_TYPES.map(cli => [cli, null]));
|
|
9
|
+
const DEFAULT_SESSION_DESCRIPTIONS = {
|
|
10
|
+
claude: "Claude Session",
|
|
11
|
+
codex: "Codex Session",
|
|
12
|
+
gemini: "Gemini Session"
|
|
13
|
+
};
|
|
14
|
+
export class FileSessionManager {
|
|
15
|
+
storagePath;
|
|
16
|
+
storage = { sessions: {}, activeSession: createEmptyActiveSessions() };
|
|
17
|
+
sessionTtlMs;
|
|
18
|
+
constructor(customPath, sessionTtlMs) {
|
|
19
|
+
this.sessionTtlMs = sessionTtlMs ?? DEFAULT_SESSION_TTL_SECONDS * 1000;
|
|
20
|
+
this.storagePath = customPath || join(homedir(), ".llm-cli-gateway", "sessions.json");
|
|
21
|
+
this.ensureStorageDirectory();
|
|
22
|
+
this.loadStorage();
|
|
23
|
+
}
|
|
24
|
+
isExpired(session) {
|
|
25
|
+
const ts = new Date(session.lastUsedAt).getTime();
|
|
26
|
+
if (!Number.isFinite(ts))
|
|
27
|
+
return true; // malformed → expired
|
|
28
|
+
return Date.now() - ts > this.sessionTtlMs;
|
|
29
|
+
}
|
|
30
|
+
evictExpiredSessions() {
|
|
31
|
+
let count = 0;
|
|
32
|
+
for (const [id, session] of Object.entries(this.storage.sessions)) {
|
|
33
|
+
if (this.isExpired(session)) {
|
|
34
|
+
delete this.storage.sessions[id];
|
|
35
|
+
if (this.storage.activeSession[session.cli] === id) {
|
|
36
|
+
this.storage.activeSession[session.cli] = null;
|
|
37
|
+
}
|
|
38
|
+
count++;
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
if (count > 0)
|
|
42
|
+
this.saveStorage();
|
|
43
|
+
return count;
|
|
44
|
+
}
|
|
45
|
+
ensureStorageDirectory() {
|
|
46
|
+
const storageDir = dirname(this.storagePath);
|
|
47
|
+
if (!existsSync(storageDir)) {
|
|
48
|
+
mkdirSync(storageDir, { recursive: true });
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
loadStorage() {
|
|
52
|
+
if (existsSync(this.storagePath)) {
|
|
53
|
+
try {
|
|
54
|
+
const data = readFileSync(this.storagePath, "utf-8");
|
|
55
|
+
this.storage = JSON.parse(data);
|
|
56
|
+
}
|
|
57
|
+
catch (error) {
|
|
58
|
+
// If file is corrupted, start fresh
|
|
59
|
+
this.storage = { sessions: {}, activeSession: createEmptyActiveSessions() };
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
else {
|
|
63
|
+
this.storage = { sessions: {}, activeSession: createEmptyActiveSessions() };
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
saveStorage() {
|
|
67
|
+
const tempPath = `${this.storagePath}.tmp.${process.pid}`;
|
|
68
|
+
writeFileSync(tempPath, JSON.stringify(this.storage, null, 2), { encoding: "utf-8", mode: 0o600 });
|
|
69
|
+
const fd = openSync(tempPath, "r+");
|
|
70
|
+
try {
|
|
71
|
+
fsyncSync(fd);
|
|
72
|
+
}
|
|
73
|
+
finally {
|
|
74
|
+
closeSync(fd);
|
|
75
|
+
}
|
|
76
|
+
renameSync(tempPath, this.storagePath);
|
|
77
|
+
chmodSync(this.storagePath, 0o600);
|
|
78
|
+
}
|
|
79
|
+
createSession(cli, description, sessionId) {
|
|
80
|
+
this.evictExpiredSessions();
|
|
81
|
+
const id = sessionId || randomUUID();
|
|
82
|
+
const sessionDescription = description ?? DEFAULT_SESSION_DESCRIPTIONS[cli];
|
|
83
|
+
const session = {
|
|
84
|
+
id,
|
|
85
|
+
cli,
|
|
86
|
+
createdAt: new Date().toISOString(),
|
|
87
|
+
lastUsedAt: new Date().toISOString(),
|
|
88
|
+
description: sessionDescription
|
|
89
|
+
};
|
|
90
|
+
this.storage.sessions[id] = session;
|
|
91
|
+
// Set as active session if none exists for this CLI
|
|
92
|
+
if (!this.storage.activeSession[cli]) {
|
|
93
|
+
this.storage.activeSession[cli] = id;
|
|
94
|
+
}
|
|
95
|
+
this.saveStorage();
|
|
96
|
+
return session;
|
|
97
|
+
}
|
|
98
|
+
getSession(sessionId) {
|
|
99
|
+
const session = this.storage.sessions[sessionId];
|
|
100
|
+
if (!session)
|
|
101
|
+
return null;
|
|
102
|
+
if (this.isExpired(session)) {
|
|
103
|
+
this.deleteSession(sessionId);
|
|
104
|
+
return null;
|
|
105
|
+
}
|
|
106
|
+
return session;
|
|
107
|
+
}
|
|
108
|
+
listSessions(cli) {
|
|
109
|
+
this.evictExpiredSessions();
|
|
110
|
+
const sessions = Object.values(this.storage.sessions);
|
|
111
|
+
if (cli) {
|
|
112
|
+
return sessions.filter(s => s.cli === cli);
|
|
113
|
+
}
|
|
114
|
+
return sessions;
|
|
115
|
+
}
|
|
116
|
+
deleteSession(sessionId) {
|
|
117
|
+
if (!this.storage.sessions[sessionId]) {
|
|
118
|
+
return false;
|
|
119
|
+
}
|
|
120
|
+
const session = this.storage.sessions[sessionId];
|
|
121
|
+
delete this.storage.sessions[sessionId];
|
|
122
|
+
// If this was the active session, clear it
|
|
123
|
+
if (this.storage.activeSession[session.cli] === sessionId) {
|
|
124
|
+
this.storage.activeSession[session.cli] = null;
|
|
125
|
+
}
|
|
126
|
+
this.saveStorage();
|
|
127
|
+
return true;
|
|
128
|
+
}
|
|
129
|
+
setActiveSession(cli, sessionId) {
|
|
130
|
+
if (sessionId !== null) {
|
|
131
|
+
const session = this.storage.sessions[sessionId];
|
|
132
|
+
if (!session)
|
|
133
|
+
return false;
|
|
134
|
+
if (this.isExpired(session)) {
|
|
135
|
+
this.deleteSession(sessionId);
|
|
136
|
+
return false;
|
|
137
|
+
}
|
|
138
|
+
if (session.cli !== cli)
|
|
139
|
+
return false;
|
|
140
|
+
}
|
|
141
|
+
this.storage.activeSession[cli] = sessionId;
|
|
142
|
+
this.saveStorage();
|
|
143
|
+
return true;
|
|
144
|
+
}
|
|
145
|
+
getActiveSession(cli) {
|
|
146
|
+
const sessionId = this.storage.activeSession[cli];
|
|
147
|
+
if (!sessionId)
|
|
148
|
+
return null;
|
|
149
|
+
const session = this.storage.sessions[sessionId];
|
|
150
|
+
if (!session || this.isExpired(session)) {
|
|
151
|
+
this.storage.activeSession[cli] = null;
|
|
152
|
+
if (session)
|
|
153
|
+
delete this.storage.sessions[sessionId];
|
|
154
|
+
this.saveStorage();
|
|
155
|
+
return null;
|
|
156
|
+
}
|
|
157
|
+
return session;
|
|
158
|
+
}
|
|
159
|
+
updateSessionUsage(sessionId) {
|
|
160
|
+
const session = this.storage.sessions[sessionId];
|
|
161
|
+
if (!session)
|
|
162
|
+
return;
|
|
163
|
+
if (this.isExpired(session)) {
|
|
164
|
+
this.deleteSession(sessionId);
|
|
165
|
+
return;
|
|
166
|
+
}
|
|
167
|
+
session.lastUsedAt = new Date().toISOString();
|
|
168
|
+
this.saveStorage();
|
|
169
|
+
}
|
|
170
|
+
updateSessionMetadata(sessionId, metadata) {
|
|
171
|
+
const session = this.storage.sessions[sessionId];
|
|
172
|
+
if (!session)
|
|
173
|
+
return false;
|
|
174
|
+
if (this.isExpired(session)) {
|
|
175
|
+
this.deleteSession(sessionId);
|
|
176
|
+
return false;
|
|
177
|
+
}
|
|
178
|
+
session.metadata = { ...session.metadata, ...metadata };
|
|
179
|
+
this.saveStorage();
|
|
180
|
+
return true;
|
|
181
|
+
}
|
|
182
|
+
clearAllSessions(cli) {
|
|
183
|
+
const sessionsToDelete = cli
|
|
184
|
+
? Object.values(this.storage.sessions).filter(s => s.cli === cli)
|
|
185
|
+
: Object.values(this.storage.sessions);
|
|
186
|
+
sessionsToDelete.forEach(session => {
|
|
187
|
+
delete this.storage.sessions[session.id];
|
|
188
|
+
if (this.storage.activeSession[session.cli] === session.id) {
|
|
189
|
+
this.storage.activeSession[session.cli] = null;
|
|
190
|
+
}
|
|
191
|
+
});
|
|
192
|
+
this.saveStorage();
|
|
193
|
+
return sessionsToDelete.length;
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
// Maintain backward compatibility
|
|
197
|
+
export const SessionManager = FileSessionManager;
|
|
198
|
+
/**
|
|
199
|
+
* Factory function to create session manager
|
|
200
|
+
* Returns PostgreSQLSessionManager if config present, otherwise FileSessionManager
|
|
201
|
+
* @param config - Configuration object
|
|
202
|
+
* @param db - Optional pre-existing DatabaseConnection (avoids creating duplicate connections)
|
|
203
|
+
* @param logger - Logger instance for structured logging
|
|
204
|
+
*/
|
|
205
|
+
export async function createSessionManager(config, db, logger) {
|
|
206
|
+
if (config?.database && config?.redis) {
|
|
207
|
+
// Import dynamically to avoid loading pg/ioredis if not needed
|
|
208
|
+
const { PostgreSQLSessionManager } = await import("./session-manager-pg.js");
|
|
209
|
+
// Use provided db connection or create new one
|
|
210
|
+
if (!db) {
|
|
211
|
+
const { createDatabaseConnection } = await import("./db.js");
|
|
212
|
+
db = await createDatabaseConnection(config, logger);
|
|
213
|
+
}
|
|
214
|
+
return new PostgreSQLSessionManager(db.getPool(), db.getRedis(), config.cacheTtl, logger ?? noopLogger);
|
|
215
|
+
}
|
|
216
|
+
else {
|
|
217
|
+
// Use file-based storage with TTL from config
|
|
218
|
+
const sessionTtlMs = config?.sessionTtl
|
|
219
|
+
? config.sessionTtl * 1000
|
|
220
|
+
: DEFAULT_SESSION_TTL_SECONDS * 1000;
|
|
221
|
+
return new FileSessionManager(undefined, sessionTtlMs);
|
|
222
|
+
}
|
|
223
|
+
}
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* NDJSON parser for Claude `--output-format stream-json --include-partial-messages`.
|
|
3
|
+
*
|
|
4
|
+
* Each line of stdout is a complete JSON object. This parser extracts the
|
|
5
|
+
* final result text, cost, usage, and metadata from the stream.
|
|
6
|
+
*/
|
|
7
|
+
export interface StreamJsonUsage {
|
|
8
|
+
inputTokens: number;
|
|
9
|
+
outputTokens: number;
|
|
10
|
+
cacheReadInputTokens: number;
|
|
11
|
+
cacheCreationInputTokens: number;
|
|
12
|
+
}
|
|
13
|
+
export interface StreamJsonResult {
|
|
14
|
+
text: string;
|
|
15
|
+
costUsd: number | null;
|
|
16
|
+
usage: StreamJsonUsage | null;
|
|
17
|
+
sessionId: string | null;
|
|
18
|
+
model: string | null;
|
|
19
|
+
durationApiMs: number | null;
|
|
20
|
+
isError: boolean;
|
|
21
|
+
numTurns: number | null;
|
|
22
|
+
}
|
|
23
|
+
/**
|
|
24
|
+
* Parse completed NDJSON stdout from `claude --output-format stream-json --include-partial-messages`.
|
|
25
|
+
*
|
|
26
|
+
* Parsing strategy:
|
|
27
|
+
* 1. Split by newlines, filter empty lines
|
|
28
|
+
* 2. JSON.parse each line, skip malformed lines
|
|
29
|
+
* 3. Find the `type=result` event — contains final text, cost, usage
|
|
30
|
+
* 4. Fall back to the last `type=assistant` event if no result event
|
|
31
|
+
* 5. Extract `model` from `type=system` (init) event
|
|
32
|
+
*
|
|
33
|
+
* No rawEvents stored — the stdout buffer is already in memory.
|
|
34
|
+
*/
|
|
35
|
+
export declare function parseStreamJson(stdout: string): StreamJsonResult;
|