UNPKG

relay-companion

Version:

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

156 lines (145 loc) • 5.25 kB
// Codex `app-server` JSON-RPC client. Ported verbatim from // granular/tools/relay-companion/src/codex-app-server.js (require -> import only). // Drives thread/start, thread/name/set, thread/read against a short-lived // `codex app-server` process so a Relay row becomes a real native Codex thread. import { spawn } from "node:child_process"; import fs from "node:fs"; import readline from "node:readline"; export class CodexAppServerClient { constructor({ command = defaultCodexCommand(), args = ["app-server"], cwd = process.cwd(), notificationOptOutMethods = ["item/agentMessage/delta", "command/exec/outputDelta", "process/outputDelta"], onNotification = () => {}, } = {}) { this.command = command; this.args = args; this.cwd = cwd; this.notificationOptOutMethods = notificationOptOutMethods; this.onNotification = onNotification; this.nextId = 1; this.pending = new Map(); this.notifications = []; this.proc = null; } 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) => { if (process.env.RELAY_DEBUG) process.stderr.write(chunk); }); this.proc.on("error", (error) => { for (const { reject } of this.pending.values()) reject(error); this.pending.clear(); this.proc = null; }); readline.createInterface({ input: this.proc.stdout, crlfDelay: Infinity }).on("line", (line) => { if (!line.trim()) return; // The app-server occasionally prints a non-JSON diagnostic line to stdout; an // unguarded parse there would throw inside the readline callback and abort the // whole open (every pending request left hanging). Skip non-JSON lines. let message; try { message = JSON.parse(line); } catch { if (process.env.RELAY_DEBUG) process.stderr.write(`relay: ignoring non-JSON app-server line: ${line}\n`); return; } if (message.id != null && this.pending.has(message.id)) { const { resolve, reject } = this.pending.get(message.id); this.pending.delete(message.id); if (message.error) reject(new Error(message.error.message || JSON.stringify(message.error))); else resolve(message.result); } else { this.notifications.push(message); try { this.onNotification(message); } catch {} } }); this.proc.on("exit", (code, signal) => { const error = new Error(`codex app-server exited with ${signal || code}`); for (const { reject } of this.pending.values()) reject(error); this.pending.clear(); this.proc = null; }); await this.request("initialize", { clientInfo: { name: "granular_relay_companion", title: "Relay Companion", version: "0.1.0", }, capabilities: { experimentalApi: true, optOutNotificationMethods: this.notificationOptOutMethods, }, }); this.notify("initialized", {}); } async stop() { if (!this.proc) return; this.proc.kill("SIGTERM"); this.proc = null; } async request(method, params = {}) { if (!this.proc) throw new Error("Codex app-server is not started"); const id = this.nextId++; const payload = { method, id, params }; const promise = new Promise((resolve, reject) => { const timeout = setTimeout(() => { this.pending.delete(id); reject(new Error(`Timed out waiting for ${method}`)); }, Number(process.env.RELAY_APP_SERVER_TIMEOUT_MS || 60000)); this.pending.set(id, { resolve: (value) => { clearTimeout(timeout); resolve(value); }, reject: (error) => { clearTimeout(timeout); reject(error); }, }); }); this.proc.stdin.write(`${JSON.stringify(payload)}\n`); return promise; } notify(method, params = {}) { if (!this.proc) throw new Error("Codex app-server is not started"); this.proc.stdin.write(`${JSON.stringify({ method, params })}\n`); } async waitForNotification(predicate, { timeoutMs = 60 * 60 * 1000, pollMs = 100 } = {}) { const deadline = Date.now() + timeoutMs; let cursor = 0; for (;;) { while (cursor < this.notifications.length) { const message = this.notifications[cursor++]; if (predicate(message)) return message; } if (Date.now() >= deadline) throw new Error("Timed out waiting for Codex App Server event"); await new Promise((resolve) => setTimeout(resolve, pollMs)); } } } export async function withCodexAppServer(fn, options = {}) { const client = new CodexAppServerClient(options); await client.start(); try { return await fn(client); } finally { await client.stop(); } } export function defaultCodexCommand() { if (process.env.CODEX_CLI_PATH) return process.env.CODEX_CLI_PATH; for (const appBundledCodex of [ "/Applications/ChatGPT.app/Contents/Resources/codex", "/Applications/Codex.app/Contents/Resources/codex", ]) { if (fs.existsSync(appBundledCodex)) return appBundledCodex; } return "codex"; }