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.

193 lines • 9.4 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 * * That kills the obvious fix. "Exit when stdin hits EOF" heals nothing here, because no bridge has * lost its parent. They are not orphaned, they are 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. * * ── 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"; /** * 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); /** 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)}`); } /** 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) { 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), }; fs.writeFileSync(path.join(dir, `${process.pid}.json`), JSON.stringify(entry)); } catch (err) { logger.debug(`wake-ownership: could not announce this bridge (${err.message})`); } } /** 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; } /** * 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