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.

180 lines • 8.04 kB
/** * 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