UNPKG

relay-companion

Version:

Install Relay for Claude Code, Cowork, and Codex, then sign in from the Relay pill.

458 lines (437 loc) • 18.6 kB
import { spawn } from "node:child_process"; import fs from "node:fs"; import path from "node:path"; import readline from "node:readline"; import { CodexAppServerClient, defaultCodexCommand } from "./codex-app-server.js"; import { storeDir } from "./host-paths.js"; const DEFAULT_STALL_TIMEOUT_MS = 5 * 60 * 1000; const DEFAULT_RUN_TIMEOUT_MS = 90 * 60 * 1000; // A timeout override for a server that is not present in the user's Codex // config creates an incomplete mcp_servers table. Current Codex rejects that // table as an invalid transport before starting the run, so defaults must not // invent server names. Callers may still bound MCP servers they explicitly // know are configured. const DEFAULT_MCP_TOOL_TIMEOUTS = Object.freeze({}); const RELAY_OUTPUT_SCHEMA = { type: "object", properties: { forHuman: { type: "string" }, forAgent: { type: "string" }, }, required: ["forHuman", "forAgent"], additionalProperties: false, }; export function codexOneShotAppServerArgs({ mcpToolTimeouts = DEFAULT_MCP_TOOL_TIMEOUTS } = {}) { const args = ["app-server"]; for (const [server, seconds] of Object.entries(mcpToolTimeouts || {})) { if (!/^[A-Za-z0-9_-]+$/.test(server) || !Number.isFinite(seconds) || seconds <= 0) continue; args.push("--config", `mcp_servers.${server}.tool_timeout_sec=${seconds}`); } return args; } export function ensureCodexRelayOutputSchema(baseDir = storeDir()) { const schemaPath = path.join(baseDir, "codex-chat-agent-output.schema.json"); const body = `${JSON.stringify(RELAY_OUTPUT_SCHEMA, null, 2)}\n`; fs.mkdirSync(path.dirname(schemaPath), { recursive: true, mode: 0o700 }); let current = ""; try { current = fs.readFileSync(schemaPath, "utf8"); } catch {} if (current !== body) fs.writeFileSync(schemaPath, body, { mode: 0o600 }); return schemaPath; } export function codexOneShotArgs({ model = "", effort = "", fullAccess = false, schemaPath, mcpToolTimeouts = DEFAULT_MCP_TOOL_TIMEOUTS, }) { const args = [ "exec", "--json", "--ephemeral", "--skip-git-repo-check", "--color", "never", ]; // --approve-for-me already selects Codex's reviewed workspace-write mode. // Passing an explicit --sandbox alongside it is rejected by the CLI before // the run starts. Full-access runs opt into their sandbox directly instead. if (fullAccess) args.push("--sandbox", "danger-full-access"); else args.push("--approve-for-me"); if (model) args.push("--model", model); if (effort && effort !== "auto") args.push("--config", `model_reasoning_effort=${JSON.stringify(effort)}`); // Preserve the user's MCP setup, but do not let a provenance service hold an // anonymous chat answer forever. Codex receives the timeout as a normal // config layer; the server and all of its tools remain available to the run. for (const [server, seconds] of Object.entries(mcpToolTimeouts || {})) { if (!/^[A-Za-z0-9_-]+$/.test(server) || !Number.isFinite(seconds) || seconds <= 0) continue; args.push("--config", `mcp_servers.${server}.tool_timeout_sec=${seconds}`); } if (schemaPath) args.push("--output-schema", schemaPath); args.push("-"); return args; } function cleanStatus(text) { const value = String(text || "").replace(/\s+/g, " ").trim(); if (!value) return ""; return value.length <= 280 ? value : `${value.slice(0, 277).trimEnd()}…`; } export function codexExecEventStatus(event) { const item = event?.item || {}; if (event?.type === "turn.started") return "Codex has started working on your laptop."; if (event?.type === "item.completed" && item.type === "agent_message") { const text = String(item.text || "").trim(); if (!text || parseStructuredOutput(text)) return ""; return cleanStatus(text); } if (event?.type !== "item.started") return ""; if (["mcp_tool_call", "mcpToolCall"].includes(item.type)) { const tool = String(item.tool || item.name || "an agent tool").replace(/^mcp__/, "").replace(/__/g, " · "); return cleanStatus(`Codex is using ${tool}.`); } if (["custom_tool_call", "tool_call", "function_call"].includes(item.type)) return "Codex is working through its agent tools."; if (["command_execution", "commandExecution"].includes(item.type)) return "Codex is running a command."; if (["file_change", "fileChange"].includes(item.type)) return "Codex is updating files."; if (["web_search", "webSearch"].includes(item.type)) return "Codex is checking the web."; if (item.type === "plan") return cleanStatus(item.text || "Codex is planning the work."); return ""; } function decodePartialJsonString(source, start) { let value = ""; for (let index = start; index < source.length; index += 1) { const char = source[index]; if (char === '"') return { value, complete: true }; if (char !== "\\") { value += char; continue; } if (index + 1 >= source.length) return { value, complete: false }; const escaped = source[index + 1]; index += 1; if (escaped === "u") { const hex = source.slice(index + 1, index + 5); if (!/^[0-9a-fA-F]{4}$/.test(hex)) return { value, complete: false }; value += String.fromCharCode(Number.parseInt(hex, 16)); index += 4; continue; } const escapes = { '"':'"', "\\":"\\", "/":"/", b:"\b", f:"\f", n:"\n", r:"\r", t:"\t" }; if (Object.hasOwn(escapes, escaped)) value += escapes[escaped]; } return { value, complete: false }; } /** Extract the already-authored human answer from an in-flight structured JSON response. */ export function partialCodexRelayHuman(source) { const text = String(source || ""); const match = /"forHuman"\s*:\s*"/.exec(text); if (!match) return ""; return decodePartialJsonString(text, match.index + match[0].length).value; } function partialAnswerStatus(text) { const clean = String(text || "").replace(/\s+/g, " ").trim(); if (clean.length < 8) return ""; const visible = clean.length <= 245 ? clean : `…${clean.slice(-244)}`; return cleanStatus(`Codex is answering: ${visible}`); } function appServerItemStatus(item) { if (!item || typeof item !== "object") return ""; if (item.type === "mcpToolCall") { const server = String(item.server || "mcp").replace(/^mcp__/, ""); const tool = String(item.tool || "tool").replace(/^mcp__/, "").replace(/__/g, " · "); return cleanStatus(`Codex is using ${server} · ${tool}.`); } return codexExecEventStatus({ type:"item.started", item }); } /** * A fresh, in-memory Codex thread over the CLI app-server protocol. * * Unlike `codex exec`, app-server exposes token deltas and does not pay the * non-interactive runner's cold setup cost before every answer. `ephemeral` * keeps the invocation out of both Codex history and Relay's session list. */ export async function runCodexAppServerOneShot({ command = defaultCodexCommand(), cwd = process.cwd(), prompt, model = "", effort = "", fullAccess = false, ephemeral = true, title = "", stallTimeoutMs = Number(process.env.RELAY_CHAT_AGENT_STALL_TIMEOUT_MS || DEFAULT_STALL_TIMEOUT_MS), runTimeoutMs = Number(process.env.RELAY_CHAT_AGENT_TIMEOUT_MS || DEFAULT_RUN_TIMEOUT_MS), heartbeatIntervalMs = 30_000, mcpToolTimeouts = DEFAULT_MCP_TOOL_TIMEOUTS, onEvent = () => {}, onThreadStarted = async () => {}, onTurnStarted = async () => {}, appServerFactory = (options) => new CodexAppServerClient(options), } = {}) { const actualCwd = cwd && fs.existsSync(cwd) ? cwd : process.cwd(); let appServer; let threadId = ""; let turnId = ""; let finalMessage = ""; let streamedMessage = ""; let lastPartialStatusAt = 0; let lastPartialLength = 0; let lastActivityAt = Date.now(); let lastPhase = "starting Codex"; let terminalResolve; let terminalReject; const terminal = new Promise((resolve, reject) => { terminalResolve = resolve; terminalReject = reject; }); let settled = false; let stallTimer; let runTimer; let heartbeatTimer; const emit = (event, status = "") => { lastActivityAt = Date.now(); if (status) lastPhase = status; try { void onEvent(event, status); } catch {} }; const fail = (error) => { if (settled) return; settled = true; terminalReject(error instanceof Error ? error : new Error(String(error))); }; const complete = () => { if (settled) return; settled = true; terminalResolve(); }; const onNotification = (message) => { const params = message?.params || {}; if (threadId && params.threadId && params.threadId !== threadId) return; if (turnId && params.turnId && params.turnId !== turnId) return; lastActivityAt = Date.now(); if (message?.method === "thread/started") { emit({ type:"thread.started", thread_id:params.thread?.id || threadId }, "Codex has started working on your laptop."); return; } if (message?.method === "turn/started") { emit({ type:"turn.started" }, "Codex has started working on your laptop."); return; } if (message?.method === "item/started") { const status = appServerItemStatus(params.item); emit({ type:"item.started", item:params.item }, status); return; } if (message?.method === "item/agentMessage/delta") { streamedMessage += String(params.delta || ""); const partial = partialCodexRelayHuman(streamedMessage); const now = Date.now(); const enoughNewText = partial.length >= lastPartialLength + 12; const paragraphFinished = /\n\n$/.test(partial); if (partial && enoughNewText && (paragraphFinished || now - lastPartialStatusAt >= 700)) { const status = partialAnswerStatus(partial); if (status) { lastPartialLength = partial.length; lastPartialStatusAt = now; emit({ type:"item.agent_message.delta", item:{ type:"agent_message", text:partial } }, status); } } return; } if (message?.method === "item/completed" && params.item?.type === "agentMessage") { finalMessage = String(params.item.text || "").trim() || finalMessage; const parsed = parseStructuredOutput(finalMessage); emit({ type:"item.completed", item:{ type:"agent_message", text:finalMessage } }, parsed ? "" : cleanStatus(finalMessage)); return; } if (message?.method === "turn/completed") { const status = String(params.turn?.status || "completed").toLowerCase(); if (["failed", "error"].includes(status)) fail(new Error(params.turn?.error?.message || "Codex reported a failed run.")); else complete(); return; } if (message?.method === "error") fail(new Error(params.error?.message || params.message || "Codex reported a failed run.")); }; try { appServer = appServerFactory({ command, args: codexOneShotAppServerArgs({ mcpToolTimeouts }), cwd: actualCwd, notificationOptOutMethods: ["command/exec/outputDelta", "process/outputDelta"], onNotification, }); await appServer.start(); const thread = await appServer.request("thread/start", { cwd: actualCwd, approvalPolicy: fullAccess ? "never" : "on-request", ...(fullAccess ? {} : { approvalsReviewer:"auto_review" }), sandbox: fullAccess ? "danger-full-access" : "workspace-write", ...(ephemeral ? { ephemeral:true } : {}), threadSource: ephemeral ? "appServer" : "user", serviceName: "relay_owned_agent", ...(model ? { model } : {}), }); threadId = String(thread?.thread?.id || ""); if (!threadId) throw new Error("Codex did not return a thread id"); if (title) await appServer.request("thread/name/set", { threadId, name:title }); await onThreadStarted({ threadId, appServer }); emit({ type:"thread.started", thread_id:threadId }, "Codex has started working on your laptop."); const turn = await appServer.request("turn/start", { threadId, input: [{ type:"text", text:String(prompt || ""), text_elements:[] }], outputSchema: RELAY_OUTPUT_SCHEMA, ...(effort && effort !== "auto" ? { effort } : {}), }); turnId = String(turn?.turn?.id || ""); if (!turnId) throw new Error("Codex did not return a turn id"); await onTurnStarted({ threadId, turnId, appServer }); emit({ type:"turn.started", turn_id:turnId }, "Codex has started working on your laptop."); stallTimer = setInterval(() => { if (Date.now() - lastActivityAt <= stallTimeoutMs) return; fail(new Error(`Codex stopped producing activity while ${lastPhase}.`)); }, Math.min(10_000, Math.max(10, Math.floor(stallTimeoutMs / 4)))); heartbeatTimer = setInterval(() => { const idleSeconds = Math.max(1, Math.round((Date.now() - lastActivityAt) / 1000)); emit({ type:"relay.heartbeat" }, `Codex is still active on your laptop (${idleSeconds}s since its last event).`); }, heartbeatIntervalMs); heartbeatTimer.unref?.(); runTimer = setTimeout(() => fail(new Error("Codex reached Relay's run time limit.")), runTimeoutMs); await terminal; return { threadId, finalMessage: finalMessage || streamedMessage }; } catch (error) { // Older Codex CLIs may have `exec` but not the ephemeral app-server fields. // Falling back is safe only before a turn id proves model work was accepted; // a timed-out turn/start response is ambiguous and must not duplicate work. const failure = error instanceof Error ? error : new Error(String(error)); if (!turnId && !/Timed out waiting for turn\/start/i.test(failure.message)) { failure.relayExecFallbackSafe = true; } throw failure; } finally { if (stallTimer) clearInterval(stallTimer); if (heartbeatTimer) clearInterval(heartbeatTimer); if (runTimer) clearTimeout(runTimer); await appServer?.stop().catch(() => {}); } } function parseStructuredOutput(text) { const source = String(text || "").trim(); if (!source) return null; const candidates = [source]; const fenced = source.match(/^```(?:json)?\s*([\s\S]*?)\s*```$/i); if (fenced?.[1]) candidates.push(fenced[1]); for (const candidate of candidates) { try { const parsed = JSON.parse(candidate); if (typeof parsed?.forHuman === "string" && typeof parsed?.forAgent === "string") return parsed; } catch {} } return null; } export function codexRelayCompletion(text) { const structured = parseStructuredOutput(text); if (structured) { const forHuman = structured.forHuman.trim(); const forAgent = structured.forAgent.trim(); if (forHuman && forAgent) return { forHuman, forAgent }; } const fallback = String(text || "").trim(); if (!fallback) return null; return { forHuman: fallback, forAgent: fallback }; } export function runCodexOneShot({ command = defaultCodexCommand(), cwd = process.cwd(), prompt, model = "", effort = "", fullAccess = false, schemaPath = ensureCodexRelayOutputSchema(), stallTimeoutMs = Number(process.env.RELAY_CHAT_AGENT_STALL_TIMEOUT_MS || DEFAULT_STALL_TIMEOUT_MS), runTimeoutMs = Number(process.env.RELAY_CHAT_AGENT_TIMEOUT_MS || DEFAULT_RUN_TIMEOUT_MS), heartbeatIntervalMs = 30_000, mcpToolTimeouts = DEFAULT_MCP_TOOL_TIMEOUTS, onEvent = () => {}, spawnProcess = spawn, } = {}) { return new Promise((resolve, reject) => { const args = codexOneShotArgs({ model, effort, fullAccess, schemaPath, mcpToolTimeouts }); const child = spawnProcess(command, args, { cwd: cwd && fs.existsSync(cwd) ? cwd : process.cwd(), env: { ...process.env }, stdio: ["pipe", "pipe", "pipe"], windowsHide: true, }); let settled = false; let terminal = false; let lastActivityAt = Date.now(); let lastAgentMessage = ""; let threadId = ""; let lastPhase = "starting Codex"; let stderr = ""; let stallTimer; let runTimer; let heartbeatTimer; const cleanup = () => { if (stallTimer) clearInterval(stallTimer); if (runTimer) clearTimeout(runTimer); if (heartbeatTimer) clearInterval(heartbeatTimer); }; const fail = (error) => { if (settled) return; settled = true; cleanup(); if (child.exitCode == null) child.kill("SIGTERM"); reject(error); }; const finish = () => { if (settled) return; settled = true; cleanup(); resolve({ threadId, finalMessage: lastAgentMessage }); }; const emit = (event) => { lastActivityAt = Date.now(); if (event?.type === "thread.started") threadId = String(event.thread_id || event.threadId || ""); if (event?.type === "item.completed" && event.item?.type === "agent_message") { lastAgentMessage = String(event.item.text || "").trim() || lastAgentMessage; } const status = codexExecEventStatus(event); if (status) lastPhase = status; try { void onEvent(event, status); } catch {} if (event?.type === "turn.completed") terminal = true; if (event?.type === "turn.failed" || event?.type === "error") { const message = event?.error?.message || event?.message || "Codex reported a failed run."; fail(new Error(String(message))); } }; child.once("error", fail); readline.createInterface({ input: child.stdout, crlfDelay: Infinity }).on("line", (line) => { if (!line.trim()) return; try { emit(JSON.parse(line)); } catch { lastActivityAt = Date.now(); } }); child.stderr.on("data", (chunk) => { lastActivityAt = Date.now(); stderr = `${stderr}${chunk}`.slice(-4_000); }); child.once("exit", (code, signal) => { if (settled) return; if (code === 0 && terminal && lastAgentMessage) finish(); else fail(new Error( stderr.trim() || `Codex exited before returning an answer (${signal || code || "unknown"}).`, )); }); child.stdin.once("error", fail); stallTimer = setInterval(() => { if (Date.now() - lastActivityAt <= stallTimeoutMs) return; fail(new Error(`Codex stopped producing activity while ${lastPhase}.`)); }, Math.min(10_000, Math.max(10, Math.floor(stallTimeoutMs / 4)))); heartbeatTimer = setInterval(() => { const idleSeconds = Math.max(1, Math.round((Date.now() - lastActivityAt) / 1000)); try { void onEvent({ type: "relay.heartbeat" }, `Codex is still active on your laptop (${idleSeconds}s since its last event).`); } catch {} }, heartbeatIntervalMs); heartbeatTimer.unref?.(); runTimer = setTimeout(() => fail(new Error("Codex reached Relay's one-shot run time limit.")), runTimeoutMs); child.stdin.end(String(prompt || "")); }); }