@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.
252 lines • 12.2 kB
JavaScript
/**
* cogent-codex — launch codex with Cogent real-time peer wake, ZERO setup.
*
* WHY: codex >=0.145 has a single-writer thread-store. A codex started the normal
* way holds an exclusive writer on its thread, so the bridge's real-time wake
* (`codex exec resume`) fails `-32600 active writer`. Launched through this wrapper,
* codex instead attaches to a shared app-server DAEMON (`--remote`); the daemon is
* the single writer, and the bridge injects the peer's message as a turn through
* that same daemon — no conflict.
*
* The ONLY user step is running `cogent-codex` instead of `codex`. This wrapper
* self-bootstraps everything else, no exports / config / paths / system commands:
* 1. ensures codex's managed build (the daemon needs it) — installing it on first
* run with a single Y/n confirmation if it is missing;
* 2. ensures the app-server daemon (idempotent);
* 3. execs `codex --remote unix://<sock> -C <this dir>` with your args passed through
* (the `-C` pins the working root to where you launched — the shared daemon's own
* cwd must never leak in; see buildRemoteArgs).
* The bridge then AUTO-DETECTS the app-server rail from its process ancestry (in
* `--remote` mode the daemon, not the codex TUI, spawns the plugin MCP) — so
* real-time wake needs NO env flag or config. If any of the above can't happen
* (managed build declined/unavailable, non-interactive shell), it launches plain
* codex on the default turn-boundary rail — never an error, never a manual step.
*
* The daemon lifecycle is the SAME code the bridge uses at wake time (ensureDaemon
* in services/codex-app-server.ts) — one source of truth.
*
* NOTE (platforms): attaches over the daemon's unix socket (`unix://…`), covering
* macOS + Linux. Windows (loopback `ws://…`) is not yet wired here.
*/
import { spawn, spawnSync } from "node:child_process";
import { realpathSync } from "node:fs";
import { readFile } from "node:fs/promises";
import { createInterface } from "node:readline";
import { pathToFileURL } from "node:url";
import { loadConfig, getConfig } from "../config.js";
import { ensureDaemon, resetDaemonCache } from "../services/codex-app-server.js";
import { detectCodexCogentServerCollision, codexConfigPath, } from "../services/codex-preflight.js";
function log(msg) {
process.stderr.write(`[cogent-codex] ${msg}\n`);
}
/**
* Loud launch-time guard: a standalone [mcp_servers.cogent] in ~/.codex/config.toml
* collides by name with the cogent@cogent plugin's own `cogent` MCP server, which
* breaks codex MCP startup ("connection closed: initialize response"). Warn before
* we hand off to codex so the user sees it. Never throws.
*/
async function warnOnServerCollision() {
const cfgPath = codexConfigPath();
try {
const text = await readFile(cfgPath, "utf8");
if (!detectCodexCogentServerCollision(text))
return;
}
catch {
return; // no readable config → nothing to collide
}
log("──────────────────────────────────────────────────────────────");
log("⚠️ NAME COLLISION: a standalone [mcp_servers.cogent] exists in");
log(` ${cfgPath}`);
log(" It shares the name `cogent` with the cogent@cogent plugin's MCP");
log(" server — Codex can fail MCP startup. Remove that [mcp_servers.cogent]");
log(" block; the plugin already provides the cogent tools. Launching anyway…");
log("──────────────────────────────────────────────────────────────");
}
/** Whether an ensureDaemon() error is the "managed codex build missing" case. */
export function isManagedCodexMissing(err) {
return /MANAGED_CODEX_REQUIRED/.test(err?.message ?? String(err));
}
/**
* One-time Y/n confirmation (default Yes). Returns false with no prompt when stdin
* is not a TTY — a non-interactive shell can't answer, so the caller falls back to
* turn-boundary rather than hang or assume consent.
*/
async function confirmYes(question) {
if (!process.stdin.isTTY)
return false;
const rl = createInterface({ input: process.stdin, output: process.stderr });
try {
const answer = await new Promise((resolve) => rl.question(question, resolve));
const a = answer.trim().toLowerCase();
return a === "" || a === "y" || a === "yes";
}
finally {
rl.close();
}
}
/** Install codex's managed build via the official installer. Returns success. */
function installManagedCodex() {
log("setting up real-time wake — installing codex's managed build (one-time)…");
const res = spawnSync("sh", ["-c", "curl -fsSL https://chatgpt.com/codex/install.sh | sh"], {
stdio: "inherit",
});
if (res.status === 0) {
log("managed codex installed.");
return true;
}
log(`managed-codex install did not complete (exit ${res.status ?? "?"}).`);
return false;
}
/**
* codex subcommands that are NOT an interactive TUI session. The app-server rail
* (`--remote`) exists to make a HUMAN's interactive session answer peers in real time, so
* these must be handed to codex **untouched**: they are headless (`exec`, `review`),
* local config/utility (`login`, `mcp`, `plugin`, `doctor`, `update`, `completion`,
* `apply`, `sandbox`, `debug`, session admin), or a server in their own right
* (`mcp-server`, `app-server`, `remote-control`) — attaching them to the daemon is at best
* pointless and at worst wrong. `agents` already talks to the daemon itself.
* (`resume` is deliberately ABSENT: it opens an interactive TUI, so it SHOULD get the rail.)
*/
const NON_TUI_SUBCOMMANDS = new Set([
"exec", "e", "review", "login", "logout", "mcp", "plugin", "mcp-server", "app-server",
"remote-control", "completion", "update", "doctor", "sandbox", "debug", "apply", "a",
"queue", "archive", "delete", "unarchive", "migrate-rollouts", "agents",
]);
/** Top-level flags that just print and exit — never worth starting a daemon for. */
const PRINT_AND_EXIT_FLAGS = new Set(["--help", "-h", "--version", "-V"]);
/**
* Should this invocation get the app-server (real-time wake) rail?
*
* `cogent-codex` is a full encapsulation of `codex`: ANY codex command works through it.
* Only an interactive TUI session benefits from the daemon, so:
* - `cogent-codex` / `cogent-codex "prompt"` / `cogent-codex resume <id> …` → rail ON
* - `cogent-codex exec …` / `login` / `plugin add …` / `--help` → passed through as-is,
* and we do NOT even start the daemon.
* Everything after a `--` separator is the user's payload, never a subcommand.
*/
export function wantsDaemonRail(passthrough) {
for (const a of passthrough) {
if (a === "--")
break;
if (PRINT_AND_EXIT_FLAGS.has(a))
return false;
if (!a.startsWith("-") && NON_TUI_SUBCOMMANDS.has(a))
return false;
}
return true;
}
/**
* Build the `codex --remote` argv, pinning the working root to THIS launch directory.
*
* WHY (critical): the app-server daemon is a shared, machine-wide singleton rooted at
* whatever directory it FIRST started in. A plain `codex --remote` TUI inherits the
* DAEMON's cwd — so launching cogent-codex from project B, while the daemon started in
* project A, would run codex against project A's files (wrong, and unsafe). codex's
* global `-C, --cd <DIR>` sets the working root per-session (verified: `--remote -C /x`
* roots the conversation at /x regardless of the daemon's cwd), so we pin it to the
* caller's cwd. A user-supplied `-C`/`--cd` always wins (we don't add ours).
*/
export function buildRemoteArgs(sock, passthrough, cwd) {
// Only args BEFORE a `--` are flags; after it they are the user's payload.
const flags = passthrough.slice(0, passthrough.indexOf("--") === -1 ? undefined : passthrough.indexOf("--"));
const userSetCwd = flags.some((a) => a === "-C" || a.startsWith("-C") || a === "--cd" || a.startsWith("--cd="));
const cwdArgs = userSetCwd ? [] : ["-C", cwd];
// `--remote` + `-C` are global flags and must precede any subcommand (e.g. `resume`),
// which is why they are prepended rather than appended.
return ["--remote", `unix://${sock}`, ...cwdArgs, ...passthrough];
}
/** Spawn codex with the given args, inherit the terminal, propagate its exit code. */
function handOff(codexBin, args) {
const child = spawn(codexBin, args, { stdio: "inherit" });
child.on("exit", (code, signal) => {
if (signal)
process.kill(process.pid, signal);
else
process.exit(code ?? 0);
});
child.on("error", (err) => {
log(`failed to launch '${codexBin}': ${err.message}`);
process.exit(127);
});
}
/**
* Resolve the app-server socket, self-bootstrapping the managed codex build if it's
* missing (one Y/n). Returns the socket, or null to launch plain codex (turn-boundary).
*/
async function resolveDaemonSock() {
try {
return await ensureDaemon();
}
catch (err) {
if (isManagedCodexMissing(err)) {
const ok = await confirmYes("[cogent-codex] Real-time peer wake needs codex's managed build (one-time install). Install it now? [Y/n] ");
if (ok && installManagedCodex()) {
resetDaemonCache();
try {
return await ensureDaemon();
}
catch (retryErr) {
log(`daemon still unavailable after install (${retryErr?.message ?? retryErr}).`);
return null;
}
}
log("skipped — launching on the default rail; peers reach you at your next turn.");
return null;
}
log(`app-server daemon unavailable (${err?.message ?? err}); launching plain codex.`);
return null;
}
}
async function main() {
loadConfig(process.env);
const codexBin = getConfig().COGENT_CODEX_PATH;
const passthrough = process.argv.slice(2);
// FULL ENCAPSULATION: any codex command runs through cogent-codex. Non-interactive ones
// (exec/login/plugin/mcp/--help/…) go straight to codex untouched — no injected flags and
// no daemon started (starting one just to print --help would be absurd).
if (!wantsDaemonRail(passthrough)) {
handOff(codexBin, passthrough);
return;
}
await warnOnServerCollision();
const sock = await resolveDaemonSock();
if (sock) {
log("real-time peer wake ON.");
// Pin the working root to THIS dir — the shared daemon's cwd must NOT leak in. See
// buildRemoteArgs.
handOff(codexBin, buildRemoteArgs(sock, passthrough, process.cwd()));
}
else {
handOff(codexBin, passthrough);
}
}
/**
* Is this module being run as a CLI (rather than imported by a test)?
*
* 🔴 MUST compare REALPATHS. npm installs `cogent-codex` as a SYMLINK in its bin dir, so when a
* user runs it `process.argv[1]` is the link (…/bin/cogent-codex) while `import.meta.url` is the
* real file (…/dist/bin/cogent-codex.js). Comparing them directly is always false through a
* symlink, so `main()` never ran: the command printed nothing, exited 0, and returned the user
* straight to a fresh prompt — the entire codex real-time-wake feature was dark for every
* npm-installed user while running the file directly (how it was always tested) worked fine.
*/
function invokedAsCli() {
const argv1 = process.argv[1];
if (!argv1)
return false;
try {
return import.meta.url === pathToFileURL(realpathSync(argv1)).href;
}
catch {
return import.meta.url === pathToFileURL(argv1).href; // unreadable path → best effort
}
}
if (invokedAsCli()) {
main().catch((err) => {
log(`fatal: ${err?.message ?? err}`);
process.exit(1);
});
}
//# sourceMappingURL=cogent-codex.js.map