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,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;