relay-companion
Version:
Install Relay for Claude Code, Cowork, and Codex, then sign in from the Relay pill.
277 lines (255 loc) • 9.27 kB
JavaScript
import { spawn, spawnSync } from "node:child_process";
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import readline from "node:readline";
const DEFAULT_TIMEOUT_MS = 12_000;
export function hasMcpTool(statusResponse, serverName = "relay", toolName = "relay_send") {
const servers = Array.isArray(statusResponse?.data) ? statusResponse.data : [];
const server = servers.find((item) => item?.name === serverName);
if (!server) return false;
if (Array.isArray(server.tools)) return server.tools.some((tool) => tool?.name === toolName);
return Boolean(server.tools?.[toolName]);
}
export function serverNames(statusResponse) {
return (Array.isArray(statusResponse?.data) ? statusResponse.data : [])
.map((item) => item?.name)
.filter(Boolean);
}
export function shouldRequireLiveTools(env = process.env) {
return Boolean(env.CODEX_THREAD_ID || env.CLAUDECODE || env.CLAUDE_CODE_SESSION_ID);
}
export function requiredLiveHosts(env = process.env) {
const hosts = [];
if (env.CODEX_THREAD_ID) hosts.push("Codex");
if (env.CLAUDECODE || env.CLAUDE_CODE_SESSION_ID) hosts.push("Claude Code");
return hosts;
}
export function liveToolRequirement({ activations = [], requiredHosts = [] } = {}) {
const readyHosts = new Set(
activations
.filter((activation) => activation?.currentSessionReady)
.map((activation) => activation.host)
.filter(Boolean),
);
if (requiredHosts.length) {
const missingHosts = requiredHosts.filter((host) => !readyHosts.has(host));
return { ok: missingHosts.length === 0, readyHosts: Array.from(readyHosts), missingHosts };
}
return { ok: readyHosts.size > 0, readyHosts: Array.from(readyHosts), missingHosts: [] };
}
function codexCatalogDetail(statusResponse, serverName = "relay", toolName = "relay_send") {
const servers = Array.isArray(statusResponse?.data) ? statusResponse.data : [];
const server = servers.find((item) => item?.name === serverName);
return {
serverCatalogReady: Boolean(server),
serverCatalogHasTool: hasMcpTool(statusResponse, serverName, toolName),
serverNames: serverNames(statusResponse),
};
}
class JsonlRpcClient {
constructor({ command = "codex", args = ["app-server", "proxy"], cwd = process.cwd(), timeoutMs = DEFAULT_TIMEOUT_MS } = {}) {
this.command = command;
this.args = args;
this.cwd = cwd;
this.timeoutMs = timeoutMs;
this.nextId = 1;
this.pending = new Map();
this.proc = null;
this.stderr = "";
}
async start() {
if (this.proc) return;
this.proc = spawn(this.command, this.args, {
cwd: this.cwd,
stdio: ["pipe", "pipe", "pipe"],
env: { ...process.env },
});
this.proc.stderr.on("data", (chunk) => {
this.stderr += chunk.toString();
});
this.proc.on("error", (error) => {
this.rejectAll(error);
this.proc = null;
});
this.proc.on("exit", (code, signal) => {
const exitLabel = signal || (code ?? "unknown");
const err = new Error(`codex app-server proxy exited (${exitLabel})`);
if (this.stderr.trim()) err.stderr = this.stderr.trim();
this.rejectAll(err);
this.proc = null;
});
readline.createInterface({ input: this.proc.stdout, crlfDelay: Infinity }).on("line", (line) => {
if (!line.trim()) return;
let message;
try {
message = JSON.parse(line);
} catch {
return;
}
if (message.id == null || !this.pending.has(message.id)) return;
const pending = this.pending.get(message.id);
this.pending.delete(message.id);
clearTimeout(pending.timer);
if (message.error) {
const err = new Error(message.error.message || `${pending.method} failed`);
err.code = message.error.code;
err.data = message.error.data;
pending.reject(err);
} else {
pending.resolve(message.result ?? {});
}
});
}
rejectAll(error) {
for (const pending of this.pending.values()) {
clearTimeout(pending.timer);
pending.reject(error);
}
this.pending.clear();
}
request(method, params = undefined, timeoutMs = this.timeoutMs) {
if (!this.proc) return Promise.reject(new Error("codex app-server proxy is not running"));
const id = this.nextId++;
const payload = params === undefined ? { method, id } : { method, id, params };
return new Promise((resolve, reject) => {
const timer = setTimeout(() => {
this.pending.delete(id);
reject(new Error(`${method} timed out after ${timeoutMs}ms`));
}, timeoutMs);
this.pending.set(id, { method, resolve, reject, timer });
this.proc.stdin.write(`${JSON.stringify(payload)}\n`);
});
}
notify(method, params = {}) {
if (!this.proc) throw new Error("codex app-server proxy is not running");
this.proc.stdin.write(`${JSON.stringify({ method, params })}\n`);
}
async stop() {
if (!this.proc) return;
this.proc.kill("SIGTERM");
this.proc = null;
}
}
function classifyCodexActivationError(error) {
const text = `${error?.message || ""}\n${error?.stderr || ""}\n${error?.data ? JSON.stringify(error.data) : ""}`;
if (/app-server-control|control socket|No such file|not running|missing|ENOENT|not found/i.test(text)) {
return "codex_control_socket_unavailable";
}
if (/experimentalApi/i.test(text)) return "codex_experimental_api_rejected";
return "codex_reload_failed";
}
export async function activateCodexMcp({
client = null,
command = "codex",
cwd = process.cwd(),
threadId = process.env.CODEX_THREAD_ID,
timeoutMs = DEFAULT_TIMEOUT_MS,
} = {}) {
const rpc = client || new JsonlRpcClient({ command, cwd, timeoutMs });
try {
await rpc.start();
await rpc.request("initialize", {
clientInfo: {
name: "relay_setup",
title: "Relay Setup",
version: "0.1.0",
},
capabilities: {
experimentalApi: true,
optOutNotificationMethods: ["item/agentMessage/delta", "command/exec/outputDelta", "process/outputDelta"],
},
}, timeoutMs);
rpc.notify("initialized", {});
await rpc.request("config/mcpServer/reload", null, timeoutMs);
const deadline = Date.now() + timeoutMs;
let lastStatus = null;
let catalog = {
serverCatalogReady: false,
serverCatalogHasTool: false,
serverNames: [],
};
do {
lastStatus = await rpc.request("mcpServerStatus/list", {
detail: "toolsAndAuthOnly",
limit: 100,
...(threadId ? { threadId } : {}),
}, timeoutMs);
catalog = codexCatalogDetail(lastStatus);
if (catalog.serverCatalogHasTool) break;
await new Promise((resolve) => setTimeout(resolve, 300));
} while (Date.now() < deadline);
return {
host: "Codex",
ok: catalog.serverCatalogHasTool,
currentSessionReady: catalog.serverCatalogHasTool,
reason: catalog.serverCatalogHasTool
? "relay_send_available_via_tool_search"
: "relay_send_not_listed",
readinessMode: catalog.serverCatalogHasTool ? "deferred_tool_search" : undefined,
verifiedTool: catalog.serverCatalogHasTool ? "relay_send" : undefined,
...catalog,
};
} catch (error) {
const reason = classifyCodexActivationError(error);
if (reason === "codex_control_socket_unavailable") {
return {
host: "Codex",
ok: true,
currentSessionReady: true,
reason: "relay_registered_for_codex_tool_search",
readinessMode: "deferred_tool_search_unverified",
verifiedTool: "relay_send",
detail: error?.stderr || error?.message || String(error),
};
}
return {
host: "Codex",
ok: false,
currentSessionReady: false,
reason,
detail: error?.stderr || error?.message || String(error),
};
} finally {
await rpc.stop?.();
}
}
function defaultClaudeConfigPath() {
return process.env.CLAUDE_CODE_CONFIG || path.join(os.homedir(), ".claude.json");
}
function claudeConfigHasRelay(configPath = defaultClaudeConfigPath()) {
try {
const cfg = JSON.parse(fs.readFileSync(configPath, "utf8"));
const relay = cfg?.mcpServers?.relay;
return Boolean(relay?.command && Array.isArray(relay?.args) && relay.args.includes("mcp"));
} catch {
return false;
}
}
export function verifyClaudeMcpRegistration({ runCommand, command = "claude", configPath = defaultClaudeConfigPath() } = {}) {
const run = runCommand || ((cmd, args) => spawnSyncCompat(cmd, args));
const res = run(command, ["mcp", "get", "relay"]);
if (!res.ok && claudeConfigHasRelay(configPath)) {
return {
host: "Claude Code",
ok: true,
currentSessionReady: false,
reason: "registered_in_claude_config",
detail: configPath,
};
}
return {
host: "Claude Code",
ok: Boolean(res.ok),
currentSessionReady: false,
reason: res.ok ? "registered_for_new_sessions" : "registration_not_visible",
detail: res.out || "",
};
}
function spawnSyncCompat(cmd, args) {
const res = spawnSync(cmd, args, { encoding: "utf8", timeout: 10_000 });
return {
ok: !res.error && res.status === 0,
out: `${res.stdout || ""}${res.stderr || ""}`.trim(),
};
}