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.
@@ -207,15 +207,21 @@ export class AsyncJobManager {
207
207
  * (sorted keys → JSON-stringified). This prevents two Mistral requests with the
208
208
  * same argv but different `VIBE_ACTIVE_MODEL` from deduping onto each other.
209
209
  */
210
- buildRequestKey(cli, args, env, stdin) {
210
+ buildRequestKey(cli, args, env, stdin, cwd) {
211
211
  // Slice κ: stdin participates in the dedup key. Two Claude requests
212
212
  // with identical argv but different cache_control content blocks
213
213
  // would otherwise collide on dedup and the second caller would get
214
214
  // the wrong response. The legacy "no stdin" code path passes
215
215
  // stdin=undefined, which serialises to the same empty marker the
216
216
  // previous version emitted — non-κ dedup is unchanged.
217
+ // Slice λ: cwd participates similarly. Two requests with identical
218
+ // argv but different worktrees would otherwise collide on dedup and
219
+ // the second caller would receive a response executed in the wrong
220
+ // worktree. cwd=undefined preserves the pre-λ key shape — non-λ
221
+ // dedup is unchanged.
217
222
  const extraEnv = canonicaliseEnvForKey(env);
218
- const extra = stdin === undefined ? extraEnv : `${extraEnv}|stdin:${stdin}`;
223
+ const withStdin = stdin === undefined ? extraEnv : `${extraEnv}|stdin:${stdin}`;
224
+ const extra = cwd === undefined ? withStdin : `${withStdin}|cwd:${cwd}`;
219
225
  return computeRequestKey(cli, args, extra);
220
226
  }
221
227
  fireOnComplete(job) {
@@ -449,7 +455,7 @@ export class AsyncJobManager {
449
455
  */
450
456
  startJobWithDedup(cli, args, correlationId, opts = {}) {
451
457
  const { cwd, idleTimeoutMs, outputFormat, forceRefresh, env: extraEnv, stdin, onComplete, flightRecorderEntry, extractUsage, writeFlightStart, } = opts;
452
- const requestKey = this.buildRequestKey(cli, args, extraEnv, stdin);
458
+ const requestKey = this.buildRequestKey(cli, args, extraEnv, stdin, cwd);
453
459
  if (!forceRefresh && this.store) {
454
460
  try {
455
461
  const existing = this.store.findByRequestKey(requestKey);
@@ -810,8 +816,9 @@ export class AsyncJobManager {
810
816
  job.error = "Output exceeded maximum size (50MB)";
811
817
  job.finishedAt = new Date().toISOString();
812
818
  job.clearIdleTimer?.();
813
- if (job.process)
819
+ if (job.process) {
814
820
  killProcessGroup(job.process, "SIGTERM");
821
+ }
815
822
  this.logger.info(`Job ${job.id} killed due to output overflow`, {
816
823
  correlationId: job.correlationId,
817
824
  });
@@ -819,11 +826,16 @@ export class AsyncJobManager {
819
826
  this.persistComplete(job);
820
827
  this.writeFlightComplete(job, "failed", "Output exceeded maximum size (50MB)");
821
828
  this.fireOnComplete(job);
822
- setTimeout(() => {
823
- if (!job.exited && job.process)
824
- killProcessGroup(job.process, "SIGKILL");
829
+ if (job.process) {
830
+ setTimeout(() => {
831
+ if (!job.exited && job.process)
832
+ killProcessGroup(job.process, "SIGKILL");
833
+ job.cleanupGroup?.();
834
+ }, 5000);
835
+ }
836
+ else {
825
837
  job.cleanupGroup?.();
826
- }, 5000);
838
+ }
827
839
  }
828
840
  return;
829
841
  }
package/dist/executor.js CHANGED
@@ -139,18 +139,48 @@ export function resolveCommandForSpawn(command, args, options = {}) {
139
139
  if ([".cmd", ".bat"].includes(extname(resolved).toLowerCase())) {
140
140
  return {
141
141
  command: "cmd.exe",
142
- args: ["/d", "/s", "/c", `"${buildWindowsCmdCommand(resolved, args)}"`],
142
+ args: [
143
+ "/d",
144
+ "/s",
145
+ "/c",
146
+ // Windows .cmd/.bat shims require cmd.exe. `buildWindowsCmdCommand`
147
+ // applies CommandLineToArgvW quoting and cmd metacharacter escaping
148
+ // to every dynamic segment before it reaches this shell boundary.
149
+ //
150
+ // codeql[js/shell-command-constructed-from-input]
151
+ `"${buildWindowsCmdCommand(resolved, args)}"`,
152
+ ],
143
153
  windowsVerbatimArguments: true,
144
154
  };
145
155
  }
146
156
  return { command: resolved, args };
147
157
  }
148
158
  function buildWindowsCmdCommand(command, args) {
159
+ // codeql[js/shell-command-constructed-from-input]
149
160
  return [escapeWindowsCmdCommand(command), ...args.map(escapeWindowsCmdArgument)].join(" ");
150
161
  }
151
- const WINDOWS_CMD_META_CHARS = /([()\][%!^"`<>&|;, *?])/g;
162
+ const WINDOWS_CMD_META_CHARS = new Set([
163
+ "(",
164
+ ")",
165
+ "]",
166
+ "[",
167
+ "%",
168
+ "!",
169
+ "^",
170
+ '"',
171
+ "`",
172
+ "<",
173
+ ">",
174
+ "&",
175
+ "|",
176
+ ";",
177
+ ",",
178
+ " ",
179
+ "*",
180
+ "?",
181
+ ]);
152
182
  function escapeWindowsCmdCommand(value) {
153
- return win32.normalize(value).replace(WINDOWS_CMD_META_CHARS, "^$1");
183
+ return escapeWindowsCmdMetaChars(win32.normalize(value));
154
184
  }
155
185
  // CommandLineToArgvW rules: a run of N backslashes before a literal " must be
156
186
  // doubled and followed by \" (yielding 2N+1 backslashes total, so the parser
@@ -158,11 +188,38 @@ function escapeWindowsCmdCommand(value) {
158
188
  // before the closing " must be doubled (2N) so the quote still terminates the
159
189
  // arg. Then wrap in quotes and caret-escape cmd.exe metacharacters.
160
190
  function escapeWindowsCmdArgument(value) {
161
- let arg = `${value}`;
162
- arg = arg.replace(/(\\*)"/g, '$1$1\\"');
163
- arg = arg.replace(/(\\*)$/, "$1$1");
164
- arg = `"${arg}"`;
165
- return arg.replace(WINDOWS_CMD_META_CHARS, "^$1");
191
+ return escapeWindowsCmdMetaChars(quoteWindowsArgForCommandLineToArgv(`${value}`));
192
+ }
193
+ function quoteWindowsArgForCommandLineToArgv(value) {
194
+ let encoded = "";
195
+ let backslashes = 0;
196
+ for (const ch of value) {
197
+ if (ch === "\\") {
198
+ backslashes += 1;
199
+ continue;
200
+ }
201
+ if (ch === '"') {
202
+ encoded += "\\".repeat(backslashes * 2 + 1);
203
+ encoded += '"';
204
+ backslashes = 0;
205
+ continue;
206
+ }
207
+ encoded += "\\".repeat(backslashes);
208
+ backslashes = 0;
209
+ encoded += ch;
210
+ }
211
+ encoded += "\\".repeat(backslashes * 2);
212
+ return `"${encoded}"`;
213
+ }
214
+ function escapeWindowsCmdMetaChars(value) {
215
+ let escaped = "";
216
+ for (const ch of value) {
217
+ if (WINDOWS_CMD_META_CHARS.has(ch)) {
218
+ escaped += "^";
219
+ }
220
+ escaped += ch;
221
+ }
222
+ return escaped;
166
223
  }
167
224
  function resolveWindowsCommandPath(command, envPath) {
168
225
  if (/[\\/]/.test(command)) {
package/dist/index.d.ts CHANGED
@@ -67,6 +67,36 @@ type GatewayLogger = typeof logger;
67
67
  */
68
68
  export declare const MAX_TURNS_SCHEMA: z.ZodNumber;
69
69
  export declare const MAX_PRICE_SCHEMA: z.ZodNumber;
70
+ /**
71
+ * Slice λ: shared worktree directive for all 10 `*_request` / `*_request_async`
72
+ * tools. `true` creates a fresh worktree under `<repoRoot>/.worktrees/<uuid>`
73
+ * branched from HEAD. `{ name?, ref? }` lets the caller supply a sanitized
74
+ * name and/or git ref (default ref: HEAD).
75
+ *
76
+ * Lifecycle is gateway-owned: the gateway pre-creates the worktree via
77
+ * `git worktree add`, then spawns the child CLI with `cwd: <worktree-path>`.
78
+ * No `-w` / `--worktree` flag is ever emitted to the underlying CLI. When
79
+ * the request carries a sessionId and the session already has a worktree,
80
+ * that worktree is reused. On session_delete or TTL eviction the gateway
81
+ * runs `git worktree remove --force`.
82
+ *
83
+ * Tool response: when a worktree was used, the successful response stdout
84
+ * is prefixed with `[gateway] worktree=<absolute-path>\n` so callers can
85
+ * parse/use the path without a schema change (slice λ §1.d).
86
+ *
87
+ * NOTE: callers should `.gitignore` the `.worktrees/` directory in their
88
+ * repo (the gateway does NOT auto-gitignore — see slice λ spec Q4).
89
+ */
90
+ export declare const WORKTREE_SCHEMA: z.ZodUnion<[z.ZodBoolean, z.ZodObject<{
91
+ name: z.ZodOptional<z.ZodString>;
92
+ ref: z.ZodOptional<z.ZodString>;
93
+ }, "strict", z.ZodTypeAny, {
94
+ name?: string | undefined;
95
+ ref?: string | undefined;
96
+ }, {
97
+ name?: string | undefined;
98
+ ref?: string | undefined;
99
+ }>]>;
70
100
  export declare const SESSION_PROVIDER_VALUES: readonly ["claude", "codex", "gemini", "grok", "mistral"];
71
101
  export declare const SESSION_PROVIDER_ENUM: z.ZodEnum<["claude", "codex", "gemini", "grok", "mistral"]>;
72
102
  export type SessionProvider = (typeof SESSION_PROVIDER_VALUES)[number];
@@ -97,6 +127,57 @@ export interface GatewayServerRuntime {
97
127
  export declare function resolveGatewayServerRuntime(deps?: GatewayServerDeps, options?: {
98
128
  isolateState?: boolean;
99
129
  }): GatewayServerRuntime;
130
+ /**
131
+ * Slice λ: shape returned by `resolveWorktreeForRequest`. `cwd` is what
132
+ * the spawn helpers (`executeCli`, `startJobWithDedup`) consume;
133
+ * `worktreePath` is what the tool handler embeds in the response prefix
134
+ * so callers can discover the path.
135
+ */
136
+ export interface ResolvedWorktree {
137
+ cwd?: string;
138
+ worktreePath?: string;
139
+ }
140
+ /**
141
+ * Slice λ: resolve a request's worktree directive into a spawn cwd.
142
+ *
143
+ * - `worktreeOpt` is the Zod-validated input value (boolean |
144
+ * `{ name?, ref? }` | undefined).
145
+ * - When the request has a session AND the session already has a
146
+ * `metadata.worktreePath`, that path is reused (resume semantics).
147
+ * The reused path is returned without touching git; if the directory
148
+ * was externally removed between requests, the next CLI invocation
149
+ * will surface the error naturally.
150
+ * - When no reusable worktree exists, `createWorktree` runs; on success
151
+ * the new path is written to `session.metadata` (only when a session
152
+ * exists — request-scoped worktrees do NOT persist).
153
+ * - Returns `{}` when `worktreeOpt` is undefined/false (preserves
154
+ * pre-λ behaviour at non-worktree call sites).
155
+ * - Errors propagate as `WorktreeError`/`Error`; the caller wraps them
156
+ * in a `createErrorResponse` envelope. Do NOT swallow.
157
+ *
158
+ * Spec: docs/plans/slice-lambda.spec.md §"Implementation surface to
159
+ * verify" §5.
160
+ */
161
+ export declare function resolveWorktreeForRequest(worktreeOpt: boolean | {
162
+ name?: string;
163
+ ref?: string;
164
+ } | undefined, sessionId: string | undefined, runtime: GatewayServerRuntime): Promise<ResolvedWorktree>;
165
+ /**
166
+ * Slice λ §1.d: response-envelope shape decision for `worktreePath`.
167
+ *
168
+ * We surface the worktree path inline as a stdout prefix
169
+ * (`[gateway] worktree=<absolute-path>\n`) rather than as a
170
+ * structuredContent field or JSON wrapper. Rationale:
171
+ * - zero schema change across all 10 tools and their downstream parsers
172
+ * - matches how other slice features (session warnings, cache_state
173
+ * aggregates) surface side-channel metadata today
174
+ * - callers that want the path can split on the first newline; callers
175
+ * that don't care see a single ignorable header line
176
+ *
177
+ * Use `formatWorktreePrefix(resolution.worktreePath)` once per tool, at
178
+ * the moment a successful response is constructed.
179
+ */
180
+ export declare function formatWorktreePrefix(worktreePath?: string): string;
100
181
  export declare function extractUsageAndCost(cli: "claude" | "codex" | "gemini" | "grok" | "mistral", output: string, outputFormat?: string,
101
182
  /**
102
183
  * Optional context for off-stdout telemetry sources. Today only Mistral
@@ -384,6 +465,11 @@ export interface GeminiRequestParams {
384
465
  attachments?: string[];
385
466
  /** Phase 4 slice γ: emit `--skip-trust` for fresh-workspace headless runs. */
386
467
  skipTrust?: boolean;
468
+ /** Slice λ: run this request inside a gateway-owned git worktree. */
469
+ worktree?: boolean | {
470
+ name?: string;
471
+ ref?: string;
472
+ };
387
473
  }
388
474
  export interface HandlerDeps {
389
475
  sessionManager: ISessionManager;
@@ -436,6 +522,11 @@ export interface GrokRequestParams {
436
522
  allow?: string[];
437
523
  /** Phase 4 slice θ: Grok `--deny <RULE>` (repeatable; one entry per --deny instance). */
438
524
  deny?: string[];
525
+ /** Slice λ: run this request inside a gateway-owned git worktree. */
526
+ worktree?: boolean | {
527
+ name?: string;
528
+ ref?: string;
529
+ };
439
530
  }
440
531
  export declare function handleGrokRequest(deps: HandlerDeps, params: GrokRequestParams): Promise<ExtendedToolResponse>;
441
532
  export declare function handleGrokRequestAsync(deps: AsyncHandlerDeps, params: Omit<GrokRequestParams, "optimizeResponse">): Promise<ExtendedToolResponse>;
@@ -470,6 +561,11 @@ export interface MistralRequestParams {
470
561
  workingDir?: string;
471
562
  /** Phase 4 slice ζ: Vibe `--add-dir <DIR>` repeatable add-dir parity. */
472
563
  addDir?: string[];
564
+ /** Slice λ: run this request inside a gateway-owned git worktree. */
565
+ worktree?: boolean | {
566
+ name?: string;
567
+ ref?: string;
568
+ };
473
569
  }
474
570
  export declare function handleMistralRequest(deps: HandlerDeps, params: MistralRequestParams): Promise<ExtendedToolResponse>;
475
571
  export declare function handleMistralRequestAsync(deps: AsyncHandlerDeps, params: Omit<MistralRequestParams, "optimizeResponse">): Promise<ExtendedToolResponse>;
@@ -504,6 +600,11 @@ export declare function handleCodexRequestAsync(deps: AsyncHandlerDeps, params:
504
600
  ignoreRules?: boolean;
505
601
  workingDir?: string;
506
602
  addDir?: string[];
603
+ /** Slice λ: run this request inside a gateway-owned git worktree. */
604
+ worktree?: boolean | {
605
+ name?: string;
606
+ ref?: string;
607
+ };
507
608
  }): Promise<ExtendedToolResponse>;
508
609
  export declare function createGatewayServer(deps?: GatewayServerDeps): McpServer;
509
610
  export {};