llm-cli-gateway 1.13.2 → 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.
@@ -1,25 +1,68 @@
1
1
  import { z } from "zod";
2
+ export interface PromptPartsCacheControl {
3
+ system?: boolean;
4
+ tools?: boolean;
5
+ context?: boolean;
6
+ }
2
7
  export interface PromptParts {
3
8
  system?: string;
4
9
  tools?: string;
5
10
  context?: string;
6
11
  task: string;
12
+ /**
13
+ * Slice κ (Claude only): per-block opt-in to Anthropic `cache_control`
14
+ * breakpoints. Setting `system: true` (or tools/context) marks that
15
+ * block with `cache_control: {type:"ephemeral", ttl:"1h"}` in the
16
+ * stream-json payload the gateway pipes to `claude --input-format
17
+ * stream-json`. The `task` block is NEVER marked (it's the volatile
18
+ * tail). Empty parts are silently skipped even if their flag is true.
19
+ *
20
+ * Constraint: callers MUST also pass `outputFormat:"stream-json"` —
21
+ * mixing cacheControl with text/json output returns an error response.
22
+ * `ttl` is hard-coded to `"1h"` because Claude Code injects its own
23
+ * 1h-marked system blocks ahead of caller content and Anthropic
24
+ * rejects a 1h block after a 5m block.
25
+ */
26
+ cacheControl?: PromptPartsCacheControl;
7
27
  }
8
28
  export declare const PromptPartsSchema: z.ZodObject<{
9
29
  system: z.ZodOptional<z.ZodString>;
10
30
  tools: z.ZodOptional<z.ZodString>;
11
31
  context: z.ZodOptional<z.ZodString>;
12
32
  task: z.ZodString;
33
+ cacheControl: z.ZodOptional<z.ZodObject<{
34
+ system: z.ZodOptional<z.ZodBoolean>;
35
+ tools: z.ZodOptional<z.ZodBoolean>;
36
+ context: z.ZodOptional<z.ZodBoolean>;
37
+ }, "strict", z.ZodTypeAny, {
38
+ system?: boolean | undefined;
39
+ tools?: boolean | undefined;
40
+ context?: boolean | undefined;
41
+ }, {
42
+ system?: boolean | undefined;
43
+ tools?: boolean | undefined;
44
+ context?: boolean | undefined;
45
+ }>>;
13
46
  }, "strip", z.ZodTypeAny, {
14
47
  task: string;
15
48
  system?: string | undefined;
16
49
  tools?: string | undefined;
17
50
  context?: string | undefined;
51
+ cacheControl?: {
52
+ system?: boolean | undefined;
53
+ tools?: boolean | undefined;
54
+ context?: boolean | undefined;
55
+ } | undefined;
18
56
  }, {
19
57
  task: string;
20
58
  system?: string | undefined;
21
59
  tools?: string | undefined;
22
60
  context?: string | undefined;
61
+ cacheControl?: {
62
+ system?: boolean | undefined;
63
+ tools?: boolean | undefined;
64
+ context?: boolean | undefined;
65
+ } | undefined;
23
66
  }>;
24
67
  export interface AssembleResult {
25
68
  text: string;
@@ -36,3 +79,34 @@ export interface ResolvePromptInputArgs {
36
79
  promptParts?: PromptParts;
37
80
  }
38
81
  export declare function resolvePromptInput(input: ResolvePromptInputArgs): ResolvedPromptInput;
82
+ export interface ClaudeContentBlock {
83
+ type: "text";
84
+ text: string;
85
+ cache_control?: {
86
+ type: "ephemeral";
87
+ ttl: "1h";
88
+ };
89
+ }
90
+ export interface ClaudeStreamJsonUserMessage {
91
+ type: "user";
92
+ message: {
93
+ role: "user";
94
+ content: ClaudeContentBlock[];
95
+ };
96
+ }
97
+ export interface AssembleClaudeCacheBlocksResult {
98
+ payload: ClaudeStreamJsonUserMessage;
99
+ markedBlockCount: number;
100
+ }
101
+ /**
102
+ * Slice κ: build the Claude `--input-format stream-json` payload from
103
+ * a `PromptParts`. Each non-empty part becomes one content block in
104
+ * `system → tools → context → task` order; parts whose name is `true`
105
+ * in `cacheControl` get `cache_control: {type:"ephemeral", ttl:"1h"}`.
106
+ *
107
+ * Empty parts are skipped (no zero-byte blocks) — a true flag on an
108
+ * empty part is silently a no-op and not counted in `markedBlockCount`.
109
+ * The `task` block is never marked, even if a caller accidentally
110
+ * tries (the schema doesn't expose `task` in `cacheControl`).
111
+ */
112
+ export declare function assembleClaudeCacheBlocks(parts: PromptParts): AssembleClaudeCacheBlocksResult;
@@ -1,10 +1,18 @@
1
1
  import { createHash } from "crypto";
2
2
  import { z } from "zod";
3
+ const CacheControlSchema = z
4
+ .object({
5
+ system: z.boolean().optional(),
6
+ tools: z.boolean().optional(),
7
+ context: z.boolean().optional(),
8
+ })
9
+ .strict();
3
10
  export const PromptPartsSchema = z.object({
4
11
  system: z.string().optional(),
5
12
  tools: z.string().optional(),
6
13
  context: z.string().optional(),
7
14
  task: z.string().min(1),
15
+ cacheControl: CacheControlSchema.optional(),
8
16
  });
9
17
  const SEPARATOR = "\n\n";
10
18
  export function assemble(parts) {
@@ -40,3 +48,42 @@ export function resolvePromptInput(input) {
40
48
  stablePrefixTokens: null,
41
49
  };
42
50
  }
51
+ /**
52
+ * Slice κ: build the Claude `--input-format stream-json` payload from
53
+ * a `PromptParts`. Each non-empty part becomes one content block in
54
+ * `system → tools → context → task` order; parts whose name is `true`
55
+ * in `cacheControl` get `cache_control: {type:"ephemeral", ttl:"1h"}`.
56
+ *
57
+ * Empty parts are skipped (no zero-byte blocks) — a true flag on an
58
+ * empty part is silently a no-op and not counted in `markedBlockCount`.
59
+ * The `task` block is never marked, even if a caller accidentally
60
+ * tries (the schema doesn't expose `task` in `cacheControl`).
61
+ */
62
+ export function assembleClaudeCacheBlocks(parts) {
63
+ const blocks = [];
64
+ let markedBlockCount = 0;
65
+ const cc = parts.cacheControl ?? {};
66
+ const stableEntries = [
67
+ ["system", parts.system],
68
+ ["tools", parts.tools],
69
+ ["context", parts.context],
70
+ ];
71
+ for (const [name, value] of stableEntries) {
72
+ if (value === undefined || value.length === 0)
73
+ continue;
74
+ const block = { type: "text", text: value };
75
+ if (cc[name]) {
76
+ block.cache_control = { type: "ephemeral", ttl: "1h" };
77
+ markedBlockCount += 1;
78
+ }
79
+ blocks.push(block);
80
+ }
81
+ blocks.push({ type: "text", text: parts.task });
82
+ return {
83
+ payload: {
84
+ type: "user",
85
+ message: { role: "user", content: blocks },
86
+ },
87
+ markedBlockCount,
88
+ };
89
+ }
@@ -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
  }
