llm-cli-gateway 1.14.0 → 1.15.1
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 +249 -46
- package/README.md +139 -29
- package/dist/async-job-manager.js +20 -8
- package/dist/executor.js +65 -8
- package/dist/index.d.ts +101 -0
- package/dist/index.js +311 -26
- package/dist/request-helpers.js +12 -0
- package/dist/session-manager.d.ts +20 -2
- package/dist/session-manager.js +28 -3
- package/dist/worktree-manager.d.ts +41 -0
- package/dist/worktree-manager.js +214 -0
- package/package.json +1 -1
package/dist/request-helpers.js
CHANGED
|
@@ -626,10 +626,22 @@ export function prependGeminiAttachments(prompt, attachments) {
|
|
|
626
626
|
if (!existsSync(p)) {
|
|
627
627
|
throw new Error(`attachments: path does not exist: ${p}`);
|
|
628
628
|
}
|
|
629
|
+
validateGeminiAttachmentTokenPath(p);
|
|
629
630
|
}
|
|
630
631
|
const tokens = attachments.map(p => `@${p}`).join(" ");
|
|
632
|
+
// Gemini attachments are prompt-level @path tokens rather than shell
|
|
633
|
+
// commands. Paths are absolute, existing, and token-safe before this join.
|
|
634
|
+
//
|
|
635
|
+
// codeql[js/shell-command-constructed-from-input]
|
|
631
636
|
return `${tokens} ${prompt}`;
|
|
632
637
|
}
|
|
638
|
+
function validateGeminiAttachmentTokenPath(path) {
|
|
639
|
+
for (const ch of path) {
|
|
640
|
+
if (ch === "@" || ch <= " ") {
|
|
641
|
+
throw new Error(`attachments: path cannot be represented as a Gemini @path token without escaping: ${path}`);
|
|
642
|
+
}
|
|
643
|
+
}
|
|
644
|
+
}
|
|
633
645
|
/**
|
|
634
646
|
* Zod schema for the U27 Gemini high-impact feature subset. Used by the
|
|
635
647
|
* `gemini_request` / `gemini_request_async` tool schemas to validate the new
|
|
@@ -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
|
-
|
|
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
|
|
78
|
+
export declare function createSessionManager(config?: Config, db?: DatabaseConnection, logger?: Logger, opts?: {
|
|
79
|
+
cleanupHook?: SessionCleanupHook;
|
|
80
|
+
}): Promise<ISessionManager>;
|
package/dist/session-manager.js
CHANGED
|
@@ -17,12 +17,31 @@ export class FileSessionManager {
|
|
|
17
17
|
storagePath;
|
|
18
18
|
storage = { sessions: {}, activeSession: createEmptyActiveSessions() };
|
|
19
19
|
sessionTtlMs;
|
|
20
|
-
|
|
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.
|
|
3
|
+
"version": "1.15.1",
|
|
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",
|