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.

313 lines • 15.7 kB
/** * 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