@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
JavaScript
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