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.

462 lines • 28.5 kB
// --------------------------------------------------------------------------- // PEER PRESENCE — what state this agent is in on its channel, why, and getting // it back to `online` without anyone being told to restart something. // // ── THE VOCABULARY, taken from what this codebase already says ────────────── // "local" COGENT_LOCAL / no relay. Presence is not a concept here. // "deferred" EXISTING TERM (backend-provider.ts): cloud-CONFIGURED but never joined — // there are no stored credentials for this working directory. Nothing to // restore; the agent must join a channel first. // "offline" Stored credentials name a peer for this agent, but this process has not // registered it. The bridge is running; the AGENT IS NOT IN THE CHANNEL. Other // peers see it as absent and cannot address it. // "online" Registered. The channel lists this agent and messages reach it. // "standby" Stored credentials name a peer, but ANOTHER live bridge has announced it for // this working directory, so this process does not register (that would take // the identity from it). This process is NOT in the channel. It used to report // "online" and log at DEBUG: marvin-coder's session bridge sat unregistered behind // a dying 3.23.8 bridge (2026-10-13), and nothing said so. // ONE RULE SET (startup.ts restorePeerPresence applies it; holderRelation below): // a. no live holder ⇒ restore from the folder's credential file. A holder whose MCP // client is GONE (an orphan, reparented) or that is DRAINING serves no agent and // is not a holder. // b. the holder is PROVEN to be this agent's own bridge — both announced the same // agent session (plugin + npx, a restart of one Claude session) ⇒ stand by, // RE-CHECK, restore once it is gone or draining. // c. the holder is, or may be, ANOTHER agent — the two session ids differ, only one // side has one, or NEITHER has one (codex, gemini) ⇒ stand by and STAY there, // exactly as on main. 🔴 Round 5 re-checked here too, and a second window's idle // bridge took the agent's name when the agent's window closed; the reopened agent // then stood by behind it and its messages woke the other session (review r5 // R5-1). 🔴 Round 6 still re-checked when neither side had an id, and a second // codex/gemini agent's bridge did the same (review r6 FIX1-2, SF-1). Nothing a // session-less bridge can read tells its own previous bridge from another // agent's, so a restarted codex agent whose old bridge lingers under the // app-server daemon registers again by hand, as on main. Only an explicit // cogent_register_peer takes a name from a bridge that may be another agent's. // // ── HOW AN AGENT ENDS UP `offline` — the mechanism, stated plainly ────────── // Registration is an explicit act: something must call `cogent_register_peer`. The // bridge starting does NOT register — it only publishes the tools. So every time an // agent session starts, the bridge comes up holding valid credentials while the agent // is absent from its own channel, and stays that way until the agent calls the tool. // If the agent never calls it — it forgot, it crashed first, the session was resumed // from a transcript that had already registered — the peer is never (re-)created, and // the relay's stale sweep eventually deletes the entry left over from last time. // // That state used to be both SILENT and SELF-PERPETUATING, because every repair in // this client sat behind a successful registration: // - announceBridge() (auto-relay) — written at register time // - reapStaleBridges() (auto-relay) — "only after a peer is genuinely registered" // - triggerReRegistration()(startup) — reads in-memory state, null in a fresh process // So when registration was the thing that did not happen, nothing recovered and nothing // cleaned up. Measured 2026-09-14 on one checkout: 18 live bridges at 3.23.9, last // successful register five days earlier, the peer absent from a healthy Team channel // whose two other agents were present and waiting on it. // // ── WHY RESTORING IT IS SAFE ──────────────────────────────────────────────── // - Credentials are keyed PER WORKING DIRECTORY (~/.cogent/credentials/<sha256(cwd)[..16]>.json, // commit f758d8ee). A bridge in project A cannot inherit project B's channel. // - The agent-session id need not be correct here: auto-relay re-resolves it on every // wake (auto-relay.ts, "Only adopt a resolved id") and register-peer itself falls // back to the channel session id on an unresolved resolution. // - `peerId` in the credential is the CONSENT SIGNAL. create/join write credentials // without one, and deregister clears it, so a deliberate departure is never undone. // // ── WHY WE DO NOT JUST ANNOUNCE ───────────────────────────────────────────── // Reap signal 4 skips any ANNOUNCED pid, reasoning that an announced bridge is 3.23.4+ // and stands down by itself. Announcing WITHOUT registering would make every leftover // immortal: exempt from reaping, yet never superseding itself (supersession is only // consulted on a wake, which a bridge with no peer never gets). The invariant is "a live // bridge either REGISTERS — and so announces and can stand down — or it stays reapable." // Restoring presence registers, so the announce it performs is correct. Since 3.25.7 that // invariant is carried differently: a bridge exits when its MCP client goes away (index.ts), so a // leftover no longer needs reaping, and every bridge is in the registry from process start under // PROCESS_MARK (wake-ownership.ts) — never under a peer name it has not registered. // --------------------------------------------------------------------------- import { logger } from "../logger.js"; import { PROCESS_MARK, registryText } from "./wake-ownership.js"; export function holderRelation(holderSession, ownSession) { if (holderSession && ownSession) return holderSession === ownSession ? "same-session" : "other-session"; return holderSession || ownSession ? "unknown" : "no-session"; } /** Process-tree roots an orphan is reparented to: init, launchd, a `systemd --user` subreaper. */ const ORPHAN_PARENT_RE = /^(init|launchd|systemd)$/; /** * The two wrappers between an npx-launched bridge and its MCP client, as `ps` shows them (measured * 2026-10-13): `node …/.bin/cogent-bridge` ← `sh -c cogent-bridge` ← `npm exec @essentialai/cogent-bridge…`. */ const NPX_WRAPPER_RE = /^(?:\S*\/)?(?:sh|bash|dash) -c cogent-bridge\b|^npm exec @essentialai\/cogent-bridge/; /** * Is the MCP client of an announced bridge GONE? Then the bridge serves no agent — it is an orphan * left draining or (pre-3.25.7) running forever, like marvin's script-spawned bridges — and must not * keep a restarted agent out of its own channel. * * - 3.25.7+ announcements record `parentPid`: gone when the bridge's CURRENT parent differs from it * (an orphan is reparented; the kernel never hands a live child to another parent otherwise). * - older announcements carry none: gone when the parent is pid 1 or an init-like process. * 🔴 Not "ppid === 1" alone: a Linux desktop reparents orphans to its `systemd --user` * subreaper (measured on this dev box, 2026-10-13: an orphaned `sleep` got ppid 1615 = systemd). * 🔴 And not the immediate parent alone (review r3 R3-5): an npx-launched bridge keeps its `sh` * and `npm exec` wrappers when the client dies — the one reparented is npm. Walk up through them. * * Unknown (the holder is not in the process list, ps failed) ⇒ NOT gone: no proof ⇒ it still counts. * A holder that announced it started WITHOUT a client (headless) cannot lose one ⇒ never gone. */ export function holderClientGone(holder, procs) { if (holder.noClient === true) return false; const byPid = new Map(procs.map((p) => [p.pid, p])); let cur = byPid.get(holder.pid); if (!cur) return false; if (typeof holder.parentPid === "number") return cur.ppid !== holder.parentPid; for (let hops = 0; hops < 3; hops++) { if (cur.ppid === 1) return true; const parent = byPid.get(cur.ppid); if (!parent) return false; const exe = parent.argv.trim().split(/\s+/)[0] ?? ""; if (ORPHAN_PARENT_RE.test(exe.split("/").pop() ?? "")) return true; if (!NPX_WRAPPER_RE.test(parent.argv.trim())) return false; cur = parent; } return false; } /** * Was this bridge started inside a `codex exec resume` wake turn? codex (unlike claude's * --strict-mcp-config, measured: no bridge starts in a claude wake turn) starts the configured MCP * servers for the resumed turn, so each codex wake spawns a bridge in the same checkout. It serves * no agent: the bridge that is waking it does. It must never register as the peer (review r3 R3-1) * and must not be reported as an agent locked out of its channel (review r2 NEW-4). */ export function insideCodexWakeTurn(selfPid, procs) { const byPid = new Map(procs.map((p) => [p.pid, p])); const seen = new Set(); let cur = byPid.get(selfPid)?.ppid; while (cur && cur > 1 && !seen.has(cur)) { seen.add(cur); const p = byPid.get(cur); if (!p) break; // `codex exec resume …`, `node …/codex.js exec resume …`, `…/vendor/…/codex exec resume …` if (/(^|[\s/])codex(\.js)?\s+exec\s+resume(\s|$)/.test(p.argv)) return true; cur = p.ppid; } return false; } /** * The OTHER live bridges registered in this folder — what an explicit cogent_register_peer tells * its caller about (otherBridgeNotice). Not counted: this process, a bridge that announced this same * agent session (the agent's own second bridge), a draining bridge and one whose MCP client is gone * (they serve no agent), and the process marks. */ export function otherBridgesInFolder(entries, self, /** Called at most once, and only when a candidate is left to check (one `ps`). */ listProcs) { let procs = null; return entries.filter((e) => e.peerId !== PROCESS_MARK && e.pid !== self.pid && !e.draining && holderRelation(e.sessionId, self.sessionId) !== "same-session" && !holderClientGone(e, (procs ??= listProcs()))); } /** * COG-85: how long a closed agent's old connection can still hold its name. MEASURED 2026-10-15 (codex * 0.162.1, Linux and macOS, probe MCP server): codex's app-server keeps a quit thread's MCP server * alive for 60.0 s, then SIGTERMs it. Plain `codex` uses that daemon too. Gemini and `codex --no-daemon` * end it at once. A name still held after this is held by another live connection. */ export const HOLDER_LINGER_SECONDS = 60; /** * The live bridge in this folder that holds `peerId`, from otherBridgesInFolder (so never this * process, this agent session's own bridge, a draining one, or one whose client is gone). */ export function heldInFolder(others, peerId, ownSession) { return others.find((o) => o.peerId === peerId && !mayBeOwnPreSessionBridge(o, ownSession)); } /** * A bridge older than 3.25.7 records no agent session at all. Beside a bridge that knows its Claude * session it may be that session's OWN older bridge — the plugin's bridge next to an `npx @latest` one * (r5 SEC-2) — which lives as long as the session, so "wait 60 s" would never free the name (side-agent * review 2026-10-15). Not refused, as before COG-85. Only a plain x.y.z version below 3.25.7 qualifies: * an unparseable one is treated as current. */ export function mayBeOwnPreSessionBridge(holder, ownSession) { if (holder.sessionId || !ownSession) return false; const v = /^(\d+)\.(\d+)\.(\d+)$/.exec(String(holder.version ?? "")); if (!v) return false; const [maj, min, pat] = [Number(v[1]), Number(v[2]), Number(v[3])]; return maj < 3 || (maj === 3 && (min < 25 || (min === 25 && pat < 7))); } /** * What an agent locked out of a held name is told to do (COG-85, operator 2026-10-15: retry in 60+ s). * 🔴 COG-80: "wait 60 s" is only true when the holder may be this agent's OWN previous connection. When * it is provably ANOTHER agent's live bridge (another session, or another platform), waiting frees * nothing — KLAIRE 2026-10-16: Codex was told to wait while Claude's live bridge held its name. */ export function heldNameAdvice(peerId, holder, own) { const otherSession = !!(holder?.sessionId && own?.sessionId && holder.sessionId !== own.sessionId); const otherPlatform = !!(holder?.platform && own?.platform && holder.platform !== own.platform); if (otherSession || otherPlatform) { const who = [holder?.platform && `a ${holder.platform} agent`, holder?.sessionId && `session ${holder.sessionId}`] .filter(Boolean).join(", "); return (`'${peerId}' is held by ANOTHER agent's live bridge in this folder (${who}). It keeps the name while that ` + `agent runs, so waiting will not free it. Do not take the name and do not rename: tell the user which ` + `agent holds '${peerId}' and ask them to have THAT agent run /cogent:register with ITS OWN peer name ` + `(which releases '${peerId}'); after that, run /cogent:register again with the SAME peer name '${peerId}'`); } return (`If this agent was just restarted, its previous connection closes within ${HOLDER_LINGER_SECONDS} seconds: ` + `wait ${HOLDER_LINGER_SECONDS}+ seconds, then run /cogent:register again with the SAME peer name '${peerId}'. ` + `If '${peerId}' is still held after that, another live connection is using it — another agent, or an old ` + `window of this one still open. Do not take the name and do not rename: ask the user`); } /** True when one of them is PROVEN to be another agent session (both ids recorded, different). */ export function anotherAgentSessionAmong(others, ownSession) { return others.some((o) => holderRelation(o.sessionId, ownSession) === "other-session"); } /** * What the agent that just registered `peerId` is told about them, in plain words (operator, * 2026-10-13: "notify"). The registration is NOT refused: a lingering old window must not lock an * agent out of its own name. `verdict` is checkWakeOwnership AFTER this bridge announced: the * newest PROCESS wakes for a name, which is not always the one that registered last. * * 🔴 "Another agent session" is said only when both session ids are recorded and differ. A bridge * that recorded none is usually this agent's OWN — a restarted codex agent's previous thread, the * plugin 3.25.6 bridge beside `npx @latest` — and was told two agents shared its folder (r5 SEC-2). */ export function otherBridgeNotice(others, peerId, verdict, ownSession) { const lines = others.map((o) => { // Registry text is quoted through registryText only: these sentences are read by the agent. const version = registryText(o.version); const who = holderRelation(o.sessionId, ownSession) === "other-session" ? `Another live agent session in this folder (bridge pid ${o.pid}, version ${version}, agent session ${registryText(o.sessionId)})` : `Another live bridge in this folder (pid ${o.pid}, version ${version}; its agent session cannot be ` + `compared with this one, so it may be this agent's own previous or second bridge)`; if (o.peerId !== peerId) { // 🔴 NOT "or waits behind it" (review r7 SEC2-5, measured): a standby behind another session is final. return (`${who} is still registered as '${registryText(o.peerId)}'. It keeps that name while it runs, but the ` + `folder's saved identity now points at this bridge ('${peerId}'): after a restart that bridge's ` + `agent comes back as '${peerId}' only if this bridge is no longer running then; otherwise it ` + `stays out of the channel until it registers again.`); } if (verdict.isOwner) { return (`${who} was registered as '${peerId}'. It is now superseded: this bridge holds '${peerId}' and ` + `that bridge has stopped answering for it.`); } // 🔴 NOT "it keeps answering" (review r5 SEC-1, measured): the newest process is the one that // wakes, but this registration took the name on the relay, which refuses that bridge's replies. // 🔴 "in this channel" (review r7 SEC2-5, measured): registered in ANOTHER channel, that bridge // keeps answering there; the registry does not say which channel it is in. return (`${who} is registered as '${peerId}' too. NOBODY answers for '${peerId}' in this channel now: the ` + `bridge that started last (pid ${verdict.owner?.pid ?? "unknown"}) is the one that wakes its agent, ` + `but in this channel the relay registration now belongs to this bridge, so the relay refuses that ` + `bridge's replies here and this bridge does not wake. It stays that way until that bridge is closed ` + `or its agent registers again. If that bridge is registered in another channel, it still answers there.`); }); return (lines.join(" ") + (anotherAgentSessionAmong(others, ownSession) ? ` Each agent in this folder keeps its own saved name (COG-80): after a restart it comes back under that name, not the other agent's.` : "")); } /** * Automatic presence restore. ON by default — a default-off self-heal heals nobody. * Opt out with COGENT_AUTO_PRESENCE=false to observe the state without acting on it. */ export function autoPresenceEnabled(env = process.env) { const v = env.COGENT_AUTO_PRESENCE; if (v === undefined || v === "") return true; return !/^(0|false|no|off)$/i.test(v.trim()); } /** * Determine this agent's presence and, when it is `offline` with a peer it owns, * bring it back `online`. * * NEVER THROWS. Runs fire-and-forget after the MCP transport connects — the * `initialize` handshake must never wait on I/O (scripts/startup-nonblocking-test.mjs). */ export async function restorePresence(deps) { try { if (!deps.isCloudConfigured()) { return { state: "local", restored: false, detail: "local mode — presence does not apply" }; } if (deps.alreadyRegisteredInProcess()) { return { state: "online", restored: false, detail: "registered by this process" }; } const creds = await deps.loadCredentials(); const skip = creds ? undefined : deps.restoreSkip?.(); if (skip) { return { state: "deferred", restored: false, detail: `nothing restored: ${skip}` }; } if (!creds || !creds.endpoint || !creds.sessionId || !creds.token) { return { state: "deferred", restored: false, detail: "no stored channel credentials for this working directory — join a channel first", }; } if (!creds.peerId) { // Credentials WITHOUT a peerId: created by join/create, or cleared by a deliberate // deregister. Either way this agent is not meant to be in the channel right now. return { state: "deferred", restored: false, detail: "channel credentials exist but name no peer — nothing was registered here", }; } const peerId = creds.peerId; // 🔴 BEFORE any holder check (review r3 R3-1): the bridge that woke this turn may be draining or // orphaned — not a holder — and this one then fell through and registered as the peer. if (deps.startedByWakeTurn?.()) { return { state: "deferred", restored: false, detail: `this bridge runs inside a codex wake turn ('codex exec resume'); peer '${peerId}' belongs ` + `to the agent's own bridge, which started this turn — nothing to restore here`, }; } // Someone live is already this peer. Registering again would take the identity from them. // So stand by — and SAY so: this process is not in the channel. const served = deps.peerAlreadyServed(peerId); if (served) { const session = deps.agentSessionId?.(); const holder = holderRelation(served.sessionId, session); // What is REPORTED (the detail, the log, the health check's servedBy) quotes registry text // through registryText only; the comparison above read the recorded value itself. const servedBy = { ...served, ...(served.version === undefined ? {} : { version: registryText(served.version) }), ...(served.sessionId === undefined ? {} : { sessionId: registryText(served.sessionId) }), }; const started = Number.isFinite(servedBy.startedAt) ? new Date(servedBy.startedAt).toISOString() : "unknown"; // Re-checked only behind a PROVEN own bridge (rule b). Everything else is final (rule c). const recheck = holder === "same-session"; const every = deps.recheckSeconds ? ` (re-checked every ${deps.recheckSeconds}s)` : ""; // COG-85: register refuses while that bridge lives (PEER_HELD_IN_FOLDER), so the advice is to wait it out. const take = heldNameAdvice(peerId); const why = { "same-session": `That bridge serves this same agent session (${session})`, "no-session": `Neither bridge records an agent session (codex, gemini), so it cannot be told whether that ` + `bridge is this agent's previous one or another agent's. ${take}`, "other-session": `That bridge serves another agent session (${servedBy.sessionId}; ours: ${session}). ` + heldNameAdvice(peerId, { sessionId: servedBy.sessionId }, { sessionId: session }), unknown: `Its agent session cannot be compared with this one (that bridge: ${servedBy.sessionId ?? "not recorded"}; ` + `this bridge: ${session ?? "not known"}): it may be this agent's own other bridge of an older ` + `version, or another agent's. ${take}`, }; return { state: "standby", restored: false, peerId, servedBy, holder, recheck, // A final standby is evaluated once, so it says "when this bridge started": the health // check keeps showing this text after that bridge is gone. detail: `peer '${peerId}' ${recheck ? "is already served" : "was already served when this bridge started"} ` + `by a live bridge for this working directory ` + `(pid ${servedBy.pid}, version ${servedBy.version ?? "unknown"}, started ${started}); ` + `this bridge has NOT registered and ` + (recheck ? `restores '${peerId}' by itself once that bridge is gone${every}. ` : `does NOT take '${peerId}' by itself, also after that bridge is gone. `) + why[holder], }; } if (!autoPresenceEnabled(deps.env ?? process.env)) { // Report the state, take no action. This is the "observe, do not act" mode. return { state: "offline", restored: false, detail: `peer '${peerId}' is not registered and COGENT_AUTO_PRESENCE is off — this agent is absent from its channel`, }; } await deps.goOnline({ peerId, endpoint: creds.endpoint, sessionId: creds.sessionId, token: creds.token, cwd: deps.cwd(), // 🔴 THE PEER'S DISPLAY LABEL IS NOT PERSISTED ANYWHERE — only the CHANNEL's is, // in credentials.label (create-session.ts writes `label: data.label`; join-session // writes the resolved channel label). Using that would label the agent after the // channel: a restore once put "paymentorchestrator" beside a sibling called "POFE". // peerId is the one identifier we hold that genuinely names this agent. The // operator's chosen label returns on the next cogent_register_peer, which carries it. label: peerId, // 🔴 WITHOUT THIS THE RESTORE CANNOT RECLAIM ITS OWN PEER. A re-join issues a NEW // token, and AUD-003 ownership is proven by the token that CREATED the peer or by // the secret issued with it. Omitting it means an entry owned by an earlier token // answers PEER_NOT_YOURS and the agent stays offline — precisely the state this // module exists to leave. The secret is in the credential file; send it. peerSecret: creds.peerSecret, }); return { state: "online", restored: true, peerId, // Says what THIS process did. Whether the relay now lists the peer is the relay's // state, and asserting it from here would be a success nobody measured. detail: `re-registered peer '${peerId}' from stored credentials`, }; } catch (err) { const reason = err instanceof Error ? err.message : String(err); return { state: "offline", restored: false, detail: `presence restore failed: ${reason}`, }; } } /** * Last evaluated presence, so cogent_health_check can report the agent's own state * without re-probing the relay. * * It lives HERE, in the leaf module, and not in startup.ts: a tool importing startup.ts * creates an import cycle (tools -> startup -> tools) and the imported binding reads back * `undefined` at call time. That cost 26 health-check tests once; do not move it. */ let lastPresence = null; /** Record the evaluated presence. Called by startup after each evaluation. */ export function setPresence(outcome) { lastPresence = outcome; } /** The agent's own view of its channel presence. Null until first evaluated. */ export function getPresence() { return lastPresence; } /** Report the outcome once, at a level that matches how much it matters. */ export function logPresence(outcome) { if (outcome.state === "online" && outcome.restored) { logger.info(`Presence: offline -> online — ${outcome.detail}. This agent is addressable on its ` + `channel again, and leftover bridges for this checkout are now reapable.`); } else if (outcome.state === "offline") { logger.warn(`Presence: OFFLINE — ${outcome.detail}.`); } else if (outcome.state === "standby" && outcome.holder === "other-session") { // Another agent session holds the name, proven: this bridge's agent is absent from its channel. logger.warn(`Presence: STANDBY — ${outcome.detail}.`); } else if (outcome.state === "standby" || outcome.state === "deferred") { // A deliberate skip still decides whether this agent is reachable. DEBUG-only made both // branches invisible at the default level (marvin 445619: nothing after "preflight OK"). logger.info(`Presence: ${outcome.state.toUpperCase()} — ${outcome.detail}.`); } else { logger.debug(`Presence: ${outcome.state} — ${outcome.detail}.`); } } //# sourceMappingURL=peer-presence.js.map