llm-cli-gateway 1.0.0 → 1.1.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 +21 -0
- package/README.md +31 -1
- package/dist/approval-manager.js +7 -4
- package/dist/async-job-manager.js +18 -11
- package/dist/claude-mcp-config.js +7 -4
- package/dist/config.js +15 -9
- package/dist/db.js +4 -4
- package/dist/executor.js +20 -13
- package/dist/flight-recorder.d.ts +48 -0
- package/dist/flight-recorder.js +220 -0
- package/dist/health.js +3 -3
- package/dist/index.d.ts +1 -0
- package/dist/index.js +812 -259
- package/dist/logger.js +1 -1
- package/dist/metrics.js +9 -12
- package/dist/migrate-sessions.js +2 -2
- package/dist/model-registry.js +12 -14
- package/dist/optimizer.js +9 -9
- package/dist/process-monitor.js +24 -8
- package/dist/request-helpers.d.ts +7 -0
- package/dist/request-helpers.js +24 -2
- package/dist/resources.js +32 -32
- package/dist/retry.js +6 -4
- package/dist/review-integrity.d.ts +6 -38
- package/dist/review-integrity.js +41 -275
- package/dist/session-manager-pg.js +6 -4
- package/dist/session-manager.js +7 -4
- package/dist/stream-json-parser.js +8 -6
- package/package.json +7 -3
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,27 @@
|
|
|
2
2
|
|
|
3
3
|
All notable changes to the llm-cli-gateway project.
|
|
4
4
|
|
|
5
|
+
## [1.1.0] - 2026-04-04
|
|
6
|
+
|
|
7
|
+
### Added
|
|
8
|
+
|
|
9
|
+
- **SQLite flight recorder** — New `src/flight-recorder.ts` module logs all LLM requests/responses to `~/.llm-cli-gateway/logs.db` with two-phase logging (logStart/logComplete), WAL mode for concurrent Datasette reads, and graceful degradation when better-sqlite3 is unavailable
|
|
10
|
+
- **`LLM_GATEWAY_LOGS_DB` env var** — Configure flight recorder database path; set to empty string or `"none"` to disable logging entirely
|
|
11
|
+
- **`structuredContent` in MCP tool responses** — All tool handlers now return machine-readable metadata (model, cli, correlationId, sessionId, durationMs, token usage, exitCode) alongside the text response
|
|
12
|
+
- **`better-sqlite3` dependency** — Native SQLite addon for flight recorder (synchronous writes, WAL support)
|
|
13
|
+
|
|
14
|
+
### Changed
|
|
15
|
+
|
|
16
|
+
- **review-integrity.ts simplified** — Reduced from 323 lines to 83 lines. Retains 3 violation types: empty_allowed_tools, critical_tools_disallowed, tool_suppression. Removed inlined_code detection and multi-pattern matching
|
|
17
|
+
- **`buildCliResponse` signature** — Now requires `cli` and `durationMs` parameters for structuredContent population
|
|
18
|
+
- **`createErrorResponse`** — Returns sanitized `errorCategory` enum in structuredContent instead of raw error messages (prevents path/secret leakage)
|
|
19
|
+
- **Flight recorder writes are idempotent** — logComplete only updates rows with status='started', preventing double-completion
|
|
20
|
+
|
|
21
|
+
### Tests
|
|
22
|
+
|
|
23
|
+
- 284 tests passing (15 test files)
|
|
24
|
+
- Rewritten review-integrity tests to match simplified API
|
|
25
|
+
|
|
5
26
|
## [1.3.0] - 2026-02-15
|
|
6
27
|
|
|
7
28
|
### Fixed
|
package/README.md
CHANGED
|
@@ -14,6 +14,10 @@ A Model Context Protocol (MCP) server providing unified access to Claude Code, C
|
|
|
14
14
|
- **Correlation ID Tracking**: Full request tracing across all LLM interactions
|
|
15
15
|
- **Cross-Tool Collaboration**: LLMs can use each other via MCP (validated through dogfooding)
|
|
16
16
|
|
|
17
|
+
### Observability
|
|
18
|
+
- **SQLite Flight Recorder**: Every request/response logged to `~/.llm-cli-gateway/logs.db` with correlation IDs, token usage, duration, retry counts, and circuit breaker state. Browse with [Datasette](https://datasette.io/): `datasette ~/.llm-cli-gateway/logs.db`
|
|
19
|
+
- **Structured Metadata**: Tool responses include machine-readable `structuredContent` (model, cli, correlationId, sessionId, durationMs, token counts)
|
|
20
|
+
|
|
17
21
|
### Reliability & Performance
|
|
18
22
|
- **Retry Logic**: Exponential backoff with circuit breaker for transient failures
|
|
19
23
|
- **Atomic File Writes**: Process-specific temp files with fsync for data integrity
|
|
@@ -22,7 +26,7 @@ A Model Context Protocol (MCP) server providing unified access to Claude Code, C
|
|
|
22
26
|
- **Long-Running Jobs**: Non-time-bound async execution via `*_request_async` + polling tools
|
|
23
27
|
|
|
24
28
|
### Security & Quality
|
|
25
|
-
- **Comprehensive Testing**:
|
|
29
|
+
- **Comprehensive Testing**: 284 tests covering unit, integration, and regression scenarios
|
|
26
30
|
- **Input Validation**: Zod schemas prevent injection attacks
|
|
27
31
|
- **No Secret Leakage**: Generic session descriptions only (file permissions 0o600)
|
|
28
32
|
- **No ReDoS**: Bounded regex patterns prevent catastrophic backtracking
|
|
@@ -360,6 +364,13 @@ await callTool("session_delete", {
|
|
|
360
364
|
```bash
|
|
361
365
|
LLM_GATEWAY_APPROVAL_POLICY=strict node dist/index.js
|
|
362
366
|
```
|
|
367
|
+
- `LLM_GATEWAY_LOGS_DB`: Path to SQLite flight recorder database. Default: `~/.llm-cli-gateway/logs.db`. Set to empty string or `none` to disable logging.
|
|
368
|
+
```bash
|
|
369
|
+
# Custom path
|
|
370
|
+
LLM_GATEWAY_LOGS_DB=/var/log/gateway/logs.db node dist/index.js
|
|
371
|
+
# Disable flight recorder
|
|
372
|
+
LLM_GATEWAY_LOGS_DB=none node dist/index.js
|
|
373
|
+
```
|
|
363
374
|
|
|
364
375
|
### CLI-Specific Settings
|
|
365
376
|
|
|
@@ -368,6 +379,25 @@ Each CLI can be configured through its own configuration files:
|
|
|
368
379
|
- Codex: `~/.codex/config.toml`
|
|
369
380
|
- Gemini: `~/.gemini/config.json`
|
|
370
381
|
|
|
382
|
+
## For Fans of Simon Willison
|
|
383
|
+
|
|
384
|
+
Simon's `llm` tool made it trivially easy to talk to any LLM from the command line. But as AI-assisted development matures, the challenge shifts from "how do I call a model" to "how do I orchestrate multiple models reliably, and what did they actually do?"
|
|
385
|
+
|
|
386
|
+
**Multiple models increase the confidence factor.** When Claude writes code, Codex reviews it, and Gemini checks for bugs -- each bringing different training data and reasoning patterns -- the result is more robust than any single model alone. And often this isn't even enough. Having the models do iterative reviews is where you start getting real confidence.
|
|
387
|
+
|
|
388
|
+
**Every interaction should be queryable data.** Inspired by `llm`'s SQLite logging philosophy, the gateway records every request and response to a local SQLite database. Not just prompts and responses -- retry counts, circuit breaker states, approval decisions, thinking blocks, cost estimates. Open it with Datasette and you have a complete operational picture of your AI usage:
|
|
389
|
+
|
|
390
|
+
datasette ~/.llm-cli-gateway/logs.db
|
|
391
|
+
|
|
392
|
+
**The `llm-gateway` plugin bridges both worlds.** Install it, and your existing `llm` workflows gain orchestration features without changing how you work:
|
|
393
|
+
|
|
394
|
+
llm install llm-gateway
|
|
395
|
+
llm -m gateway-claude "explain this function"
|
|
396
|
+
|
|
397
|
+
Your gateway interactions appear in both `llm logs` (for your personal history) and the gateway's flight recorder (for operational observability). Two audiences, one workflow.
|
|
398
|
+
|
|
399
|
+
**Composability over monoliths.** The gateway doesn't replace `llm` -- it complements it. Use `llm` directly when you want simplicity. Route through the gateway when you want resilience, multi-model coordination, or detailed operational telemetry. The plugin is the bridge, not the destination.
|
|
400
|
+
|
|
371
401
|
## Development
|
|
372
402
|
|
|
373
403
|
### Project Structure
|
package/dist/approval-manager.js
CHANGED
|
@@ -83,7 +83,9 @@ export class ApprovalManager {
|
|
|
83
83
|
// Canonicalize to handle scoped forms like "Read(*)", "Bash(git:*)"
|
|
84
84
|
const canonicalized = request.disallowedTools.map(s => {
|
|
85
85
|
const trimmed = s.trim();
|
|
86
|
-
const cut = Math.min(...[trimmed.indexOf("("), trimmed.indexOf(":")]
|
|
86
|
+
const cut = Math.min(...[trimmed.indexOf("("), trimmed.indexOf(":")]
|
|
87
|
+
.filter(i => i >= 0)
|
|
88
|
+
.concat([trimmed.length]));
|
|
87
89
|
return trimmed.slice(0, cut).trim();
|
|
88
90
|
});
|
|
89
91
|
const blockedCritical = criticalTools.filter(t => canonicalized.includes(t));
|
|
@@ -103,7 +105,8 @@ export class ApprovalManager {
|
|
|
103
105
|
if (request.reviewIntegrity && request.reviewIntegrity.violations.length > 0) {
|
|
104
106
|
for (const violation of request.reviewIntegrity.violations) {
|
|
105
107
|
// Skip empty_allowed_tools and critical_tools_disallowed — already handled in context-dependent scoring above
|
|
106
|
-
if (violation.type === "empty_allowed_tools" ||
|
|
108
|
+
if (violation.type === "empty_allowed_tools" ||
|
|
109
|
+
violation.type === "critical_tools_disallowed")
|
|
107
110
|
continue;
|
|
108
111
|
score += violation.score;
|
|
109
112
|
reasons.push(`Review integrity: ${violation.detail}`);
|
|
@@ -128,12 +131,12 @@ export class ApprovalManager {
|
|
|
128
131
|
bypassRequested: request.bypassRequested,
|
|
129
132
|
fullAuto: request.fullAuto,
|
|
130
133
|
metadata: request.metadata,
|
|
131
|
-
reviewIntegrity: request.reviewIntegrity
|
|
134
|
+
reviewIntegrity: request.reviewIntegrity,
|
|
132
135
|
};
|
|
133
136
|
appendFileSync(this.logPath, `${JSON.stringify(record)}\n`, { encoding: "utf-8", mode: 0o600 });
|
|
134
137
|
this.logger.info(`Approval decision: ${status} (score=${score}, policy=${policy})`, {
|
|
135
138
|
cli: request.cli,
|
|
136
|
-
operation: request.operation
|
|
139
|
+
operation: request.operation,
|
|
137
140
|
});
|
|
138
141
|
return record;
|
|
139
142
|
}
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { spawn } from "child_process";
|
|
2
2
|
import { randomUUID } from "crypto";
|
|
3
|
-
import { getExtendedPath, killProcessGroup, registerProcessGroup, unregisterProcessGroup } from "./executor.js";
|
|
3
|
+
import { getExtendedPath, killProcessGroup, registerProcessGroup, unregisterProcessGroup, } from "./executor.js";
|
|
4
4
|
import { noopLogger } from "./logger.js";
|
|
5
5
|
import { ProcessMonitor } from "./process-monitor.js";
|
|
6
6
|
const MAX_OUTPUT_SIZE = 50 * 1024 * 1024;
|
|
@@ -12,7 +12,7 @@ function truncateText(value, maxChars) {
|
|
|
12
12
|
}
|
|
13
13
|
return {
|
|
14
14
|
text: value.slice(value.length - maxChars),
|
|
15
|
-
truncated: true
|
|
15
|
+
truncated: true,
|
|
16
16
|
};
|
|
17
17
|
}
|
|
18
18
|
export class AsyncJobManager {
|
|
@@ -101,7 +101,7 @@ export class AsyncJobManager {
|
|
|
101
101
|
cwd,
|
|
102
102
|
detached: true,
|
|
103
103
|
stdio: ["ignore", "pipe", "pipe"],
|
|
104
|
-
env: { ...process.env, PATH: getExtendedPath() }
|
|
104
|
+
env: { ...process.env, PATH: getExtendedPath() },
|
|
105
105
|
});
|
|
106
106
|
if (child.pid)
|
|
107
107
|
registerProcessGroup(child.pid);
|
|
@@ -133,7 +133,7 @@ export class AsyncJobManager {
|
|
|
133
133
|
exited: false,
|
|
134
134
|
metricsRecorded: false,
|
|
135
135
|
outputFormat,
|
|
136
|
-
cleanupGroup
|
|
136
|
+
cleanupGroup,
|
|
137
137
|
};
|
|
138
138
|
this.jobs.set(id, job);
|
|
139
139
|
this.logger.info(`Job ${id} started for ${cli}`, { correlationId });
|
|
@@ -152,7 +152,9 @@ export class AsyncJobManager {
|
|
|
152
152
|
job.error = `Process killed after ${idleTimeoutMs}ms of inactivity`;
|
|
153
153
|
job.finishedAt = new Date().toISOString();
|
|
154
154
|
killProcessGroup(job.process, "SIGTERM");
|
|
155
|
-
this.logger.info(`Job ${id} killed due to inactivity (${idleTimeoutMs}ms)`, {
|
|
155
|
+
this.logger.info(`Job ${id} killed due to inactivity (${idleTimeoutMs}ms)`, {
|
|
156
|
+
correlationId,
|
|
157
|
+
});
|
|
156
158
|
this.emitMetrics(job);
|
|
157
159
|
setTimeout(() => {
|
|
158
160
|
if (!job.exited)
|
|
@@ -233,7 +235,7 @@ export class AsyncJobManager {
|
|
|
233
235
|
stdout: stdout.text,
|
|
234
236
|
stderr: stderr.text,
|
|
235
237
|
stdoutTruncated: stdout.truncated,
|
|
236
|
-
stderrTruncated: stderr.truncated
|
|
238
|
+
stderrTruncated: stderr.truncated,
|
|
237
239
|
};
|
|
238
240
|
}
|
|
239
241
|
cancelJob(jobId) {
|
|
@@ -262,8 +264,11 @@ export class AsyncJobManager {
|
|
|
262
264
|
for (const [id, job] of this.jobs) {
|
|
263
265
|
if (job.status === "running") {
|
|
264
266
|
result.push({
|
|
265
|
-
jobId: id,
|
|
266
|
-
|
|
267
|
+
jobId: id,
|
|
268
|
+
cli: job.cli,
|
|
269
|
+
status: job.status,
|
|
270
|
+
pid: job.process.pid ?? null,
|
|
271
|
+
startedAt: job.startedAt,
|
|
267
272
|
});
|
|
268
273
|
}
|
|
269
274
|
}
|
|
@@ -279,7 +284,7 @@ export class AsyncJobManager {
|
|
|
279
284
|
runningJobs: running.length,
|
|
280
285
|
deadJobs: health.filter(h => h.isDead).length,
|
|
281
286
|
zombieJobs: health.filter(h => h.isZombie).length,
|
|
282
|
-
jobs: health
|
|
287
|
+
jobs: health,
|
|
283
288
|
};
|
|
284
289
|
}
|
|
285
290
|
getJobOutputFormat(jobId) {
|
|
@@ -298,7 +303,7 @@ export class AsyncJobManager {
|
|
|
298
303
|
stdoutBytes: Buffer.byteLength(job.stdout),
|
|
299
304
|
stderrBytes: Buffer.byteLength(job.stderr),
|
|
300
305
|
error: job.error,
|
|
301
|
-
exited: job.exited
|
|
306
|
+
exited: job.exited,
|
|
302
307
|
};
|
|
303
308
|
}
|
|
304
309
|
appendOutput(job, stream, chunk) {
|
|
@@ -312,7 +317,9 @@ export class AsyncJobManager {
|
|
|
312
317
|
job.finishedAt = new Date().toISOString();
|
|
313
318
|
job.clearIdleTimer?.();
|
|
314
319
|
killProcessGroup(job.process, "SIGTERM");
|
|
315
|
-
this.logger.info(`Job ${job.id} killed due to output overflow`, {
|
|
320
|
+
this.logger.info(`Job ${job.id} killed due to output overflow`, {
|
|
321
|
+
correlationId: job.correlationId,
|
|
322
|
+
});
|
|
316
323
|
this.emitMetrics(job);
|
|
317
324
|
setTimeout(() => {
|
|
318
325
|
if (!job.exited)
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { existsSync, mkdirSync, readFileSync, readdirSync, writeFileSync, renameSync, openSync, fsyncSync, closeSync, chmodSync } from "fs";
|
|
1
|
+
import { existsSync, mkdirSync, readFileSync, readdirSync, writeFileSync, renameSync, openSync, fsyncSync, closeSync, chmodSync, } from "fs";
|
|
2
2
|
import { homedir } from "os";
|
|
3
3
|
import { dirname, join } from "path";
|
|
4
4
|
import { parse as parseToml } from "toml";
|
|
@@ -45,7 +45,7 @@ function readCodexServerConfig(server) {
|
|
|
45
45
|
return {
|
|
46
46
|
command,
|
|
47
47
|
args,
|
|
48
|
-
env
|
|
48
|
+
env,
|
|
49
49
|
};
|
|
50
50
|
}
|
|
51
51
|
catch {
|
|
@@ -120,7 +120,7 @@ function toClaudeServerDef(server) {
|
|
|
120
120
|
return {
|
|
121
121
|
command,
|
|
122
122
|
args,
|
|
123
|
-
...(Object.keys(env).length > 0 ? { env } : {})
|
|
123
|
+
...(Object.keys(env).length > 0 ? { env } : {}),
|
|
124
124
|
};
|
|
125
125
|
}
|
|
126
126
|
export function buildClaudeMcpConfig(servers) {
|
|
@@ -142,7 +142,10 @@ export function buildClaudeMcpConfig(servers) {
|
|
|
142
142
|
try {
|
|
143
143
|
mkdirSync(configDir, { recursive: true });
|
|
144
144
|
const tempPath = `${configPath}.tmp.${process.pid}`;
|
|
145
|
-
writeFileSync(tempPath, JSON.stringify({ mcpServers }, null, 2), {
|
|
145
|
+
writeFileSync(tempPath, JSON.stringify({ mcpServers }, null, 2), {
|
|
146
|
+
encoding: "utf-8",
|
|
147
|
+
mode: 0o600,
|
|
148
|
+
});
|
|
146
149
|
const fd = openSync(tempPath, "r+");
|
|
147
150
|
try {
|
|
148
151
|
fsyncSync(fd);
|
package/dist/config.js
CHANGED
|
@@ -1,6 +1,11 @@
|
|
|
1
1
|
import { z } from "zod";
|
|
2
2
|
// Zod schemas for configuration validation
|
|
3
|
-
const DatabaseUrlSchema = z
|
|
3
|
+
const DatabaseUrlSchema = z
|
|
4
|
+
.string()
|
|
5
|
+
.url()
|
|
6
|
+
.refine(url => url.startsWith("postgresql://") || url.startsWith("postgres://"), {
|
|
7
|
+
message: "Database URL must start with postgresql:// or postgres://",
|
|
8
|
+
});
|
|
4
9
|
const RedisUrlSchema = z.string().url().startsWith("redis://");
|
|
5
10
|
export const DEFAULT_SESSION_TTL_SECONDS = 2592000; // 30 days
|
|
6
11
|
/**
|
|
@@ -15,11 +20,12 @@ export function loadConfig() {
|
|
|
15
20
|
const cacheTtl = {
|
|
16
21
|
session: 3600, // 1 hour
|
|
17
22
|
activeSession: 1800, // 30 minutes
|
|
18
|
-
sessionList: 120 // 2 minutes
|
|
23
|
+
sessionList: 120, // 2 minutes
|
|
19
24
|
};
|
|
20
25
|
const rawSessionTtl = parseInt(process.env.SESSION_TTL || String(DEFAULT_SESSION_TTL_SECONDS), 10);
|
|
21
|
-
const sessionTtl =
|
|
22
|
-
? rawSessionTtl
|
|
26
|
+
const sessionTtl = Number.isFinite(rawSessionTtl) && rawSessionTtl > 0
|
|
27
|
+
? rawSessionTtl
|
|
28
|
+
: DEFAULT_SESSION_TTL_SECONDS;
|
|
23
29
|
// If no database config, return base config (file-based storage)
|
|
24
30
|
if (!databaseUrl || !redisUrl) {
|
|
25
31
|
return { cacheTtl, sessionTtl };
|
|
@@ -39,18 +45,18 @@ export function loadConfig() {
|
|
|
39
45
|
max: 10,
|
|
40
46
|
idleTimeoutMillis: 30000,
|
|
41
47
|
connectionTimeoutMillis: 5000,
|
|
42
|
-
statementTimeout: 10000
|
|
43
|
-
}
|
|
48
|
+
statementTimeout: 10000,
|
|
49
|
+
},
|
|
44
50
|
},
|
|
45
51
|
redis: {
|
|
46
52
|
url: redisUrl,
|
|
47
53
|
retryStrategy: {
|
|
48
54
|
maxRetries: 3,
|
|
49
55
|
initialDelay: 50,
|
|
50
|
-
maxDelay: 2000
|
|
51
|
-
}
|
|
56
|
+
maxDelay: 2000,
|
|
57
|
+
},
|
|
52
58
|
},
|
|
53
59
|
cacheTtl,
|
|
54
|
-
sessionTtl
|
|
60
|
+
sessionTtl,
|
|
55
61
|
};
|
|
56
62
|
}
|
package/dist/db.js
CHANGED
|
@@ -26,7 +26,7 @@ export class DatabaseConnection {
|
|
|
26
26
|
max: this.config.database.pool.max,
|
|
27
27
|
idleTimeoutMillis: this.config.database.pool.idleTimeoutMillis,
|
|
28
28
|
connectionTimeoutMillis: this.config.database.pool.connectionTimeoutMillis,
|
|
29
|
-
statement_timeout: this.config.database.pool.statementTimeout
|
|
29
|
+
statement_timeout: this.config.database.pool.statementTimeout,
|
|
30
30
|
};
|
|
31
31
|
this.pool = new Pool(poolConfig);
|
|
32
32
|
// Test PostgreSQL connection
|
|
@@ -54,7 +54,7 @@ export class DatabaseConnection {
|
|
|
54
54
|
// Reconnect on READONLY and ECONNRESET errors
|
|
55
55
|
const targetErrors = ["READONLY", "ECONNRESET"];
|
|
56
56
|
return targetErrors.some(targetError => err.message.includes(targetError));
|
|
57
|
-
}
|
|
57
|
+
},
|
|
58
58
|
};
|
|
59
59
|
this.redis = new Redis(this.config.redis.url, redisOptions);
|
|
60
60
|
// Test Redis connection
|
|
@@ -101,7 +101,7 @@ export class DatabaseConnection {
|
|
|
101
101
|
async healthCheck() {
|
|
102
102
|
const result = {
|
|
103
103
|
postgres: { connected: false, latency: 0 },
|
|
104
|
-
redis: { connected: false, latency: 0 }
|
|
104
|
+
redis: { connected: false, latency: 0 },
|
|
105
105
|
};
|
|
106
106
|
// Check PostgreSQL
|
|
107
107
|
if (this.pool) {
|
|
@@ -137,7 +137,7 @@ export class DatabaseConnection {
|
|
|
137
137
|
}
|
|
138
138
|
this.logger.debug("Health check completed", {
|
|
139
139
|
postgres: result.postgres.connected,
|
|
140
|
-
redis: result.redis.connected
|
|
140
|
+
redis: result.redis.connected,
|
|
141
141
|
});
|
|
142
142
|
return result;
|
|
143
143
|
}
|
package/dist/executor.js
CHANGED
|
@@ -28,7 +28,7 @@ function getNvmPath() {
|
|
|
28
28
|
try {
|
|
29
29
|
const versions = readdirSync(nvmVersionsDir);
|
|
30
30
|
cachedNvmPath = versions.length
|
|
31
|
-
? versions.map(
|
|
31
|
+
? versions.map(version => join(nvmVersionsDir, version, "bin")).join(":")
|
|
32
32
|
: null;
|
|
33
33
|
}
|
|
34
34
|
catch {
|
|
@@ -43,7 +43,7 @@ export function getExtendedPath() {
|
|
|
43
43
|
join(home, ".local/bin"),
|
|
44
44
|
dirname(process.execPath), // Current node's bin directory
|
|
45
45
|
"/usr/local/bin",
|
|
46
|
-
"/usr/bin"
|
|
46
|
+
"/usr/bin",
|
|
47
47
|
];
|
|
48
48
|
// Add all nvm node version bin directories
|
|
49
49
|
const nvmPath = getNvmPath();
|
|
@@ -75,7 +75,9 @@ export function killAllProcessGroups() {
|
|
|
75
75
|
try {
|
|
76
76
|
process.kill(-pid, "SIGTERM");
|
|
77
77
|
}
|
|
78
|
-
catch {
|
|
78
|
+
catch {
|
|
79
|
+
/* ESRCH ok */
|
|
80
|
+
}
|
|
79
81
|
}
|
|
80
82
|
return new Promise(resolve => {
|
|
81
83
|
setTimeout(() => {
|
|
@@ -83,7 +85,9 @@ export function killAllProcessGroups() {
|
|
|
83
85
|
try {
|
|
84
86
|
process.kill(-pid, "SIGKILL");
|
|
85
87
|
}
|
|
86
|
-
catch {
|
|
88
|
+
catch {
|
|
89
|
+
/* ESRCH ok */
|
|
90
|
+
}
|
|
87
91
|
}
|
|
88
92
|
activeProcessGroups.clear();
|
|
89
93
|
resolve();
|
|
@@ -129,7 +133,7 @@ export async function executeCli(command, args, options = {}) {
|
|
|
129
133
|
cwd,
|
|
130
134
|
detached: true,
|
|
131
135
|
stdio: ["ignore", "pipe", "pipe"],
|
|
132
|
-
env: { ...process.env, PATH: extendedPath }
|
|
136
|
+
env: { ...process.env, PATH: extendedPath },
|
|
133
137
|
});
|
|
134
138
|
if (proc.pid)
|
|
135
139
|
registerProcessGroup(proc.pid);
|
|
@@ -152,7 +156,9 @@ export async function executeCli(command, args, options = {}) {
|
|
|
152
156
|
if (proc.pid)
|
|
153
157
|
unregisterProcessGroup(proc.pid);
|
|
154
158
|
};
|
|
155
|
-
const timeoutMs = typeof timeout === "number" && Number.isFinite(timeout) && timeout > 0
|
|
159
|
+
const timeoutMs = typeof timeout === "number" && Number.isFinite(timeout) && timeout > 0
|
|
160
|
+
? timeout
|
|
161
|
+
: undefined;
|
|
156
162
|
const timeoutId = timeoutMs
|
|
157
163
|
? setTimeout(() => {
|
|
158
164
|
timedOut = true;
|
|
@@ -166,7 +172,8 @@ export async function executeCli(command, args, options = {}) {
|
|
|
166
172
|
: undefined;
|
|
167
173
|
// Idle timeout: kill process if no stdout/stderr activity for idleMs
|
|
168
174
|
const idleMs = typeof idleTimeout === "number" && Number.isFinite(idleTimeout) && idleTimeout > 0
|
|
169
|
-
? idleTimeout
|
|
175
|
+
? idleTimeout
|
|
176
|
+
: undefined;
|
|
170
177
|
let idleTimerId;
|
|
171
178
|
const resetIdleTimer = () => {
|
|
172
179
|
if (!idleMs)
|
|
@@ -222,19 +229,19 @@ export async function executeCli(command, args, options = {}) {
|
|
|
222
229
|
stderr += text;
|
|
223
230
|
}
|
|
224
231
|
};
|
|
225
|
-
proc.stdout.on("data",
|
|
232
|
+
proc.stdout.on("data", data => {
|
|
226
233
|
if (settled) {
|
|
227
234
|
return;
|
|
228
235
|
}
|
|
229
236
|
handleOutputChunk(data, "stdout");
|
|
230
237
|
});
|
|
231
|
-
proc.stderr.on("data",
|
|
238
|
+
proc.stderr.on("data", data => {
|
|
232
239
|
if (settled) {
|
|
233
240
|
return;
|
|
234
241
|
}
|
|
235
242
|
handleOutputChunk(data, "stderr");
|
|
236
243
|
});
|
|
237
|
-
proc.on("close",
|
|
244
|
+
proc.on("close", code => {
|
|
238
245
|
exited = true;
|
|
239
246
|
if (idleTimerId) {
|
|
240
247
|
clearTimeout(idleTimerId);
|
|
@@ -253,7 +260,7 @@ export async function executeCli(command, args, options = {}) {
|
|
|
253
260
|
const result = {
|
|
254
261
|
stdout,
|
|
255
262
|
stderr: stderr + `\nProcess timed out after ${timeoutMs}ms`,
|
|
256
|
-
code: 124 // Standard timeout exit code
|
|
263
|
+
code: 124, // Standard timeout exit code
|
|
257
264
|
};
|
|
258
265
|
const error = new Error(result.stderr);
|
|
259
266
|
error.code = 124;
|
|
@@ -265,7 +272,7 @@ export async function executeCli(command, args, options = {}) {
|
|
|
265
272
|
const result = {
|
|
266
273
|
stdout,
|
|
267
274
|
stderr: stderr + `\nProcess killed after ${idleMs}ms of inactivity`,
|
|
268
|
-
code: 125
|
|
275
|
+
code: 125,
|
|
269
276
|
};
|
|
270
277
|
const error = new Error(result.stderr);
|
|
271
278
|
error.code = 125;
|
|
@@ -283,7 +290,7 @@ export async function executeCli(command, args, options = {}) {
|
|
|
283
290
|
}
|
|
284
291
|
resolve(result);
|
|
285
292
|
});
|
|
286
|
-
proc.on("error",
|
|
293
|
+
proc.on("error", err => {
|
|
287
294
|
exited = true;
|
|
288
295
|
if (idleTimerId) {
|
|
289
296
|
clearTimeout(idleTimerId);
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
export interface FlightLogStart {
|
|
2
|
+
correlationId: string;
|
|
3
|
+
cli: "claude" | "codex" | "gemini";
|
|
4
|
+
model: string;
|
|
5
|
+
prompt: string;
|
|
6
|
+
system?: string;
|
|
7
|
+
sessionId?: string;
|
|
8
|
+
asyncJobId?: string;
|
|
9
|
+
}
|
|
10
|
+
export interface FlightLogResult {
|
|
11
|
+
response: string;
|
|
12
|
+
inputTokens?: number;
|
|
13
|
+
outputTokens?: number;
|
|
14
|
+
durationMs: number;
|
|
15
|
+
retryCount: number;
|
|
16
|
+
circuitBreakerState: string;
|
|
17
|
+
costUsd?: number;
|
|
18
|
+
approvalDecision?: string;
|
|
19
|
+
optimizationApplied: boolean;
|
|
20
|
+
thinkingBlocks?: string[];
|
|
21
|
+
exitCode: number;
|
|
22
|
+
errorMessage?: string;
|
|
23
|
+
status: "completed" | "failed";
|
|
24
|
+
}
|
|
25
|
+
interface LoggerLike {
|
|
26
|
+
info: (message: string, ...args: any[]) => void;
|
|
27
|
+
error: (message: string, ...args: any[]) => void;
|
|
28
|
+
}
|
|
29
|
+
export declare function resolveFlightRecorderDbPath(): string | null;
|
|
30
|
+
export declare class FlightRecorder {
|
|
31
|
+
private db;
|
|
32
|
+
private insertStartTxn;
|
|
33
|
+
private updateCompleteTxn;
|
|
34
|
+
constructor(dbPath: string);
|
|
35
|
+
logStart(entry: FlightLogStart): void;
|
|
36
|
+
logComplete(correlationId: string, result: FlightLogResult): void;
|
|
37
|
+
flush(): void;
|
|
38
|
+
close(): void;
|
|
39
|
+
}
|
|
40
|
+
export declare class NoopFlightRecorder {
|
|
41
|
+
logStart(_entry: FlightLogStart): void;
|
|
42
|
+
logComplete(_correlationId: string, _result: FlightLogResult): void;
|
|
43
|
+
flush(): void;
|
|
44
|
+
close(): void;
|
|
45
|
+
}
|
|
46
|
+
export type FlightRecorderLike = FlightRecorder | NoopFlightRecorder;
|
|
47
|
+
export declare function createFlightRecorder(logger: LoggerLike): FlightRecorderLike;
|
|
48
|
+
export {};
|