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.

187 lines • 9.08 kB
/** * 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; /** 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