llm-cli-gateway 1.0.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.
Files changed (48) hide show
  1. package/CHANGELOG.md +541 -0
  2. package/LICENSE +21 -0
  3. package/README.md +545 -0
  4. package/dist/approval-manager.d.ts +43 -0
  5. package/dist/approval-manager.js +156 -0
  6. package/dist/async-job-manager.d.ts +57 -0
  7. package/dist/async-job-manager.js +334 -0
  8. package/dist/claude-mcp-config.d.ts +8 -0
  9. package/dist/claude-mcp-config.js +161 -0
  10. package/dist/config.d.ts +35 -0
  11. package/dist/config.js +56 -0
  12. package/dist/db.d.ts +48 -0
  13. package/dist/db.js +170 -0
  14. package/dist/executor.d.ts +30 -0
  15. package/dist/executor.js +315 -0
  16. package/dist/health.d.ts +20 -0
  17. package/dist/health.js +32 -0
  18. package/dist/index.d.ts +67 -0
  19. package/dist/index.js +1503 -0
  20. package/dist/logger.d.ts +6 -0
  21. package/dist/logger.js +5 -0
  22. package/dist/metrics.d.ts +23 -0
  23. package/dist/metrics.js +57 -0
  24. package/dist/migrate-sessions.d.ts +12 -0
  25. package/dist/migrate-sessions.js +145 -0
  26. package/dist/migrate.d.ts +2 -0
  27. package/dist/migrate.js +100 -0
  28. package/dist/model-registry.d.ts +10 -0
  29. package/dist/model-registry.js +346 -0
  30. package/dist/optimizer.d.ts +3 -0
  31. package/dist/optimizer.js +183 -0
  32. package/dist/process-monitor.d.ts +54 -0
  33. package/dist/process-monitor.js +146 -0
  34. package/dist/request-helpers.d.ts +25 -0
  35. package/dist/request-helpers.js +32 -0
  36. package/dist/resources.d.ts +26 -0
  37. package/dist/resources.js +201 -0
  38. package/dist/retry.d.ts +72 -0
  39. package/dist/retry.js +146 -0
  40. package/dist/review-integrity.d.ts +50 -0
  41. package/dist/review-integrity.js +283 -0
  42. package/dist/session-manager-pg.d.ts +76 -0
  43. package/dist/session-manager-pg.js +383 -0
  44. package/dist/session-manager.d.ts +62 -0
  45. package/dist/session-manager.js +223 -0
  46. package/dist/stream-json-parser.d.ts +35 -0
  47. package/dist/stream-json-parser.js +94 -0
  48. package/package.json +90 -0
