@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
TypeScript
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