llm-cli-gateway 1.14.0 → 1.15.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.
@@ -15,11 +15,27 @@ export interface SessionStorage {
15
15
  sessions: Record<string, Session>;
16
16
  activeSession: Record<CliType, string | null>;
17
17
  }
18
+ /**
19
+ * Slice λ: callback invoked before a session record is removed (whether via
20
+ * explicit `deleteSession`, TTL eviction, or `clearAllSessions`). Used to
21
+ * tear down per-session resources owned by the gateway — currently git
22
+ * worktrees registered on `session.metadata.worktreePath`. The hook
23
+ * receives the path; promise failures are logged but do not block session
24
+ * removal (gateway-owned-lifecycle invariant: `session_delete` must always
25
+ * succeed for the caller).
26
+ */
27
+ export type SessionCleanupHook = (session: Session) => void | Promise<void>;
18
28
  export declare class FileSessionManager {
19
29
  private storagePath;
20
30
  private storage;
21
31
  private readonly sessionTtlMs;
22
- constructor(customPath?: string, sessionTtlMs?: number);
32
+ private readonly cleanupHook?;
33
+ private readonly logger;
34
+ constructor(customPath?: string, sessionTtlMs?: number, opts?: {
35
+ cleanupHook?: SessionCleanupHook;
36
+ logger?: Logger;
37
+ });
38
+ private invokeCleanupHook;
23
39
  private isExpired;
24
40
  private evictExpiredSessions;
25
41
  private ensureStorageDirectory;
@@ -59,4 +75,6 @@ export interface ISessionManager {
59
75
  * @param db - Optional pre-existing DatabaseConnection (avoids creating duplicate connections)
60
76
  * @param logger - Logger instance for structured logging
61
77
  */
62
- export declare function createSessionManager(config?: Config, db?: DatabaseConnection, logger?: Logger): Promise<ISessionManager>;
78
+ export declare function createSessionManager(config?: Config, db?: DatabaseConnection, logger?: Logger, opts?: {
79
+ cleanupHook?: SessionCleanupHook;
80
+ }): Promise<ISessionManager>;
@@ -17,12 +17,31 @@ export class FileSessionManager {
17
17
  storagePath;
18
18
  storage = { sessions: {}, activeSession: createEmptyActiveSessions() };
19
19
  sessionTtlMs;
20
- constructor(customPath, sessionTtlMs) {
20
+ cleanupHook;
21
+ logger;
22
+ constructor(customPath, sessionTtlMs, opts) {
21
23
  this.sessionTtlMs = sessionTtlMs ?? DEFAULT_SESSION_TTL_SECONDS * 1000;
22
24
  this.storagePath = customPath || join(homedir(), ".llm-cli-gateway", "sessions.json");
25
+ this.cleanupHook = opts?.cleanupHook;
26
+ this.logger = opts?.logger ?? noopLogger;
23
27
  this.ensureStorageDirectory();
24
28
  this.loadStorage();
25
29
  }
30
+ invokeCleanupHook(session) {
31
+ if (!this.cleanupHook)
32
+ return;
33
+ try {
34
+ const result = this.cleanupHook(session);
35
+ if (result && typeof result.catch === "function") {
36
+ result.catch(err => {
37
+ this.logger.error(`session cleanup hook rejected for ${session.id}`, err);
38
+ });
39
+ }
40
+ }
41
+ catch (err) {
42
+ this.logger.error(`session cleanup hook threw for ${session.id}`, err);
43
+ }
44
+ }
26
45
  isExpired(session) {
27
46
  const ts = new Date(session.lastUsedAt).getTime();
28
47
  if (!Number.isFinite(ts))
@@ -33,6 +52,7 @@ export class FileSessionManager {
33
52
  let count = 0;
34
53
  for (const [id, session] of Object.entries(this.storage.sessions)) {
35
54
  if (this.isExpired(session)) {
55
+ this.invokeCleanupHook(session);
36
56
  delete this.storage.sessions[id];
37
57
  if (this.storage.activeSession[session.cli] === id) {
38
58
  this.storage.activeSession[session.cli] = null;
@@ -123,6 +143,7 @@ export class FileSessionManager {
123
143
  return false;
124
144
  }
125
145
  const session = this.storage.sessions[sessionId];
146
+ this.invokeCleanupHook(session);
126
147
  delete this.storage.sessions[sessionId];
127
148
  // If this was the active session, clear it
128
149
  if (this.storage.activeSession[session.cli] === sessionId) {
@@ -189,6 +210,7 @@ export class FileSessionManager {
189
210
  ? Object.values(this.storage.sessions).filter(s => s.cli === cli)
190
211
  : Object.values(this.storage.sessions);
191
212
  sessionsToDelete.forEach(session => {
213
+ this.invokeCleanupHook(session);
192
214
  delete this.storage.sessions[session.id];
193
215
  if (this.storage.activeSession[session.cli] === session.id) {
194
216
  this.storage.activeSession[session.cli] = null;
@@ -207,7 +229,7 @@ export const SessionManager = FileSessionManager;
207
229
  * @param db - Optional pre-existing DatabaseConnection (avoids creating duplicate connections)
208
230
  * @param logger - Logger instance for structured logging
209
231
  */
210
- export async function createSessionManager(config, db, logger) {
232
+ export async function createSessionManager(config, db, logger, opts) {
211
233
  if (config?.database && config?.redis) {
212
234
  // Import dynamically to avoid loading pg/ioredis if not needed
213
235
  const { PostgreSQLSessionManager } = await import("./session-manager-pg.js");
@@ -223,6 +245,9 @@ export async function createSessionManager(config, db, logger) {
223
245
  const sessionTtlMs = config?.sessionTtl
224
246
  ? config.sessionTtl * 1000
225
247
  : DEFAULT_SESSION_TTL_SECONDS * 1000;
226
- return new FileSessionManager(undefined, sessionTtlMs);
248
+ return new FileSessionManager(undefined, sessionTtlMs, {
249
+ cleanupHook: opts?.cleanupHook,
250
+ logger,
251
+ });
227
252
  }
228
253
  }
@@ -0,0 +1,41 @@
1
+ import { type Logger } from "./logger.js";
2
+ export interface WorktreeHandle {
3
+ name: string;
4
+ path: string;
5
+ ref: string;
6
+ createdAt: string;
7
+ }
8
+ export interface CreateWorktreeOptions {
9
+ repoRoot: string;
10
+ name?: string;
11
+ ref?: string;
12
+ logger?: Logger;
13
+ }
14
+ export interface RemoveWorktreeOptions {
15
+ repoRoot: string;
16
+ path: string;
17
+ name?: string;
18
+ logger?: Logger;
19
+ }
20
+ export declare class WorktreeError extends Error {
21
+ constructor(message: string);
22
+ }
23
+ export declare class WorktreeCollisionError extends WorktreeError {
24
+ constructor(path: string);
25
+ }
26
+ export declare function sanitizeWorktreeName(input: string): string;
27
+ export declare function createWorktree(opts: CreateWorktreeOptions): Promise<WorktreeHandle>;
28
+ /**
29
+ * Build a SessionCleanupHook that tears down per-session worktrees. The
30
+ * hook reads `session.metadata.worktreePath` (recorded by
31
+ * `resolveWorktreeForRequest`) and the optional `session.metadata.worktreeName`,
32
+ * derives `repoRoot` from the path layout (`<repoRoot>/.worktrees/<name>`),
33
+ * and fires `removeWorktree` asynchronously. Failures are logged by
34
+ * `removeWorktree` itself — the hook always resolves so session deletion
35
+ * never blocks on git.
36
+ */
37
+ export declare function createWorktreeSessionCleanupHook(logger: Logger): (session: {
38
+ id: string;
39
+ metadata?: Record<string, unknown>;
40
+ }) => Promise<void>;
41
+ export declare function removeWorktree(opts: RemoveWorktreeOptions): Promise<void>;
@@ -0,0 +1,214 @@
1
+ import { spawn } from "child_process";
2
+ import { randomUUID } from "crypto";
3
+ import { existsSync } from "fs";
4
+ import { resolve as resolvePath, join, sep } from "path";
5
+ import { logWarn, noopLogger } from "./logger.js";
6
+ const GIT_TIMEOUT_MS = 10_000;
7
+ const NAME_PATTERN = /^[A-Za-z0-9._-]{1,64}$/;
8
+ export class WorktreeError extends Error {
9
+ constructor(message) {
10
+ super(message);
11
+ this.name = "WorktreeError";
12
+ }
13
+ }
14
+ export class WorktreeCollisionError extends WorktreeError {
15
+ constructor(path) {
16
+ super(`worktree path already exists and is not a registered git worktree: ${path}. ` +
17
+ `Remove the stale directory (or pick a different worktree name) and retry.`);
18
+ this.name = "WorktreeCollisionError";
19
+ }
20
+ }
21
+ export function sanitizeWorktreeName(input) {
22
+ if (typeof input !== "string") {
23
+ throw new WorktreeError("worktree name must be a string");
24
+ }
25
+ if (input.length === 0) {
26
+ throw new WorktreeError("worktree name must not be empty");
27
+ }
28
+ if (input.length > 64) {
29
+ throw new WorktreeError("worktree name must be ≤ 64 characters");
30
+ }
31
+ if (input === "." || input === "..") {
32
+ throw new WorktreeError(`worktree name "${input}" is reserved`);
33
+ }
34
+ if (input.startsWith(".")) {
35
+ throw new WorktreeError("worktree name must not start with '.'");
36
+ }
37
+ if (input.startsWith("-")) {
38
+ throw new WorktreeError("worktree name must not start with '-'");
39
+ }
40
+ if (input.includes("..")) {
41
+ throw new WorktreeError("worktree name must not contain '..'");
42
+ }
43
+ if (!NAME_PATTERN.test(input)) {
44
+ throw new WorktreeError(`worktree name "${input}" contains disallowed characters ` +
45
+ `(allowed: A-Z a-z 0-9 . _ -, length 1-64)`);
46
+ }
47
+ return input;
48
+ }
49
+ function generateDefaultName() {
50
+ return randomUUID().replace(/-/g, "");
51
+ }
52
+ async function execGit(repoRoot, args, logger) {
53
+ return new Promise((resolveExec, rejectExec) => {
54
+ const proc = spawn("git", args, {
55
+ cwd: repoRoot,
56
+ stdio: ["ignore", "pipe", "pipe"],
57
+ env: process.env,
58
+ });
59
+ const stdoutChunks = [];
60
+ const stderrChunks = [];
61
+ let settled = false;
62
+ const timer = setTimeout(() => {
63
+ if (settled)
64
+ return;
65
+ settled = true;
66
+ try {
67
+ proc.kill("SIGKILL");
68
+ }
69
+ catch {
70
+ // ignore — process may already be gone
71
+ }
72
+ rejectExec(new WorktreeError(`git ${args.join(" ")} timed out after ${GIT_TIMEOUT_MS}ms`));
73
+ }, GIT_TIMEOUT_MS);
74
+ proc.stdout.on("data", chunk => stdoutChunks.push(chunk));
75
+ proc.stderr.on("data", chunk => stderrChunks.push(chunk));
76
+ proc.on("error", err => {
77
+ if (settled)
78
+ return;
79
+ settled = true;
80
+ clearTimeout(timer);
81
+ rejectExec(new WorktreeError(`git ${args.join(" ")} failed to spawn: ${err.message}`));
82
+ });
83
+ proc.on("close", code => {
84
+ if (settled)
85
+ return;
86
+ settled = true;
87
+ clearTimeout(timer);
88
+ logger.debug?.(`git ${args.join(" ")} exited ${code}`);
89
+ resolveExec({
90
+ stdout: Buffer.concat(stdoutChunks).toString("utf8"),
91
+ stderr: Buffer.concat(stderrChunks).toString("utf8"),
92
+ code: code ?? -1,
93
+ });
94
+ });
95
+ });
96
+ }
97
+ async function listExistingWorktreePaths(repoRoot, logger) {
98
+ const result = await execGit(repoRoot, ["worktree", "list", "--porcelain"], logger);
99
+ if (result.code !== 0) {
100
+ throw new WorktreeError(`git worktree list failed (code ${result.code}): ${result.stderr.trim()}`);
101
+ }
102
+ const paths = new Set();
103
+ for (const line of result.stdout.split("\n")) {
104
+ if (line.startsWith("worktree ")) {
105
+ paths.add(line.slice("worktree ".length).trim());
106
+ }
107
+ }
108
+ return paths;
109
+ }
110
+ export async function createWorktree(opts) {
111
+ const logger = opts.logger ?? noopLogger;
112
+ if (!opts.repoRoot || !existsSync(opts.repoRoot)) {
113
+ throw new WorktreeError(`repoRoot does not exist: ${opts.repoRoot}`);
114
+ }
115
+ const name = opts.name ? sanitizeWorktreeName(opts.name) : generateDefaultName();
116
+ const worktreesDir = join(opts.repoRoot, ".worktrees");
117
+ const expectedPrefix = worktreesDir + sep;
118
+ const worktreePath = resolvePath(opts.repoRoot, ".worktrees", name);
119
+ // Defense in depth — sanitizeWorktreeName already blocks slashes, but
120
+ // double-check that the resolved path is under <repoRoot>/.worktrees/.
121
+ if (!worktreePath.startsWith(expectedPrefix)) {
122
+ throw new WorktreeError(`resolved worktree path escapes the expected prefix: ${worktreePath} (expected under ${expectedPrefix})`);
123
+ }
124
+ const refArg = opts.ref ?? "HEAD";
125
+ const revParse = await execGit(opts.repoRoot, ["rev-parse", "--verify", `${refArg}^{commit}`], logger);
126
+ if (revParse.code !== 0) {
127
+ throw new WorktreeError(`git rev-parse ${refArg} failed (code ${revParse.code}): ${revParse.stderr.trim()}`);
128
+ }
129
+ const resolvedRef = revParse.stdout.trim();
130
+ const existingPaths = await listExistingWorktreePaths(opts.repoRoot, logger);
131
+ const pathOnDisk = existsSync(worktreePath);
132
+ const registered = existingPaths.has(worktreePath);
133
+ if (pathOnDisk) {
134
+ if (!registered) {
135
+ throw new WorktreeCollisionError(worktreePath);
136
+ }
137
+ // Resume reuse: the worktree already exists and is registered with git.
138
+ // Return a handle pointing at it without touching anything.
139
+ logger.info?.(`reusing existing worktree at ${worktreePath}`);
140
+ return {
141
+ name,
142
+ path: worktreePath,
143
+ ref: resolvedRef,
144
+ createdAt: new Date().toISOString(),
145
+ };
146
+ }
147
+ if (registered) {
148
+ // Path is registered but the directory is gone — let git prune it
149
+ // before we recreate, so `worktree add` doesn't error on the stale
150
+ // registration.
151
+ const prune = await execGit(opts.repoRoot, ["worktree", "prune"], logger);
152
+ if (prune.code !== 0) {
153
+ logWarn(logger, `git worktree prune failed before creating ${name}: ${prune.stderr.trim()}`);
154
+ }
155
+ }
156
+ const branch = `gateway/${name}`;
157
+ const add = await execGit(opts.repoRoot, ["worktree", "add", "-b", branch, worktreePath, resolvedRef], logger);
158
+ if (add.code !== 0) {
159
+ throw new WorktreeError(`git worktree add failed (code ${add.code}): ${add.stderr.trim() || add.stdout.trim()}`);
160
+ }
161
+ return {
162
+ name,
163
+ path: worktreePath,
164
+ ref: resolvedRef,
165
+ createdAt: new Date().toISOString(),
166
+ };
167
+ }
168
+ /**
169
+ * Build a SessionCleanupHook that tears down per-session worktrees. The
170
+ * hook reads `session.metadata.worktreePath` (recorded by
171
+ * `resolveWorktreeForRequest`) and the optional `session.metadata.worktreeName`,
172
+ * derives `repoRoot` from the path layout (`<repoRoot>/.worktrees/<name>`),
173
+ * and fires `removeWorktree` asynchronously. Failures are logged by
174
+ * `removeWorktree` itself — the hook always resolves so session deletion
175
+ * never blocks on git.
176
+ */
177
+ export function createWorktreeSessionCleanupHook(logger) {
178
+ return async (session) => {
179
+ const meta = session.metadata ?? {};
180
+ const worktreePath = typeof meta.worktreePath === "string" ? meta.worktreePath : undefined;
181
+ if (!worktreePath)
182
+ return;
183
+ const worktreeName = typeof meta.worktreeName === "string" ? meta.worktreeName : undefined;
184
+ // Layout invariant from createWorktree: <repoRoot>/.worktrees/<name>.
185
+ // Strip the trailing two segments to recover repoRoot.
186
+ const marker = `${sep}.worktrees${sep}`;
187
+ const markerIdx = worktreePath.lastIndexOf(marker);
188
+ if (markerIdx === -1) {
189
+ logWarn(logger, `worktreePath on session ${session.id} does not match the gateway layout — skipping cleanup: ${worktreePath}`);
190
+ return;
191
+ }
192
+ const repoRoot = worktreePath.slice(0, markerIdx);
193
+ await removeWorktree({ repoRoot, path: worktreePath, name: worktreeName, logger });
194
+ };
195
+ }
196
+ export async function removeWorktree(opts) {
197
+ const logger = opts.logger ?? noopLogger;
198
+ if (!opts.repoRoot || !opts.path) {
199
+ return;
200
+ }
201
+ const remove = await execGit(opts.repoRoot, ["worktree", "remove", "--force", opts.path], logger);
202
+ if (remove.code !== 0) {
203
+ logWarn(logger, `git worktree remove --force ${opts.path} failed (code ${remove.code}): ${remove.stderr.trim()}`);
204
+ }
205
+ if (opts.name) {
206
+ const branch = `gateway/${opts.name}`;
207
+ const del = await execGit(opts.repoRoot, ["branch", "-D", branch], logger);
208
+ if (del.code !== 0) {
209
+ // Branch may already be gone (user deleted, never existed if add
210
+ // half-failed, etc.). Demote to debug — this is best-effort cleanup.
211
+ logger.debug?.(`git branch -D ${branch} returned code ${del.code}: ${del.stderr.trim()}`);
212
+ }
213
+ }
214
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "llm-cli-gateway",
3
- "version": "1.14.0",
3
+ "version": "1.15.0",
4
4
  "mcpName": "io.github.verivus-oss/llm-cli-gateway",
5
5
  "description": "MCP server providing unified access to Claude Code, Codex, Gemini, Grok, and Mistral Vibe CLIs with session management, retry logic, async job orchestration, durable job results, and cross-LLM validation.",
6
6
  "license": "MIT",