@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.
305 lines • 17.2 kB
JavaScript
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
import { SERVER_NAME, SERVER_VERSION } from "./constants.js";
import { SERVER_INSTRUCTIONS } from "./instructions.js";
import { startUpdateCheckLoop } from "./backend/npm-update-check.js";
import { runStartup, runPreflights, resolvePendingLabel, isCloudMode, setInboxNotifier, cloudWsClient, cloudInbox, autoRelay, restorePeerPresence, cancelPresenceRetry } from "./startup.js";
import { getConfig } from "./config.js";
import { logger } from "./logger.js";
import { registerRegisterPeerTool } from "./tools/register-peer.js";
import { registerSendMessageTool } from "./tools/send-message.js";
import { registerListPeersTool } from "./tools/list-peers.js";
import { registerGetHistoryTool } from "./tools/get-history.js";
import { registerDeregisterPeerTool } from "./tools/deregister-peer.js";
import { registerHealthCheckTool } from "./tools/health-check.js";
import { registerWakeLogTool } from "./tools/wake-log.js";
import { registerCreateSessionTool } from "./tools/create-session.js";
import { registerJoinSessionTool } from "./tools/join-session.js";
import { registerSetupMailTool } from "./tools/setup-mail.js";
import { registerSendMailTool } from "./tools/send-mail.js";
import { registerFetchMailTool } from "./tools/fetch-mail.js";
import { registerRotateMailCredsTool } from "./tools/rotate-mail-creds.js";
import { heartbeat } from "./services/heartbeat.js";
import { drainCapMs } from "./services/wake-inflight.js";
import { markBridgeProcess, startedWithoutClient, withdrawBridge, PROCESS_MARK } from "./services/wake-ownership.js";
// ---------------------------------------------------------------------------
// Global error handlers -- registered FIRST before anything else
// ---------------------------------------------------------------------------
process.on("uncaughtException", (err) => {
// EPIPE means the parent process disconnected -- exit cleanly, and SAY so: this exit used to be
// silent, so an orphan that died on its first inbound message left no trace in its own log.
if (err.code === "EPIPE") {
stdoutDead = true;
void clientGone("stdout EPIPE — the MCP client is gone");
return;
}
logger.error(`Uncaught exception: ${err.message}`, { stack: err.stack });
// Do NOT exit -- MCP server should try to continue
});
process.on("unhandledRejection", (reason) => {
const msg = reason instanceof Error ? reason.message : String(reason);
logger.error(`Unhandled rejection: ${msg}`);
});
// ---------------------------------------------------------------------------
// Graceful shutdown handlers
// ---------------------------------------------------------------------------
let shuttingDown = false;
let draining = false;
/** Set on the first EPIPE: nothing written to stdout can reach the client any more. */
let stdoutDead = false;
/** The ONE exit path: logged, and idempotent (stdin 'end' and 'close' both fire on one EOF). */
function shutdown(reason) {
if (shuttingDown)
return;
shuttingDown = true;
try {
logger.info(`Shutting down: ${reason}`);
autoRelay.stop(); // also withdraws this bridge's ownership announcement
withdrawBridge(process.cwd(), PROCESS_MARK);
heartbeat.stop();
if (cloudWsClient) {
cloudWsClient.disconnect();
}
logger.close();
}
finally {
process.exit(0);
}
}
// A signal is an explicit stop (the host after its grace period, the reaper, a human): exit NOW.
// The SDK's own client sends SIGTERM 2 s after closing stdin, so a drain there is cut short anyway.
process.on("SIGTERM", () => shutdown("SIGTERM received"));
process.on("SIGINT", () => shutdown("SIGINT received"));
/** Client requests still being handled, id → what they are (for the drain log). */
const inFlightRequests = new Map();
/**
* The MCP client went away (stdin EOF/close, stdout EPIPE): finish what is IN FLIGHT, then exit.
*
* Exiting at once dropped the reply to a request already being handled (piped initialize +
* tools/call + EOF got no response) and abandoned a running wake, whose reply goes to the RELAY,
* not to the departed client. Takes no new work: the relay socket and heartbeat stop first, so
* nothing new is delivered, and a pending presence restore is cancelled.
*
* 🔴 FIRST, STOP CLAIMING THE PEER NAME. A draining bridge serves no agent; while it counted as the
* holder, the RESTARTED agent's new bridge stood by behind it and never came online (review r2
* NEW-1). Its announcement is marked `draining`, not removed: removed, the new bridge's reaper killed
* it mid-wake (review r3 R3-2). The relay registration and the wake keep running; only the claim goes.
*
* 🔴 MCP REQUESTS ARE DRAINED ONLY WHILE STDOUT IS ALIVE. Once a write has hit EPIPE the response
* cannot be delivered, and the SDK's send() waits forever for a 'drain' that never comes — so it
* held every drain to the cap (review r2 NEW-2). A wake still counts: its result goes to the RELAY.
*
* CAP = drainCapMs(COGENT_TIMEOUT_MS): the product's own bound on one wake (two execRemote runs,
* 3 × the timeout), never more than 30 min.
*/
async function clientGone(reason) {
if (draining || shuttingDown)
return;
draining = true;
autoRelay.announceDraining();
const busy = () => {
const d = autoRelay.getDiagnostics();
return { requests: stdoutDead ? [] : [...inFlightRequests.values()], wake: d.processing, queued: d.queueLength };
};
const b0 = busy();
if (!b0.requests.length && !b0.wake && !b0.queued)
return shutdown(reason);
let capMs = drainCapMs(300_000);
try {
capMs = drainCapMs(getConfig().COGENT_TIMEOUT_MS);
}
catch { /* config not loaded: default */ }
logger.info(`Client gone (${reason}) — draining before exit (cap ${Math.round(capMs / 1000)}s): ` +
`${b0.requests.length} MCP request(s) [${b0.requests.join(", ")}], wake running: ${b0.wake}, queued: ${b0.queued}`);
cancelPresenceRetry();
heartbeat.stop();
cloudWsClient?.disconnect();
const t0 = Date.now();
let b = b0;
while (Date.now() - t0 < capMs) {
b = busy();
if (!b.requests.length && !b.wake && !b.queued)
break;
await new Promise((r) => setTimeout(r, 50));
}
// A response can still sit in stdout's buffer (pipes are asynchronous on macOS): flush it.
if (!stdoutDead) {
await new Promise((r) => {
const t = setTimeout(r, 1_000);
try {
process.stdout.write("", () => { clearTimeout(t); r(); });
}
catch {
clearTimeout(t);
r();
}
});
}
const left = b.requests.length || b.wake || b.queued;
const undeliverable = stdoutDead && inFlightRequests.size
? ` (${inFlightRequests.size} MCP response(s) undeliverable: stdout closed)`
: "";
shutdown(left
? `${reason}; drain cap reached after ${Date.now() - t0}ms — ABANDONED ${b.requests.length} MCP ` +
`request(s) [${b.requests.join(", ")}], wake running: ${b.wake}, queued: ${b.queued}${undeliverable}`
: `${reason}; drained in ${Date.now() - t0}ms (${b0.requests.length} MCP request(s), wake: ${b0.wake}, queued: ${b0.queued})${undeliverable}`);
}
// ---------------------------------------------------------------------------
// Main entry point
// ---------------------------------------------------------------------------
async function main() {
// FIRST: be recognisable as a current bridge to every reaper in this folder, registered or not.
markBridgeProcess(process.cwd(), SERVER_VERSION);
// Startup MUST complete before MCP transport starts
// (handles first-run prompt, config loading, logger init, validation)
await runStartup();
// COG-81: `instructions` is the one text a cold agent reads before any tool schema or skill.
const server = new McpServer({ name: SERVER_NAME, version: SERVER_VERSION }, { instructions: SERVER_INSTRUCTIONS });
// Register all 8 tools (6 existing + create-session + join-session)
registerRegisterPeerTool(server);
registerDeregisterPeerTool(server);
registerSendMessageTool(server);
registerListPeersTool(server);
registerGetHistoryTool(server);
registerHealthCheckTool(server);
registerWakeLogTool(server);
registerCreateSessionTool(server);
registerJoinSessionTool(server);
registerSetupMailTool(server);
registerSendMailTool(server);
registerFetchMailTool(server);
registerRotateMailCredsTool(server);
// Register the MCP inbox resource for cloud mode.
//
// 🔴 This used to be gated on `cloudInbox` EXISTING AT STARTUP, which quietly
// excluded every deferred agent — i.e. anyone who joins with
// cogent_join_session rather than pre-supplying credentials. Their inbox is
// created later by reinitCloudBackend(), long after this registration ran, so
// `cogent://inbox` was never advertised and the sendResourceUpdated() below
// fired for a resource that did not exist. Gate on cloud MODE and read the
// inbox LAZILY inside the handler, so deferred and pre-credentialed agents get
// the same surface.
if (isCloudMode()) {
server.registerResource("cogent_inbox", "cogent://inbox", {
description: "Incoming messages received via cloud bridge WebSocket. Read this resource to see new messages from other peers.",
mimeType: "application/json",
},
// Read the inbox LAZILY: in deferred mode it does not exist yet and is
// created later by reinitCloudBackend(). An empty inbox is the honest
// answer for "connected to cloud, no session joined yet".
async (uri) => ({
contents: [{
uri: uri.href,
mimeType: "application/json",
text: JSON.stringify({
messages: cloudInbox ? cloudInbox.getUnread() : [],
lastMessageId: cloudInbox ? cloudInbox.getLastMessageId() : null,
}),
}],
}));
// When WS messages arrive, notify the MCP host that new messages are available.
// Registered through startup so it also binds to an inbox created LATER
// (deferred join) — attaching directly here would only ever see the
// startup-time inbox, i.e. never in deferred mode.
setInboxNotifier(() => {
try {
server.server.sendResourceUpdated({ uri: "cogent://inbox" });
}
catch (err) {
// sendResourceUpdated may fail if transport is not connected yet
logger.warn(`Failed to send resource update notification: ${err}`);
}
});
}
// Connect stdio transport
const transport = new StdioServerTransport();
// Set BEFORE connect: the SDK chains a pre-existing onclose, replacing it afterwards would drop
// the SDK's own. SDK 1.26.0 never fires it on EOF — the stdin listeners below are what work.
transport.onclose = () => void clientGone("MCP transport closed");
await server.connect(transport);
// Track requests from arrival to response so a departing client's in-flight work can be drained.
// Wrapped AFTER connect: connect() is what installs the SDK's onmessage.
const sdkOnMessage = transport.onmessage;
transport.onmessage = (msg) => {
if ("method" in msg && "id" in msg) {
const tool = msg.method === "tools/call" ? msg.params?.name : undefined;
inFlightRequests.set(msg.id, typeof tool === "string" ? `tools/call ${tool}` : msg.method);
}
else if ("method" in msg && msg.method === "notifications/cancelled") {
// The SDK never answers a cancelled request — left here it would hold every drain to the cap.
const id = msg.params?.requestId;
if (typeof id === "string" || typeof id === "number")
inFlightRequests.delete(id);
}
sdkOnMessage?.(msg);
};
const sdkSend = transport.send.bind(transport);
transport.send = (msg) => {
const settles = !("method" in msg) && "id" in msg ? msg.id : undefined;
return sdkSend(msg).finally(() => {
if (settles !== undefined)
inFlightRequests.delete(settles);
});
};
// 🔴 EXIT WHEN THE MCP CLIENT GOES AWAY. The SDK listens to stdin 'data'/'error' only, and the
// relay WebSocket is the one handle never unref'd — so a REGISTERED bridge outlived its parent
// indefinitely, holding the peer name and its wakes (marvin, 2026-10-12/13: script-spawned
// bridges reparented to pid 1 kept marvin-coder). A client's stdin is a pipe or a socket. A
// character device is a terminal (a human) or /dev/null (started headless: `stdio: "ignore"`,
// systemd, nohup) — nobody is on the other end, so its EOF says nothing. 🔴 Round 1 exited on
// /dev/null too, and a bridge a script had started as a wake daemon was gone at once (review r5 R5-4).
const stdinIsDevice = startedWithoutClient(); // also recorded in this bridge's announcements
if (!process.stdin.isTTY && !stdinIsDevice) {
process.stdin.once("end", () => void clientGone("stdin EOF — the MCP client is gone"));
process.stdin.once("close", () => void clientGone("stdin closed — the MCP client is gone"));
}
// Startup banner: always prints regardless of log level
process.stderr.write(`${SERVER_NAME} v${SERVER_VERSION} running on stdio\n`);
// A COGENT_SESSION_ID *label* needs a relay round-trip to become a session UUID.
// It runs HERE, after connect, for exactly the same reason preflight does:
// awaiting it beforehand added a reproducible +2437 ms to `initialize` against an
// unreachable relay (measured by scripts/startup-nonblocking-test.mjs). Bounding
// that fetch with a 2500 ms AbortSignal — what the code used to do — caps the
// stall but does not remove it. Fire-and-forget; on success it upgrades the
// deferred backend via reinitCloudBackend().
void resolvePendingLabel().catch((err) => {
logger.warn(`Label resolve error (non-fatal): ${err instanceof Error ? err.message : String(err)}`);
});
// Platform CLI + sandbox preflight runs AFTER the transport connects: it SPAWNS
// the vendor CLI (`codex/claude --help`, capped at 10s) and must never precede
// the `initialize` handshake — a slow spawn there closed the MCP connection on
// cold hosts. Fire-and-forget: warn-only diagnostics + the sandbox-fallback
// override, both consulted only later at wake time. Never throws, but guard anyway.
void runPreflights().catch((err) => {
logger.warn(`Preflight error (non-fatal): ${err instanceof Error ? err.message : String(err)}`);
});
// PRESENCE. Work out whether this agent is actually IN its channel, and if it owns a
// peer there but has not registered it, put it back. An agent whose bridge is running
// while its peer is absent is invisible to everyone messaging it, and every repair in
// this client used to sit behind a successful register — so when registration was the
// thing that did not happen, nothing recovered and nothing cleaned up. Measured: 18
// live bridges on one checkout, peer absent from a healthy channel for five days.
// States and the full explanation: services/peer-presence.ts.
// Fire-and-forget for the same reason as the two above: it does network I/O and must
// never precede `initialize`. Never throws; guarded anyway.
void restorePeerPresence().catch((err) => {
logger.warn(`Presence restore error (non-fatal): ${err instanceof Error ? err.message : String(err)}`);
});
// Best-effort proactive update nudge. Fire-and-forget: never blocks the server
// becoming ready, never throws, and writes only to STDERR (stdout is the MCP
// protocol). Stays silent unless a newer cogent-bridge is published on npm.
// Re-checks on an interval, not just at boot: this process runs for days, and a one-shot check
// could never see a version published after startup (3.21.4→3.21.8 all shipped inside one day).
// The timer is unref'd, so it never keeps the process alive at shutdown.
startUpdateCheckLoop(SERVER_VERSION);
logger.info("Server ready. Tools: cogent_register_peer, cogent_deregister_peer, cogent_send_message, cogent_list_peers, cogent_get_history, cogent_health_check, cogent_create_session, cogent_join_session");
}
main().catch((err) => {
const msg = err instanceof Error ? err.message : String(err);
logger.error(`Fatal startup error: ${msg}`);
// Fallback to stderr in case logger failed
process.stderr.write(`Fatal error: ${msg}\n`);
logger.close();
process.exit(1);
});
//# sourceMappingURL=index.js.map