@@ -0,0 +1,156 @@
1
+ import { createHash, randomUUID } from "crypto";
2
+ import { appendFileSync, existsSync, mkdirSync, readFileSync } from "fs";
3
+ import { homedir } from "os";
4
+ import { dirname, join } from "path";
5
+ import { noopLogger } from "./logger.js";
6
+ import { isReviewContext } from "./review-integrity.js";
7
+ function parsePolicy(policy) {
8
+ if (policy) {
9
+ return policy;
10
+ }
11
+ const envPolicy = (process.env.LLM_GATEWAY_APPROVAL_POLICY || "").trim();
12
+ if (envPolicy === "strict" || envPolicy === "balanced" || envPolicy === "permissive") {
13
+ return envPolicy;
14
+ }
15
+ return "balanced";
16
+ }
17
+ function promptPreview(prompt) {
18
+ if (process.env.APPROVAL_LOG_PROMPTS === "1") {
19
+ return prompt.replace(/\s+/g, " ").trim().slice(0, 280);
20
+ }
21
+ return "[redacted]";
22
+ }
23
+ function promptHash(prompt) {
24
+ return createHash("sha256").update(prompt).digest("hex");
25
+ }
26
+ function parseLogLine(line) {
27
+ try {
28
+ return JSON.parse(line);
29
+ }
30
+ catch {
31
+ return null;
32
+ }
33
+ }
34
+ export class ApprovalManager {
35
+ logger;
36
+ logPath;
37
+ constructor(customPath, logger = noopLogger) {
38
+ this.logger = logger;
39
+ this.logPath = customPath || join(homedir(), ".llm-cli-gateway", "approvals.jsonl");
40
+ const dir = dirname(this.logPath);
41
+ mkdirSync(dir, { recursive: true });
42
+ }
43
+ decide(request) {
44
+ const policy = parsePolicy(request.policy);
45
+ const reasons = [];
46
+ let score = 0;
47
+ if (request.bypassRequested) {
48
+ score += 3;
49
+ reasons.push("Request includes full permission bypass");
50
+ }
51
+ if (request.fullAuto) {
52
+ score += 2;
53
+ reasons.push("Request enables full-auto execution");
54
+ }
55
+ if (request.bypassRequested && request.fullAuto) {
56
+ score += 2;
57
+ reasons.push("Request combines full permission bypass with full-auto execution");
58
+ }
59
+ if (request.requestedMcpServers.includes("exa")) {
60
+ score += 2;
61
+ reasons.push("Request enables external web/company research MCP (exa)");
62
+ }
63
+ if (request.requestedMcpServers.includes("ref_tools")) {
64
+ score += 1;
65
+ reasons.push("Request enables documentation retrieval MCP (ref_tools)");
66
+ }
67
+ if (request.allowedTools && request.allowedTools.length === 0) {
68
+ // Independently verify review context from the prompt — never trust caller-supplied flags alone
69
+ const promptIsReview = isReviewContext(request.prompt);
70
+ if (promptIsReview) {
71
+ score += 6;
72
+ reasons.push("Empty allowedTools in review context — reviewers need tool access");
73
+ }
74
+ else {
75
+ // Neutral score — tool restrictions should never reduce risk score
76
+ // (prevents gaming via review-context evasion + restrictive tools = negative score)
77
+ reasons.push("No tool permissions requested");
78
+ }
79
+ }
80
+ if (request.disallowedTools && request.disallowedTools.length > 0) {
81
+ const promptIsReviewForDisallowed = isReviewContext(request.prompt);
82
+ const criticalTools = ["Read", "Grep", "Glob", "Bash"];
83
+ // Canonicalize to handle scoped forms like "Read(*)", "Bash(git:*)"
84
+ const canonicalized = request.disallowedTools.map(s => {
85
+ const trimmed = s.trim();
86
+ const cut = Math.min(...[trimmed.indexOf("("), trimmed.indexOf(":")].filter(i => i >= 0).concat([trimmed.length]));
87
+ return trimmed.slice(0, cut).trim();
88
+ });
89
+ const blockedCritical = criticalTools.filter(t => canonicalized.includes(t));
90
+ if (promptIsReviewForDisallowed && blockedCritical.length > 0) {
91
+ score += 6;
92
+ reasons.push(`Critical review tools disallowed: ${blockedCritical.join(", ")} — reviewers need these`);
93
+ }
94
+ else {
95
+ // Neutral score — tool restrictions should never reduce risk score
96
+ reasons.push("Has explicit disallowed tool restrictions");
97
+ }
98
+ }
99
+ if (/\b(delete|destroy|wipe|exfiltrate|credential|token|password|secret)\b/i.test(request.prompt)) {
100
+ score += 3;
101
+ reasons.push("Prompt contains sensitive or destructive keywords");
102
+ }
103
+ if (request.reviewIntegrity && request.reviewIntegrity.violations.length > 0) {
104
+ for (const violation of request.reviewIntegrity.violations) {
105
+ // Skip empty_allowed_tools and critical_tools_disallowed — already handled in context-dependent scoring above
106
+ if (violation.type === "empty_allowed_tools" || violation.type === "critical_tools_disallowed")
107
+ continue;
108
+ score += violation.score;
109
+ reasons.push(`Review integrity: ${violation.detail}`);
110
+ }
111
+ }
112
+ // Balanced policy allows routine full-auto requests with standard MCP servers,
113
+ // while still denying bypass/sensitive combinations.
114
+ const threshold = policy === "strict" ? 2 : policy === "balanced" ? 5 : 7;
115
+ const status = score <= threshold ? "approved" : "denied";
116
+ const record = {
117
+ id: randomUUID(),
118
+ ts: new Date().toISOString(),
119
+ status,
120
+ policy,
121
+ cli: request.cli,
122
+ operation: request.operation,
123
+ score,
124
+ reasons,
125
+ promptPreview: promptPreview(request.prompt),
126
+ promptSha256: promptHash(request.prompt),
127
+ requestedMcpServers: request.requestedMcpServers,
128
+ bypassRequested: request.bypassRequested,
129
+ fullAuto: request.fullAuto,
130
+ metadata: request.metadata,
131
+ reviewIntegrity: request.reviewIntegrity
132
+ };
133
+ appendFileSync(this.logPath, `${JSON.stringify(record)}\n`, { encoding: "utf-8", mode: 0o600 });
134
+ this.logger.info(`Approval decision: ${status} (score=${score}, policy=${policy})`, {
135
+ cli: request.cli,
136
+ operation: request.operation
137
+ });
138
+ return record;
139
+ }
140
+ list(limit = 50, cli) {
141
+ if (!existsSync(this.logPath)) {
142
+ return [];
143
+ }
144
+ const content = readFileSync(this.logPath, "utf-8");
145
+ const rows = content
146
+ .split("\n")
147
+ .map(line => line.trim())
148
+ .filter(Boolean)
149
+ .map(parseLogLine)
150
+ .filter((row) => row !== null);
151
+ const filtered = cli ? rows.filter(row => row.cli === cli) : rows;
152
+ const result = filtered.slice(Math.max(0, filtered.length - limit)).reverse();
153
+ this.logger.debug(`Approval list retrieved: ${result.length} records`, { cli, limit });
154
+ return result;
155
+ }
156
+ }
@@ -0,0 +1,57 @@
1
+ import type { Logger } from "./logger.js";
2
+ import { type JobHealth } from "./process-monitor.js";
3
+ export type LlmCli = "claude" | "codex" | "gemini";
4
+ export type AsyncJobStatus = "running" | "completed" | "failed" | "canceled";
5
+ export interface AsyncJobSnapshot {
6
+ id: string;
7
+ cli: LlmCli;
8
+ status: AsyncJobStatus;
9
+ startedAt: string;
10
+ finishedAt: string | null;
11
+ exitCode: number | null;
12
+ correlationId: string;
13
+ outputTruncated: boolean;
14
+ stdoutBytes: number;
15
+ stderrBytes: number;
16
+ error: string | null;
17
+ exited: boolean;
18
+ }
19
+ export interface AsyncJobResult extends AsyncJobSnapshot {
20
+ stdout: string;
21
+ stderr: string;
22
+ stdoutTruncated: boolean;
23
+ stderrTruncated: boolean;
24
+ }
25
+ export declare class AsyncJobManager {
26
+ private logger;
27
+ private onJobComplete?;
28
+ private jobs;
29
+ private evictionTimer;
30
+ private processMonitor;
31
+ constructor(logger?: Logger, onJobComplete?: ((cli: LlmCli, durationMs: number, success: boolean) => void) | undefined);
32
+ private emitMetrics;
33
+ private evictCompletedJobs;
34
+ startJob(cli: LlmCli, args: string[], correlationId: string, cwd?: string, idleTimeoutMs?: number, outputFormat?: string): AsyncJobSnapshot;
35
+ getJobSnapshot(jobId: string): AsyncJobSnapshot | null;
36
+ getJobResult(jobId: string, maxChars?: number): AsyncJobResult | null;
37
+ cancelJob(jobId: string): {
38
+ canceled: boolean;
39
+ reason?: string;
40
+ };
41
+ getRunningJobs(): {
42
+ jobId: string;
43
+ cli: string;
44
+ status: string;
45
+ pid: number | null;
46
+ startedAt: string;
47
+ }[];
48
+ getJobHealth(): {
49
+ runningJobs: number;
50
+ deadJobs: number;
51
+ zombieJobs: number;
52
+ jobs: JobHealth[];
53
+ };
54
+ getJobOutputFormat(jobId: string): string | undefined;
55
+ private snapshot;
56
+ private appendOutput;
57
+ }
@@ -0,0 +1,334 @@
1
+ import { spawn } from "child_process";
2
+ import { randomUUID } from "crypto";
3
+ import { getExtendedPath, killProcessGroup, registerProcessGroup, unregisterProcessGroup } from "./executor.js";
4
+ import { noopLogger } from "./logger.js";
5
+ import { ProcessMonitor } from "./process-monitor.js";
6
+ const MAX_OUTPUT_SIZE = 50 * 1024 * 1024;
7
+ const JOB_TTL_MS = 60 * 60 * 1000; // 1 hour
8
+ const EVICTION_INTERVAL_MS = 5 * 60 * 1000; // Check every 5 minutes
9
+ function truncateText(value, maxChars) {
10
+ if (value.length <= maxChars) {
11
+ return { text: value, truncated: false };
12
+ }
13
+ return {
14
+ text: value.slice(value.length - maxChars),
15
+ truncated: true
16
+ };
17
+ }
18
+ export class AsyncJobManager {
19
+ logger;
20
+ onJobComplete;
21
+ jobs = new Map();
22
+ evictionTimer = null;
23
+ processMonitor;
24
+ constructor(logger = noopLogger, onJobComplete) {
25
+ this.logger = logger;
26
+ this.onJobComplete = onJobComplete;
27
+ this.processMonitor = new ProcessMonitor(logger);
28
+ this.evictionTimer = setInterval(() => this.evictCompletedJobs(), EVICTION_INTERVAL_MS);
29
+ // Allow the process to exit even if the timer is active
30
+ if (this.evictionTimer.unref) {
31
+ this.evictionTimer.unref();
32
+ }
33
+ }
34
+ emitMetrics(job) {
35
+ if (job.metricsRecorded)
36
+ return;
37
+ if (job.status === "canceled")
38
+ return;
39
+ if (job.status !== "completed" && job.status !== "failed")
40
+ return;
41
+ job.metricsRecorded = true;
42
+ const durationMs = Date.now() - new Date(job.startedAt).getTime();
43
+ try {
44
+ this.onJobComplete?.(job.cli, durationMs, job.status === "completed");
45
+ }
46
+ catch (err) {
47
+ this.logger.error("onJobComplete callback threw", err);
48
+ }
49
+ }
50
+ evictCompletedJobs() {
51
+ const now = Date.now();
52
+ let evicted = 0;
53
+ // Dead process auto-recovery: check for running jobs whose process no longer exists
54
+ for (const [id, job] of this.jobs) {
55
+ if (job.status === "running" && job.process.pid) {
56
+ try {
57
+ process.kill(job.process.pid, 0);
58
+ }
59
+ catch (err) {
60
+ if (err.code === "ESRCH") {
61
+ job.status = "failed";
62
+ job.exitCode = job.exitCode ?? 1;
63
+ job.error = "Process no longer exists (dead process detected)";
64
+ job.finishedAt = new Date().toISOString();
65
+ job.exited = true;
66
+ unregisterProcessGroup(job.process.pid);
67
+ this.logger.error(`Job ${id} process ${job.process.pid} no longer exists, marking as failed`);
68
+ this.emitMetrics(job);
69
+ }
70
+ // EPERM: process exists but we can't signal it — ignore
71
+ }
72
+ }
73
+ // Check for exited flag mismatch (close handler may have fired but status wasn't updated)
74
+ if (job.status === "running" && job.exited) {
75
+ job.status = "failed";
76
+ job.error = "Process exited without proper status transition";
77
+ job.finishedAt = job.finishedAt || new Date().toISOString();
78
+ if (job.process.pid)
79
+ unregisterProcessGroup(job.process.pid);
80
+ this.logger.error(`Job ${id} has exited flag but was still in running state, marking as failed`);
81
+ this.emitMetrics(job);
82
+ }
83
+ }
84
+ for (const [id, job] of this.jobs) {
85
+ if (job.status !== "running" && job.finishedAt) {
86
+ const finishedMs = new Date(job.finishedAt).getTime();
87
+ if (now - finishedMs > JOB_TTL_MS) {
88
+ this.jobs.delete(id);
89
+ evicted++;
90
+ }
91
+ }
92
+ }
93
+ if (evicted > 0) {
94
+ this.logger.debug(`Evicted ${evicted} completed jobs`);
95
+ }
96
+ }
97
+ startJob(cli, args, correlationId, cwd, idleTimeoutMs, outputFormat) {
98
+ const id = randomUUID();
99
+ const startedAt = new Date().toISOString();
100
+ const child = spawn(cli, args, {
101
+ cwd,
102
+ detached: true,
103
+ stdio: ["ignore", "pipe", "pipe"],
104
+ env: { ...process.env, PATH: getExtendedPath() }
105
+ });
106
+ if (child.pid)
107
+ registerProcessGroup(child.pid);
108
+ child.unref();
109
+ // Single cleanup flag to prevent double-unregister
110
+ let groupCleaned = false;
111
+ const cleanupGroup = () => {
112
+ if (groupCleaned)
113
+ return;
114
+ groupCleaned = true;
115
+ if (child.pid)
116
+ unregisterProcessGroup(child.pid);
117
+ };
118
+ const job = {
119
+ id,
120
+ cli,
121
+ args: [...args],
122
+ correlationId,
123
+ status: "running",
124
+ startedAt,
125
+ finishedAt: null,
126
+ exitCode: null,
127
+ stdout: "",
128
+ stderr: "",
129
+ outputTruncated: false,
130
+ canceled: false,
131
+ error: null,
132
+ process: child,
133
+ exited: false,
134
+ metricsRecorded: false,
135
+ outputFormat,
136
+ cleanupGroup
137
+ };
138
+ this.jobs.set(id, job);
139
+ this.logger.info(`Job ${id} started for ${cli}`, { correlationId });
140
+ // Idle timeout: kill process if no output activity for idleTimeoutMs
141
+ let idleTimerId;
142
+ const resetIdleTimer = () => {
143
+ if (!idleTimeoutMs || idleTimeoutMs <= 0)
144
+ return;
145
+ if (idleTimerId)
146
+ clearTimeout(idleTimerId);
147
+ idleTimerId = setTimeout(() => {
148
+ if (job.status !== "running")
149
+ return;
150
+ job.status = "failed";
151
+ job.exitCode = 125;
152
+ job.error = `Process killed after ${idleTimeoutMs}ms of inactivity`;
153
+ job.finishedAt = new Date().toISOString();
154
+ killProcessGroup(job.process, "SIGTERM");
155
+ this.logger.info(`Job ${id} killed due to inactivity (${idleTimeoutMs}ms)`, { correlationId });
156
+ this.emitMetrics(job);
157
+ setTimeout(() => {
158
+ if (!job.exited)
159
+ killProcessGroup(job.process, "SIGKILL");
160
+ job.cleanupGroup?.();
161
+ }, 5000);
162
+ }, idleTimeoutMs);
163
+ };
164
+ job.resetIdleTimer = resetIdleTimer;
165
+ job.clearIdleTimer = () => {
166
+ if (idleTimerId)
167
+ clearTimeout(idleTimerId);
168
+ };
169
+ resetIdleTimer();
170
+ child.stdout?.on("data", (chunk) => {
171
+ this.appendOutput(job, "stdout", chunk);
172
+ });
173
+ child.stderr?.on("data", (chunk) => {
174
+ this.appendOutput(job, "stderr", chunk);
175
+ });
176
+ child.on("error", (error) => {
177
+ job.exited = true;
178
+ job.clearIdleTimer?.();
179
+ job.cleanupGroup?.();
180
+ if (job.status === "running") {
181
+ job.status = job.canceled ? "canceled" : "failed";
182
+ job.error = error.message;
183
+ job.finishedAt = new Date().toISOString();
184
+ this.logger.error(`Job ${id} error: ${error.message}`, { correlationId });
185
+ this.emitMetrics(job);
186
+ }
187
+ });
188
+ child.on("close", (code) => {
189
+ job.exited = true;
190
+ job.clearIdleTimer?.();
191
+ // Unregister process group on clean exit (no kill was issued)
192
+ if (!job.canceled && job.status === "running") {
193
+ job.cleanupGroup?.();
194
+ }
195
+ if (job.status !== "running") {
196
+ job.exitCode = code ?? job.exitCode;
197
+ if (!job.finishedAt) {
198
+ job.finishedAt = new Date().toISOString();
199
+ }
200
+ return;
201
+ }
202
+ job.exitCode = code ?? 0;
203
+ job.finishedAt = new Date().toISOString();
204
+ if (job.canceled) {
205
+ job.status = "canceled";
206
+ }
207
+ else if (job.exitCode === 0) {
208
+ job.status = "completed";
209
+ }
210
+ else {
211
+ job.status = "failed";
212
+ }
213
+ this.emitMetrics(job);
214
+ });
215
+ return this.snapshot(job);
216
+ }
217
+ getJobSnapshot(jobId) {
218
+ const job = this.jobs.get(jobId);
219
+ if (!job) {
220
+ return null;
221
+ }
222
+ return this.snapshot(job);
223
+ }
224
+ getJobResult(jobId, maxChars = 200000) {
225
+ const job = this.jobs.get(jobId);
226
+ if (!job) {
227
+ return null;
228
+ }
229
+ const stdout = truncateText(job.stdout, maxChars);
230
+ const stderr = truncateText(job.stderr, maxChars);
231
+ return {
232
+ ...this.snapshot(job),
233
+ stdout: stdout.text,
234
+ stderr: stderr.text,
235
+ stdoutTruncated: stdout.truncated,
236
+ stderrTruncated: stderr.truncated
237
+ };
238
+ }
239
+ cancelJob(jobId) {
240
+ const job = this.jobs.get(jobId);
241
+ if (!job) {
242
+ return { canceled: false, reason: "Job not found" };
243
+ }
244
+ if (job.status !== "running") {
245
+ return { canceled: false, reason: `Job is already ${job.status}` };
246
+ }
247
+ job.canceled = true;
248
+ job.status = "canceled";
249
+ job.finishedAt = new Date().toISOString();
250
+ job.clearIdleTimer?.();
251
+ killProcessGroup(job.process, "SIGTERM");
252
+ this.logger.info(`Job ${jobId} canceled`, { correlationId: job.correlationId });
253
+ setTimeout(() => {
254
+ if (!job.exited)
255
+ killProcessGroup(job.process, "SIGKILL");
256
+ job.cleanupGroup?.();
257
+ }, 5000);
258
+ return { canceled: true };
259
+ }
260
+ getRunningJobs() {
261
+ const result = [];
262
+ for (const [id, job] of this.jobs) {
263
+ if (job.status === "running") {
264
+ result.push({
265
+ jobId: id, cli: job.cli, status: job.status,
266
+ pid: job.process.pid ?? null, startedAt: job.startedAt
267
+ });
268
+ }
269
+ }
270
+ return result;
271
+ }
272
+ getJobHealth() {
273
+ const running = this.getRunningJobs();
274
+ const health = this.processMonitor.checkJobHealth(running);
275
+ // Clean up stale CPU samples for PIDs that are no longer running
276
+ const activePids = new Set(running.map(j => j.pid).filter((p) => p !== null));
277
+ this.processMonitor.cleanupSamples(activePids);
278
+ return {
279
+ runningJobs: running.length,
280
+ deadJobs: health.filter(h => h.isDead).length,
281
+ zombieJobs: health.filter(h => h.isZombie).length,
282
+ jobs: health
283
+ };
284
+ }
285
+ getJobOutputFormat(jobId) {
286
+ return this.jobs.get(jobId)?.outputFormat;
287
+ }
288
+ snapshot(job) {
289
+ return {
290
+ id: job.id,
291
+ cli: job.cli,
292
+ status: job.status,
293
+ startedAt: job.startedAt,
294
+ finishedAt: job.finishedAt,
295
+ exitCode: job.exitCode,
296
+ correlationId: job.correlationId,
297
+ outputTruncated: job.outputTruncated,
298
+ stdoutBytes: Buffer.byteLength(job.stdout),
299
+ stderrBytes: Buffer.byteLength(job.stderr),
300
+ error: job.error,
301
+ exited: job.exited
302
+ };
303
+ }
304
+ appendOutput(job, stream, chunk) {
305
+ const totalBytes = Buffer.byteLength(job.stdout) + Buffer.byteLength(job.stderr) + chunk.length;
306
+ if (totalBytes > MAX_OUTPUT_SIZE) {
307
+ job.outputTruncated = true;
308
+ if (job.status === "running") {
309
+ job.status = "failed";
310
+ job.exitCode = 126;
311
+ job.error = "Output exceeded maximum size (50MB)";
312
+ job.finishedAt = new Date().toISOString();
313
+ job.clearIdleTimer?.();
314
+ killProcessGroup(job.process, "SIGTERM");
315
+ this.logger.info(`Job ${job.id} killed due to output overflow`, { correlationId: job.correlationId });
316
+ this.emitMetrics(job);
317
+ setTimeout(() => {
318
+ if (!job.exited)
319
+ killProcessGroup(job.process, "SIGKILL");
320
+ job.cleanupGroup?.();
321
+ }, 5000);
322
+ }
323
+ return;
324
+ }
325
+ job.resetIdleTimer?.();
326
+ const text = chunk.toString();
327
+ if (stream === "stdout") {
328
+ job.stdout += text;
329
+ }
330
+ else {
331
+ job.stderr += text;
332
+ }
333
+ }
334
+ }
@@ -0,0 +1,8 @@
1
+ export declare const CLAUDE_MCP_SERVER_NAMES: readonly ["sqry", "exa", "ref_tools", "trstr"];
2
+ export type ClaudeMcpServerName = (typeof CLAUDE_MCP_SERVER_NAMES)[number];
3
+ export interface ClaudeMcpConfigResult {
4
+ path: string;
5
+ enabled: ClaudeMcpServerName[];
6
+ missing: ClaudeMcpServerName[];
7
+ }
8
+ export declare function buildClaudeMcpConfig(servers: ClaudeMcpServerName[]): ClaudeMcpConfigResult;