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.

322 lines • 16 kB
import { MessageRecord, Attachment } from "../types.js"; import { type SessionResolution } from "./session-resolver.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 declare const RELAY_STATUS: { readonly QUEUED: "queued"; readonly PROCESSING: "processing"; readonly DELIVERED: "delivered"; readonly NEEDS_MANUAL: "needs-manual"; readonly FAILED: "failed"; }; export type RelayStatus = (typeof RELAY_STATUS)[keyof typeof RELAY_STATUS]; /** * 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 declare function buildStatusNotice(status: RelayStatus, peerId: string, traceId?: string): string; /** * 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 declare function parseStatusNotice(message: string): RelayStatus | null; export declare function formatRelayMessage(fromLabel: string, fromPeerId: string, message: string, attachments?: Attachment[]): string; /** * 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 declare function describeWakeFailure(exitCode: number | null | undefined, stderr: string | undefined, stdout?: string): string; export declare class AutoRelayService { private localPeerId; private localSessionId; /** * 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. */ private pinnedSessionId; private localCwd; /** * 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. */ private localMode; /** * 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. */ private localLabel; private processing; /** Senders already told this bridge is superseded — one notice each, not one per message. */ private supersededNotified; /** Reaping leftovers is a one-shot per process, not a per-registration action. */ private reapedStaleBridges; private queue; private awarenessOnly; private expectingResponseFrom; private responseWaiters; /** * 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. */ private deliveryAckWaiters; /** * 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. */ private recentAcks; private sendDeliveryAckFn; /** * Set the local peer info. Called from register-peer tool after * successful registration so we know which session to inject into. */ setLocalPeer(peerId: string, sessionId: string, cwd: string, mode?: "observer" | "agent", label?: string, pinnedSessionId?: string): void; /** * 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. */ private _trace; /** * 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.) */ private _shouldSkipAutoRelay; /** * Get the local peer info for re-registration after eviction. * Returns null if no peer has been registered yet. */ getLocalPeer(): { peerId: string; sessionId: string; cwd: string; label: string; mode: "observer" | "agent"; } | null; /** * 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. */ refreshSessionId(): Promise<SessionResolution>; /** * Set the function used to send delivery acknowledgments via WebSocket. * Injected by startup.ts to avoid circular dependency with ws-client. */ setSendDeliveryAck(fn: (messageId: string, fromPeerId: string, toPeerId: string) => void): void; /** * 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: string): void; /** * 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: string, timeoutMs: number): Promise<string>; /** * Resolve the oldest pending waiter for `fromPeerId` with the given message. * Returns true if a waiter was found and resolved, false otherwise. */ private _popAndResolveWaiter; /** * Clear the expectation for a peer and cancel any pending waiter. */ clearExpectation(fromPeerId: string): void; /** * 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: string): void; /** * 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. */ private _cancelWaiter; /** * 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: string, messageId: string, timeoutMs: number): Promise<void>; /** * 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: string, messageId: string): void; /** Remember an ack whose waiter does not exist yet, with bounded retention. */ private _bufferEarlyAck; private _cancelDeliveryAckWaiter; /** * 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 */ handleIncoming(msg: MessageRecord): Promise<void>; /** * 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. */ private _reportSuperseded; /** * 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. */ private _dispatch; /** * 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. */ private _emitStatusNotice; /** * 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. */ private _relayCapturedReply; /** * Internal: process a single message -- calls the local agent CLI via execRemote and relay response. */ private _processMessage; /** * 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. */ private _recordWake; /** * 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. */ private _recordNoReplyFailure; /** * Inject a message into the local session without relaying the response. * Used for expected responses to our own send_message calls. */ private _injectOnly; /** * 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. */ private _injectPeerBroadcast; /** * Get diagnostic info about the auto-relay state. * Used by health-check to help debug relay issues. */ getDiagnostics(): { registered: boolean; localPeerId: string | null; mode: "observer" | "agent"; processing: boolean; queueLength: number; expectingResponseFrom: string[]; activeWaiters: string[]; activeDeliveryAckWaiters: string[]; }; /** * Stop the auto-relay service: clear queue and reset state. */ stop(): void; } /** Singleton instance used throughout the application. */ export declare const autoRelay: AutoRelayService; //# sourceMappingURL=auto-relay.d.ts.map