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.

305 lines • 17.2 kB
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