@sghanavati/relay-mcp 0.1.2 → 0.1.4
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/README.md +109 -1
- package/dist/config/providers.js +9 -0
- package/dist/contracts/delegate.js +4 -3
- package/dist/server.js +3 -1
- package/dist/startup.js +8 -44
- package/dist/telemetry/run-event.js +10 -0
- package/dist/tools/check_workers.js +15 -0
- package/dist/tools/delegate.js +189 -16
- package/dist/utils/version.js +18 -0
- package/dist/workers/codex.js +13 -1
- package/dist/workers/diagnostics.js +56 -0
- package/dist/workers/lmstudio.js +84 -0
- package/dist/workers/openrouter.js +88 -0
- package/dist/workers/registry.js +15 -0
- package/dist/workers/runner.js +1 -0
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -138,12 +138,111 @@ Typical response payload:
|
|
|
138
138
|
}
|
|
139
139
|
```
|
|
140
140
|
|
|
141
|
+
## Use Cases
|
|
142
|
+
|
|
143
|
+
### 1. Context-Preserving Orchestration
|
|
144
|
+
Keep Claude's planning session lean by delegating implementation work to Codex.
|
|
145
|
+
Claude holds the architecture; Codex executes the sub-tasks without polluting the
|
|
146
|
+
orchestrator's context window.
|
|
147
|
+
|
|
148
|
+
```javascript
|
|
149
|
+
delegate({
|
|
150
|
+
task: "Implement the auth middleware per the spec in PLAN.md",
|
|
151
|
+
workdir: "/path/to/repo"
|
|
152
|
+
});
|
|
153
|
+
```
|
|
154
|
+
|
|
155
|
+
### 2. Batch Code Generation
|
|
156
|
+
Generate multiple independent artifacts — components, migrations, API routes — each
|
|
157
|
+
in a fresh Codex context. The orchestrator's growing session history never bleeds into
|
|
158
|
+
the worker's input.
|
|
159
|
+
|
|
160
|
+
```javascript
|
|
161
|
+
delegate({
|
|
162
|
+
task: "Generate CRUD routes for users, posts, and comments in src/routes/",
|
|
163
|
+
workdir: "/path/to/repo"
|
|
164
|
+
});
|
|
165
|
+
```
|
|
166
|
+
|
|
167
|
+
### 3. Test Generation from Spec
|
|
168
|
+
After a feature is built, delegate test writing as a standalone, self-contained task.
|
|
169
|
+
|
|
170
|
+
```javascript
|
|
171
|
+
delegate({
|
|
172
|
+
task: "Write Jest unit tests for src/auth/middleware.ts covering success, expired token, and missing token cases",
|
|
173
|
+
workdir: "/path/to/repo"
|
|
174
|
+
});
|
|
175
|
+
```
|
|
176
|
+
|
|
177
|
+
### 4. Scoped Refactoring
|
|
178
|
+
Delegate well-defined refactors without polluting the main session with diff noise.
|
|
179
|
+
|
|
180
|
+
```javascript
|
|
181
|
+
delegate({
|
|
182
|
+
task: "Migrate all fetch() calls in src/api/ to use the axios client",
|
|
183
|
+
workdir: "/path/to/repo"
|
|
184
|
+
});
|
|
185
|
+
```
|
|
186
|
+
|
|
187
|
+
### 5. Documentation Generation
|
|
188
|
+
Auto-generate JSDoc, API docs, or README sections for a module after implementation.
|
|
189
|
+
|
|
190
|
+
```javascript
|
|
191
|
+
delegate({
|
|
192
|
+
task: "Add JSDoc comments to all exported functions in src/utils/",
|
|
193
|
+
workdir: "/path/to/repo"
|
|
194
|
+
});
|
|
195
|
+
```
|
|
196
|
+
|
|
197
|
+
### 6. Plan-Driven Execution (Orchestrator Pattern)
|
|
198
|
+
Use Claude as the planning engine and relay-mcp as the execution bridge.
|
|
199
|
+
Each task in a written plan maps to a `delegate` call; Claude reviews `files_changed`
|
|
200
|
+
before moving to the next task.
|
|
201
|
+
|
|
202
|
+
```
|
|
203
|
+
Plan phase → Claude writes task specs
|
|
204
|
+
Execute → delegate each task to Codex
|
|
205
|
+
Review → Claude reads files_changed, verifies output
|
|
206
|
+
Repeat → next task
|
|
207
|
+
```
|
|
208
|
+
|
|
209
|
+
### When not to use it
|
|
210
|
+
- Tasks that require back-and-forth decisions mid-execution (Codex runs `--full-auto`)
|
|
211
|
+
- Work that needs full conversation context (Codex sees only what you pass in `task`/`context`)
|
|
212
|
+
- Very short one-liners where the delegation roundtrip (Codex startup + execution) exceeds the time to do it inline
|
|
213
|
+
|
|
214
|
+
## Orchestration with tmux + dmux
|
|
215
|
+
|
|
216
|
+
[dmux](https://dmux.ai) (`npm install -g dmux`) arranges a tmux layout where Claude Code
|
|
217
|
+
orchestrates from one pane while Codex sessions run in adjacent panes.
|
|
218
|
+
|
|
219
|
+
```
|
|
220
|
+
tmux window
|
|
221
|
+
├── pane 0 Claude Code — GSD orchestrator, relay-mcp registered
|
|
222
|
+
├── pane 1 tail -f <relay-log> live output from task 1
|
|
223
|
+
├── pane 2 tail -f <relay-log> live output from task 2
|
|
224
|
+
└── ...
|
|
225
|
+
```
|
|
226
|
+
|
|
227
|
+
**The loop:**
|
|
228
|
+
1. CC writes a plan (GSD), then fires `delegate` calls — one per task
|
|
229
|
+
2. relay-mcp spawns Codex for each and returns `files_changed` + output when done
|
|
230
|
+
3. *(Phase 3)* Each task streams output to a log file; adjacent panes `tail -f` those logs live
|
|
231
|
+
4. CC reads `files_changed`, verifies output, moves to the next task
|
|
232
|
+
5. You supervise only CC — Codex runs unattended
|
|
233
|
+
|
|
234
|
+
> **Note:** Live log streaming (`meta.log_file`) lands in Phase 3. Steps 1, 2, 4, and 5 work today.
|
|
235
|
+
|
|
141
236
|
## Configuration
|
|
142
237
|
| Variable | Default | Description |
|
|
143
238
|
| --- | --- | --- |
|
|
144
239
|
| `RELAY_CODEX_PATH` | `codex` from `PATH` | Full path to Codex binary for PATH-limited environments (especially Claude Desktop). |
|
|
145
240
|
| `RELAY_LOG_LEVEL` | `info` | Startup log verbosity control. `error` suppresses the ready banner; other values show it. |
|
|
146
241
|
|
|
242
|
+
### Provider model requirement
|
|
243
|
+
- `provider: "codex"`: `model` is optional.
|
|
244
|
+
- `provider: "openrouter"` or `provider: "lmstudio"`: `model` is required.
|
|
245
|
+
|
|
147
246
|
## Troubleshooting
|
|
148
247
|
### 1) PATH issues (Codex not found)
|
|
149
248
|
Symptom:
|
|
@@ -179,9 +278,18 @@ Fix:
|
|
|
179
278
|
- Confirm network/DNS connectivity and any proxy requirements.
|
|
180
279
|
- Re-run `codex login` if your session may have expired.
|
|
181
280
|
|
|
281
|
+
### 4) `npx` from this package's own repo root
|
|
282
|
+
Symptom:
|
|
283
|
+
`sh: relay-mcp: command not found` when running `npx -y @sghanavati/relay-mcp` from inside the `relay-mcp` source checkout.
|
|
284
|
+
|
|
285
|
+
Fix:
|
|
286
|
+
|
|
287
|
+
- For local development in this repo, run `node dist/index.js` instead.
|
|
288
|
+
- For published-package verification, run from any other directory (or use `npm --prefix /tmp exec --yes --package=@sghanavati/relay-mcp -- relay-mcp --version`).
|
|
289
|
+
|
|
182
290
|
## Security
|
|
183
291
|
- `relay-mcp` only delegates work inside the `workdir` you pass to `delegate`.
|
|
184
|
-
- `workdir` values are canonicalized with `path.resolve()` before subprocess execution
|
|
292
|
+
- `workdir` values are canonicalized with `path.resolve()` before subprocess execution.
|
|
185
293
|
- Codex runs with `--full-auto` and can read/write files in that delegated directory.
|
|
186
294
|
- `relay-mcp` does not store credentials, API keys, or session tokens; authentication remains in Codex CLI.
|
|
187
295
|
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
export function getOpenRouterApiKey() {
|
|
2
|
+
return process.env["OPENROUTER_API_KEY"]?.trim() || null;
|
|
3
|
+
}
|
|
4
|
+
export function getLmStudioEndpoint() {
|
|
5
|
+
return process.env["LMSTUDIO_ENDPOINT"]?.trim() || "http://localhost:1234";
|
|
6
|
+
}
|
|
7
|
+
export function getLmStudioApiKey() {
|
|
8
|
+
return process.env["LMSTUDIO_API_KEY"]?.trim() || null;
|
|
9
|
+
}
|
|
@@ -3,10 +3,11 @@ const delegateSchemaShape = {
|
|
|
3
3
|
task: z.string().min(1).describe("The coding task to delegate to the worker"),
|
|
4
4
|
workdir: z.string().describe("Absolute path to the working directory"),
|
|
5
5
|
provider: z
|
|
6
|
-
.enum(["codex"])
|
|
6
|
+
.enum(["codex", "openrouter", "lmstudio"])
|
|
7
7
|
.optional()
|
|
8
8
|
.default("codex")
|
|
9
|
-
.describe("Worker provider (
|
|
9
|
+
.describe("Worker provider. 'codex' = agentic execution (modifies files). " +
|
|
10
|
+
"'openrouter' and 'lmstudio' = generation-only (text response, no file changes)."),
|
|
10
11
|
context: z
|
|
11
12
|
.string()
|
|
12
13
|
.optional()
|
|
@@ -20,7 +21,7 @@ const delegateSchemaShape = {
|
|
|
20
21
|
model: z
|
|
21
22
|
.string()
|
|
22
23
|
.optional()
|
|
23
|
-
.describe("Model override for
|
|
24
|
+
.describe("Model override. Optional for 'codex'. Required for 'openrouter' and 'lmstudio' (pass an explicit provider model ID)."),
|
|
24
25
|
};
|
|
25
26
|
const delegateArgsSchema = z.object(delegateSchemaShape);
|
|
26
27
|
export const delegateSchema = delegateArgsSchema.shape;
|
package/dist/server.js
CHANGED
|
@@ -3,13 +3,15 @@ import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js"
|
|
|
3
3
|
import { delegateSchema } from "./contracts/delegate.js";
|
|
4
4
|
import { validateStartup } from "./startup.js";
|
|
5
5
|
import { handleDelegate } from "./tools/delegate.js";
|
|
6
|
+
import { handleCheckWorkers } from "./tools/check_workers.js";
|
|
6
7
|
export async function startServer() {
|
|
7
|
-
await validateStartup();
|
|
8
8
|
const server = new McpServer({
|
|
9
9
|
name: "relay-mcp",
|
|
10
10
|
version: "1.0.0",
|
|
11
11
|
});
|
|
12
12
|
server.tool("delegate", delegateSchema, async (args) => handleDelegate(args));
|
|
13
|
+
server.tool("check_workers", {}, async () => handleCheckWorkers());
|
|
13
14
|
const transport = new StdioServerTransport();
|
|
14
15
|
await server.connect(transport);
|
|
16
|
+
await validateStartup();
|
|
15
17
|
}
|
package/dist/startup.js
CHANGED
|
@@ -1,52 +1,16 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import {
|
|
3
|
-
import { MIN_CODEX_VERSION } from "./config/constants.js";
|
|
4
|
-
import { getCodexBin, getRelayLogLevel } from "./config/runtime.js";
|
|
5
|
-
const execFileAsync = promisify(execFile);
|
|
1
|
+
import { getRelayLogLevel } from "./config/runtime.js";
|
|
2
|
+
import { diagnoseCodexWorker } from "./workers/diagnostics.js";
|
|
6
3
|
function shouldPrintReadyBanner() {
|
|
7
4
|
return getRelayLogLevel() !== "error";
|
|
8
5
|
}
|
|
9
|
-
function isVersionBelow(version, min) {
|
|
10
|
-
const match = version.match(/(\d+)\.(\d+)\.(\d+)/);
|
|
11
|
-
if (!match)
|
|
12
|
-
return true;
|
|
13
|
-
const [, maj, min_, pat] = match.map(Number);
|
|
14
|
-
if (maj !== min[0])
|
|
15
|
-
return maj < min[0];
|
|
16
|
-
if (min_ !== min[1])
|
|
17
|
-
return min_ < min[1];
|
|
18
|
-
return pat < min[2];
|
|
19
|
-
}
|
|
20
6
|
export async function validateStartup() {
|
|
21
|
-
const
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
const { stdout } = await execFileAsync(codexBin, ["--version"]);
|
|
27
|
-
codexVersion = stdout.trim();
|
|
28
|
-
}
|
|
29
|
-
catch {
|
|
30
|
-
const binaryHint = configuredPath
|
|
31
|
-
? `RELAY_CODEX_PATH=${configuredPath}`
|
|
32
|
-
: "PATH (default `codex` binary)";
|
|
33
|
-
failures.push(`Error: Codex binary not found via ${binaryHint}.
|
|
34
|
-
Fix: run \`npm install -g @openai/codex\` or set \`RELAY_CODEX_PATH=/full/path/to/codex\` in your MCP config, then restart relay-mcp.`);
|
|
35
|
-
}
|
|
36
|
-
if (codexVersion && isVersionBelow(codexVersion, MIN_CODEX_VERSION)) {
|
|
37
|
-
failures.push(`Error: Codex version ${codexVersion} is below minimum 0.39.0.\nFix: run \`npm install -g @openai/codex@latest\` then restart relay-mcp.`);
|
|
38
|
-
}
|
|
39
|
-
try {
|
|
40
|
-
await execFileAsync(codexBin, ["login", "status"]);
|
|
41
|
-
}
|
|
42
|
-
catch {
|
|
43
|
-
failures.push("Error: Codex not authenticated.\nFix: run `codex login` then restart relay-mcp.");
|
|
44
|
-
}
|
|
45
|
-
if (failures.length > 0) {
|
|
46
|
-
process.stderr.write(failures.join("\n\n") + "\n");
|
|
47
|
-
process.exit(1);
|
|
7
|
+
const codexDiagnostic = await diagnoseCodexWorker();
|
|
8
|
+
if (!codexDiagnostic.available) {
|
|
9
|
+
process.stderr.write(`Error: ${codexDiagnostic.reason}\nFix: ${codexDiagnostic.remediation}\n` +
|
|
10
|
+
"Tip: run the `check_workers` MCP tool for a structured readiness report.\n");
|
|
11
|
+
return;
|
|
48
12
|
}
|
|
49
13
|
if (shouldPrintReadyBanner()) {
|
|
50
|
-
process.stderr.write(`relay-mcp ready\n codex: ${
|
|
14
|
+
process.stderr.write(`relay-mcp ready\n codex: ${codexDiagnostic.version} ✓\n auth: authenticated ✓\n listening on stdio...\n`);
|
|
51
15
|
}
|
|
52
16
|
}
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import { appendFileSync } from "node:fs";
|
|
2
|
+
export const RELAY_EVENTS_LOG = "/tmp/relay-mcp-events.jsonl";
|
|
3
|
+
export function writeRunEvent(event, logPath = RELAY_EVENTS_LOG) {
|
|
4
|
+
try {
|
|
5
|
+
appendFileSync(logPath, JSON.stringify(event) + "\n");
|
|
6
|
+
}
|
|
7
|
+
catch {
|
|
8
|
+
// Best-effort — never crash the server over a log write.
|
|
9
|
+
}
|
|
10
|
+
}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import { diagnoseCodexWorker } from "../workers/diagnostics.js";
|
|
2
|
+
export async function handleCheckWorkers() {
|
|
3
|
+
const codexDiagRaw = await diagnoseCodexWorker();
|
|
4
|
+
const codexDiag = {
|
|
5
|
+
worker: codexDiagRaw.worker,
|
|
6
|
+
available: codexDiagRaw.available,
|
|
7
|
+
reason: codexDiagRaw.reason,
|
|
8
|
+
remediation: codexDiagRaw.remediation,
|
|
9
|
+
};
|
|
10
|
+
const result = {
|
|
11
|
+
workers: [codexDiag],
|
|
12
|
+
all_available: codexDiag.available,
|
|
13
|
+
};
|
|
14
|
+
return { content: [{ type: "text", text: JSON.stringify(result, null, 2) }] };
|
|
15
|
+
}
|
package/dist/tools/delegate.js
CHANGED
|
@@ -1,13 +1,42 @@
|
|
|
1
1
|
import * as path from "node:path";
|
|
2
|
+
import { createHash, randomUUID } from "node:crypto";
|
|
3
|
+
import { createWriteStream, readFileSync } from "node:fs";
|
|
4
|
+
import { join } from "node:path";
|
|
2
5
|
import { DEFAULT_TIMEOUT_MS, TOKEN_HARD_CAP, TOKEN_WARN_THRESHOLD } from "../config/constants.js";
|
|
3
6
|
import { makeError } from "../errors.js";
|
|
4
7
|
import { getWorkdirMutex } from "../concurrency/index.js";
|
|
5
8
|
import { diffSnapshots, takeSnapshot } from "../git/snapshot.js";
|
|
6
|
-
import {
|
|
9
|
+
import { getRunner } from "../workers/registry.js";
|
|
10
|
+
import { writeRunEvent } from "../telemetry/run-event.js";
|
|
7
11
|
/** Canonical workdir normalization - used by mutex key, snapshot, and worker. */
|
|
8
12
|
function normalizeWorkdir(rawWorkdir) {
|
|
9
13
|
return path.resolve(rawWorkdir);
|
|
10
14
|
}
|
|
15
|
+
function computeLogPath(workdir) {
|
|
16
|
+
const hash = createHash("sha1").update(workdir).digest("hex").slice(0, 8);
|
|
17
|
+
return `/tmp/relay-mcp-${hash}.log`;
|
|
18
|
+
}
|
|
19
|
+
/**
|
|
20
|
+
* Resolve AGENTS.md from workdir upward to filesystem root.
|
|
21
|
+
* Nearest non-empty file wins.
|
|
22
|
+
*/
|
|
23
|
+
export function readNearestAgentsMd(workdir) {
|
|
24
|
+
let current = path.resolve(workdir);
|
|
25
|
+
while (true) {
|
|
26
|
+
try {
|
|
27
|
+
const content = readFileSync(join(current, "AGENTS.md"), "utf8").trim();
|
|
28
|
+
if (content.length > 0)
|
|
29
|
+
return content;
|
|
30
|
+
}
|
|
31
|
+
catch {
|
|
32
|
+
// Continue traversing to parent.
|
|
33
|
+
}
|
|
34
|
+
const parent = path.dirname(current);
|
|
35
|
+
if (parent === current)
|
|
36
|
+
return null;
|
|
37
|
+
current = parent;
|
|
38
|
+
}
|
|
39
|
+
}
|
|
11
40
|
function buildDelegateMeta(params) {
|
|
12
41
|
return {
|
|
13
42
|
duration_ms: params.duration_ms,
|
|
@@ -16,6 +45,11 @@ function buildDelegateMeta(params) {
|
|
|
16
45
|
model: params.model ?? null,
|
|
17
46
|
token_estimate: params.token_estimate,
|
|
18
47
|
exit_code: params.exit_code,
|
|
48
|
+
log_file: params.log_file,
|
|
49
|
+
run_id: params.run_id,
|
|
50
|
+
provider: params.provider,
|
|
51
|
+
spawn_time_ms: params.spawn_time_ms,
|
|
52
|
+
token_usage: params.token_usage,
|
|
19
53
|
};
|
|
20
54
|
}
|
|
21
55
|
function toMcpResult(response) {
|
|
@@ -43,52 +77,186 @@ export function applyTruncation(output, warnings) {
|
|
|
43
77
|
tokenEstimate,
|
|
44
78
|
};
|
|
45
79
|
}
|
|
80
|
+
function normalizeThrownError(err) {
|
|
81
|
+
if (err instanceof Error && err.message.startsWith("PROVIDER_NOT_CONFIGURED:")) {
|
|
82
|
+
return makeError("PROVIDER_NOT_CONFIGURED", err.message, false);
|
|
83
|
+
}
|
|
84
|
+
return makeError("UNKNOWN", `Unexpected worker error: ${String(err)}`, false);
|
|
85
|
+
}
|
|
46
86
|
export async function handleDelegate(args) {
|
|
47
|
-
const { task, workdir: rawWorkdir, context, timeout_ms, model } = args;
|
|
87
|
+
const { task, workdir: rawWorkdir, context, timeout_ms, model, provider = "codex" } = args;
|
|
88
|
+
const modelOverride = model?.trim() ? model.trim() : undefined;
|
|
89
|
+
const run_id = randomUUID();
|
|
90
|
+
const queued_at = Date.now();
|
|
48
91
|
const workdir = normalizeWorkdir(rawWorkdir);
|
|
49
|
-
const
|
|
50
|
-
const
|
|
51
|
-
|
|
92
|
+
const agentsContext = readNearestAgentsMd(workdir);
|
|
93
|
+
const parts = [];
|
|
94
|
+
if (agentsContext)
|
|
95
|
+
parts.push(agentsContext);
|
|
96
|
+
if (context)
|
|
97
|
+
parts.push(context);
|
|
98
|
+
parts.push(task);
|
|
99
|
+
const finalTask = parts.join("\n\n");
|
|
100
|
+
const knownProviders = new Set(["codex", "openrouter", "lmstudio"]);
|
|
101
|
+
const isKnownProvider = knownProviders.has(provider);
|
|
102
|
+
const isAgenticProvider = provider === "codex";
|
|
103
|
+
const logPath = computeLogPath(workdir);
|
|
104
|
+
if (!isKnownProvider) {
|
|
105
|
+
const response = {
|
|
106
|
+
status: "error",
|
|
107
|
+
output: "",
|
|
108
|
+
files_changed: [],
|
|
109
|
+
meta: buildDelegateMeta({
|
|
110
|
+
duration_ms: 0,
|
|
111
|
+
truncated: false,
|
|
112
|
+
warnings: [],
|
|
113
|
+
model: modelOverride,
|
|
114
|
+
token_estimate: 0,
|
|
115
|
+
exit_code: null,
|
|
116
|
+
log_file: logPath,
|
|
117
|
+
run_id,
|
|
118
|
+
provider,
|
|
119
|
+
spawn_time_ms: 0,
|
|
120
|
+
token_usage: null,
|
|
121
|
+
}),
|
|
122
|
+
error: makeError("PROVIDER_NOT_CONFIGURED", `PROVIDER_NOT_CONFIGURED: unknown provider "${provider}"`, false),
|
|
123
|
+
};
|
|
124
|
+
return toMcpResult(response);
|
|
125
|
+
}
|
|
126
|
+
if (!isAgenticProvider && !modelOverride) {
|
|
127
|
+
const response = {
|
|
128
|
+
status: "error",
|
|
129
|
+
output: "",
|
|
130
|
+
files_changed: [],
|
|
131
|
+
meta: buildDelegateMeta({
|
|
132
|
+
duration_ms: 0,
|
|
133
|
+
truncated: false,
|
|
134
|
+
warnings: [],
|
|
135
|
+
model: modelOverride,
|
|
136
|
+
token_estimate: 0,
|
|
137
|
+
exit_code: null,
|
|
138
|
+
log_file: logPath,
|
|
139
|
+
run_id,
|
|
140
|
+
provider,
|
|
141
|
+
spawn_time_ms: 0,
|
|
142
|
+
token_usage: null,
|
|
143
|
+
}),
|
|
144
|
+
error: makeError("INVALID_ARGS", `model is required when provider is "${provider}". Pass an explicit provider model ID.`, false),
|
|
145
|
+
};
|
|
146
|
+
return toMcpResult(response);
|
|
147
|
+
}
|
|
148
|
+
writeRunEvent({ run_id, provider, model: modelOverride ?? null, workdir, status: "queued", queued_at });
|
|
149
|
+
let release = null;
|
|
150
|
+
let preSnapshot = null;
|
|
151
|
+
if (isAgenticProvider) {
|
|
152
|
+
release = await getWorkdirMutex(workdir).acquire();
|
|
153
|
+
preSnapshot = await takeSnapshot(workdir);
|
|
154
|
+
}
|
|
155
|
+
const logStream = createWriteStream(logPath, { flags: "a" });
|
|
156
|
+
logStream.on("error", () => {
|
|
157
|
+
/* swallow write errors - log is best-effort */
|
|
158
|
+
});
|
|
52
159
|
let workerResult;
|
|
160
|
+
let postSnapshot = null;
|
|
161
|
+
const started_at = Date.now();
|
|
162
|
+
const spawn_time_ms = started_at - queued_at;
|
|
163
|
+
writeRunEvent({
|
|
164
|
+
run_id,
|
|
165
|
+
provider,
|
|
166
|
+
model: modelOverride ?? null,
|
|
167
|
+
workdir,
|
|
168
|
+
status: "running",
|
|
169
|
+
queued_at,
|
|
170
|
+
started_at,
|
|
171
|
+
spawn_time_ms,
|
|
172
|
+
});
|
|
53
173
|
try {
|
|
174
|
+
const runner = getRunner(provider);
|
|
54
175
|
const workerTask = {
|
|
55
176
|
task: finalTask,
|
|
56
177
|
workdir,
|
|
57
178
|
timeout_ms: timeout_ms ?? DEFAULT_TIMEOUT_MS,
|
|
58
|
-
model,
|
|
179
|
+
model: modelOverride,
|
|
180
|
+
logStream,
|
|
181
|
+
onStderr: (text) => {
|
|
182
|
+
process.stderr.write(text);
|
|
183
|
+
logStream.write(text);
|
|
184
|
+
},
|
|
185
|
+
run_id,
|
|
186
|
+
provider,
|
|
59
187
|
};
|
|
60
|
-
workerResult = await
|
|
188
|
+
workerResult = await runner.run(workerTask);
|
|
189
|
+
if (isAgenticProvider) {
|
|
190
|
+
postSnapshot = await takeSnapshot(workdir);
|
|
191
|
+
}
|
|
61
192
|
}
|
|
62
193
|
catch (err) {
|
|
194
|
+
logStream.end();
|
|
195
|
+
const finished_at = Date.now();
|
|
196
|
+
const normalizedError = normalizeThrownError(err);
|
|
197
|
+
writeRunEvent({
|
|
198
|
+
run_id,
|
|
199
|
+
provider,
|
|
200
|
+
model: modelOverride ?? null,
|
|
201
|
+
workdir,
|
|
202
|
+
status: "error",
|
|
203
|
+
queued_at,
|
|
204
|
+
started_at,
|
|
205
|
+
finished_at,
|
|
206
|
+
duration_ms: finished_at - started_at,
|
|
207
|
+
spawn_time_ms,
|
|
208
|
+
error_code: normalizedError.code,
|
|
209
|
+
});
|
|
63
210
|
const response = {
|
|
64
211
|
status: "error",
|
|
65
212
|
output: "",
|
|
66
213
|
files_changed: [],
|
|
67
214
|
meta: buildDelegateMeta({
|
|
68
|
-
duration_ms:
|
|
215
|
+
duration_ms: finished_at - started_at,
|
|
69
216
|
truncated: false,
|
|
70
217
|
warnings: [],
|
|
71
|
-
model,
|
|
218
|
+
model: modelOverride,
|
|
72
219
|
token_estimate: 0,
|
|
73
220
|
exit_code: null,
|
|
221
|
+
log_file: logPath,
|
|
222
|
+
run_id,
|
|
223
|
+
provider,
|
|
224
|
+
spawn_time_ms,
|
|
225
|
+
token_usage: null,
|
|
74
226
|
}),
|
|
75
|
-
error:
|
|
227
|
+
error: normalizedError,
|
|
76
228
|
};
|
|
77
229
|
return toMcpResult(response);
|
|
78
230
|
}
|
|
79
231
|
finally {
|
|
80
|
-
release();
|
|
232
|
+
release?.();
|
|
81
233
|
}
|
|
82
|
-
|
|
83
|
-
const
|
|
234
|
+
logStream.end();
|
|
235
|
+
const finished_at = Date.now();
|
|
236
|
+
const duration_ms = finished_at - started_at;
|
|
237
|
+
const filesChanged = isAgenticProvider ? diffSnapshots(preSnapshot, postSnapshot) : [];
|
|
84
238
|
const warnings = [];
|
|
85
|
-
if (preSnapshot === null) {
|
|
239
|
+
if (isAgenticProvider && preSnapshot === null) {
|
|
86
240
|
warnings.push("workdir is not a git repository - files_changed tracking unavailable");
|
|
87
241
|
}
|
|
88
|
-
else if (filesChanged.length === 0 && workerResult.status === "success") {
|
|
242
|
+
else if (isAgenticProvider && filesChanged.length === 0 && workerResult.status === "success") {
|
|
89
243
|
warnings.push("No files were modified by Codex");
|
|
90
244
|
}
|
|
91
245
|
const { output, truncated, tokenEstimate } = applyTruncation(workerResult.output, warnings);
|
|
246
|
+
const eventStatus = workerResult.status === "success"
|
|
247
|
+
? "success"
|
|
248
|
+
: workerResult.status === "timeout"
|
|
249
|
+
? "timeout"
|
|
250
|
+
: "error";
|
|
251
|
+
writeRunEvent({
|
|
252
|
+
run_id, provider, model: modelOverride ?? null, workdir, status: eventStatus,
|
|
253
|
+
queued_at, started_at, finished_at, duration_ms,
|
|
254
|
+
spawn_time_ms,
|
|
255
|
+
output_size_chars: output.length,
|
|
256
|
+
exit_code: workerResult.exit_code,
|
|
257
|
+
token_usage: workerResult.token_usage ?? null,
|
|
258
|
+
...(workerResult.error ? { error_code: workerResult.error.code } : {}),
|
|
259
|
+
});
|
|
92
260
|
const response = {
|
|
93
261
|
status: workerResult.status,
|
|
94
262
|
output,
|
|
@@ -97,9 +265,14 @@ export async function handleDelegate(args) {
|
|
|
97
265
|
duration_ms: workerResult.duration_ms,
|
|
98
266
|
truncated,
|
|
99
267
|
warnings,
|
|
100
|
-
model,
|
|
268
|
+
model: modelOverride,
|
|
101
269
|
token_estimate: tokenEstimate,
|
|
102
270
|
exit_code: workerResult.exit_code,
|
|
271
|
+
log_file: logPath,
|
|
272
|
+
run_id,
|
|
273
|
+
provider,
|
|
274
|
+
spawn_time_ms,
|
|
275
|
+
token_usage: workerResult.token_usage ?? null,
|
|
103
276
|
}),
|
|
104
277
|
...(workerResult.error ? { error: workerResult.error } : {}),
|
|
105
278
|
};
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Returns true when `version` is lower than `min`.
|
|
3
|
+
* Accepted version format: MAJOR.MINOR.PATCH (optionally prefixed/suffixed).
|
|
4
|
+
*/
|
|
5
|
+
export function isVersionBelow(version, min) {
|
|
6
|
+
const match = version.match(/(\d+)\.(\d+)\.(\d+)/);
|
|
7
|
+
if (!match)
|
|
8
|
+
return true;
|
|
9
|
+
const [, maj, min_, pat] = match.map(Number);
|
|
10
|
+
if (maj !== min[0])
|
|
11
|
+
return maj < min[0];
|
|
12
|
+
if (min_ !== min[1])
|
|
13
|
+
return min_ < min[1];
|
|
14
|
+
return pat < min[2];
|
|
15
|
+
}
|
|
16
|
+
export function formatVersionTriplet(version) {
|
|
17
|
+
return version.join(".");
|
|
18
|
+
}
|
package/dist/workers/codex.js
CHANGED
|
@@ -47,11 +47,17 @@ export async function runCodexWorker(task) {
|
|
|
47
47
|
const message = parseCodexLine(line);
|
|
48
48
|
if (message) {
|
|
49
49
|
agentMessages.push(message);
|
|
50
|
+
task.logStream?.write(message + "\n");
|
|
50
51
|
}
|
|
51
52
|
}
|
|
52
53
|
});
|
|
53
54
|
child.stderr?.on("data", (chunk) => {
|
|
54
|
-
|
|
55
|
+
const text = chunk.toString("utf8");
|
|
56
|
+
if (task.onStderr) {
|
|
57
|
+
task.onStderr(text);
|
|
58
|
+
return;
|
|
59
|
+
}
|
|
60
|
+
process.stderr.write(text);
|
|
55
61
|
});
|
|
56
62
|
let timedOut = false;
|
|
57
63
|
const timeoutHandle = setTimeout(() => {
|
|
@@ -76,6 +82,7 @@ export async function runCodexWorker(task) {
|
|
|
76
82
|
const message = parseCodexLine(stdoutBuf);
|
|
77
83
|
if (message) {
|
|
78
84
|
agentMessages.push(message);
|
|
85
|
+
task.logStream?.write(message + "\n");
|
|
79
86
|
}
|
|
80
87
|
}
|
|
81
88
|
const duration_ms = Date.now() - startTime;
|
|
@@ -123,3 +130,8 @@ export async function runCodexWorker(task) {
|
|
|
123
130
|
});
|
|
124
131
|
});
|
|
125
132
|
}
|
|
133
|
+
export class CodexRunner {
|
|
134
|
+
run(task) {
|
|
135
|
+
return runCodexWorker(task);
|
|
136
|
+
}
|
|
137
|
+
}
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
import { execFile } from "node:child_process";
|
|
2
|
+
import { promisify } from "node:util";
|
|
3
|
+
import { MIN_CODEX_VERSION } from "../config/constants.js";
|
|
4
|
+
import { getCodexBin } from "../config/runtime.js";
|
|
5
|
+
import { formatVersionTriplet, isVersionBelow } from "../utils/version.js";
|
|
6
|
+
const execFileAsync = promisify(execFile);
|
|
7
|
+
export async function diagnoseCodexWorker() {
|
|
8
|
+
const codexBin = getCodexBin();
|
|
9
|
+
const configuredPath = process.env.RELAY_CODEX_PATH?.trim();
|
|
10
|
+
const binaryHint = configuredPath
|
|
11
|
+
? `RELAY_CODEX_PATH=${configuredPath}`
|
|
12
|
+
: "PATH (default `codex` binary)";
|
|
13
|
+
const minVersion = formatVersionTriplet(MIN_CODEX_VERSION);
|
|
14
|
+
let version = "";
|
|
15
|
+
try {
|
|
16
|
+
const { stdout } = await execFileAsync(codexBin, ["--version"]);
|
|
17
|
+
version = stdout.trim();
|
|
18
|
+
}
|
|
19
|
+
catch {
|
|
20
|
+
return {
|
|
21
|
+
worker: "codex",
|
|
22
|
+
available: false,
|
|
23
|
+
version: null,
|
|
24
|
+
reason: `Codex binary not found via ${binaryHint}`,
|
|
25
|
+
remediation: "run: npm install -g @openai/codex OR set RELAY_CODEX_PATH=/full/path/to/codex in your MCP config",
|
|
26
|
+
};
|
|
27
|
+
}
|
|
28
|
+
if (isVersionBelow(version, MIN_CODEX_VERSION)) {
|
|
29
|
+
return {
|
|
30
|
+
worker: "codex",
|
|
31
|
+
available: false,
|
|
32
|
+
version,
|
|
33
|
+
reason: `Codex ${version} is below minimum ${minVersion}`,
|
|
34
|
+
remediation: "run: npm install -g @openai/codex@latest then restart relay-mcp",
|
|
35
|
+
};
|
|
36
|
+
}
|
|
37
|
+
try {
|
|
38
|
+
await execFileAsync(codexBin, ["login", "status"]);
|
|
39
|
+
}
|
|
40
|
+
catch {
|
|
41
|
+
return {
|
|
42
|
+
worker: "codex",
|
|
43
|
+
available: false,
|
|
44
|
+
version,
|
|
45
|
+
reason: `Codex ${version} found but not authenticated`,
|
|
46
|
+
remediation: "run: codex login then restart relay-mcp",
|
|
47
|
+
};
|
|
48
|
+
}
|
|
49
|
+
return {
|
|
50
|
+
worker: "codex",
|
|
51
|
+
available: true,
|
|
52
|
+
version,
|
|
53
|
+
reason: `${version} authenticated`,
|
|
54
|
+
remediation: "",
|
|
55
|
+
};
|
|
56
|
+
}
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
import { makeError } from "../errors.js";
|
|
2
|
+
import { getLmStudioEndpoint, getLmStudioApiKey } from "../config/providers.js";
|
|
3
|
+
export function parseLmStudioResponse(body) {
|
|
4
|
+
const choices = body["choices"] ?? [];
|
|
5
|
+
const output = choices[0]?.message?.content ?? "";
|
|
6
|
+
const usage = body["usage"];
|
|
7
|
+
return { output, token_usage: usage?.["completion_tokens"] ?? null };
|
|
8
|
+
}
|
|
9
|
+
export class LmStudioRunner {
|
|
10
|
+
async run(task) {
|
|
11
|
+
const model = task.model?.trim();
|
|
12
|
+
if (!model) {
|
|
13
|
+
return {
|
|
14
|
+
status: "error",
|
|
15
|
+
output: "",
|
|
16
|
+
duration_ms: 0,
|
|
17
|
+
exit_code: null,
|
|
18
|
+
error: makeError("INVALID_ARGS", "model is required when provider is \"lmstudio\".", false),
|
|
19
|
+
};
|
|
20
|
+
}
|
|
21
|
+
const endpoint = getLmStudioEndpoint();
|
|
22
|
+
const apiKey = getLmStudioApiKey();
|
|
23
|
+
const startTime = Date.now();
|
|
24
|
+
const controller = new AbortController();
|
|
25
|
+
const timeout = setTimeout(() => controller.abort(), task.timeout_ms);
|
|
26
|
+
const headers = {
|
|
27
|
+
"Content-Type": "application/json",
|
|
28
|
+
};
|
|
29
|
+
if (apiKey) {
|
|
30
|
+
headers["Authorization"] = `Bearer ${apiKey}`;
|
|
31
|
+
}
|
|
32
|
+
const requestBody = {
|
|
33
|
+
model,
|
|
34
|
+
messages: [{ role: "user", content: task.task }],
|
|
35
|
+
};
|
|
36
|
+
let res;
|
|
37
|
+
try {
|
|
38
|
+
res = await fetch(`${endpoint}/v1/chat/completions`, {
|
|
39
|
+
method: "POST",
|
|
40
|
+
headers,
|
|
41
|
+
body: JSON.stringify(requestBody),
|
|
42
|
+
signal: controller.signal,
|
|
43
|
+
});
|
|
44
|
+
}
|
|
45
|
+
catch (err) {
|
|
46
|
+
clearTimeout(timeout);
|
|
47
|
+
const duration_ms = Date.now() - startTime;
|
|
48
|
+
if (err.name === "AbortError") {
|
|
49
|
+
return {
|
|
50
|
+
status: "timeout",
|
|
51
|
+
output: "",
|
|
52
|
+
duration_ms,
|
|
53
|
+
exit_code: null,
|
|
54
|
+
error: makeError("TIMEOUT", `LM Studio timed out after ${task.timeout_ms}ms`, true),
|
|
55
|
+
};
|
|
56
|
+
}
|
|
57
|
+
return {
|
|
58
|
+
status: "error",
|
|
59
|
+
output: "",
|
|
60
|
+
duration_ms,
|
|
61
|
+
exit_code: null,
|
|
62
|
+
error: makeError("PROVIDER_ERROR", `LM Studio fetch failed: ${String(err)}. Is it running at ${endpoint}?`, true),
|
|
63
|
+
};
|
|
64
|
+
}
|
|
65
|
+
finally {
|
|
66
|
+
clearTimeout(timeout);
|
|
67
|
+
}
|
|
68
|
+
const duration_ms = Date.now() - startTime;
|
|
69
|
+
if (!res.ok) {
|
|
70
|
+
const body = await res.text();
|
|
71
|
+
return {
|
|
72
|
+
status: "error",
|
|
73
|
+
output: "",
|
|
74
|
+
duration_ms,
|
|
75
|
+
exit_code: res.status,
|
|
76
|
+
error: makeError("PROVIDER_ERROR", `LM Studio returned ${res.status}: ${body}`, res.status >= 500),
|
|
77
|
+
};
|
|
78
|
+
}
|
|
79
|
+
const body = (await res.json());
|
|
80
|
+
const { output, token_usage } = parseLmStudioResponse(body);
|
|
81
|
+
task.logStream?.write(output + "\n");
|
|
82
|
+
return { status: "success", output, duration_ms, exit_code: 0, token_usage };
|
|
83
|
+
}
|
|
84
|
+
}
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
import { makeError } from "../errors.js";
|
|
2
|
+
import { getOpenRouterApiKey } from "../config/providers.js";
|
|
3
|
+
export function parseOpenRouterResponse(body) {
|
|
4
|
+
const choices = body["choices"] ?? [];
|
|
5
|
+
const output = choices[0]?.message?.content ?? "";
|
|
6
|
+
const usage = body["usage"];
|
|
7
|
+
return { output, token_usage: usage?.["completion_tokens"] ?? null };
|
|
8
|
+
}
|
|
9
|
+
export class OpenRouterRunner {
|
|
10
|
+
async run(task) {
|
|
11
|
+
const model = task.model?.trim();
|
|
12
|
+
if (!model) {
|
|
13
|
+
return {
|
|
14
|
+
status: "error",
|
|
15
|
+
output: "",
|
|
16
|
+
duration_ms: 0,
|
|
17
|
+
exit_code: null,
|
|
18
|
+
error: makeError("INVALID_ARGS", "model is required when provider is \"openrouter\".", false),
|
|
19
|
+
};
|
|
20
|
+
}
|
|
21
|
+
const apiKey = getOpenRouterApiKey();
|
|
22
|
+
if (!apiKey) {
|
|
23
|
+
return {
|
|
24
|
+
status: "error",
|
|
25
|
+
output: "",
|
|
26
|
+
duration_ms: 0,
|
|
27
|
+
exit_code: null,
|
|
28
|
+
error: makeError("PROVIDER_NOT_CONFIGURED", "OPENROUTER_API_KEY is not set. Add it to your MCP config env.", false),
|
|
29
|
+
};
|
|
30
|
+
}
|
|
31
|
+
const startTime = Date.now();
|
|
32
|
+
const controller = new AbortController();
|
|
33
|
+
const timeout = setTimeout(() => controller.abort(), task.timeout_ms);
|
|
34
|
+
let res;
|
|
35
|
+
try {
|
|
36
|
+
res = await fetch("https://openrouter.ai/api/v1/chat/completions", {
|
|
37
|
+
method: "POST",
|
|
38
|
+
headers: {
|
|
39
|
+
Authorization: `Bearer ${apiKey}`,
|
|
40
|
+
"Content-Type": "application/json",
|
|
41
|
+
},
|
|
42
|
+
body: JSON.stringify({
|
|
43
|
+
model,
|
|
44
|
+
messages: [{ role: "user", content: task.task }],
|
|
45
|
+
}),
|
|
46
|
+
signal: controller.signal,
|
|
47
|
+
});
|
|
48
|
+
}
|
|
49
|
+
catch (err) {
|
|
50
|
+
clearTimeout(timeout);
|
|
51
|
+
const duration_ms = Date.now() - startTime;
|
|
52
|
+
if (err.name === "AbortError") {
|
|
53
|
+
return {
|
|
54
|
+
status: "timeout",
|
|
55
|
+
output: "",
|
|
56
|
+
duration_ms,
|
|
57
|
+
exit_code: null,
|
|
58
|
+
error: makeError("TIMEOUT", `OpenRouter timed out after ${task.timeout_ms}ms`, true),
|
|
59
|
+
};
|
|
60
|
+
}
|
|
61
|
+
return {
|
|
62
|
+
status: "error",
|
|
63
|
+
output: "",
|
|
64
|
+
duration_ms,
|
|
65
|
+
exit_code: null,
|
|
66
|
+
error: makeError("PROVIDER_ERROR", `OpenRouter fetch failed: ${String(err)}`, true),
|
|
67
|
+
};
|
|
68
|
+
}
|
|
69
|
+
finally {
|
|
70
|
+
clearTimeout(timeout);
|
|
71
|
+
}
|
|
72
|
+
const duration_ms = Date.now() - startTime;
|
|
73
|
+
if (!res.ok) {
|
|
74
|
+
const body = await res.text();
|
|
75
|
+
return {
|
|
76
|
+
status: "error",
|
|
77
|
+
output: "",
|
|
78
|
+
duration_ms,
|
|
79
|
+
exit_code: res.status,
|
|
80
|
+
error: makeError("PROVIDER_ERROR", `OpenRouter returned ${res.status}: ${body}`, res.status >= 500),
|
|
81
|
+
};
|
|
82
|
+
}
|
|
83
|
+
const body = (await res.json());
|
|
84
|
+
const { output, token_usage } = parseOpenRouterResponse(body);
|
|
85
|
+
task.logStream?.write(output + "\n");
|
|
86
|
+
return { status: "success", output, duration_ms, exit_code: 0, token_usage };
|
|
87
|
+
}
|
|
88
|
+
}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import { CodexRunner } from "./codex.js";
|
|
2
|
+
import { OpenRouterRunner } from "./openrouter.js";
|
|
3
|
+
import { LmStudioRunner } from "./lmstudio.js";
|
|
4
|
+
const runners = {
|
|
5
|
+
codex: new CodexRunner(),
|
|
6
|
+
openrouter: new OpenRouterRunner(),
|
|
7
|
+
lmstudio: new LmStudioRunner(),
|
|
8
|
+
};
|
|
9
|
+
export function getRunner(provider) {
|
|
10
|
+
const runner = runners[provider];
|
|
11
|
+
if (!runner) {
|
|
12
|
+
throw new Error(`PROVIDER_NOT_CONFIGURED: unknown provider "${provider}"`);
|
|
13
|
+
}
|
|
14
|
+
return runner;
|
|
15
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|