@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 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 (SAFE-01 / CVE-2025-59532 mitigation).
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 (currently only codex)"),
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 the provider (e.g., o4-mini for Codex)"),
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 { execFile } from "node:child_process";
2
- import { promisify } from "node:util";
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 failures = [];
22
- let codexVersion = "";
23
- const codexBin = getCodexBin();
24
- const configuredPath = process.env.RELAY_CODEX_PATH?.trim();
25
- try {
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: ${codexVersion} ✓\n auth: authenticated ✓\n listening on stdio...\n`);
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
+ }
@@ -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 { runCodexWorker } from "../workers/codex.js";
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 finalTask = context ? `${context}\n\n${task}` : task;
50
- const preSnapshot = await takeSnapshot(workdir);
51
- const release = await getWorkdirMutex(workdir).acquire();
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 runCodexWorker(workerTask);
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: 0,
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: makeError("UNKNOWN", `Unexpected worker error: ${String(err)}`, false),
227
+ error: normalizedError,
76
228
  };
77
229
  return toMcpResult(response);
78
230
  }
79
231
  finally {
80
- release();
232
+ release?.();
81
233
  }
82
- const postSnapshot = await takeSnapshot(workdir);
83
- const filesChanged = diffSnapshots(preSnapshot, postSnapshot);
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
+ }
@@ -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
- process.stderr.write(chunk);
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 {};
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sghanavati/relay-mcp",
3
- "version": "0.1.2",
3
+ "version": "0.1.4",
4
4
  "description": "MCP server that lets Claude Code delegate coding tasks to Codex",
5
5
  "type": "module",
6
6
  "bin": {