@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.
312 lines • 12.9 kB
JavaScript
import crypto from "node:crypto";
import fs from "node:fs/promises";
import path from "node:path";
import { getConfig } from "../config.js";
import { logger } from "../logger.js";
import { BridgeError, BridgeErrorCode } from "../errors.js";
/* ------------------------------------------------------------------ */
/* File-based shared state */
/* Two or more MCP server processes share a state JSON file */
/* State path is config-driven via COGENT_STATE_PATH */
/* ------------------------------------------------------------------ */
const MAX_MESSAGES = 1500;
const LOCK_RETRY_MS = 50;
const LOCK_MAX_WAIT_MS = 5_000;
/** Derive state file path from config at call time (not module load time). */
function getStatePath() {
return path.join(getConfig().COGENT_STATE_PATH, "cogent-state.json");
}
/** Derive lock file path from state path at call time. */
function getLockPath() {
return getStatePath() + ".lock";
}
/* ---- low-level helpers ------------------------------------------- */
function emptyState() {
return { peers: {}, messages: [] };
}
/** Migrate legacy state: ensure all peers have lastSeenAt field. */
function migrateState(state) {
for (const peer of Object.values(state.peers)) {
if (!peer.lastSeenAt) {
peer.lastSeenAt = peer.registeredAt;
}
}
return state;
}
async function readState() {
const statePath = getStatePath();
try {
const raw = await fs.readFile(statePath, "utf-8");
return migrateState(JSON.parse(raw));
}
catch (err) {
if (err.code === "ENOENT") {
return migrateState(emptyState());
}
if (err instanceof SyntaxError) {
// Corrupt JSON -- auto-recover with backup
const backupPath = statePath + ".corrupt." + Date.now();
try {
await fs.copyFile(statePath, backupPath);
}
catch {
// Backup failure is non-fatal
}
logger.warn(`STATE_CORRUPT: State file corrupt (invalid JSON), backed up to ${backupPath}. Starting with empty state.`);
return migrateState(emptyState());
}
throw new BridgeError(BridgeErrorCode.STATE_WRITE_FAILED, `Failed to read state file: ${err.message}`, `Check permissions on ${statePath}`);
}
}
async function writeState(state) {
const statePath = getStatePath();
const tmp = statePath + "." + process.pid + ".tmp";
try {
await fs.writeFile(tmp, JSON.stringify(state, null, 2), "utf-8");
await fs.rename(tmp, statePath); // atomic on same filesystem
}
catch (err) {
throw new BridgeError(BridgeErrorCode.STATE_WRITE_FAILED, `Failed to write state file: ${err.message}`, `Check permissions on ${statePath}`);
}
}
/* ---- file-lock (O_CREAT | O_EXCL) ------------------------------- */
async function acquireLock() {
const lockPath = getLockPath();
const deadline = Date.now() + LOCK_MAX_WAIT_MS;
while (Date.now() < deadline) {
try {
// Atomic create-if-not-exists
await fs.writeFile(lockPath, String(process.pid), {
flag: "wx", // O_WRONLY | O_CREAT | O_EXCL
});
return; // lock acquired
}
catch (err) {
if (err.code === "EEXIST") {
// Check for stale lock (process died)
try {
const content = await fs.readFile(lockPath, "utf-8");
const lockPid = parseInt(content, 10);
if (!isNaN(lockPid)) {
try {
process.kill(lockPid, 0); // just checks existence
}
catch {
// Holder PID looks dead -- candidate stale lock. But it may have
// been released and RE-acquired by another process since our read
// (TOCTOU). Re-read and unlink ONLY if the holder PID is still the
// same dead PID, so we never delete a freshly-acquired lock.
let removed = false;
try {
const recheck = parseInt(await fs.readFile(lockPath, "utf-8"), 10);
if (recheck === lockPid) {
await fs.unlink(lockPath);
removed = true;
}
}
catch {
// Lock vanished or re-read failed -- fall through and back off.
}
if (removed) {
logger.info(`Cleaned up stale lock from PID ${lockPid}`);
continue; // lock freed -- reclaim immediately
}
// Holder changed, or unlink failed: back off before retrying so a
// persistently-undeletable lock cannot hot-spin the event loop.
await sleep(LOCK_RETRY_MS);
continue;
}
}
}
catch { /* lock file disappeared, retry */
continue;
}
await sleep(LOCK_RETRY_MS);
continue;
}
throw err;
}
}
// Timeout -- read lock to report holder PID
let lockPid = "unknown";
try {
const content = await fs.readFile(lockPath, "utf-8");
lockPid = content.trim();
}
catch { /* ignore read failure */ }
throw new BridgeError(BridgeErrorCode.LOCK_TIMEOUT, `Failed to acquire lock within ${LOCK_MAX_WAIT_MS}ms (held by PID ${lockPid})`, `Another cogent process may be stuck. Delete ${lockPath} if no other instance is running.`);
}
async function releaseLock() {
await fs.unlink(getLockPath()).catch(() => { });
}
function sleep(ms) {
return new Promise((r) => setTimeout(r, ms));
}
/** Run a mutating operation under an exclusive file lock. */
async function withLock(fn) {
await acquireLock();
try {
return await fn();
}
finally {
await releaseLock();
}
}
/* ---- public API (all async) -------------------------------------- */
export async function registerPeer(peerId, sessionId, cwd, label, clientVersion, mode, channelSessionId, capabilities, workspaceId, threadId) {
return withLock(async () => {
const state = await readState();
const now = new Date().toISOString();
const peer = {
peerId,
sessionId,
cwd,
label,
registeredAt: now,
lastSeenAt: now,
};
// Conditional set: never write { clientVersion: undefined } onto the
// PeerInfo object — keeps JSON.stringify output free of placeholder fields
// for older clients that omit the version (3.1.6 backward-compat contract).
if (clientVersion !== undefined) {
peer.clientVersion = clientVersion;
}
// Same conditional-set pattern for the new 3.2.0 mode field (Phase 17
// OBS-01). Absent === "agent" by default — don't serialize the field
// unless an operator explicitly opts in. Keeps local state JSON clean
// for upgrade-only operators.
if (mode !== undefined) {
peer.mode = mode;
}
// Same conditional-set contract for the 3.5.9 durable-identity rails —
// never serialize undefined so pre-3.5.9 state stays byte-for-byte clean.
if (channelSessionId !== undefined) {
peer.channelSessionId = channelSessionId;
}
if (capabilities !== undefined) {
peer.capabilities = capabilities;
}
// Slice 5 / Story 8.6 durable identity (same conditional-set contract).
if (workspaceId !== undefined) {
peer.workspaceId = workspaceId;
}
if (threadId !== undefined) {
peer.threadId = threadId;
}
state.peers[peerId] = peer;
await writeState(state);
return peer;
});
}
export async function updateLastSeen(peerId) {
return withLock(async () => {
const state = await readState();
const peer = state.peers[peerId];
if (peer) {
peer.lastSeenAt = new Date().toISOString();
await writeState(state);
}
});
}
export async function deregisterPeer(peerId) {
return withLock(async () => {
const state = await readState();
if (!(peerId in state.peers))
return false;
delete state.peers[peerId];
await writeState(state);
return true;
});
}
export async function getPeer(peerId) {
const state = await readState();
return state.peers[peerId];
}
export async function listPeers() {
const state = await readState();
return Object.values(state.peers);
}
export async function recordMessage(record) {
return withLock(async () => {
const state = await readState();
const full = {
...record,
id: crypto.randomUUID(),
timestamp: new Date().toISOString(),
};
state.messages.push(full);
// Cap history to prevent unbounded growth
if (state.messages.length > MAX_MESSAGES) {
state.messages = state.messages.slice(-MAX_MESSAGES);
}
await writeState(state);
return full;
});
}
/**
* The metadataOnly projection contract — the EXACT key set, sorted.
*
* One documented contract has two implementations: this file (local mode) and
* server/src/routes/messages.ts (the relay). They silently disagreed for the
* whole of Cogent Mail M2.5 — the relay gained attachmentCount, local mode did
* not — because each was tested only against itself. This const is the bridge
* side's single source of truth: the local projection is asserted against it,
* and src/e2e/metadata-contract.e2e.test.ts asserts a REAL relay response
* against the same list, so the two cannot drift apart without a test going
* red. The tool description in src/tools/get-history.ts must name these keys.
*/
export const METADATA_ONLY_KEYS = [
"attachmentCount",
"durationMs",
"fromPeerId",
"id",
"isRelayEcho",
"success",
"timestamp",
"toPeerId",
];
export async function getHistory(peerId, limit = 150, includeRelayEchoes = false, metadataOnly = false) {
const state = await readState();
let filtered = state.messages;
// 260511-o3p: local-mode mirror of the server-side filter. Strip
// auto-relay echoes before peerId narrowing and before the limit slice,
// so the budget is spent on real records, not on the duplicate
// FE->BE response mirrors.
if (!includeRelayEchoes) {
filtered = filtered.filter((m) => m.isRelayEcho !== true);
}
if (peerId) {
filtered = filtered.filter((m) => m.fromPeerId === peerId || m.toPeerId === peerId);
}
const sliced = filtered.slice(-limit);
// HIST-02: when metadataOnly is true, build a NEW object literal with
// exactly the 8 contract keys — message and response are absent (not
// nulled, not truncated). Constructed via literal (not delete-on-clone)
// so JSON.stringify output has no placeholder keys. For older records
// without isRelayEcho, emit false so the 8-key contract is stable.
//
// 🔴 attachmentCount was added to the CLOUD projection by Cogent Mail M2.5
// (server/src/routes/messages.ts) and never mirrored here, so the same tool
// with the same flag returned 7 keys locally and 8 through the relay — one
// documented contract, two implementations. Local mode never persists
// attachments, so the value is always 0; emitting it anyway is the whole
// point of a STABLE contract, and is the same reason isRelayEcho is emitted
// as false rather than omitted on records that predate the field.
// Structural subset for HIST-02 metadataOnly contract; consumer treats
// response as opaque JSON, so the MessageRecord[] cast is acceptable.
if (metadataOnly) {
const projected = sliced.map((m) => ({
id: m.id,
fromPeerId: m.fromPeerId,
toPeerId: m.toPeerId,
timestamp: m.timestamp,
durationMs: m.durationMs,
isRelayEcho: m.isRelayEcho === true,
success: m.success,
attachmentCount: m.attachments?.length ?? 0,
}));
return projected;
}
return sliced;
}
//# sourceMappingURL=peer-registry.js.map