@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.
200 lines • 9.87 kB
JavaScript
/**
* Wake in-flight marker — the happens-before edge between rail B and rail C.
*
* WHY THIS EXISTS (COG-20). Rail B (auto-relay wake) records the agent's reply only AFTER
* it has captured it. Rail C (the check-on-stop Stop hook) fires at the END OF THE VERY TURN
* B is capturing. So C polls the relay, sees no recorded reply, and tells the agent "you have
* 1 unanswered message — reply with cogent_send_message" — while B's prompt for that same
* turn said "you should NOT call cogent_send_message". The agent resolves the contradiction
* by appending an extra message, and B then captures THAT instead of the real answer.
*
* Production evidence (all2wiki, 2026-08-27 07:42:16-23Z): the agent answered at 07:42:20,
* C injected at 07:42:20, the agent emitted "Already responded… No duplicate sent." at
* 07:42:23, and B captured that meta-message at 07:42:23. The real answer never left the box.
*
* B publishes "I am handling message X" before waking and retracts it once the outcome is
* recorded. C ignores messages covered by an UNEXPIRED marker and surfaces everything else.
*
* ── The five things this file gets right on purpose ──────────────────────────────────────
*
* 1. HOMEDIR-BASED, NEVER COGENT_STATE_PATH. B resolves config from the MCP server's env;
* C is a hook process spawned from the user's shell. Those envs differ — CLAUDE.md
* documents exactly this trap for COGENT_CHECK_ON_STOP, and credential-store.ts already
* keys credentials off homedir for the same reason. A marker keyed on COGENT_STATE_PATH
* would land in two different places the moment an operator sets it in .mcp.json, and the
* guard would silently no-op.
*
* 2. KEYED BY cwd AND peerId. The cwd hash mirrors the credential file (the one per-cwd
* artifact both processes already agree on). peerId is in the key because two peers can
* share a cwd, and a human broadcast carries the SAME relay message id to every peer — so
* a cwd-only key would let one peer's wake suppress another peer's genuinely-unanswered
* message.
*
* 3. WRITER-STAMPED EXPIRY, not a reader-side constant. A wake's real maximum duration is
* COGENT_TIMEOUT_MS, and a single wake can run execRemote TWICE (primary + rotated-session
* retry). A fixed 5-minute reader TTL therefore expires while long or retried wakes are
* still running — reopening the race on precisely the slow turns rail C exists for. B knows
* its own timeout, so B stamps `expiresAt`; C honours it, capped so a corrupt or hostile
* marker can never mute C indefinitely.
*
* 4. ATOMIC WRITES (temp + rename), per the repo convention. writeFileSync truncates in place,
* so a concurrent reader can observe a partial file, fail to parse, and fall through to the
* unguarded path — silently.
*
* 5. NEVER UNLINKS THE FILE. Clearing used to delete the file when its own snapshot looked
* empty, which would drop entries another bridge wrote in between (two bridges per cwd
* demonstrably happen — a stale monitor duplicated a peer here for 12 days). We write back
* the pruned map instead and let expiry clean up.
*
* Every failure path degrades LOUD (C speaks up, today's behaviour) and never SILENT (C muted),
* because a muted rail C is the safety net disappearing.
*/
import crypto from "node:crypto";
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import { getConfig } from "../config.js";
import { logger } from "../logger.js";
/**
* Hard ceiling on how long ANY marker may suppress rail C, whatever the writer stamped.
* Bounds a corrupt/hostile/forgotten marker: worst case C is quiet for this long, then
* resumes. MUST equal check-on-stop.mjs INFLIGHT_MAX_MS.
*/
export const INFLIGHT_MAX_MS = 1_800_000; // 30 min
/** Multiplier over COGENT_TIMEOUT_MS covering primary + retry execRemote plus overhead. */
export const INFLIGHT_TIMEOUT_FACTOR = 3;
/**
* How long a bridge whose MCP client is gone may keep draining a wake before it exits anyway.
*
* The SAME bound this file already gives a wake in flight: up to two execRemote runs (primary +
* rotated-session retry) at COGENT_TIMEOUT_MS each, i.e. INFLIGHT_TIMEOUT_FACTOR × the timeout,
* never more than INFLIGHT_MAX_MS. A shorter cap abandons a retried wake partway; an uncapped one
* let a huge COGENT_TIMEOUT_MS (it has no maximum) keep an orphan alive indefinitely. The drained
* bridge has already withdrawn its claim on the peer name, so the wait no longer blocks a restart.
*/
export function drainCapMs(timeoutMs) {
const t = Number.isFinite(timeoutMs) && timeoutMs > 0 ? timeoutMs : 300_000;
return Math.min(INFLIGHT_TIMEOUT_FACTOR * t, INFLIGHT_MAX_MS);
}
/** MUST equal check-on-stop.lib.mjs credHashForCwd and credential-store's hash. */
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/wake-inflight/<sha256(cwd)[..16]>.<peerId>.json`
*
* Deliberately homedir-based (see note 1). `cwd` defaults to the bridge's own process.cwd(),
* which is what credential-store keys on and what the hook's `input.cwd` reports — using the
* peer's *registered* cwd string instead would let a symlinked or sub-path registration
* silently key the two processes differently.
*/
export function wakeInflightPath(peerId, cwd = process.cwd()) {
return path.join(os.homedir(), ".cogent", "wake-inflight", `${hashCwd(cwd)}.${safe(peerId)}.json`);
}
/** How long a wake started now may legitimately run, per THIS bridge's configuration. */
function ttlMs() {
let timeout = 300_000;
try {
const configured = getConfig()?.COGENT_TIMEOUT_MS;
if (typeof configured === "number" && Number.isFinite(configured) && configured > 0)
timeout = configured;
}
catch {
/* config not loaded — fall back to the documented default */
}
return Math.min(timeout * INFLIGHT_TIMEOUT_FACTOR, INFLIGHT_MAX_MS);
}
function readFileSafe(p) {
try {
const parsed = JSON.parse(fs.readFileSync(p, "utf-8"));
return parsed && typeof parsed === "object" && parsed.messages ? parsed : { messages: {} };
}
catch {
return { messages: {} };
}
}
/** Temp + rename so a concurrent reader never observes a truncated file. */
function writeAtomic(p, data) {
fs.mkdirSync(path.dirname(p), { recursive: true });
const tmp = `${p}.${process.pid}.tmp`;
fs.writeFileSync(tmp, JSON.stringify(data, null, 2) + "\n", "utf-8");
fs.renameSync(tmp, p);
}
/** Drop entries that have expired (or are structurally unusable). */
function prune(f, now) {
for (const [id, rec] of Object.entries(f.messages)) {
const ok = rec &&
typeof rec.startedAt === "number" &&
typeof rec.expiresAt === "number" &&
Number.isFinite(rec.expiresAt) &&
rec.expiresAt > now;
if (!ok)
delete f.messages[id];
}
}
/**
* Announce that rail B is handling `messageId` for `peerId`.
*
* Called when the message is ACCEPTED (including when it is queued behind an in-progress
* wake), not when the subprocess starts: a message sitting in B's queue is equally "about to
* be answered by B", and rail C firing about it mid-capture corrupts the SAME turn — the
* message-burst shape that Slack produces constantly.
*
* Synchronous on purpose: the marker's whole value is ordering — it must be on disk before
* the agent's turn can end. Never throws; a marker we could not write degrades to today's
* behaviour, never to a failed wake.
*/
export function markWakeInFlight(peerId, messageId, traceId, cwd = process.cwd()) {
try {
const now = Date.now();
const p = wakeInflightPath(peerId, cwd);
const cur = readFileSafe(p);
prune(cur, now);
cur.messages[messageId] = { startedAt: now, expiresAt: now + ttlMs(), ...(traceId ? { traceId } : {}) };
writeAtomic(p, cur);
}
catch (err) {
logger.debug(`wake-inflight: could not mark ${messageId}: ${err}`);
}
}
/**
* The wakes running RIGHT NOW for this peer — the "active wakes" view.
*
* Added for visibility (2026-09-04): an external report described a live worktree being
* edited by wakes with no way to see that any were running. The markers already existed
* for the rail-B/rail-C race; this only reads them. Expired entries are omitted rather
* than reported as live — a leaked marker must not become a phantom "active wake".
*/
export function listWakeInFlight(peerId, cwd = process.cwd()) {
try {
const cur = readFileSafe(wakeInflightPath(peerId, cwd));
const now = Date.now();
return Object.entries(cur.messages)
.filter(([, e]) => e.expiresAt > now)
.map(([messageId, e]) => ({ messageId, ...e }))
.sort((a, b) => a.startedAt - b.startedAt);
}
catch {
return [];
}
}
/**
* Retract the marker once the outcome is recorded (a captured reply OR a fail-loud notice).
* Runs from a `finally` — a leaked marker is the one failure mode that would mute rail C.
* Writes the pruned map back rather than unlinking, so a concurrent bridge's live entries
* survive (see note 5).
*/
export function clearWakeInFlight(peerId, messageId, cwd = process.cwd()) {
try {
const p = wakeInflightPath(peerId, cwd);
const cur = readFileSafe(p);
delete cur.messages[messageId];
prune(cur, Date.now());
writeAtomic(p, cur);
}
catch (err) {
logger.debug(`wake-inflight: could not clear ${messageId}: ${err}`);
}
}
//# sourceMappingURL=wake-inflight.js.map