UNPKG

@essentialai/cogent-bridge

Version:

Cogent Bridge — cross-agent comms for Claude Code, OpenAI Codex and Slack. Codex users: install with `curl -fsSL https://cogent.tools/install.sh | sh`, then start with `cogent-codex` for real-time peer wake.

484 lines • 20.2 kB
import path from "node:path"; import { statSync } from "node:fs"; import fs from "node:fs/promises"; import { spawn, spawnSync } from "node:child_process"; import WebSocket from "ws"; import { getConfig } from "../config.js"; import { codexHomeDir } from "./agent-config.js"; const defaultWsFactory = (url, opts) => new WebSocket(url, opts); /** Control socket for the codex app-server daemon under a given codex home. */ export function appServerSocketPath(codexHome = codexHomeDir()) { return path.join(codexHome, "app-server-control", "app-server-control.sock"); } /** * Is an app-server daemon ALREADY listening on this codex home's control socket? * * 🔴 WHY ANCESTRY WAS NOT ENOUGH. {@link runningUnderAppServerDaemon} answers "was I spawned * under a daemon?" — but the daemon is a SHARED, MACHINE-WIDE SINGLETON (see ensureDaemon), so a * process that is not its descendant can still use it. A codex agent whose bridge runs outside a * `cogent-codex`-launched codex — a standalone monitor script, a systemd unit, an MCP server under * a plain codex — always answered "no", fell back to `exec-resume`, and `codex exec resume * <thread>` then took the EXCLUSIVE WRITER on that thread. On 2026-09-02 that locked the operator * out of their own live session (`already has an active writer (code -32600)`) while a perfectly * good daemon was running on the same machine. * * Deliberately narrow: this reports only whether a daemon is ALREADY up. It never starts one, so a * machine without a daemon keeps its previous behaviour exactly. A stale socket file costs one * failed attempt and falls through to `exec-resume`, which is the pre-existing path anyway. * * `isSocket()` rather than `existsSync` on purpose: a leftover REGULAR FILE at the socket path * must not be mistaken for a live daemon. */ export function appServerDaemonRunning(codexHome = codexHomeDir()) { try { return statSync(appServerSocketPath(codexHome)).isSocket(); } catch { return false; } } /** The `ws+unix://<sock>:/rpc` URL the daemon speaks JSON-RPC over. */ export function rpcUrl(sock) { return `ws+unix://${sock}:/rpc`; } // --------------------------------------------------------------------------- // Am I running under the app-server daemon? (rail auto-detection) // --------------------------------------------------------------------------- /** * When codex runs `--remote` (attached to the daemon, e.g. launched via * cogent-codex), codex's app-server DAEMON — not the codex TUI — is the process * that spawns the plugin's MCP server. So the wake decision cannot rely on an env * flag reaching the bridge (the daemon doesn't carry it). Instead we detect the * situation structurally: the bridge is a descendant of `codex … app-server` * exactly when codex is `--remote`. Then the app-server rail is the RIGHT one and * we use it automatically — no `COGENT_CODEX_WAKE` needed anywhere. * * Pure + unit-testable: walk an ancestry via the injected `getProc`, return true * if any ancestor's command line is the codex app-server. `codex-cli` (Homebrew/npm) * and other names never match; only `codex … app-server`. */ export function ancestryHasAppServer(startPid, getProc, maxDepth = 8) { let pid = startPid; for (let i = 0; i < maxDepth && pid > 1; i++) { const p = getProc(pid); if (!p) return false; if (/(?:^|\/)codex\S*\s+app-server(?:\s|$)/.test(p.cmd)) return true; pid = p.ppid; } return false; } /** `ps`-backed process lookup (cross-platform: macOS + Linux). null if unknown. */ function psProc(pid) { try { const out = spawnSync("ps", ["-o", "ppid=,args=", "-p", String(pid)], { encoding: "utf8", timeout: 3000, }).stdout?.trim(); const m = out?.match(/^(\d+)\s+(.*)$/s); if (!m) return null; return { ppid: parseInt(m[1], 10), cmd: m[2] }; } catch { return null; } } let _underDaemon; /** * Memoized: is THIS bridge process running under a `codex … app-server` daemon * (i.e. codex is `--remote`)? Cached for the process lifetime — ancestry is fixed. */ export function runningUnderAppServerDaemon() { if (_underDaemon === undefined) { _underDaemon = ancestryHasAppServer(process.ppid, psProc); } return _underDaemon; } export function resetAppServerAncestryCache() { _underDaemon = undefined; } /** * Whether the app-server daemon has been ensured in THIS bridge process. The * daemon is a long-lived background process, so we start/confirm it once and * cache the socket; if it later dies, the ws connect fails → appServerUnavailable * → graceful fallback (and the cache is left for the next process). Reset for * test isolation. Mirrors the `sandboxOverride` module-cache pattern. */ let ensuredSock = null; export function resetDaemonCache() { ensuredSock = null; } const defaultRun = (cmd, args, env) => new Promise((resolve) => { const child = spawn(cmd, args, { env, stdio: ["ignore", "pipe", "pipe"] }); let stdout = ""; let stderr = ""; child.stdout.on("data", (c) => (stdout += c.toString())); child.stderr.on("data", (c) => (stderr += c.toString())); child.on("error", (err) => resolve({ stdout, stderr: stderr + String(err), code: 127 })); child.on("close", (code) => resolve({ stdout, stderr, code })); }); const defaultSocketExists = async (p) => { try { await fs.stat(p); return true; } catch { return false; } }; const sleep = (ms) => new Promise((r) => setTimeout(r, ms)); /** * Ensure the codex app-server daemon is running for `codexHome` and return its * control socket. Idempotent (`codex app-server daemon start` is a no-op when the * daemon is already up). Throws if the socket never appears — the caller * (`execRemote`) catches this and falls back to the `exec-resume` rail. */ export async function ensureDaemon(codexHome = codexHomeDir(), deps = {}) { if (ensuredSock) return ensuredSock; const run = deps.run ?? defaultRun; const socketExists = deps.socketExists ?? defaultSocketExists; const waitMs = deps.waitMs ?? 8000; const pollMs = deps.pollMs ?? 150; const codexPath = getConfig().COGENT_CODEX_PATH; const env = { ...process.env, CODEX_HOME: codexHome }; const res = await run(codexPath, ["app-server", "daemon", "start"], env); // The app-server daemon spawns/updates from a MANAGED-STANDALONE codex install // at a fixed path; the npm/Homebrew `@openai/codex` package does NOT create it, // so the daemon refuses to start. Surface codex's official remediation clearly // (the caller falls back to exec-resume → the honest "queued" message). if (/managed standalone Codex install not found/i.test(`${res.stdout}\n${res.stderr}`)) { throw new Error("MANAGED_CODEX_REQUIRED: the codex app-server daemon needs the managed-standalone " + "codex install, which the npm/Homebrew @openai/codex package does not provide. " + "Install it with: curl -fsSL https://chatgpt.com/codex/install.sh | sh " + "then relaunch via cogent-codex. (Until then, codex answers at its next turn, not in real time.)"); } // `daemon start` prints JSON with the socketPath; fall back to the derived path. let sock = appServerSocketPath(codexHome); try { const parsed = JSON.parse(res.stdout.trim()); if (parsed && typeof parsed.socketPath === "string") sock = parsed.socketPath; } catch { /* non-JSON stdout (e.g. "already running") → derived path */ } const deadline = Date.now() + waitMs; while (!(await socketExists(sock))) { if (Date.now() >= deadline) { throw new Error(`app-server daemon socket did not appear at ${sock} within ${waitMs}ms (stderr: ${res.stderr.trim() || "none"})`); } await sleep(pollMs); } ensuredSock = sock; return sock; } /** True if an item looks like the agent's final message ("agentMessage"/"agent_message"). */ function isAgentMessage(item) { return typeof item?.type === "string" && /^agent[_-]?message$/i.test(item.type); } /** Pull the text out of an agent-message item (plain `.text` or a content[] of text parts). */ function itemText(item) { if (typeof item?.text === "string") return item.text; if (Array.isArray(item?.content)) { return item.content .filter((c) => typeof c?.text === "string") .map((c) => c.text) .join(""); } return ""; } /** * Extract the agent's reply text from the collected turn notifications. Prefers * the final `turn/completed` items; falls back to streamed `item/completed` * agent messages when the completed turn carries none. Tolerant of the exact * shape (experimental API) — the live test pins the real wire format. */ export function extractAgentMessageText(notes) { const fromCompleted = []; const fromStream = []; for (const n of notes) { if (n.method === "turn/completed") { const items = n.params?.items ?? n.params?.turn?.items ?? []; for (const it of items) if (isAgentMessage(it)) fromCompleted.push(itemText(it)); } else if (n.method === "item/completed") { const it = n.params?.item; if (isAgentMessage(it)) fromStream.push(itemText(it)); } } const chosen = fromCompleted.length > 0 ? fromCompleted : fromStream; return chosen.join("").trim(); } /** * A thin JSON-RPC-over-WebSocket client for the codex app-server daemon. * `connect()` resolves once the socket is open + `initialize` succeeds. */ export class AppServerClient { ws; nextId = 1; pending = new Map(); notifyListeners = new Set(); opened; openResolved = false; constructor(ws) { this.ws = ws; this.opened = new Promise((resolve, reject) => { ws.on("open", () => { this.openResolved = true; resolve(); }); ws.on("unexpected-response", (_req, res) => { if (!this.openResolved) reject(new Error(`app-server ws upgrade rejected: HTTP ${res?.statusCode}`)); }); ws.on("error", (err) => { if (!this.openResolved) reject(err); else this._failAll(err); }); ws.on("close", () => { if (this.openResolved) this._failAll(new Error("app-server ws closed")); }); }); ws.on("message", (data) => this._onMessage(data)); } /** Await the open handshake. Rejects (before open) on connect/upgrade errors. */ waitOpen() { return this.opened; } _failAll(err) { for (const [, p] of this.pending) p.reject(err); this.pending.clear(); } _onMessage(data) { let msg; try { msg = JSON.parse(data.toString()); } catch { return; // ignore non-JSON frames } if (typeof msg.id === "number" && this.pending.has(msg.id)) { const p = this.pending.get(msg.id); this.pending.delete(msg.id); if (msg.error) p.reject(Object.assign(new Error(msg.error.message ?? "rpc error"), { rpcError: msg.error })); else p.resolve(msg.result); return; } if (typeof msg.method === "string") { for (const cb of this.notifyListeners) cb(msg); } } /** Issue a JSON-RPC request and resolve with its result (rejects on rpc error). */ request(method, params) { const id = this.nextId++; const p = new Promise((resolve, reject) => { this.pending.set(id, { resolve, reject }); }); this.ws.send(JSON.stringify({ jsonrpc: "2.0", id, method, params })); return p; } /** Subscribe to server notifications; returns an unsubscribe fn. */ onNotification(cb) { this.notifyListeners.add(cb); return () => this.notifyListeners.delete(cb); } close() { try { this.ws.close(); } catch { /* ignore */ } } } /** * Map COGENT_CODEX_SANDBOX to an explicit turn/start `sandboxPolicy`, so a PEER's * injected turn is bounded by the CONFIGURED sandbox — not by whatever privilege * the human happened to launch codex with (a live thread may be * danger-full-access). Keeps the app-server rail's sandbox consistent with the * exec-resume rail. Default (workspace-write) allows edits under the cwd, no net. */ export function sandboxPolicyFor(mode, cwd) { switch (mode) { case "read-only": return { type: "readOnly", networkAccess: false }; case "danger-full-access": case "bypass": return { type: "dangerFullAccess" }; case "workspace-write": case "full-auto": default: return { type: "workspaceWrite", writableRoots: [cwd], networkAccess: false, excludeTmpdirEnvVar: false, excludeSlashTmp: false, }; } } const idempotencyCache = new Map(); /** How long a completed injection is remembered to suppress a duplicate delivery. */ const IDEMPOTENCY_TTL_MS = 300_000; /** Clear the injection idempotency cache (test isolation). */ export function resetAppServerIdempotency() { idempotencyCache.clear(); } /** * Inject `message` as a turn into the codex thread `threadId` via the app-server * daemon, and return the agent's reply as a {@link CliExecResult} (parallel to * `execCodex`). When `deps.idempotencyKey` is set, a duplicate injection of the * same message into the same thread is coalesced/short-circuited (never a second * daemon turn). Closing our client afterward only detaches THIS client — the * daemon + thread (and the human's TUI) persist. */ export function execCodexViaAppServer(threadId, message, cwd, timeoutMs, deps = {}) { const key = deps.idempotencyKey ? `${threadId}::${deps.idempotencyKey}` : null; if (!key) return _injectTurn(threadId, message, cwd, timeoutMs, deps); const existing = idempotencyCache.get(key); if (existing) { // Completed within TTL → same result (no second turn). Still in-flight → // coalesce onto the same promise (e.g. a redelivery while the daemon runs). return existing.done ? Promise.resolve(existing.done.result) : existing.promise; } const promise = _injectTurn(threadId, message, cwd, timeoutMs, deps); const entry = { promise }; idempotencyCache.set(key, entry); // `_injectTurn` always RESOLVES a CliExecResult (never throws); cache success // AND failure for the TTL so a redelivery is suppressed either way, then evict. void promise.then((result) => { entry.done = { result, at: Date.now() }; const t = setTimeout(() => idempotencyCache.delete(key), IDEMPOTENCY_TTL_MS); if (typeof t.unref === "function") t.unref(); }); return promise; } async function _injectTurn(threadId, message, cwd, timeoutMs, deps = {}) { const config = getConfig(); const effectiveTimeout = timeoutMs ?? config.COGENT_TIMEOUT_MS; const truncated = config.COGENT_CHAR_LIMIT > 0 && message.length > config.COGENT_CHAR_LIMIT ? message.slice(0, config.COGENT_CHAR_LIMIT) + "\n...[truncated]" : message; const sock = deps.socketPath ?? appServerSocketPath(); const wsFactory = deps.wsFactory ?? defaultWsFactory; let client; try { const ws = wsFactory(rpcUrl(sock), { perMessageDeflate: false, headers: { Host: "localhost" } }); client = new AppServerClient(ws); await client.waitOpen(); await client.request("initialize", { clientInfo: { name: "cogent-bridge", version: "1" }, capabilities: { experimentalApi: true }, }); } catch (err) { // Could not reach/handshake the daemon → let the caller fall back to exec-resume. return { stdout: "", stderr: `APP_SERVER_UNAVAILABLE: ${err.message}`, exitCode: 1, appServerUnavailable: true, }; } try { // Attach to the human's existing live thread (does NOT create a new one), // then inject the peer message as a turn on THAT thread. await client.request("thread/resume", { threadId, cwd }); // Subscribe BEFORE turn/start so no completion is missed; buffer until we know // OUR turn id. Verified against codex 0.147: turn/completed carries // `params.turn.id` (the same id turn/start returns). We match on it so a // CONCURRENT turn on the same thread — e.g. the human's own turn — can never be // captured as this peer's reply. If the daemon omits the id (older codex), // fall back to a threadId match. const buffered = []; let myTurnId; const isMine = (msg) => msg.method === "turn/completed" && (myTurnId !== undefined ? msg.params?.turn?.id === myTurnId : msg.params?.threadId === threadId); let deliver = null; const off = client.onNotification((msg) => { if (msg.method !== "turn/completed") return; if (myTurnId === undefined) { buffered.push(msg); // hold until we learn our turn id return; } if (isMine(msg) && deliver) { const d = deliver; deliver = null; d(msg); } }); const startResult = await client.request("turn/start", { threadId, input: [{ type: "text", text: truncated }], // Bound the injected peer turn by the CONFIGURED sandbox (not the human // session's privilege). See sandboxPolicyFor. sandboxPolicy: sandboxPolicyFor(config.COGENT_CODEX_SANDBOX, cwd), // Native correlation id (daemon does NOT auto-dedupe on it — our own // idempotency guard does; this is for daemon-side correlation/telemetry). ...(deps.idempotencyKey ? { clientUserMessageId: deps.idempotencyKey } : {}), }); myTurnId = startResult?.turn?.id; const turnCompleted = await new Promise((resolve) => { deliver = resolve; const early = buffered.find(isMine); // did our completion arrive before we knew the id? if (early) { deliver = null; resolve(early); return; } setTimeout(() => { if (deliver) { deliver = null; resolve(null); } }, effectiveTimeout); }); off(); if (!turnCompleted) { return { stdout: "", stderr: `CLI_TIMEOUT: app-server turn did not complete within ${effectiveTimeout}ms`, exitCode: null, }; } const text = extractAgentMessageText([turnCompleted]); return { stdout: text, stderr: "", exitCode: 0, captureContract: "event_stream", stable: true, }; } catch (err) { // A turn/thread RPC errored on the daemon. This is NOT a reachability // problem, so do not flag appServerUnavailable (falling back to exec-resume // would hit the same live thread and also fail). return { stdout: "", stderr: `CLI_EXEC_FAILED: app-server turn failed: ${err.message}`, exitCode: 1, }; } finally { client.close(); } } //# sourceMappingURL=codex-app-server.js.map