@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.
1,525 lines • 81.7 kB
JavaScript
import { randomUUID } from "node:crypto";
import fs from "node:fs";
import path from "node:path";
import { execRemote, detectCurrentSessionFor } from "./exec-remote.js";
import { appendWakeRecord, captureTreeState, diffTreeState } from "./wake-log.js";
import { resolveSession } from "./session-resolver.js";
import { getConfig } from "../config.js";
import { getBackend } from "../backend/index.js";
import { setSandboxOverride, effectiveSandbox } from "./codex-cli.js";
import { markWakeInFlight, clearWakeInFlight } from "./wake-inflight.js";
import { claimWake, releaseWake } from "./wake-claim.js";
import { acquireWakeLock } from "./wake-lock.js";
import { announceBridge, checkWakeOwnership, markBridgeDraining, registryOwnerLabel, withdrawBridge } from "./wake-ownership.js";
import { reapStaleBridges } from "./stale-bridge-reaper.js";
import { CLIENT_VERSION } from "../constants.js";
import { logger } from "../logger.js";
import { consumeNudgeOnce } from "../backend/npm-update-check.js";
/**
* Slice 2 — busy-state queue. A deliberately small, human-facing status
* vocabulary surfaced to the SENDER via the `message` field (cloud mode
* transmits only `message`, never `response` — the 3.5.10 lesson). The
* `[cogent:<status>]` marker is both human-readable (a person watching in Slack
* sees "⏳ … busy … queued") AND machine-recognizable, so the sender's client
* can treat a status notice as NON-TERMINAL: it must never resolve a pending
* send_message waiter (the real answer follows when the busy peer drains its
* queue) and never trigger a local wake.
*/
export const RELAY_STATUS = {
QUEUED: "queued",
PROCESSING: "processing",
DELIVERED: "delivered",
NEEDS_MANUAL: "needs-manual",
FAILED: "failed",
};
// Anchored at the START of the message: a status notice ALWAYS leads with its
// `[cogent:<status>]` marker (see buildStatusNotice). Anchoring — rather than a
// substring scan — is what prevents a genuine reply that merely QUOTES a
// `[cogent:queued]` token (very plausible when agents coordinate about
// cogent-bridge itself) from being misclassified as a non-terminal notice and
// silently dropped. (AUDIT-8.3: an unanchored match hung the sender's
// send_message for the full timeout.)
const STATUS_MARKER_RE = /^\[cogent:(queued|processing|delivered|needs-manual|failed)\]/;
/**
* Build a sender-facing status notice. The `[cogent:<status>]` marker is ALWAYS
* first so {@link parseStatusNotice} can anchor on it; the human-readable text
* (incl. the ⏳ glyph for a Slack reader) follows.
*/
export function buildStatusNotice(status, peerId, traceId) {
const tail = traceId ? ` (trace ${traceId})` : "";
if (status === RELAY_STATUS.QUEUED) {
return (`[cogent:queued] ⏳ "${peerId}" is busy with another request — your message is ` +
`queued and will be answered when it is free.${tail}`);
}
if (status === RELAY_STATUS.NEEDS_MANUAL) {
return (`[cogent:needs-manual] ⚠️ "${peerId}" replied but Cogent could not capture it from a trusted ` +
`structured stream (raw-output fallback) — treat the following reply as unverified.${tail}`);
}
return `[cogent:${status}] "${peerId}"${tail}`;
}
/**
* Parse a status notice back to its status, or null for an ordinary message.
* Anchored: only a message that STARTS with the marker is a notice, so a real
* answer that merely mentions the token mid-text is (correctly) not one.
*/
export function parseStatusNotice(message) {
const m = STATUS_MARKER_RE.exec(message);
return m ? m[1] : null;
}
/**
* Format an incoming message for injection into the local Claude Code session.
* Mirrors the pattern used in send-message.ts's formatBridgeMessage().
*/
/**
* Cogent Mail M2.5 — render attachment REFERENCES as a compact block the agent can act on.
* A `mailMessageId` ref tells the agent to `cogent_fetch_mail`; a `url` ref is a direct link.
* Returns "" when there are no attachments (so no-file messages are visually unchanged).
*/
function formatAttachmentsBlock(attachments) {
if (!attachments || attachments.length === 0)
return "";
const lines = attachments.map((a) => {
const name = a.name || a.mailMessageId || a.url || "file";
const ref = a.mailMessageId
? `fetch via cogent_fetch_mail (${a.mailMessageId})`
: a.url
? a.url
: "no fetchable reference";
const meta = [a.mimeType, a.size != null ? `${a.size}B` : null].filter(Boolean).join(", ");
return ` [file: ${name}${meta ? ` (${meta})` : ""} — ${ref}]`;
});
return `\n\n📎 Attachments (byte transport = email):\n${lines.join("\n")}`;
}
export function formatRelayMessage(fromLabel, fromPeerId, message, attachments) {
return (`[Cogent Bridge message from ${fromLabel} (${fromPeerId})]\n\n` +
`${message}${formatAttachmentsBlock(attachments)}\n\n` +
`---\n` +
`Respond directly to the message above. Your entire response is delivered automatically ` +
`to whoever should receive it — a broadcast question (to the whole channel) is answered to everyone; ` +
`a direct message, to the sender. You do NOT need to — and should NOT — call cogent_send_message or ` +
`any bridge tool to deliver it; just answer normally.` +
updateNoticeFooter());
}
/**
* One line about a newer published Cogent, appended to a message the agent is ALREADY processing —
* so the news costs no extra wake, no extra tokens and no broadcast. Empty string unless a nudge is
* pending and has not been shown, which keeps the un-notified message byte-identical to before.
*
* Worded as an FYI for the OPERATOR on purpose: a wake subprocess runs with `--strict-mcp-config`
* and no MCP servers and `--no-session-persistence`, so the agent literally cannot update itself —
* it can only pass the message on. Telling it to "update" would be an instruction it cannot follow.
*/
function updateNoticeFooter() {
const nudge = consumeNudgeOnce();
if (!nudge)
return "";
return (`\n\n---\n` +
`[Cogent FYI — not part of the message above, and not a task for you] ${nudge} ` +
`You cannot apply this yourself; mention it to your operator when convenient, then carry on. ` +
`Do not reply about it and do not call any cogent_* tool for it.`);
}
/**
* Auto-relay service that processes incoming cloud messages by calling the
* local agent CLI via execRemote() and sending the response back via the cloud backend.
*
* When a WebSocket message arrives for the local peer, this service:
* 1. Validates it's an incoming message (not our own, not already a response)
* 2. Injects it into the local agent session via execRemote() (platform-polymorphic dispatch)
* 3. Sends the agent response back to the original sender as a new message
*
* This enables automatic back-and-forth dialog between two cloud peers,
* matching the local-mode behavior where execRemote is called synchronously.
*/
/**
* Format an incoming response for inject-only mode (no relay back).
* Used when the local peer explicitly sent a message and is receiving the reply.
*/
function formatInjectOnlyMessage(fromLabel, fromPeerId, message, attachments) {
return (`[Cogent Bridge: Response from ${fromLabel} (${fromPeerId})]\n\n` +
`${message}${formatAttachmentsBlock(attachments)}\n\n` +
`---\n` +
`This is the response to your earlier cogent_send_message. No reply will be relayed.\n` +
`To continue the conversation, use cogent_send_message again.`);
}
/**
* Awareness-only notice for a PEER's broadcast (full-group-chat mode). Unlike
* formatInjectOnlyMessage — a response to our OWN send_message, which invites a
* follow-up — this is a passive FYI: the agent should take note but NOT reply and
* NOT call any bridge tool. That prohibition is what keeps agent↔agent broadcast
* awareness from becoming a broadcast storm.
*/
function formatPeerBroadcastNotice(fromLabel, fromPeerId, message, attachments) {
return (`[Cogent Bridge: broadcast from ${fromLabel} (${fromPeerId}) to the whole channel]\n\n` +
`${message}${formatAttachmentsBlock(attachments)}\n\n` +
`---\n` +
`FYI ONLY — another agent addressed the whole channel. Take note for context, but ` +
`do NOT reply and do NOT call any cogent_* tool in response. A human will drive any reply.`);
}
/** Longest diagnostic clause we will paste into a channel message. */
const DIAGNOSTIC_MAX = 220;
/**
* Turn a failed wake's REAL outcome into one readable clause.
*
* 🔴 WHY THIS EXISTS. A wake that captured nothing used to be reported as
* "it may have been busy, or the resume produced no output" — a guess, written
* while the subprocess's own explanation sat unread in `CliExecResult.stderr`.
* For a user whose Claude config had moved, that stderr said, in words,
* "No conversation found with session ID: <id>". Nobody saw it, so a one-line
* root cause took five weeks and three releases to find.
*
* Pure, bounded and unit-tested (wake-failure-diagnostic.test.ts): the wording is
* a fixture, not prose that can quietly rot. Returns "" when there is genuinely
* nothing to add, so the caller can append unconditionally.
*/
export function describeWakeFailure(exitCode, stderr, stdout) {
const raw = (stderr ?? "").trim();
if (!raw && exitCode !== 0 && !stdout)
return "";
// Collapse to the first meaningful line: a stack trace must never be pasted
// wholesale into a chat channel.
const firstLine = raw
.split("\n")
.map((l) => l.trim())
.find(Boolean) ?? "";
// The signature of the relocated-config-dir failure. Named explicitly because
// it is both common and completely actionable — the generic form of this
// message is what hid it for five weeks.
const missing = /No conversation found with session ID:?\s*(\S+)/i.exec(raw);
if (missing) {
return truncate(`claude could not find session ${missing[1]} in the config directory it searched — ` +
`the agent's Claude state is probably outside ~/.claude. Upgrade the bridge (3.21.4+ ` +
`passes the resolved config directory to the resume), or set CLAUDE_CONFIG_DIR.`);
}
if (/CLI_NOT_FOUND/.test(raw)) {
return truncate(`the claude executable was not found — install Claude Code or set COGENT_CLAUDE_PATH.`);
}
const timedOut = /CLI_TIMEOUT: CLI subprocess timed out after (\d+)ms/.exec(raw);
if (timedOut) {
return truncate(`the resume timed out after ${timedOut[1]}ms (COGENT_TIMEOUT_MS) — the agent was ` +
`still working, not idle.`);
}
// 🔴 THE CHILD SAID NOTHING ON STDERR — LOOK AT STDOUT BEFORE GIVING UP.
// cc-cli composes `CLI_EXEC_FAILED: claude exited with code N. stderr: <child stderr>`. On a
// NON-ZERO exit claude does not use stderr at all: it writes a result envelope to STDOUT
// ({is_error, terminal_reason, result}). Reporting only stderr therefore produced a diagnostic
// that ended on a dangling "stderr:" with nothing behind it, and the caller's template ran
// straight on after it — which is how this notice got misread as claude emitting Cogent's own
// words, and cost hours on 2026-09-01. The reason was captured in stdout the whole time.
const childStderr = /\. stderr:\s*([\s\S]*)$/.exec(raw)?.[1]?.trim() ?? raw;
if (!childStderr) {
const code = exitCode === null || exitCode === undefined ? "?" : exitCode;
const env = parseResultEnvelope(stdout);
if (env) {
const bits = [];
if (env.terminalReason) {
bits.push(env.terminalReason === "api_error"
? `the CLI could not complete an API call (terminal_reason "api_error") — check that agent's Claude credentials / API access`
: `terminal_reason "${env.terminalReason}"`);
}
if (env.result)
bits.push(env.result);
if (!bits.length && env.isError)
bits.push("the run reported is_error with no detail");
if (bits.length)
return truncate(`claude exited ${code}: ${bits.join(" — ")}`);
}
return truncate(stdout?.trim()
? `claude exited ${code} with nothing on stderr; its stdout was not a readable result envelope`
: `claude exited ${code} with no output at all on stdout or stderr`);
}
if (firstLine) {
const code = exitCode === null || exitCode === undefined ? "?" : exitCode;
return truncate(`claude exited ${code}: ${firstLine}`);
}
if (exitCode === 0) {
return truncate(`the resume completed successfully but returned no text.`);
}
return "";
}
/**
* Pull the useful fields out of a `claude --output-format json` result envelope.
* Never throws: unparseable or unexpected stdout yields null and the caller degrades.
*/
function parseResultEnvelope(stdout) {
const text = (stdout ?? "").trim();
if (!text.startsWith("{"))
return null;
try {
const o = JSON.parse(text);
const result = typeof o.result === "string" && o.result.trim() ? o.result.trim() : null;
const terminalReason = typeof o.terminal_reason === "string" && o.terminal_reason.trim()
? o.terminal_reason.trim()
: null;
if (o.is_error === undefined && !terminalReason && !result)
return null;
return { isError: o.is_error === true, terminalReason, result };
}
catch {
return null;
}
}
function truncate(s) {
const flat = s.replace(/\s+/g, " ").trim();
return flat.length > DIAGNOSTIC_MAX
? `${flat.slice(0, DIAGNOSTIC_MAX - 1)}…`
: flat;
}
/** How long an ack that arrived before its waiter stays usable. */
const EARLY_ACK_TTL_MS = 120_000;
/** Hard cap on buffered early acks, so a looping or hostile peer cannot grow the map. */
const EARLY_ACK_MAX = 256;
/** Is `dir` this bridge's own folder under another spelling (a symlink to it), or a folder inside it? */
function isInsideOwnFolder(dir) {
try {
const real = fs.realpathSync.native(dir);
return real === process.cwd() || real.startsWith(process.cwd() + path.sep);
}
catch {
return false; // it does not exist: not this folder
}
}
/** Is `dir` this bridge's own folder under another spelling (a symlink to it)? Not a folder inside it. */
function isOwnFolder(dir) {
try {
return fs.realpathSync.native(dir) === fs.realpathSync.native(process.cwd());
}
catch {
return false; // it does not exist: not this folder
}
}
export class AutoRelayService {
localPeerId = null;
localSessionId = null;
/**
* The local session pinned at register time (the caller was provably active
* then, so its resolution is trustworthy). At wake time the resolver
* exact-matches this pin instead of re-guessing newest-mtime — the fix for
* the silent wrong-session drift. Re-pinned when the resolver confirms a new id.
*/
pinnedSessionId = null;
localCwd = null;
/** Every folder this process announced the current peer name under — what a drain or a stop must cover. */
announcedCwds = new Set();
/**
* Peer participation mode (3.2.0, Phase 17 OBS-03).
* - "agent" (default): full auto-relay — inbound directed messages run
* through delivery-ack + queueing + execRemote.
* - "observer": short-circuit before delivery-ack and processing; the peer
* is a passive watcher. Set by `setLocalPeer()` from the register-peer
* tool when the operator passes `mode: "observer"`.
*
* Phase 20 will OR additional conditions (presence/DND/AFK) into the
* short-circuit check via the `_shouldSkipAutoRelay()` helper.
*/
localMode = "agent";
/**
* Human-readable label captured at register-peer time. Preserved here so
* triggerReRegistration() (startup.ts) can pass the original label on
* auto-reconnect after a server eviction or WS cycle, instead of falling
* back to the peerId. Closes the v3.2.1 label-normalization regression
* where 'Cogent_Master' silently became 'cogent-master' after the first
* mass-reregister event.
*/
localLabel = null;
processing = false;
/** Senders already told this bridge is superseded — one notice each, not one per message. */
supersededNotified = new Set();
/** Reaping leftovers is a one-shot per process, not a per-registration action. */
reapedStaleBridges = false;
queue = [];
// Message ids to be handled inject-only (full-group-chat awareness) rather than
// full-wake+reply. Shares the `processing` lock + `queue` so an awareness inject
// never runs a second `--resume` on the local session concurrently with a wake.
awarenessOnly = new Set();
expectingResponseFrom = new Set();
// FIFO queue of waiters per peer. Multiple in-flight send-messages to the
// same peer each get their own waiter; inbound messages from that peer
// resolve waiters in send order. The previous single-waiter design caused
// the second send to overwrite the first waiter with a placeholder string,
// so the first send-message tool call returned literal "Superseded by
// newer send_message call" instead of the actual peer response.
responseWaiters = new Map();
/**
* Pending delivery-ack waiters, keyed by MESSAGE ID.
*
* 🔴 AUD-002. This used to be keyed by PEER ID, so a peer could have only one outstanding
* waiter and a second concurrent send displaced the first — resolving it, so the first send
* reported "delivered" with no acknowledgment from anyone. Delivery acknowledgment is the one
* claim this system must never fabricate.
*/
deliveryAckWaiters = new Map();
/**
* Acks that arrived before their waiter existed, keyed by message id.
*
* The recipient acknowledges over WS as soon as it receives the message, which can beat our own
* POST response. Waiters used to be registered BEFORE the send to dodge that race, which is what
* forced peer-keying. Buffering the early ack instead lets registration happen after the message
* id is known, so correlation stays exact. Entries are consumed once and expire.
*/
recentAcks = new Map();
sendDeliveryAckFn = null;
/**
* Set the local peer info. Called from register-peer tool after
* successful registration so we know which session to inject into.
*/
setLocalPeer(peerId, sessionId, cwd, mode, label, pinnedSessionId) {
// A re-registration under ANOTHER name must not leave this process announced under the old one:
// the stale announcement kept the old name "served" for every other bridge in the folder.
// 🔴 The NAME only, never the cwd (review r6 FIX1-1): agents pass `pwd` — a symlink spelling, a
// subfolder — and every other bridge looks the name up under its OWN folder. Withdrawn there, this
// agent's second bridge and any second window registered too, and each message woke two.
if (this.localPeerId && this.localPeerId !== peerId)
this.withdrawAnnouncement();
this.localPeerId = peerId;
this.localSessionId = sessionId;
this.pinnedSessionId = pinnedSessionId ?? sessionId;
this.localCwd = cwd;
this.localMode = mode ?? "agent";
this.localLabel = label ?? peerId;
logger.info(`Auto-relay configured for peer ${peerId} (session: ${sessionId}, pinned: ${this.pinnedSessionId}, mode: ${this.localMode}, label: ${this.localLabel})`);
// SELF-HEALING (2026-09-05). Announce this process as a bridge serving this identity, so a
// LEFTOVER bridge for the same (cwd, peerId) — one a plugin update did not stop — can see it
// has been superseded and stand down on its own. Nobody has to run pkill. See
// services/wake-ownership.ts for why the key is (cwd, peerId) and not cwd alone.
// 🔴 ALSO UNDER THE BRIDGE'S OWN FOLDER when `cwd` is another spelling of it — a symlink, a
// subfolder (review r7 FIX2-3): after a first-ever register with such a cwd, a second window
// found no holder in the folder, registered too, and every message woke both sessions.
// 🔴 NOT COVERED (review r8 R8-3, measured): a first register whose cwd is OUTSIDE the bridge's
// folder — its parent, an unrelated folder. A second window there still takes the name and every
// message wakes both sessions (on main: one wake, by the window's session). Left for COG-80.
const dirs = new Set([path.resolve(cwd)]);
if (isInsideOwnFolder(path.resolve(cwd)))
dirs.add(process.cwd());
for (const dir of dirs) {
this.announcedCwds.add(dir);
announceBridge(dir, peerId, CLIENT_VERSION, getConfig().COGENT_ENDPOINT ?? "local");
}
this.supersededNotified.clear();
// ...and reap the leftovers that CANNOT stand down on their own. Supersession is client code,
// so a bridge running 3.23.3 or older never executes it and would keep waking this checkout
// forever. Runs ONCE per process and only now, after a peer is genuinely registered, so a probe
// or a half-started process never reaps anything. Never throws — a failed scan reaps nothing.
if (!this.reapedStaleBridges) {
this.reapedStaleBridges = true;
try {
reapStaleBridges(cwd);
}
catch (err) {
logger.debug(`stale-bridge reap skipped: ${err.message}`);
}
}
}
/**
* Emit one content-free wake-trace line (ids/decisions/timings only — NEVER
* message or response text). Correlated by a per-wake traceId. Events:
* delivered · resolved · wake · captured · refused · failed.
*/
_trace(traceId, event, extra = {}) {
logger.info(`wake_trace ${event}`, {
traceId,
event,
peerId: this.localPeerId,
...extra,
});
}
/**
* Internal predicate: should auto-relay short-circuit BEFORE delivery-ack,
* BEFORE waiter resolution, and BEFORE _processMessage calls the local agent CLI via execRemote?
*
* Extracted as a method so Phase 20 (presence/DND/AFK) can OR additional
* conditions in here without rewriting handleIncoming. The short-circuit
* site is upstream of delivery-ack on purpose: observers are passive — the
* sender should not believe the message was "delivered to an agent that
* will act on it". (STATE.md blocker for Phase 20 reusability.)
*/
_shouldSkipAutoRelay() {
return this.localMode === "observer";
}
/**
* Get the local peer info for re-registration after eviction.
* Returns null if no peer has been registered yet.
*/
getLocalPeer() {
if (!this.localPeerId || !this.localSessionId || !this.localCwd) {
return null;
}
return {
peerId: this.localPeerId,
sessionId: this.localSessionId,
cwd: this.localCwd,
label: this.localLabel ?? this.localPeerId,
mode: this.localMode,
};
}
/**
* Re-detect the current local agent session from disk. If the session has
* rotated (CC/Codex creates a new session file after ~1hr idle), update
* localSessionId so the next execRemote call targets the active session.
*/
async refreshSessionId() {
if (!this.localCwd) {
return {
sessionId: this.localSessionId,
decision: "none",
confidence: "none",
candidateCount: 0,
mtimeGapMs: null,
fallbackReason: "no cwd registered",
};
}
const res = await resolveSession(getConfig().COGENT_PLATFORM, this.localCwd, this.pinnedSessionId ?? this.localSessionId ?? undefined);
// Only adopt a resolved id; on ambiguous/none (sessionId null) keep the
// last-known-good pin — never silently swap to a newest-mtime guess.
if (res.sessionId && res.sessionId !== this.localSessionId) {
logger.info(`Auto-relay: session resolved ${this.localSessionId} -> ${res.sessionId} (${res.decision}/${res.confidence})`);
this.localSessionId = res.sessionId;
this.pinnedSessionId = res.sessionId;
}
return res;
}
/**
* Set the function used to send delivery acknowledgments via WebSocket.
* Injected by startup.ts to avoid circular dependency with ws-client.
*/
setSendDeliveryAck(fn) {
this.sendDeliveryAckFn = fn;
}
/**
* Mark that we expect a response from a peer (because we just sent them
* an explicit message via send_message). When their response arrives,
* inject it into the local session but do NOT relay our output back.
* This breaks the infinite auto-relay loop.
*/
expectResponse(fromPeerId) {
this.expectingResponseFrom.add(fromPeerId);
logger.debug(`Auto-relay: expecting response from ${fromPeerId} (will inject-only)`);
}
/**
* Wait for a response from a peer. Returns a Promise that resolves with
* the response message content when the target peer responds.
*
* This is the key mechanism that makes cloud mode behave like local mode:
* send-message calls this before sending, then awaits the Promise.
* When the response arrives via WS, the Promise resolves with the actual
* response text, which send-message returns as the tool result.
*
* On timeout, the Promise rejects but the expectation is kept in
* expectingResponseFrom so late responses still get inject-only treatment
* (preventing infinite auto-relay loops).
*/
waitForResponse(fromPeerId, timeoutMs) {
return new Promise((resolve, reject) => {
const waiter = { resolve, reject, timer: undefined };
waiter.timer = setTimeout(() => {
// Remove this specific waiter from the FIFO queue (it may not be the
// head if other waiters were enqueued after it). Keep
// expectingResponseFrom set if other waiters remain.
const queue = this.responseWaiters.get(fromPeerId);
if (queue) {
const idx = queue.indexOf(waiter);
if (idx >= 0)
queue.splice(idx, 1);
if (queue.length === 0)
this.responseWaiters.delete(fromPeerId);
}
// Keep expectingResponseFrom so late responses still get inject-only
// treatment (preventing infinite auto-relay loops).
reject(new Error(`Response timeout from ${fromPeerId} after ${timeoutMs}ms`));
}, timeoutMs);
// Append to FIFO queue (do NOT cancel previous waiters)
const queue = this.responseWaiters.get(fromPeerId) ?? [];
queue.push(waiter);
this.responseWaiters.set(fromPeerId, queue);
this.expectingResponseFrom.add(fromPeerId);
logger.debug(`Auto-relay: waiting for response from ${fromPeerId} (timeout: ${timeoutMs}ms, queue depth: ${queue.length})`);
});
}
/**
* Resolve the oldest pending waiter for `fromPeerId` with the given message.
* Returns true if a waiter was found and resolved, false otherwise.
*/
_popAndResolveWaiter(fromPeerId, message) {
const queue = this.responseWaiters.get(fromPeerId);
if (!queue || queue.length === 0)
return false;
const waiter = queue.shift();
clearTimeout(waiter.timer);
if (queue.length === 0)
this.responseWaiters.delete(fromPeerId);
waiter.resolve(message);
return true;
}
/**
* Clear the expectation for a peer and cancel any pending waiter.
*/
clearExpectation(fromPeerId) {
this.expectingResponseFrom.delete(fromPeerId);
this._cancelWaiter(fromPeerId);
this._cancelDeliveryAckWaiter(fromPeerId);
}
/**
* Cancel only the active response waiter for a peer while keeping the
* expectation marker. Used when send-message returns early but a late
* response should still be treated as inject-only.
*/
cancelResponseWaiter(fromPeerId) {
this._cancelWaiter(fromPeerId);
}
/**
* Cancel ALL pending response waiters for a peer by rejecting them with
* a clear error. Used by clearExpectation()/cancelResponseWaiter() to
* unblock callers without lying to them. The previous behaviour resolved
* waiters with the literal string "Superseded by newer send_message call",
* which was then returned as the peer's "response" -- a confusing and
* easily misinterpreted artifact.
*/
_cancelWaiter(fromPeerId) {
const queue = this.responseWaiters.get(fromPeerId);
if (!queue || queue.length === 0)
return;
this.responseWaiters.delete(fromPeerId);
for (const w of queue) {
clearTimeout(w.timer);
w.reject(new Error(`Response wait cancelled for ${fromPeerId}`));
}
}
/**
* Wait for a delivery acknowledgment from a peer. Returns a Promise that
* resolves when the target peer's MCP sends a delivery_ack frame, or
* rejects on timeout (recipient likely offline).
*/
waitForDeliveryAck(fromPeerId, messageId, timeoutMs) {
// An ack that beat this registration is already buffered — consume it once.
if (this.recentAcks.delete(messageId)) {
logger.debug(`Auto-relay: delivery ack for ${messageId} was already buffered`);
return Promise.resolve();
}
return new Promise((resolve, reject) => {
const timer = setTimeout(() => {
this.deliveryAckWaiters.delete(messageId);
reject(new Error(`Delivery ack timeout from ${fromPeerId} for ${messageId} after ${timeoutMs}ms`));
}, timeoutMs);
this.deliveryAckWaiters.set(messageId, { peerId: fromPeerId, resolve, reject, timer });
});
}
/**
* Settle the waiter for exactly this message.
*
* 🔴 Correlation is by MESSAGE ID, and the acknowledging peer must match the peer we sent to.
* The frame has carried `messageId` all along (src/cloud/ws-client.ts:52); startup.ts used to
* drop it and resolve by peer, so any ack settled any outstanding send to that peer — and an ack
* naming a peer the sender never messaged could settle a waiter outright (AUD-003).
*
* An ack with no matching waiter is BUFFERED rather than discarded: the recipient can legitimately
* acknowledge before our POST response returns.
*/
resolveDeliveryAck(fromPeerId, messageId) {
const waiter = this.deliveryAckWaiters.get(messageId);
if (waiter) {
if (waiter.peerId !== fromPeerId) {
// Not the peer we sent this message to. Never settle on it.
logger.warn(`Auto-relay: IGNORED delivery ack for ${messageId} — claimed by '${fromPeerId}' but ` +
`that message was sent to '${waiter.peerId}'`);
return;
}
clearTimeout(waiter.timer);
this.deliveryAckWaiters.delete(messageId);
waiter.resolve();
logger.debug(`Auto-relay: delivery ack received from ${fromPeerId} for ${messageId}`);
return;
}
this._bufferEarlyAck(messageId);
}
/** Remember an ack whose waiter does not exist yet, with bounded retention. */
_bufferEarlyAck(messageId) {
const now = Date.now();
for (const [id, at] of this.recentAcks) {
if (now - at > EARLY_ACK_TTL_MS)
this.recentAcks.delete(id);
}
// Hard cap so a hostile or looping peer cannot grow this without bound.
while (this.recentAcks.size >= EARLY_ACK_MAX) {
const oldest = this.recentAcks.keys().next().value;
if (oldest === undefined)
break;
this.recentAcks.delete(oldest);
}
this.recentAcks.set(messageId, now);
logger.debug(`Auto-relay: buffered early delivery ack for ${messageId}`);
}
_cancelDeliveryAckWaiter(fromPeerId) {
// 🔴 REJECT, never resolve. Resolving a cancelled waiter reports a delivery that never
// happened — the AUD-002 defect. Cancels every outstanding send to this peer, since the
// caller is abandoning the conversation, not one message.
for (const [messageId, waiter] of this.deliveryAckWaiters) {
if (waiter.peerId !== fromPeerId)
continue;
clearTimeout(waiter.timer);
this.deliveryAckWaiters.delete(messageId);
waiter.reject(new Error(`Delivery ack wait for ${messageId} cancelled (${fromPeerId})`));
}
}
/**
* Process an incoming cloud message by calling the local agent CLI via execRemote()
* and relaying the response back to the sender.
*
* Guards:
* - Not registered yet (localPeerId is null)
* - Message not addressed to us
* - Message is from ourselves (prevent echo)
* - Message is already a response (response field is non-null)
* - Serialized processing with queue to prevent concurrent execRemote calls
*/
async handleIncoming(msg) {
// Guard: not registered yet
if (!this.localPeerId || !this.localSessionId || !this.localCwd) {
logger.debug("Auto-relay: skipping message (not registered yet)");
return;
}
// Guard: message not for us (accept direct messages AND broadcasts)
if (msg.toPeerId !== this.localPeerId && msg.toPeerId !== "*") {
return;
}
// Guard: don't relay our own outgoing messages
if (msg.fromPeerId === this.localPeerId) {
return;
}
// Guard: this is already a response, not a new incoming message needing relay
if (msg.response !== null) {
return;
}
// Send delivery acknowledgment IMMEDIATELY -- before any branching.
// This is the single source of truth for ack emission. Returning the ack
// promptly is what tells the SENDER we are alive, regardless of whether
// we then route this message to a pending waiter, inject-only handler,
// or full auto-relay execRemote dispatch. The previous design omitted the ack on
// the waiter-resolution path, causing senders that were themselves
// awaiting a response to time out their 15s ack-wait and report the
// recipient as offline -- even though both peers were online and the
// message had in fact been delivered.
// Mint (or reuse) a per-wake trace id and stamp it on the record so it
// rides through the busy-queue and onto the relay-back echo.
const traceId = msg.traceId ?? randomUUID();
msg.traceId = traceId;
if (this.sendDeliveryAckFn) {
this.sendDeliveryAckFn(msg.id, this.localPeerId, msg.fromPeerId);
this._trace(traceId, "delivered", { messageId: msg.id, fromPeerId: msg.fromPeerId });
}
// Slice 2: a busy-state status notice (⏳ queued / processing / …) is
// INFORMATIONAL and NON-TERMINAL. Recognize it BEFORE the waiter-resolution
// path so it never resolves a pending send_message (the real answer follows
// when the busy peer drains) and never triggers a local wake. The delivery
// ack already fired above, so the sender still learns the notice arrived.
const statusNotice = parseStatusNotice(msg.message);
if (statusNotice) {
this._trace(traceId, "status_recv", {
status: statusNotice,
fromPeerId: msg.fromPeerId,
});
logger.info(`Auto-relay: non-terminal status '${statusNotice}' from ${msg.fromPeerId} — leaving any waiter pending`);
return;
}
// Guard: we sent this peer an explicit message and are expecting their response.
// If there's an active waiter (send-message is blocking), resolve it with
// the response content. Otherwise fall back to inject-only mode.
if (this.expectingResponseFrom.has(msg.fromPeerId)) {
// Check if send-message is actively waiting (FIFO queue per peer)
const resolved = this._popAndResolveWaiter(msg.fromPeerId, msg.message);
if (resolved) {
// Only clear the expectation marker once the waiter queue is empty;
// additional in-flight sends from this peer should still resolve
// their own waiters in order.
if ((this.responseWaiters.get(msg.fromPeerId)?.length ?? 0) === 0) {
this.expectingResponseFrom.delete(msg.fromPeerId);
}
logger.info(`Auto-relay: response from ${msg.fromPeerId} delivered to waiting send-message`);
return;
}
// No active waiter (timed out or used expectResponse directly)
this.expectingResponseFrom.delete(msg.fromPeerId);
logger.info(`Auto-relay: inject-only response from ${msg.fromPeerId} (no active waiter)`);
await this._injectOnly(msg);
return;
}
// Observer-mode short-circuit (3.2.0 / Phase 17 OBS-03).
// Delivery ack has already fired above (the WS frame WAS delivered to us
// — the sender deserves to know that), and any active send_message waiter
// has been resolved (the user's own outbound exchange still works on an
// observer client). What we DON'T do is call the local agent CLI via execRemote or enqueue:
// observers are passive watchers. CONTEXT.md decision: observers remain
// targetable and receive frames, but never auto-respond. Phase 20 will
// OR presence/DND conditions into `_shouldSkipAutoRelay()`.
if (this._shouldSkipAutoRelay()) {
logger.info(`Auto-relay: observer mode — skipping subprocess for message ${msg.id} from ${msg.fromPeerId}`);
return;
}
// STORM GUARD (ALL scopes — the structural loop-break). A relay-echo is a
// PEER's own auto-reply / status echo (tagged isRelayEcho by _relayCapturedReply
// / the relay), NEVER a fresh question. Its only legitimate consumption is
// resolving a directed waiter — handled above, which already returned. Any
// surviving echo, whether a "*" broadcast OR a DIRECTED reply (which is how
// COGENT_REPLY_BROADCAST=false records an answer), must NEVER wake us to reply.
// Guarding here — before the broadcast gate and the wake path, and for every
// scope — is what makes replies-beget-replies (the N² debate storm AND the A↔B
// directed echo ping-pong) structurally impossible. With COGENT_BROADCAST_AWARENESS
// on, INJECT it so the agent SEES channel context (reply discarded; the notice
// forbids tool calls) — falling through to the SHARED serialize machinery so the
// resume is locked against a concurrent wake; otherwise ignore it entirely.
if (msg.isRelayEcho === true) {
if (getConfig().COGENT_BROADCAST_AWARENESS) {
this._trace(traceId, "broadcast_awareness", {
fromPeerId: msg.fromPeerId,
echo: true,
originPlatform: msg.originPlatform ?? "unknown",
});
logger.info(`Auto-relay: peer reply-echo from ${msg.fromPeerId} — inject-only awareness (no re-reply).`);
this.awarenessOnly.add(msg.id);
}
else {
this._trace(traceId, "broadcast_echo_ignored", {
fromPeerId: msg.fromPeerId,
originPlatform: msg.originPlatform ?? "unknown",
});
logger.info(`Auto-relay: peer reply-echo from ${msg.fromPeerId} — ignored (no re-reply).`);
return;
}
}
// --- FRESH agent-origin broadcast handling (toPeerId "*") ---
// A DIRECTED message skips this block and always wakes+replies below. A
// HUMAN-origin broadcast (slack/gchat/web — e.g. a Slack user in a mapped
// channel) also skips it and always auto-relays, so those users always get an
// answer. Echoes were fully handled by the storm guard above, so only a FRESH
// agent-origin channel question reaches here.
const HUMAN_ORIGINS = new Set(["slack", "gchat", "web"]);
const isHumanOriginBroadcast = msg.originPlatform !== undefined && HUMAN_ORIGINS.has(msg.originPlatform);
if (msg.toPeerId === "*" &&
!isHumanOriginBroadcast &&
msg.isRelayEcho !== true) {
const cfg = getConfig();
// DEFAULT: an agent ALWAYS wakes + answers a fresh channel QUESTION (the
// operator invariant "always wake+respond to a channel message"). Opt out to
// the legacy "humans drive broadcast replies" behavior with
// COGENT_BROADCAST_MANUAL=true. COGENT_BROADCAST_AUTORELAY is a back-compat
// force-answer that overrides MANUAL.
const answer = cfg.COGENT_BROADCAST_AUTORELAY || !cfg.COGENT_BROADCAST_MANUAL;
if (!answer) {
// Manual mode: with COGENT_BROADCAST_AWARENESS on, INJECT for visibility
// (reply discarded; notice forbids tool calls) via the SHARED serialize
// machinery; otherwise drop it.
if (cfg.COGENT_BROADCAST_AWARENESS) {
this._trace(traceId, "broadcast_awareness", {
fromPeerId: msg.fromPeerId,
echo: false,
originPlatform: msg.originPlatform ?? "unknown",
});
logger.info(`Auto-relay: agent-origin broadcast from ${msg.fromPeerId} — manual inject-only awareness (no reply relayed).`);
this.awarenessOnly.add(msg.id);
}
else {
this._trace(traceId, "broadcast_manual", {
fromPeerId: msg.fromPeerId,
originPlatform: msg.originPlatform ?? "unknown",
});
logger.info(`Auto-relay: agent-origin broadcast from ${msg.fromPeerId} — manual mode (no auto-reply); unset COGENT_BROADCAST_MANUAL to answer channel questions.`);
return;
}
}
else {
// Fresh agent-origin channel QUESTION → fall through to a full wake + real
// reply (always-respond). Storm-safe: the reply is tagged isRelayEcho and
// hits the storm guard above on every peer, so it is never re-answered.
this._trace(traceId, "broadcast_answer", {
fromPeerId: msg.fromPeerId,
originPlatform: msg.originPlatform ?? "unknown",
});
logger.info(`Auto-relay: agent-origin broadcast question from ${msg.fromPeerId} — answering (always-respond).`);
}
}
// COG-20: publish "rail B is handling this message" as soon as we ACCEPT it — including
// while it waits in the queue behind an in-progress wake. A queued message is equally
// "about to be answered by B", and rail C firing about it lands in the SAME capture
// window and corrupts the in-flight turn's reply. Slack bursts produce this shape
// constantly. Awareness-only injects are excluded: they are never answered, and rail C's
// scope (directed + human-broadcast) does not cover agent broadcasts anyway.
// Retracted in _dispatch's finally. See services/wake-inflight.ts.
// 🔴 CROSS-PROCESS DEDUP (marvin-coder, 2026-09-05). The relay keeps an ARRAY of connections
// per peer and `sendToPeer` writes to every one, so N bridge processes registered as this peer
// each receive THIS SAME message. The queue below serializes within ONE process only, so
// without this every one of them resumes the same session and repeats the same work — four
// identically-titled commits on a real user's main branch.
//
// Claimed BEFORE the queue, because losing the claim means another process owns the whole
// message and we must not hold it, notify about it, or answer it. Released in _dispatch's
// finally. Fails OPEN: an unclaimable message is still woken (see wake-claim.ts).
// 🔴 SELF-HEALING: STAND DOWN IF SUPERSEDED — and do it BEFORE claiming.
// Order matters absolutely here. If a leftover bridge claimed the message first and only then
// discovered it was superseded, the real owner would find the claim taken and skip the
// message: duplicate work would become TOTAL SILENCE, which is far worse. So ownership is
// decided before anything is held. Fails open (see wake-ownership.ts).
// 🔴 ALSO UNDER THE BRIDGE'S OWN FOLDER when the registered cwd is another SPELLING of it (review
// r8 R8-2, R8-4): registered through a symlink, this bridge looked only under the symlink, where it
// is alone, so it never stood down for the newer bridge registered with the folder's real path —
// each message woke the agent twice, and that bridge's register notice said this one had stopped.
// A cwd INSIDE the folder is not checked there: bridges that share a working directory (a client
// started in $HOME) and registered one name for two projects are two agents, and both keep waking.
if (this.localPeerId && this.localCwd) {
const dirs = new Set([path.resolve(this.localCwd), ...(isOwnFolder(this.localCwd) ? [process.cwd()] : [])]);
for (const dir of dirs) {
const own = checkWakeOwnership(dir, this.localPeerId);
if (!own.isOwner) {
await this._reportSuperseded(msg, own.owner);
return;
}
}
}
if (this.localPeerId && !this.awarenessOnly.has(msg.id)) {
if (!claimWake(this.localPeerId, msg.id, this.localCwd ?? process.cwd())) {
return; // another live process in this cwd owns this message
}
markWakeInFlight(this.localPeerId, msg.id, msg.traceId);
}
// Serialize processing: queue if already busy
if (this.processing) {
logger.debug(`Auto-relay: queuing message from ${msg.fromPeerId} (busy processing)`);
this.queue.push(msg);
// Slice 2: tell the SENDER their message is ⏳ queued — not lost, and NOT
// resumed mid-turn (which would corrupt an active edit/test). Non-terminal:
// the real answer follows when we drain. Exactly one notice per deferred
// message (this branch runs once per message). An awareness-inject has no
// waiting sender, so it gets no notice.
if (!this.awarenessOnly.has(msg.id)) {
await this._emitStatusNotice(msg.fromPeerId, RELAY_STATUS.QUEUED, msg.traceId);
}
return;
}
this.processing = true;
try {
await this._dispatch(msg);
}
finally {
this.processing = false;
// Process queued messages
while (this.queue.length > 0) {
const next = this.queue.shift();
this.processing = true;
try {
await this._dispatch(next);
}
finally {
this.processing = false;
}
}
}
}
/**
* Tell the sender we are a LEFTOVER bridge and are not answering — instead of going silent.
*
* Silence is the failure mode this codebase treats as worst: it is indistinguishable from
* "nothing to do". A superseded bridge is usually reachable on a channel the newer bridge is NOT
* on (the measured case had the ghost on the FREE relay and the live one on TEAM), so without
* this the sender would wait forever with no explanation.
*
* Once per sender per process — a leftover that is being messaged steadily must not turn into a
* notice generator.
*/
async _reportSuperseded(msg, owner) {
const who = owner ? registryOwnerLabel(owner) : "a newer bridge";
logger.warn(`Auto-relay: NOT waking for ${msg.fromPeerId} — this bridge (pid ${process.pid}, ` +
`v${CLIENT_VERSION}) has been superseded for peer "${this.localPeerId}" in this checkout by ` +
`${who}. It is a leftover process; the newer bridge is answering. Standing down.`);
if (this.supersededNotified.has(msg.fromPeerId))
return;
this.supersededNotified.add(msg.fromPeerId);
try {
await getBackend().recordMessage({
fromPeerId: this.localPeerId,
toPeerId: msg.fromPeerId,
message: `⚠️ "${this.localPeerId}" is not answering on this channel: this bridge process is a ` +
`leftover (v${CLIENT_VERSION}) that a newer one has replaced in the same checkout. ` +
`Send to the agent on its current channel instead.`,
response: null,
durationMs: 0,
success: false,
error: "BRIDGE_SUPERSEDED",
isRelayEcho: true,
traceId: msg.traceId,
});
}
catch (err) {
logger.error(`Auto-relay: failed to record superseded notice: ${err}`);
}
}
/**
* Dispatch a message under the `processing` lock: an awareness-marked broadcast is
* inject-only (see, no reply); everything else is a full wake+reply. Routing both
* through here guarantees we never run two session resumes concurrently.
*/
async _dispatch(msg) {
if (this.awarenessOnly.delete(msg.id)) {
await this._injectPeerBroadcast(msg);
return;
}
// The in-flight marker was published when the message was ACCEPTED (handleIncoming), so
// it also covers the time this message spent queued. Retract it here in a `finally` —
// whatever happened — because a leaked marker is the one failure mode that would mute
// rail C for this peer until the writer-stamped expiry.
const inflightPeer = this.localPeerId;
const claimCwd = this.localCwd ?? process.cwd();
try {
await this._processMessage(msg);
}
finally {
if (inflightPeer) {
clearWakeInFlight(inflightPeer, msg.id);
// Release the cross-process claim too, or a crash-free but slow peer would keep other
// processes out until the writer-stamped expiry.
releaseWake(inflightPeer, msg.id, claimCwd);
}
}
}
/**
* Slice 2: relay a NON-TERMINAL status notice back to the sender as a new
* cloud message. The notice rides in the `message` field (cloud transmits
* only `message`) with a `[cogent:<status>]` marker the sender's client
* recognizes, so it does NOT resolve a pending send_message waiter. Tagged
* `isRelayEcho` so server 3.1.6+ filters it from history like other echoes.
* Best-effort: a failure here must never break the busy-queue path.
*/
async _emitStatusNotice(toPeerId, status, traceId) {
if (!this.localPeerId)
return;
try {
await getBackend().recordMessage({
fromPeerId: this.localPeerId,
toPeerId,
message: buildStatusNotice(status, this.localPeerId, traceId),
response: null,
durationMs: null,
success: true,
error: null,
isRelayEcho: true,
traceId,
});
this._trace(traceId ?? "", "status_sent", { status, toPeerId });
}
catch (err) {
logger.error(`Auto-relay: failed to emit '${status}' status notice: ${err}`);
}
}
/**
* Slice 3 (Story 8.4): relay a captured reply back to the sender, recording
* HOW it was captured so a downgrade (structured `event_stream` → raw
* `stdout_compat`, or a structured error) is EXPLICIT in the wake trace and
* never disguised as a clean auto-capture (AC3). On an unstable capture it
* also emits a visible, non-terminal `[cogent:needs-manual]` notice to the
* sender — the answer still relays, but the human/agent knows it wasn't a
* trusted structured capture. Used by BOTH the primary and rotated-session
* retry paths (F-B) so neither can disguise a downgrade.
*/
async _relayCapturedReply(msg, result, traceId, durationMs, retried) {
this._trace(traceId, "captured", {
durationMs,
retried,
captureContract: result.captureContract ?? "unknown",
stable: result.stable ?? null,
});
// A downgrade is only when the contract is EXPLICITLY known to be lower-trust
// (stdout_compat fallback, or a structured capture whose run errored). An
// ABSENT contract (legacy/unknown result) is NOT a downgrade — don't emit a
// spurious needs-manual notice; preserve the pre-Slice-3 relay behavior.
const isDowngrade = result.captureContract !== undefined && result.stable !== true;
if (isDowngrade) {
this._trace(traceId, "capture_downgrade", {
to: result.captureContract,
retried,
});
await this._emitStatusNotice(msg.fromPeerId, RELAY_STATUS.NEEDS_MANUAL, traceId);
}
// MIRROR the incoming scope: a broadcast question (toPeerId "*") gets a broadcast
// reply so the whole channel sees the answer; a directed / P2P question replies to
// the sender only. Gated by COGENT_REPLY_BROADCAST (default on). The reply stays
// tagged isRelayEcho:true — GET /poll delivers it to every peer (no echo filter)
// while GET /messages history filters it, so live views broadcast but history
// stays clean. Agent-origin broadcasts don't auto-wake other agents (Slice 4), so
// a broadcast reply reaches humans without triggering an agent debate storm.
const replyToPeerId = getConfig().COGENT_REPLY_BROADCAST && msg.toPeerId === "*"
? "*"
: msg.fromPeerId;
try {
await getBackend().recordMessage({
fromPeerId: this.localPeerId,
toPeerId: replyToPeerId,
message: result.stdout,
response: null,
durationMs,
success: true,
error: null,
// 260511-o3p: tag the auto-relay echo so server 3.1.6+ filters it from
// cogent_get_history by default.
isRelayEcho: true,
traceId,
});
logger.info(`Auto-relay: response ${replyToPeerId === "*" ? "broadcast to channel" : `sent back to ${replyToPeerId}`} (${durationMs}ms${retried ? ", retried" : ""})`);
}
catch (recordErr) {
logger.error(`Auto-relay: failed to record response message: ${recordErr}`);
}
}
/**
* Internal: process a single message -- calls the local agent CLI via execRemote and relay response.
*/
async _processMessage(msg) {
const backend = getBackend();
// Look up sender peer to get their label for better context
let fromLabel = msg.fromPeerId;
try {
const senderPeer = await backend.getPeer(msg.fromPeerId);
if (senderPeer) {
fromLabel = senderPeer.label;
}
}
catch {
// Fall back to peerId as label
}
const formatted = formatRelayMessage(fromLabel, msg.fromPeerId, msg.message, msg.attachments);
logger.info(`Auto-relay: processing message from ${fromLabel} (${msg.fromPeerId})`);
const traceId = msg.traceId ?? randomUUID();
const startMs = Date.now();
// Fail-loud (2026-08-11): track whether a reply was actually relayed so a wake
// that captured NO output can never vanish silently (the demo busy-miss class).
let replied = false;
// Visibility (2026-09-04). A wake mutates the peer's REAL worktree with
// --dangerously-skip-permissions and is run with --no-session-persistence, so the CLI
// writes no transcript. Nothing downstream records that this turn happened or what it
// touched. These four track exactly that, and feed the durable log in `finally`.
let treeBefore = null;
let wakeExitCode = null;
let refused = false;
let threw = false;
// Held only across the resume itself; released in the finally below so a thrown wake can
// never leave the next turn (or another process) blocked behind a dead lease.
let wakeLease = null;
try {
// Confidence-aware resolve (prefer-exact / refuse-if-close) instead of a
// silent newest-mtime guess.
const res = await this.refreshSessionId();
this._trace(traceId, "resolved", {
decision: res.decision,
confidence: res.confidence,
candidateCount: res.candidateCount,
mtimeGapMs: res.mtimeGapMs,
});
// ACCEPTANCE GATE: on ambiguous/none we REFUSE to wake and tell the sender
// a manual response is required — never resume the wrong session silently.
if (!res.sessionId) {
refused = true;
this._trace(traceId, "refused", {
decision: res.decision,
candidateCount: res.candidateCount,
});
logger.warn(`Auto-relay: REFUSED wake for ${msg.fromPeerId} — ${res.fallbackReason}`);
try {
// Put the full, actionable guidance in `message`: in cloud mode ONLY
// the message field crosses the wire to the sender (the relay
// reconstructs `response` server-side), so a terse message would strand
// the useful text. Carries the reason + traceId so the human/agent can
// see WHY the wake was refused and correlate it in the logs.
await backend.recordMessage({
fromPeerId: this.localPeerId,
toPeerId: msg.fromPeerId,
message: `⚠️ Cogent could not safely resume "${this.localPeerId}": ${res.fallbackReason}. Manual response required — the target agent must reply in its own session. (trace ${traceId})`,
response: null,
durationMs: Date.now() - startMs,
success: false,
error: "AMBIGUOUS_SESSION",
isRelayEcho: true,
traceId,
});
}
catch (recordErr) {
logger.error(`Auto-relay: failed to record refusal message: ${recordErr}`);
}
return;
}
this._trace(traceId, "wake", {
decision: res.decision,
confidence: res.confidence,
});
// 🔴 CROSS-PROCESS SERIALISATION PER CHECKOUT (marvin-coder, 2026-09-05).
// The per-message claim above cannot cover this: a second bridge reached through a
// DIFFERENT relay carries a different message id, so both claims succeed and both
// processes run a resume over the same worktree at once. Keyed on the CWD, not the
// session — two processes can hold different session pins for one checkout, and the
// corruption is a property of the worktree either way. Waits rather than drops; fails
// open. See services/wake-lock.ts and wake-dedup-matrix-test.mjs --topology two-relay.
wakeLease = await acquireWakeLock(this.localCwd, this.localSessionId);
// Calls the local agent CLI via execRemote (platform-polymorphic dispatch).
// Routes to `claude --resume` on CC or `codex exec resume` on Codex.
// This is NOT the HttpBackend stub (which is a no-op).
// Snapshot BEFORE the wake so the log can name what changed while it ran.
// Read-only, failure-tolerant (null when not a git repo) — never blocks the wake.
// Snapshotted AFTER the lease, so it describes the tree we are about to act on rather
// than one another process was still mutating.
treeBefore = captureTreeState(this.localCwd);
const result = await execRemote(getConfig().COGENT_PLATFORM, this.localSessionId, formatted, this.localCwd, undefined,
// Idempotency: the codex app-server rail runs turns on the daemon
// independent of our client, so a redelivery of this same message must
// not start a second turn. Keyed by the stable relay message id.
{ idempotencyKey: msg.id });
const durationMs = Date.now() - startMs;
wakeExitCode = result.exitCode;
if (result.exitCode === 0 && result.stdout) {
await this._relayCapturedReply(msg, result, traceId, durationMs, false);
replied = true;
}
else {
this._trace(traceId, "failed", { exitCode: result.exitCode, sandboxBlocked: result.sandboxBlocked, durationMs });
logger.warn(`Auto-relay: execRemote failed (exit=${result.exitCode}): ${result.stderr}`);
// Blocked OS sandbox is HOST-WIDE — a rotated-session retry can't fix it,
// and the failure used to be dropped silently (the sender just saw the
// task never finish). Two things happen here:
// (1) RUNTIME self-heal. The startup probe only tests unprivileged-userns
// creation, but codex's bwrap ALSO sets up a network namespace — a
// distinct kernel control (RTM_NEWADDR EPERM) the probe can't see — so
// a task can hit a sandbox block at runtime on a host that probed
// "ok"/"unknown". If auto-recovery is on (default) and we're not
// already bypassing, switch to bypass and retry ONCE so the task
// self-heals (matching the product-wide promise).
// (2) If recovery is off (opt-out) or bypass didn't fix it, surface an
// accurate, config-aware message (text rides in `message`, the only
// field that crosses the wire).
if (result.sandboxBlocked) {
const cfg = getConfig();
const fallbackOn = cfg.COGENT_CODEX_SANDBOX_FALLBACK;
if (fallbackOn && cfg.COGENT_PLATFORM === "codex" && effectiveSandbox(cfg) !== "bypass") {
setSandboxOverride("bypass");
logger.warn(`Auto-relay: Codex sandbox failed at runtime for "${this.localPeerId}" — auto-recovering: ` +
`switching to --dangerously-bypass-approvals-and-sandbox and retrying once. Set ` +
`COGENT_CODEX_SANDBOX_FALLBACK=0 to keep the sandbox strict.`);
const bypassRetry = await execRemote(cfg.COGENT_PLATFORM, this.localSessionId, formatted, this.localCwd);
if (bypassRetry.exitCode === 0 && bypassRetry.stdout) {
await this._relayCapturedReply(msg, bypassRetry, traceId, Date.now() - startMs, true);
replied = true;
return;
}
logger.warn(`Auto-relay: sandbox-bypass retry also failed (exit=${bypassRetry.exitCode})`);
}
const remedy = fallbackOn
? `auto-recovery bypassed the sandbox but the task still failed — this looks like a different problem; check the agent host log`
: `auto-recovery is disabled here (COGENT_CODEX_SANDBOX_FALLBACK is off). Remedy on the agent host: unset COGENT_CODEX_SANDBOX_FALLBACK (default on) or set COGENT_CODEX_SANDBOX=bypass, or enable unprivileged userns (sysctl kernel.apparmor_restrict_unprivileged_userns=0)`;
try {
await backend.recordMessage({
fromPeerId: this.localPeerId,
toPeerId: msg.fromPeerId,
message: `⚠️ Cogent could not run "${this.localPeerId}": the Codex sandbox failed on the agent host, so it can't edit files or run tests — ${remedy}. (trace ${traceId})`,
response: null,
durationMs,
success: false,
error: "SANDBOX_BLOCKED",
isRelayEcho: true,
traceId,
});
}
catch (recordErr) {
logger.error(`Auto-relay: failed to record sandbox-blocked message: ${recordErr}`);
}
return;
}
// Session may have rotated -- retry once with fresh detection
const freshId = await detectCurrentSessionFor(getConfig().COGENT_PLATFORM, this.localCwd);
if (freshId && freshId !== this.localSessionId) {
logger.info(`Auto-relay: retry with rotated session ${this.localSessionId} -> ${freshId}`);
this.localSessionId = freshId;
const retry = await execRemote(getConfig().COGENT_PLATFORM, this.localSessionId, formatted, this.localCwd, undefined, { idempotencyKey: msg.id });
if (retry.exitCode === 0 && retry.stdout) {
// Retry path must apply the SAME capture-contract handling as the
// primary (Story 8.4 F-B): a downgrade on the retry is otherwise
// disguised as a clean auto-capture.
await this._relayCapturedReply(msg, retry, traceId, Date.now() - startMs, true);
replied = true;
}
else {
logger.warn(`Auto-relay: retry also failed (exit=${retry.exitCode})`);
}
}
}
// Fail-loud: no reply was relayed on ANY path (empty stdout, non-zero exit
// with no rotated session, or a failed retry). Surface a single visible
// "manual response required" notice to the sender — never a silent drop.
if (!replied) {
await this._recordNoReplyFailure(msg, traceId, Date.now() - startMs, {
codexLiveSession: result.codexLiveSession === true,
exitCode: result.exitCode,
stderr: result.stderr,
stdout: result.stdout,
});
}
}
catch (err) {
const durationMs = Date.now() - startMs;
threw = true;
this._trace(traceId, "failed", { threw: true, durationMs });
logger.error(`Auto-relay: execRemote threw after ${durationMs}ms: ${err}`);
if (!replied) {
await this._recordNoReplyFailure(msg, traceId, durationMs, {
stderr: `EXEC_THREW: ${err}`,
});
}
}
finally {
// Release BEFORE recording: _recordWake does file I/O and a git snapshot, and holding a
// cross-process lease across bookkeeping would stall the other bridge for no reason.
wakeLease?.release();
// ALWAYS, on every path including the early `return` of a refused wake. The one
// outcome a forensic log must never have is a gap where something clearly happened.
this._recordWake(msg, traceId, {
outcome: threw ? "threw" : refused ? "refused" : replied ? "replied" : "failed",
exitCode: wakeExitCode,
durationMs: Date.now() - startMs,
treeBefore,
});
}
}
/**
* Write the durable wake record. Wrapped whole: visibility must never be able to fail a
* wake that would otherwise have succeeded, so every error here is swallowed and logged.
*/
_recordWake(msg, traceId, outcome) {
try {
const cwd = this.localCwd;
if (!cwd)
return;
// Diff against a snapshot taken AFTER the wake settled. `filesChanged` is null when
// the cwd is not a git worktree — "I cannot tell" recorded honestly, never guessed.
const filesChanged = outcome.treeBefore
? diffTreeState(outcome.treeBefore, captureTreeState(cwd))
: null;
appendWakeRecord({
traceId,
messageId: msg.id,
fromPeerId: msg.fromPeerId,
toPeerId: this.localPeerId ?? "",
cwd,
sessionId: this.localSessionId ?? "",
platform: getConfig().COGENT_PLATFORM,
outcome: outcome.outcome,
exitCode: outcome.exitCode,
durationMs: outcome.durationMs,
filesChanged,
messagePreview: msg.message ?? "",
}, cwd);
if (filesChanged && filesChanged.length > 0) {
// Loud on the one event the report was about: a peer's message changed local files.
logger.info(`Auto-relay: wake for message ${msg.id} from ${msg.fromPeerId} changed ${filesChanged.length} path(s): ${filesChanged.slice(0, 10).join(", ")}`);
}
}
catch (err) {
logger.warn(`Auto-relay: wake-log record failed (wake itself unaffected): ${err}`);
}
}
/**
* Fail-loud (2026-08-11): a wake that captured NO reply — empty stdout, a
* non-zero exit with no rotated-session retry, a failed retry, or a thrown
* execRemote — used to record NOTHING, so the sender saw response:null with no
* error (the invisible failure that hid the demo busy-miss). Record ONE visible
* success:false notice to the sender so a human/agent knows to reply manually.
* On Codex this pairs with Wake-C (check-on-stop), which auto-recovers the reply
* at the next turn boundary. Refusal (AMBIGUOUS_SESSION) and sandbox-blocked
* paths record their own failure and return before this, so no double-record.
*/
async _recordNoReplyFailure(msg, traceId, durationMs, opts) {
// codex >=0.145 single-writer case: the agent is live in a TUI, so the resume
// couldn't get the writer. This is NOT a lost message — it's queued — so say so
// honestly and point at the real-time rail, instead of the alarming generic notice.
const codexLive = opts?.codexLiveSession === true;
const message = codexLive
? `📨 Queued for "${this.localPeerId}": it's live in an interactive Codex session, so Cogent can't ` +
`resume it in real time — it will see this at its next turn. For real-time replies, launch it via ` +
`\`cogent-codex\` (COGENT_CODEX_WAKE=app-server). (trace ${traceId})`
: `⚠️ Cogent could not capture a reply from "${this.localPeerId}". ` +
// Say WHAT the subprocess actually reported. Falling back to the old
// guess only when it reported nothing at all keeps the notice honest:
// a real diagnosis when one exists, an admitted unknown when it does not.
`${describeWakeFailure(opts?.exitCode, opts?.stderr, opts?.stdout) || "It may have been busy, or the resume produced no output."} ` +
`Manual response required — the target agent must reply in its own session. (trace ${traceId})`;
try {
await getBackend().recordMessage({
fromPeerId: this.localPeerId,
toPeerId: msg.fromPeerId,
message,
response: null,
durationMs,
success: false,
error: codexLive ? "CODEX_LIVE_SESSION" : "NO_REPLY_CAPTURED",
isRelayEcho: true,
traceId,
});
}
catch (recordErr) {
logger.error(`Auto-relay: failed to record no-reply failure: ${recordErr}`);
}
}
/**
* Inject a message into the local session without relaying the response.
* Used for expected responses to our own send_message calls.
*/
async _injectOnly(msg) {
if (!this.localSessionId || !this.localCwd)
return;
let fromLabel = msg.fromPeerId;
try {
const backend = getBackend();
const senderPeer = await backend.getPeer(msg.fromPeerId);
if (senderPeer) {
fromLabel = senderPeer.label;
}
}
catch {
// Fall back to peerId as label
}
const formatted = formatInjectOnlyMessage(fromLabel, msg.fromPeerId, msg.message, msg.attachments);
// An inject runs a real resume too, so it must serialise with every other turn in this
// checkout — including one started by a different bridge process. The per-message claim
// does NOT cover this path (it is skipped for awareness-only injects).
const lease = await acquireWakeLock(this.localCwd, this.localSessionId);
try {
// Re-detect session before each call -- CC rotates .jsonl after ~1hr idle
await this.refreshSessionId();
const result = await execRemote(getConfig().COGENT_PLATFORM, this.localSessionId, formatted, this.localCwd);
if (result.exitCode === 0) {
logger.info(`Auto-relay: inject-only complete from ${msg.fromPeerId} (response discarded)`);
}
else {
logger.warn(`Auto-relay: inject-only execRemote failed (exit=${result.exitCode})`);
}
}
catch (err) {
logger.error(`Auto-relay: inject-only execRemote threw: ${err}`);
}
finally {
lease.release();
}
}
/**
* Full-group-chat awareness (COGENT_BROADCAST_AWARENESS): inject a PEER's broadcast
* into the local session so the agent SEES what its peers said. The response is
* discarded and the notice forbids replies/tool calls, so this can never emit another
* broadcast — no storm. Mirrors _injectOnly but with the awareness-only formatter.
*/
async _injectPeerBroadcast(msg) {
if (!this.localSessionId || !this.localCwd)
return;
let fromLabel = msg.fromPeerId;
try {
const backend = getBackend();
const senderPeer = await backend.getPeer(msg.fromPeerId);
if (senderPeer) {
fromLabel = senderPeer.label;
}
}
catch {
// Fall back to peerId as label
}
const formatted = formatPeerBroadcastNotice(fromLabel, msg.fromPeerId, msg.message, msg.attachments);
// Same reasoning as _injectOnly: an awareness inject is a real resume over this checkout.
const lease = await acquireWakeLock(this.localCwd, this.localSessionId);
try {
// Re-detect session before each call -- CC rotates .jsonl after ~1hr idle
await this.refreshSessionId();
const result = await execRemote(getConfig().COGENT_PLATFORM, this.localSessionId, formatted, this.localCwd);
if (result.exitCode === 0) {
logger.info(`Auto-relay: peer-broadcast awareness inject from ${msg.fromPeerId} (response discarded, no reply relayed)`);
}
else {
logger.warn(`Auto-relay: peer-broadcast awareness inject execRemote failed (exit=${result.exitCode})`);
}
}
catch (err) {
logger.error(`Auto-relay: peer-broadcast awareness inject execRemote threw: ${err}`);
}
finally {
lease.release();
}
}
/**
* Get diagnostic info about the auto-relay state.
* Used by health-check to help debug relay issues.
*/
getDiagnostics() {
return {
registered: this.localPeerId !== null,
localPeerId: this.localPeerId,
// 3.2.0 (Phase 17 OBS-03): surface the peer's participation mode so
// health_check / debug output is honest about whether auto-relay is
// active for this peer.
mode: this.localMode,
processing: this.processing,
queueLength: this.queue.length,
expectingResponseFrom: Array.from(this.expectingResponseFrom),
activeWaiters: Array.from(this.responseWaiters.keys()),
activeDeliveryAckWaiters: Array.from(this.deliveryAckWaiters.keys()),
};
}
/**
* Stop claiming this peer's name for this checkout, WITHOUT stopping work already in flight.
* Called the moment the MCP client is gone: a draining bridge serves no agent, and while it
* counted as the peer's holder, the restarted agent's new bridge stood by behind it (review r2
* NEW-1). The announcement is MARKED, not removed — see markBridgeDraining (R3-2). Idempotent.
* 🔴 EVERY announcement, not only the last registered cwd's (review r7 SEC2-1, ABC2-2, FIX2-5):
* after a register with another spelling of the folder, the one for the bridge's own folder stayed
* unmarked, and the restarted agent waited out the drain or stood by for good.
*/
announceDraining() {
if (!this.localPeerId)
return;
for (const dir of this.announcedCwds)
markBridgeDraining(dir, this.localPeerId);
}
/** Remove this bridge's ownership announcements (graceful shutdown, a rename). Idempotent. */
withdrawAnnouncement() {
if (this.localPeerId)
for (const dir of this.announcedCwds)
withdrawBridge(dir, this.localPeerId);
this.announcedCwds.clear();
}
/**
* Stop the auto-relay service: clear queue and reset state.
*/
stop() {
// 🔴 AUD-012. Settle every waiter so callers don't hang — but settle them HONESTLY.
// This used to resolve response waiters with "" and delivery-ack waiters as successful, so a
// shutdown mid-flight was indistinguishable from "the peer answered nothing" and from "the
// message was delivered". Incomplete work must never be reported as completed work.
for (const [, queue] of this.responseWaiters) {
for (const waiter of queue) {
clearTimeout(waiter.timer);
waiter.reject(new Error("Auto-relay shut down while waiting for a response"));
}
}
this.responseWaiters.clear();
for (const [messageId, waiter] of this.deliveryAckWaiters) {
clearTimeout(waiter.timer);
waiter.reject(new Error(`Auto-relay shut down while waiting for delivery ack (${messageId})`));
}
this.deliveryAckWaiters.clear();
this.recentAcks.clear();
// Withdraw this bridge's ownership announcement BEFORE clearing the identity it is keyed on,
// so a graceful shutdown does not leave a record another process has to prune by pid liveness.
this.withdrawAnnouncement();
this.queue = [];
this.awarenessOnly.clear();
this.supersededNotified.clear();
this.localPeerId = null;
this.localSessionId = null;
this.localCwd = null;
this.localMode = "agent";
this.localLabel = null;
this.processing = false;
this.expectingResponseFrom.clear();
this.sendDeliveryAckFn = null;
logger.debug("Auto-relay stopped");
}
}
/** Singleton instance used throughout the application. */
export const autoRelay = new AutoRelayService();
//# sourceMappingURL=auto-relay.js.map