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.

1,525 lines • 81.7 kB
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