@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.
313 lines • 15.7 kB
JavaScript
/**
* WAKE OWNERSHIP — a superseded bridge process stops waking, by itself, with nobody running pkill.
*
* ── WHY (operator, 2026-09-05) ───────────────────────────────────────────────────────────
* "killed, but my users dont do that - so think how to have self healing mechanism"
*
* ── WHAT IS ACTUALLY ON A REAL MACHINE (measured here, 2026-09-05) ───────────────────────
* 37 live cogent-bridge processes, and NOT ONE of them is an orphan — every parent is alive:
*
* codex app-server (1 daemon, up 3.5 days) ......... 7 bridges, all at a stale 3.22.0
* claude --resume <one interactive session> ........ 2 bridges (plugin AND an npx @latest)
* 5 other interactive `claude --resume` sessions ... 1 each
*
* On that machine "exit when stdin hits EOF" would have healed nothing, because no bridge had lost
* its parent. They were not orphaned, they were REDUNDANT: a long-lived daemon spawns a bridge per
* MCP session and never reaps them, and one agent session can have cogent registered twice.
* A plugin update does not stop any of them, so old versions keep serving for days.
*
* 🔴 CORRECTED 2026-10-13: orphans DO happen. A script that spawns a bridge over stdio and exits
* leaves it reparented to pid 1, and a REGISTERED bridge never noticed (its WebSocket keeps the
* loop alive) — marvin's orphans held marvin-coder for hours. index.ts now exits on stdin EOF. That
* is a separate fix for a separate cause; it does not replace supersession below.
*
* ── THE SIGNAL: SAME peerId, NOT same cwd ────────────────────────────────────────────────
* Superseding on cwd alone would break a legitimate setup — two agents working one checkout (say
* `marvin-coder` and `marvin-mobile`) are two DIFFERENT peers and both must keep waking. What is
* never legitimate is the same peerId served by two bridges in one checkout: that is the ghost,
* and it is exactly what was measured on the real user (`marvin-coder` live on the FREE relay at
* v3.20.0 and on the TEAM relay at v3.23.1, same cwd, same thread).
*
* So ownership is keyed on (cwd, peerId). The wake LOCK (wake-lock.ts) stays keyed on cwd alone,
* because worktree corruption does not care which peer caused it. Two invariants, two keys:
* ownership → only ONE process does this peer's work (kills duplicate work)
* lock → only ONE turn runs in this checkout (kills concurrent corruption)
*
* ── NEWEST WINS, BY PROCESS START, SO IT CANNOT OSCILLATE ────────────────────────────────
* The owner is the live bridge with the latest PROCESS START time. Start time is fixed for the
* life of a process, so a superseded bridge can never win the title back — if ownership were keyed
* on registration time instead, a stood-down bridge that re-registered would steal it back and the
* two would ping-pong forever.
*
* ── ONE FILE PER PID, SO THERE IS NO WRITE RACE ──────────────────────────────────────────
* Each process writes ONLY its own `<pid>.json`; nobody rewrites a shared document, so concurrent
* registration cannot corrupt or lose an entry. Reading is a readdir plus a liveness check, and
* dead entries are pruned opportunistically by whoever notices them.
*
* ── FAILS OPEN ───────────────────────────────────────────────────────────────────────────
* Any unreadable/unwritable state resolves to "you are the owner, go ahead and wake". A
* self-healing mechanism that can silence a healthy agent is a worse bug than the one it heals —
* that is the 3.21.5 lesson, where a fix took auto-wake down for every default-config macOS user.
*/
import crypto from "node:crypto";
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import { logger } from "../logger.js";
/**
* The AGENT SESSION this bridge serves, when its MCP client says which one — else undefined.
*
* MEASURED 2026-10-13 (real CLIs, a probe MCP server): Claude Code 2.1.296 sets
* CLAUDE_CODE_SESSION_ID for every stdio MCP server it spawns (its changelog: since 2.1.154). Codex
* 0.162.0 and gemini 0.58.0 pass none. It is fixed at spawn (stale after `/clear`), so it is used
* ONLY to tell "my own other bridge" from "another agent session" in messages — never to choose
* credentials. COGENT_PLATFORM is checked because a harness-launched codex bridge can inherit the
* variable from a Claude Code shell (real codex strips the environment).
*/
export function agentSessionId(env = process.env) {
if (env.COGENT_PLATFORM && env.COGENT_PLATFORM !== "cc")
return undefined;
const id = env.CLAUDE_CODE_SESSION_ID?.trim();
return id && id.length <= 128 ? id : undefined;
}
/**
* When this process started. Captured at module load — as close to process start as we can get
* from inside — and never recomputed, which is what makes ownership stable and non-oscillating.
*/
const PROCESS_STARTED_AT = Date.now() - Math.floor(process.uptime() * 1000);
/**
* Was this bridge started WITHOUT an MCP client? A client's stdin is a pipe or a socket; a character
* device is a terminal (a human) or /dev/null (a script, systemd, nohup). Read once.
*/
let withoutClient;
export function startedWithoutClient() {
if (withoutClient === undefined) {
try {
withoutClient = fs.fstatSync(0).isCharacterDevice();
}
catch {
withoutClient = false; /* unreadable: treat as a client */
}
}
return withoutClient;
}
/** MUST match wake-lock / wake-claim / credential-store — one cwd, one key, everywhere. */
function hashCwd(cwd) {
return crypto.createHash("sha256").update(path.resolve(cwd)).digest("hex").slice(0, 16);
}
const safe = (s) => String(s).replace(/[^A-Za-z0-9._-]/g, "_");
/** `~/.cogent/bridges/<sha256(cwd)[..16]>.<peerId>/` — one directory per identity. */
export function bridgeDirFor(cwd, peerId) {
return path.join(os.homedir(), ".cogent", "bridges", `${hashCwd(cwd)}.${safe(peerId)}`);
}
/**
* The pseudo-peer every bridge is recorded under FROM PROCESS START, registered or not.
*
* 🔴 WHY. The reaper spares announced pids, and a bridge used to announce only once it registered —
* so a current bridge that was still waiting to register (a fresh session, a standby) looked like a
* pre-3.23.4 leftover and was SIGTERMed (marvin, 2026-10-13; review r4 problem 5). This mark makes a
* 3.25.7+ bridge recognisable whatever its argv and whether or not it ever registers.
* It is an ordinary registry directory on purpose: reapers of 3.23.6–3.25.6 list every
* `<cwdhash>.<name>` directory, so they spare a marked bridge too. Ownership and presence read one
* peer's directory only and never see it.
*/
export const PROCESS_MARK = ".process";
/**
* Does this peer name land in the process marks' directory? Compared as the FILESYSTEM compares it:
* after safe(), case folded — the default macOS volume is case-insensitive, and `.PROCESS` passed an
* exact string compare while sharing the directory.
*/
export function isReservedPeerName(peerId) {
return safe(peerId).toLowerCase() === safe(PROCESS_MARK).toLowerCase();
}
/**
* A value READ FROM A REGISTRY FILE, made fit to quote in a tool result or a log line. Any local
* process of this user can write those files, and what the bridge quotes from them is read by the
* agent (review r7 SEC2-4: a planted `sessionId` put a sentence into the register result). Keeps only
* what a peer id, a session id or a version is made of — no spaces, quotes or line breaks — and at
* most 64 characters.
*/
export function registryText(value) {
return String(value ?? "").replace(/[^A-Za-z0-9._-]/g, "").slice(0, 64) || "unreadable";
}
/**
* The newer owner as named in a log line, built from its registry entry. The version goes through
* registryText(); the endpoint is a URL, so it is JSON-quoted (line breaks and quotes escaped) and capped
* rather than stripped. Review r8 R8-NEW-1: a planted newline in either field forged log lines.
*/
export function registryOwnerLabel(o) {
return `pid ${registryText(o.pid)} (v${registryText(o.version)}, ${JSON.stringify(String(o.endpoint ?? "").slice(0, 200))})`;
}
/** Is that pid a live process? Unknown/odd values count as DEAD so their entries get pruned. */
function pidAlive(pid) {
if (typeof pid !== "number" || !Number.isInteger(pid) || pid <= 0)
return false;
if (pid === process.pid)
return true;
try {
process.kill(pid, 0); // signal 0 = existence check only
return true;
}
catch (err) {
// EPERM means it EXISTS but belongs to another user — alive for our purposes.
return err?.code === "EPERM";
}
}
/**
* Announce this process as a bridge serving `peerId` in `cwd`.
*
* Called once the peer is actually registered, so a bridge that never registered never competes
* for ownership. Best-effort: a failure here leaves us un-announced, which can only make us LOSE
* ownership to someone else, never wrongly take it.
*/
export function announceBridge(cwd, peerId, version, endpoint, sessionId = agentSessionId()) {
const dir = bridgeDirFor(cwd, peerId);
try {
fs.mkdirSync(dir, { recursive: true });
const entry = {
pid: process.pid,
startedAt: PROCESS_STARTED_AT,
version,
peerId,
endpoint,
cwd: path.resolve(cwd),
parentPid: process.ppid,
...(sessionId ? { sessionId } : {}),
platform: process.env.COGENT_PLATFORM || "cc",
// 🔴 A headless bridge never had a client to lose (review r7 FIX2-2, SEC2-2): when its launching
// shell exited, the changed parent read as "client gone" and any bridge that started took its name.
...(startedWithoutClient() ? { noClient: true } : {}),
};
fs.writeFileSync(path.join(dir, `${process.pid}.json`), JSON.stringify(entry));
}
catch (err) {
logger.debug(`wake-ownership: could not announce this bridge (${err.message})`);
}
}
/**
* This bridge's MCP client is gone and it is draining: say so in its OWN announcement.
*
* 🔴 NOT a withdrawal (review r3 R3-2). Round 3 removed the announcement here so the restarted
* agent's bridge would not stand by behind it — but an unannounced bridge is what the reaper
* terminates when it cannot read its version (a global install, a checkout, a fresh npx), and the
* restarted bridge killed this one 326 ms into a 12 s wake. Marked `draining` it stays announced
* (the reaper spares it, and it stands down for the newer bridge on its own) while presence ignores
* it (it serves no agent). Best-effort like the announce itself.
*/
export function markBridgeDraining(cwd, peerId) {
const file = path.join(bridgeDirFor(cwd, peerId), `${process.pid}.json`);
try {
const entry = JSON.parse(fs.readFileSync(file, "utf-8"));
fs.writeFileSync(file, JSON.stringify({ ...entry, draining: true }));
}
catch {
/* never announced, or already withdrawn — nothing claims the peer */
}
}
/** Record this process as a running bridge for `cwd` — at start, before any registration. */
export function markBridgeProcess(cwd, version) {
listLiveBridges(cwd, PROCESS_MARK); // prunes the marks of processes that are gone
announceBridge(cwd, PROCESS_MARK, version, "");
}
/** Remove this process's announcement. Called on graceful shutdown; a crash is handled by pruning. */
export function withdrawBridge(cwd, peerId) {
try {
fs.unlinkSync(path.join(bridgeDirFor(cwd, peerId), `${process.pid}.json`));
}
catch {
/* already gone, or never announced */
}
}
/**
* Every LIVE bridge announced for this identity, newest process first.
*
* Prunes entries whose process is gone, so a machine that has been through a dozen plugin updates
* does not accumulate junk here forever.
*/
export function listLiveBridges(cwd, peerId) {
const dir = bridgeDirFor(cwd, peerId);
let names;
try {
names = fs.readdirSync(dir);
}
catch {
return []; // no directory = nobody announced = no competition
}
const live = [];
for (const name of names) {
if (!name.endsWith(".json"))
continue;
const p = path.join(dir, name);
let entry = null;
try {
entry = JSON.parse(fs.readFileSync(p, "utf-8"));
}
catch {
entry = null;
}
if (!entry || !pidAlive(entry.pid) || typeof entry.startedAt !== "number") {
try {
fs.unlinkSync(p); // prune: the process is gone or the record is unusable
}
catch {
/* raced with another pruner */
}
continue;
}
live.push(entry);
}
// Newest process first; pid breaks a same-millisecond tie deterministically so every process
// computes the SAME owner. A non-deterministic tie-break would let two bridges each believe
// they own it — the exact condition this exists to prevent.
live.sort((a, b) => b.startedAt - a.startedAt || b.pid - a.pid);
return live;
}
/** Every live bridge recorded for this folder under ANY name, the process marks included. */
export function listLiveBridgesInFolder(cwd) {
const prefix = `${hashCwd(cwd)}.`;
let dirs;
try {
dirs = fs.readdirSync(path.join(os.homedir(), ".cogent", "bridges"));
}
catch {
return []; // no registry yet — nobody has announced
}
return dirs
.filter((d) => d.startsWith(prefix))
.flatMap((d) => listLiveBridges(cwd, d.slice(prefix.length)));
}
/**
* Should THIS process wake for `peerId` in `cwd`?
*
* No when a strictly newer live bridge serves the same identity — that bridge is the one the user
* most recently started, and we are the leftover.
*/
export function checkWakeOwnership(cwd, peerId) {
let live;
try {
live = listLiveBridges(cwd, peerId);
}
catch (err) {
logger.warn(`wake-ownership: could not read bridge registry (${err.message}) — assuming ownership`);
return { isOwner: true, liveCount: 1 };
}
// Nobody announced (including us — announcement is best-effort): assume ownership. Standing
// down on missing evidence would mute a healthy, solitary agent.
if (live.length === 0)
return { isOwner: true, liveCount: 0 };
const owner = live[0];
if (owner.pid === process.pid)
return { isOwner: true, liveCount: live.length };
// We are announced but are not newest -> superseded.
if (live.some((e) => e.pid === process.pid)) {
return {
isOwner: false,
owner: { pid: owner.pid, version: owner.version, endpoint: owner.endpoint },
liveCount: live.length,
};
}
// Others are announced but we are not (our own announce failed). Do NOT stand down on the
// strength of a record we could not write: fail open.
return { isOwner: true, liveCount: live.length + 1 };
}
//# sourceMappingURL=wake-ownership.js.map