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.

309 lines • 16.2 kB
#!/usr/bin/env node /** * 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