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.
- package/CHANGELOG.md +371 -44
- package/dist/async-job-manager.d.ts +15 -1
- package/dist/async-job-manager.js +31 -6
- package/dist/cache-stats.d.ts +26 -0
- package/dist/cache-stats.js +45 -2
- package/dist/executor.d.ts +8 -0
- package/dist/executor.js +7 -2
- package/dist/flight-recorder.d.ts +7 -0
- package/dist/flight-recorder.js +27 -2
- package/dist/index.d.ts +126 -1
- package/dist/index.js +480 -50
- package/dist/prompt-parts.d.ts +74 -0
- package/dist/prompt-parts.js +47 -0
- package/dist/session-manager.d.ts +20 -2
- package/dist/session-manager.js +28 -3
- package/dist/upstream-contracts.d.ts +8 -1
- package/dist/upstream-contracts.js +37 -1
- package/dist/worktree-manager.d.ts +41 -0
- package/dist/worktree-manager.js +214 -0
- package/package.json +2 -1
package/dist/prompt-parts.d.ts
CHANGED
|
@@ -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;
|
package/dist/prompt-parts.js
CHANGED
|
@@ -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
|
-
|
|
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
|
}
|
|
@@ -1,5 +1,12 @@
|
|
|
1
1
|
import type { CliType } from "./session-manager.js";
|
|
2
|
-
|
|
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": {
|
|
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.
|
|
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'",
|