@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.
309 lines • 16.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). ONE exception (COG-83): a resume/fork
* carrying a permission flag is launched WITHOUT `--remote` on codex >=0.154, which
* refuses that combination; see buildLaunchArgs.
* 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];
}
/**
* COG-83. codex >=0.154 REFUSES a permission override on a `--remote` resume/fork and exits 1:
* "Error: Permission overrides are not supported when resuming a remote task."
* (a NEW `--remote` session still accepts them; codex <=0.153 accepted them everywhere).
* Covers the spellings codex 0.162.0 was measured to refuse: the short flags with an attached
* value (`-anever`, `-sread-only`), `--yolo`, and six `-c`/`--config` keys, attached
* (`-csandbox_mode=…`, `-c=…`) or not. codex trims the key, so ` sandbox_mode = "x"` counts;
* `sandbox_workspace_write .x=…` is a different key and codex accepts it. The permission-profile
* keys (`default_permissions`, `permissions.<name>…`) are refused too; `permission_profile`,
* `sandbox_permissions` and `default_permissions_x` are not (all measured).
*/
const PERMISSION_FLAG = /^-[sa](.|$)|^--(sandbox|ask-for-approval)(=|$)|^--(dangerously-bypass-approvals-and-sandbox|yolo|approve-for-me|not-so-yolo)$/;
const PERMISSION_CONFIG = /^\s*(sandbox_mode|approval_policy|approvals_reviewer|default_permissions|permissions|sandbox_workspace_write)(\.|\s*=)/;
/** Is this a `resume`/`fork` carrying a permission override (args before any `--` only)? */
export function hasPermissionOverrideOnResume(passthrough) {
const end = passthrough.indexOf("--");
const flags = end === -1 ? passthrough : passthrough.slice(0, end);
if (!flags.some((a) => a === "resume" || a === "fork"))
return false;
return flags.some((a, i) => {
if (PERMISSION_FLAG.test(a))
return true;
// A config KEY counts only as the value of -c/--config: attached (-cKEY, -c=KEY, --config=KEY)
// or the next token. Any other argument that merely looks like a key keeps the rail (review F5).
const attached = /^(--config=|-c=?)(.+)/.exec(a)?.[2];
const afterConfigFlag = flags[i - 1] === "-c" || flags[i - 1] === "--config";
const value = attached ?? (afterConfigFlag ? a : undefined);
return value !== undefined && PERMISSION_CONFIG.test(value);
});
}
/** `codex --version` text → does this codex refuse them? Unparseable → assume a current codex. */
export function remoteResumeRefusesPermissionFlags(versionText) {
const m = /(\d+)\.(\d+)\.\d+/.exec(versionText);
return !m || Number(m[1]) > 0 || Number(m[2]) >= 154;
}
/**
* The argv handed to codex once the daemon is up. Normally `--remote` (buildRemoteArgs). For a
* resume/fork with a permission override on a codex that refuses it, `--remote` is dropped and
* the launch-dir pin is KEPT (`-C <cwd>` + the user's args): without the pin, codex launched from
* a directory other than the thread's recorded one stops at its working-directory picker.
*/
export function buildLaunchArgs(sock, passthrough, cwd, codexVersionText) {
if (hasPermissionOverrideOnResume(passthrough) && remoteResumeRefusesPermissionFlags(codexVersionText)) {
return { args: buildRemoteArgs(sock, passthrough, cwd).slice(2), remote: false };
}
return { args: buildRemoteArgs(sock, passthrough, cwd), remote: true };
}
/** 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) {
// Pin the working root to THIS dir — the shared daemon's cwd must NOT leak in. See
// buildRemoteArgs. `--version` is spawned only for a resume/fork with a permission flag; a
// codex that hangs or prompts there must not hang the wrapper (empty → treated as current).
const version = hasPermissionOverrideOnResume(passthrough)
? String(spawnSync(codexBin, ["--version"], { encoding: "utf8", timeout: 5000 }).stdout ?? "")
: "";
const launch = buildLaunchArgs(sock, passthrough, process.cwd(), version);
log(launch.remote
? "real-time peer wake ON."
: "codex does not accept permission flags on a --remote resume/fork; launching without --remote. " +
"codex 0.159+ attaches to the daemon itself, so real-time wake stays on; older codex answers peers at your next turn.");
handOff(codexBin, launch.args);
}
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