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.

375 lines • 21.8 kB
import { z } from "zod"; import { getBackend, isCloudMode, isCloudDeferred } from "../backend/index.js"; import { BridgeError, BridgeErrorCode, successResult, errorResult } from "../errors.js"; import { getConfig } from "../config.js"; import { logger } from "../logger.js"; import { autoRelay } from "../services/auto-relay.js"; const RETRY_TIMEOUT_MS = 30_000; const ACK_TIMEOUT_MS = 15_000; function formatBridgeMessage(fromLabel, fromPeerId, message) { return (`[Cogent Bridge message from ${fromLabel} (${fromPeerId})]\n\n` + `${message}\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.`); } /** * Best-effort live status string for a peer, using whatever the relay * currently knows. Returns a short suffix the caller appends to a timeout * message so the LLM does not hallucinate "offline" when the peer is in * fact connected. Never throws: on any backend error, returns a neutral * fallback. */ async function describePeerLiveness(peerId) { try { const peer = await getBackend().getPeer(peerId); if (!peer || !peer.lastSeenAt) { return `Live status of ${peerId} is unknown (relay returned no record).`; } const lastSeenMs = Date.parse(peer.lastSeenAt); if (Number.isNaN(lastSeenMs)) { return `Live status of ${peerId} is unknown.`; } const ageSec = Math.max(0, Math.round((Date.now() - lastSeenMs) / 1000)); return `${peerId} was last seen by the relay ${ageSec}s ago.`; } catch (err) { logger.debug(`describePeerLiveness: getPeer failed for ${peerId}: ${err}`); return `Live status of ${peerId} is unknown.`; } } export function registerSendMessageTool(server) { server.registerTool("cogent_send_message", { title: "Send Message to Peer", description: "Send a message from one registered peer to another. " + "The message is relayed by resuming the target's Claude Code session " + "via CLI subprocess. Returns the target's response.", inputSchema: { fromPeerId: z .string() .describe("Peer ID of the sender, e.g. 'backend'"), toPeerId: z .string() .describe("Peer ID of the recipient, e.g. 'frontend'. Use '*' or 'broadcast' to send to all peers."), message: z .string() .describe("The message content to send to the target peer"), replyExpected: z .boolean() .optional() .describe("Set false for a TERMINAL message — an acknowledgement, confirmation or FYI that " + "conveys information and needs no answer. The recipient's check-on-stop hook then " + "never counts it as owing a reply. Omit (or true) for anything that asks a " + "question or expects a response. Without this, two agents acknowledging each " + "other never terminate: every ACK is itself a directed message demanding an ACK."), }, annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: true, }, }, async ({ fromPeerId, toPeerId, message, replyExpected }) => { try { // SECURITY: a cloud-configured agent that has not joined a channel yet // runs on the local FileBackend placeholder over the host-global // cogent-state.json. Without this guard, getPeer(toPeerId) would read // ANOTHER project's peer (leaking its session UUID) and — in the // local-mode branch, since isCloudMode() is false while deferred — // execClaude() would RESUME that project's session with attacker text // and return its output. Refuse before touching any peer. Belongs to no // channel → nothing to send. See backend/isCloudDeferred. if (isCloudDeferred(getConfig().COGENT_ENDPOINT)) { return successResult({ success: false, notJoined: true, error: "Not connected to a channel yet — call cogent_join_session (or cogent_register_peer) first. Message not sent (cross-channel send is blocked until you join).", }); } const backend = getBackend(); const from = await backend.getPeer(fromPeerId); if (!from) { return errorResult(new BridgeError(BridgeErrorCode.PEER_NOT_FOUND, `Sender peer '${fromPeerId}' not registered`, "Register the peer first with cogent_register_peer")); } // Normalize broadcast aliases const normalizedToPeerId = (toPeerId === "broadcast" || toPeerId === "*") ? "*" : toPeerId; const isBroadcast = normalizedToPeerId === "*"; // Skip peer lookup for broadcast messages const to = isBroadcast ? null : await backend.getPeer(normalizedToPeerId); if (!isBroadcast && !to) { return errorResult(new BridgeError(BridgeErrorCode.PEER_NOT_FOUND, `Target peer '${toPeerId}' not registered`, "Register the peer first with cogent_register_peer")); } // For broadcast: skip session validation and execClaude — just record the message // The server handles fanout to all peers and adapters if (isBroadcast) { const startMs = Date.now(); try { await backend.recordMessage({ fromPeerId, toPeerId: normalizedToPeerId, message, response: null, durationMs: Date.now() - startMs, success: true, error: null, ...(replyExpected === false ? { replyExpected: false } : {}), }); return successResult({ success: true, response: `Broadcast message sent to all peers in the session.`, error: null, durationMs: Date.now() - startMs, }); } catch (err) { return errorResult(err); } } // Fast-fail: check if the target session file exists on disk const sessionValid = await backend.validateSession(to.sessionId, to.cwd); if (!sessionValid) { return errorResult(new BridgeError(BridgeErrorCode.CLI_EXEC_FAILED, `Session '${to.sessionId}' not found for peer '${normalizedToPeerId}'. The session may have ended or the ID is wrong`, `Ask '${normalizedToPeerId}' to re-register with their current session ID`)); } const prefixed = formatBridgeMessage(from.label, fromPeerId, message); const startMs = Date.now(); try { let result = await backend.execClaude(to.sessionId, prefixed, to.cwd); // On timeout, retry once with a shorter timeout if (result.exitCode === null) { logger.info(`Message to '${toPeerId}' timed out, retrying with ${RETRY_TIMEOUT_MS}ms timeout`); result = await backend.execClaude(to.sessionId, prefixed, to.cwd, RETRY_TIMEOUT_MS); } const durationMs = Date.now() - startMs; const success = result.exitCode === 0; // Detect cloud mode to adjust response behavior. // Use isCloudMode() (runtime backend check) instead of config.COGENT_ENDPOINT // because reinitCloudBackend() can swap the backend after config is frozen. const config = getConfig(); const isCloud = isCloudMode(); // Update sender lastSeenAt (sender is always active) try { await backend.updateLastSeen(fromPeerId); } catch (e) { logger.error("Failed to update lastSeenAt for sender", { error: e }); } // Update target lastSeenAt only on success if (success) { try { await backend.updateLastSeen(toPeerId); } catch (e) { logger.error("Failed to update lastSeenAt for target", { error: e }); } } // Cloud mode: two-stage waiting -- first delivery ack, then response. // Stage 1: delivery ack (15s) confirms recipient's MCP received the message. // Stage 2: response (COGENT_TIMEOUT_MS) waits for execClaude result. if (isCloud && success) { // Set up both waiters BEFORE sending to avoid race conditions. // 260527-mkz (Lever 1): the inline response wait is bounded to a // short fast-path window — min(COGENT_FASTPATH_MS, COGENT_TIMEOUT_MS). // A reply within the window returns inline (quick CC<->CC turns); // beyond it we return success+pending and let the late reply land // via the existing inject-only path (expectation is retained on // timeout by waitForResponse). COGENT_TIMEOUT_MS stays the ceiling. const effectiveAckTimeout = Math.min(ACK_TIMEOUT_MS, config.COGENT_TIMEOUT_MS); const fastPathMs = Math.min(config.COGENT_FASTPATH_MS, config.COGENT_TIMEOUT_MS); // The RESPONSE waiter is peer-scoped FIFO and must exist before the send, because a // fast reply can beat our POST response. const responsePromise = autoRelay.waitForResponse(toPeerId, fastPathMs); // 🔴 This promise is deliberately ABANDONED on several paths — the no-ack branch // below returns without awaiting it, and cancelResponseWaiter/stop() reject it. An // abandoned rejected promise is an UNHANDLED REJECTION: Node logs it, and under // --unhandled-rejections=throw it takes the process down. Attaching a no-op handler // marks it handled WITHOUT swallowing anything — every later .then/.catch still // receives the rejection, because each gets its own. // // Found 2026-09-03 by the repaired E2E suite (AUD-011): seven of these were firing on // every full run, and `npm test` was exiting 1 while its summary line still read // "892 passed" — the count is a proxy, the exit code is the condition. responsePromise.catch(() => { }); // Send message via cloud relay let outboundRecord; try { outboundRecord = await backend.recordMessage({ fromPeerId, toPeerId, message, response: null, durationMs, success: true, error: null, ...(replyExpected === false ? { replyExpected: false } : {}), }); } catch (recordErr) { autoRelay.clearExpectation(toPeerId); throw recordErr; } // 🔴 AUD-002: the delivery-ack waiter is keyed by MESSAGE ID, so it can only be // registered once the relay has assigned one. Registering after the send is safe // because an ack that beats us is buffered by resolveDeliveryAck and consumed here — // the race is handled by the buffer instead of by peer-keyed correlation, which was // what let a second concurrent send report the first as delivered. const ackPromise = autoRelay.waitForDeliveryAck(toPeerId, outboundRecord.id, effectiveAckTimeout); // Wait for delivery ack first (fast-path: confirms recipient WS is live) let ackReceived = false; try { await ackPromise; ackReceived = true; } catch { // No ack received -- WS may be temporarily down, but message is queued. // Do NOT bail out: fall through and wait for the response via HTTP poll. logger.warn(`[send-message] No delivery ack from ${normalizedToPeerId} within ${effectiveAckTimeout}ms — ` + `waiting for response via poll fallback`); } if (!ackReceived) { autoRelay.cancelResponseWaiter(toPeerId); // No delivery ack within 15s. Do NOT assert "appears offline" -- // the recipient may be connected but momentarily not draining // its WS read loop (e.g. during a synchronous execClaude). Tell // the LLM what we actually know: the message is queued at the // relay, the peer's last-seen-by-relay timestamp, and where to // look for a reply. // // v3.2.1 (FIX-TIMEOUT-UX): return success:false with timedOut:true // and put the explanation in `message`. The previous shape // (success:true, response:<stub-text>) caused LLMs to mistake the // bridge's stub for the recipient's actual reply. const liveness = await describePeerLiveness(normalizedToPeerId); const offlineMessage = `Message accepted by relay; no delivery acknowledgment from ${to.label} ` + `(${normalizedToPeerId}) within ${effectiveAckTimeout}ms. ${liveness} ` + `The recipient may have replied with cogent_send_message instead of the ` + `synchronous response pattern. Use cogent_get_history to check for a reply.`; // Leave the history record's response as null so it's not // mistaken for the recipient's actual reply on later inspection. if (backend.updateMessageResponse) { try { await backend.updateMessageResponse(outboundRecord.id, null, Date.now() - startMs); } catch { logger.warn("[send-message] Failed to update message response in history (offline)"); } } return successResult({ success: false, timedOut: true, error: "DELIVERY_NO_ACK", message: offlineMessage, response: null, durationMs: Date.now() - startMs, }); } // Ack received -- now wait for the actual response try { const cloudResponse = await responsePromise; // Update the original message record with the actual response if (backend.updateMessageResponse) { try { await backend.updateMessageResponse(outboundRecord.id, cloudResponse, Date.now() - startMs); } catch { // Best-effort: response was already returned to the user logger.warn("[send-message] Failed to update message response in history"); } } return successResult({ success: true, response: cloudResponse, error: null, durationMs: Date.now() - startMs, }); } catch { // 260527-mkz (Lever 1): ack received (recipient is online and // got the message) but the reply did not arrive within the // fast-path window. This is NOT a failure — it is a successful // ASYNC delivery. The recipient serializes its turn and may // still be working; the reply will land later and be injected // via the existing inject-only path (handleIncoming), waking // this agent. We MUST NOT clear or cancel the expectation here: // waitForResponse already removed only its own (now-expired) // waiter on timeout and KEPT expectingResponseFrom, so exactly // one late _injectOnly fires. Calling clearExpectation / // cancelResponseWaiter would drop that retained expectation and // the late reply would be wrongly re-relayed (loop) or lost. // // Reframe the result: success:true + pending:true (no // RESPONSE_TIMEOUT error, response stays null). This eliminates // the false response:null failures for online recipients. const fastPathSec = Math.round(fastPathMs / 1000); const pendingMessage = `✓ Delivered to ${to.label} (${normalizedToPeerId}). They're working on it — ` + `no reply within ${fastPathSec}s, but the reply will arrive as a separate ` + `message (you'll be woken when it lands). Continue your work; don't re-send. ` + `This is a successful async delivery, not a failure.`; // Mark the history record as pending (success:true, response // still null). Do NOT write it as an error. updateMessageResponse // carries the pending flag through to the backend; the response // stays null so it is never mistaken for the recipient's reply. if (backend.updateMessageResponse) { try { await backend.updateMessageResponse(outboundRecord.id, null, Date.now() - startMs, true); } catch { logger.warn("[send-message] Failed to mark message pending in history"); } } return successResult({ success: true, pending: true, response: null, error: null, message: pendingMessage, durationMs: Date.now() - startMs, }); } } // Local mode: record with synchronous response from execClaude await backend.recordMessage({ fromPeerId, toPeerId, message, response: success ? result.stdout : null, durationMs, success, error: success ? null : result.stderr, ...(replyExpected === false ? { replyExpected: false } : {}), }); return successResult({ success, response: success ? result.stdout : null, error: success ? null : result.stderr, durationMs, }); } catch (err) { const durationMs = Date.now() - startMs; const errorMsg = err instanceof Error ? err.message : String(err); try { await backend.recordMessage({ fromPeerId, toPeerId, message, response: null, durationMs, success: false, error: errorMsg, }); } catch (recordErr) { logger.error("Failed to record failed message", { error: recordErr }); } return errorResult(err); } } catch (err) { logger.error("send-message failed", { error: err }); return errorResult(err); } }); } //# sourceMappingURL=send-message.js.map