@@ -1,5 +1,12 @@
1
1
  import type { CliType } from "./session-manager.js";
2
- export type CliFlagArity = "none" | "one" | "variadic";
2
+ /**
3
+ * `optional` (slice κ): consumes the next token as the flag's value
4
+ * ONLY if that token does not start with `-`. Used for Claude's
5
+ * `-p`/`--print`, which is a no-arg switch in claude-code 2.x but
6
+ * also doubles as the legacy `-p <prompt>` positional shorthand that
7
+ * the gateway has emitted since v0.x.
8
+ */
9
+ export type CliFlagArity = "none" | "one" | "optional" | "variadic";
3
10
  export interface CliFlagContract {
4
11
  arity: CliFlagArity;
5
12
  values?: readonly string[];
@@ -46,8 +46,16 @@ export const UPSTREAM_CLI_CONTRACTS = {
46
46
  "strictMcpConfig",
47
47
  ],
48
48
  flags: {
49
- "-p": { arity: "one", description: "Prompt text" },
49
+ "-p": {
50
+ arity: "optional",
51
+ description: "Print/non-interactive mode. Legacy gateway emission used `-p <prompt>` (consumed as positional in claude's grammar); slice κ emits `-p` standalone followed by `--input-format stream-json` so the prompt flows in on stdin.",
52
+ },
50
53
  "--model": { arity: "one", description: "Model selector" },
54
+ "--input-format": {
55
+ arity: "one",
56
+ values: ["text", "stream-json"],
57
+ description: "Slice κ: realtime JSON stdin payload. `stream-json` enables Anthropic cache_control breakpoints from caller-supplied content blocks.",
58
+ },
51
59
  "--output-format": {
52
60
  arity: "one",
53
61
  values: ["json", "stream-json"],
@@ -163,6 +171,26 @@ export const UPSTREAM_CLI_CONTRACTS = {
163
171
  ],
164
172
  expect: "pass",
165
173
  },
174
+ {
175
+ // Slice κ: when caller marks promptParts with cache_control, the
176
+ // gateway emits `-p` as a standalone flag and pipes the JSON
177
+ // content-blocks payload over stdin via `--input-format
178
+ // stream-json`. The fixture pins the exact argv combination so
179
+ // a future regression (re-emitting a positional prompt, dropping
180
+ // `--input-format`, etc.) trips loudly here.
181
+ id: "claude-input-format-stream-json",
182
+ description: "Slice κ: `-p` standalone + --input-format stream-json + --output-format stream-json + --include-partial-messages + --verbose",
183
+ args: [
184
+ "-p",
185
+ "--input-format",
186
+ "stream-json",
187
+ "--output-format",
188
+ "stream-json",
189
+ "--include-partial-messages",
190
+ "--verbose",
191
+ ],
192
+ expect: "pass",
193
+ },
166
194
  ],
167
195
  },
168
196
  codex: {
@@ -764,6 +792,14 @@ export function validateUpstreamCliArgs(cli, args) {
764
792
  i += 1;
765
793
  continue;
766
794
  }
795
+ if (flag.arity === "optional") {
796
+ const value = args[i + 1];
797
+ if (value !== undefined && !value.startsWith("-")) {
798
+ validateFlagValue(cli, arg, flag, value, i + 1, violations);
799
+ i += 1;
800
+ }
801
+ continue;
802
+ }
767
803
  let consumed = 0;
768
804
  while (i + 1 < args.length && !args[i + 1].startsWith("-")) {
769
805
  validateFlagValue(cli, arg, flag, args[i + 1], i + 1, violations);
@@ -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.13.2",
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",
@@ -70,6 +70,7 @@
70
70
  "test:integration": "INTEGRATION_TESTS=1 vitest run src/__tests__/integration.test.ts",
71
71
  "test:pg": "bash ./scripts/test-pg.sh",
72
72
  "test:all": "npm run test && npm run test:pg",
73
+ "smoke:cache-control": "node docs/plans/slice-kappa-smoke-test.mjs",
73
74
  "lint": "eslint src/**/*.ts",
74
75
  "lint:fix": "eslint src/**/*.ts --fix",
75
76
  "format": "prettier --write 'src/**/*.ts'",