@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.
180 lines • 8.04 kB
JavaScript
/**
* DURABLE WAKE LOG — the record `--no-session-persistence` guarantees nobody else writes.
*
* WHY THIS EXISTS. A wake runs
* `claude --resume <session> --dangerously-skip-permissions --no-session-persistence …`
* in the peer's REGISTERED PROJECT DIRECTORY (cc-cli.ts:311-328, auto-relay.ts:474). Two
* consequences that only look separate:
* - the wake has full Edit/Write/Bash/git in a live worktree, and
* - `--no-session-persistence` tells the CLI NOT to write the turn to any transcript.
* So the mutations are durable and the authorship is not. An external report (2026-09-03)
* hit exactly this: coherent code appeared in a live worktree over ~20h, the interactive
* session's JSONL held ZERO authoring tool-calls, and it took a multi-step forensic
* investigation to establish the edits were the operator's own peer rather than an intruder.
* That reporter nearly discarded legitimate work.
*
* `_trace()` already existed — but it writes to `logger`, which writes to process.stderr
* (logger.ts:47). For an MCP server that is the HOST's log: not correlated with the files,
* not discoverable by the operator, gone on restart. Tracing is not recording.
*
* WHAT THIS DOES NOT DO. It does not restrict the wake, gate it, or slow it down. Visibility
* only — a wake that used to succeed still succeeds byte-for-byte, and every failure path
* here is swallowed (a log must never be able to take the product down).
*
* HONEST LIMIT, STATED ONCE. `filesChanged` is a git snapshot diff across the wake's window.
* If an interactive session edits the SAME tree during that window its edits land in this
* list too — the bridge cannot see that other process (that is the unfixed wake-vs-interactive
* concurrency gap in the same report). The list is "what changed while this wake ran", which
* is strictly more than the investigation had, and less than proof of authorship.
*/
import { execFileSync } from "node:child_process";
import crypto from "node:crypto";
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
/** Bounded so an always-on peer cannot grow this without limit. Oldest are dropped. */
export const WAKE_LOG_MAX_RECORDS = 500;
/** Inbound message text kept per record — enough to recognise, not a message archive. */
const PREVIEW_LIMIT = 500;
/** MUST match wake-inflight.ts hashCwd / credential-store — one cwd, one key, everywhere. */
function hashCwd(cwd) {
return crypto.createHash("sha256").update(path.resolve(cwd)).digest("hex").slice(0, 16);
}
/**
* `~/.cogent/wake-log/<sha256(cwd)[..16]>.jsonl`
*
* Homedir-based, NOT COGENT_STATE_PATH — same reasoning as wake-inflight note 1. The whole
* point is that a process which is NOT this bridge (the operator's shell, the interactive
* session) can find it. An .mcp.json env var reaches only the bridge, so keying on
* COGENT_STATE_PATH would hide the log from precisely the readers it exists for.
*/
export function wakeLogPath(cwd) {
return path.join(os.homedir(), ".cogent", "wake-log", `${hashCwd(cwd)}.jsonl`);
}
function git(cwd, args) {
return execFileSync("git", ["-C", cwd, ...args], {
encoding: "utf-8",
timeout: 15_000,
stdio: ["ignore", "pipe", "ignore"],
});
}
/**
* Snapshot the worktree. Returns null (never throws, never guesses) when cwd is not a git
* repo or git is unavailable — "I cannot tell" is a legitimate answer and is recorded as one.
*/
export function captureTreeState(cwd) {
try {
// `git status --porcelain` alone is NOT enough: after a commit the tree is clean again,
// so a status-only snapshot reports "nothing changed" for a wake that COMMITTED — the
// single most serious case in the report. HEAD is what catches it.
const head = git(cwd, ["rev-parse", "HEAD"]).trim();
const entries = {};
// -z: NUL-separated, so paths with spaces/newlines survive. Records are
// "XY <path>" and renames carry a second NUL-separated path.
const raw = git(cwd, ["status", "--porcelain=v1", "-z", "--untracked-files=all"]);
const fields = raw.split("\0").filter((f) => f.length > 0);
for (let i = 0; i < fields.length; i++) {
const field = fields[i];
const status = field.slice(0, 2);
entries[field.slice(3)] = status;
// A rename/copy record is followed by its ORIGIN path as a separate field; consume it
// so it is never mistaken for a status record of its own.
if (status[0] === "R" || status[0] === "C")
i++;
}
return { head, entries };
}
catch {
return null;
}
}
/**
* Paths that differ between two snapshots, plus "HEAD" when the wake committed.
* null when either side could not be captured.
*/
export function diffTreeState(before, after) {
if (!before || !after)
return null;
const changed = new Set();
if (before.head !== after.head)
changed.add("HEAD");
for (const [file, status] of Object.entries(after.entries)) {
if (before.entries[file] !== status)
changed.add(file);
}
// A path that was dirty before and is clean now also changed (staged, reverted, deleted).
for (const file of Object.keys(before.entries)) {
if (!(file in after.entries))
changed.add(file);
}
return [...changed].sort();
}
/** Trim to PREVIEW_LIMIT; the log is forensic metadata, not a message store. */
function preview(message) {
return message.length > PREVIEW_LIMIT ? `${message.slice(0, PREVIEW_LIMIT)}…` : message;
}
/**
* Append one record. Never throws: a read-only home, a full disk or a race must cost the
* log entry, never the wake.
*/
export function appendWakeRecord(record, cwd) {
const target = wakeLogPath(cwd);
try {
fs.mkdirSync(path.dirname(target), { recursive: true });
const row = {
ts: new Date().toISOString(),
...record,
messagePreview: preview(record.messagePreview ?? ""),
};
// Plain append, not read-modify-write: O_APPEND is atomic for a single small write on
// POSIX, and two bridges per cwd demonstrably happen (wake-inflight note 5). A
// rewrite-every-time design would let one bridge drop the other's record.
fs.appendFileSync(target, `${JSON.stringify(row)}\n`);
pruneIfNeeded(target);
}
catch {
/* visibility is best-effort by design — see the module note */
}
}
/** Keep the NEWEST WAKE_LOG_MAX_RECORDS. The newest is the incident. */
function pruneIfNeeded(target) {
try {
const lines = fs.readFileSync(target, "utf-8").split("\n").filter((l) => l.length > 0);
if (lines.length <= WAKE_LOG_MAX_RECORDS)
return;
const kept = lines.slice(lines.length - WAKE_LOG_MAX_RECORDS);
const tmp = `${target}.tmp-${process.pid}`;
fs.writeFileSync(tmp, `${kept.join("\n")}\n`);
fs.renameSync(tmp, target); // atomic — a concurrent reader never sees a partial file
}
catch {
/* an unpruned log is still a working log */
}
}
/**
* Read the log back, oldest first. A corrupt line is SKIPPED, not fatal: a forensic log
* that throws on one bad byte is useless exactly when it is needed.
*/
export function readWakeLog(cwd, limit) {
let lines;
try {
lines = fs.readFileSync(wakeLogPath(cwd), "utf-8").split("\n").filter((l) => l.length > 0);
}
catch {
return [];
}
const rows = [];
for (const line of lines) {
try {
const parsed = JSON.parse(line);
if (parsed && typeof parsed === "object" && typeof parsed.messageId === "string") {
rows.push(parsed);
}
}
catch {
/* skip the bad line, keep the log */
}
}
return typeof limit === "number" && limit > 0 ? rows.slice(-limit) : rows;
}
//# sourceMappingURL=wake-log.js.map