@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.
647 lines • 33 kB
JavaScript
import os from "node:os";
import path from "node:path";
import fs from "node:fs/promises";
import readline from "node:readline";
import { execFile } from "node:child_process";
import { promisify } from "node:util";
import { loadConfig, getConfig } from "./config.js";
import { createBackend, isCloudEndpoint, getBackend, initBackend, resetBackend } from "./backend/index.js";
import { HttpBackend } from "./backend/http-backend.js";
import { deriveWorkspaceId } from "./services/durable-identity.js";
import { initLogger, logger } from "./logger.js";
import { BridgeError, BridgeErrorCode } from "./errors.js";
import { loadCredentials, hasJoinedInProcess, legacyGlobalCredentialsExist, } from "./cloud/credential-store.js";
import { CloudWsClient, MessageInbox } from "./cloud/index.js";
import { HttpClient } from "./cloud/http-client.js";
import { autoRelay } from "./services/auto-relay.js";
import { restorePresence, logPresence, setPresence } from "./services/peer-presence.js";
import { listLiveBridges } from "./services/wake-ownership.js";
import { heartbeat } from "./services/heartbeat.js";
import { preflightCodex, preflightCodexSandbox, preflightCodexServerCollision, } from "./services/codex-preflight.js";
import { setSandboxOverride } from "./services/codex-cli.js";
import { preflightClaude } from "./services/claude-preflight.js";
import { CLIENT_VERSION } from "./constants.js";
// Re-export autoRelay for use in index.ts shutdown
export { autoRelay } from "./services/auto-relay.js";
/** UUID v4 regex for distinguishing UUIDs from labels in COGENT_SESSION_ID. */
const UUID_V4_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;
const execFileAsync = promisify(execFile);
const PERSIST_PATH = path.join(os.homedir(), ".cogent-config.json");
// One-shot flag so a re-init (reinitCloudBackend / multiple startup calls in
// tests) does not re-emit the legacy-credentials migration warning.
let legacyWarnEmitted = false;
// ---------------------------------------------------------------------------
// Cloud service singletons -- accessible to index.ts and tool handlers
// ---------------------------------------------------------------------------
export let cloudWsClient = null;
export let cloudInbox = null;
export let cloudHttpClient = null;
/** True when this process resolved to a cloud endpoint (set by runStartup). */
let cloudModeActive = false;
/**
* Is this process in cloud mode? Needed by index.ts to decide whether to
* advertise `cogent://inbox` — which must happen even when the inbox does not
* exist YET (deferred join), so it cannot be inferred from `cloudInbox`.
*/
export function isCloudMode() {
return cloudModeActive;
}
// How index.ts wants to be told about new inbox messages, plus which inbox that
// callback is currently bound to. Kept here (not in index.ts) because the inbox
// can be REPLACED after startup by reinitCloudBackend(); binding once at startup
// would leave deferred agents with a listener on an object that was thrown away.
let inboxNotifier = null;
let notifierBoundTo = null;
/** Register the MCP host notifier; binds now and again after any inbox swap. */
export function setInboxNotifier(fn) {
inboxNotifier = fn;
bindInboxNotifier();
}
/** Idempotent: binds the notifier to the CURRENT inbox exactly once. */
function bindInboxNotifier() {
if (!inboxNotifier || !cloudInbox || notifierBoundTo === cloudInbox)
return;
cloudInbox.onNewMessage(inboxNotifier);
notifierBoundTo = cloudInbox;
}
async function loadPersistedConfig() {
try {
const raw = await fs.readFile(PERSIST_PATH, "utf-8");
return JSON.parse(raw);
}
catch {
return {};
}
}
async function savePersistedConfig(config) {
try {
await fs.writeFile(PERSIST_PATH, JSON.stringify(config, null, 2) + "\n", "utf-8");
}
catch (err) {
const msg = err instanceof Error ? err.message : String(err);
logger.warn(`Could not save config to ${PERSIST_PATH}: ${msg}`);
}
}
// ---------------------------------------------------------------------------
// First-run interactive prompt
// ---------------------------------------------------------------------------
async function firstRunPrompt(defaultPath) {
// Non-TTY (MCP host context): use defaults silently
if (!process.stdin.isTTY) {
return defaultPath;
}
// Check if persisted config already exists (not first run)
const persisted = await loadPersistedConfig();
if (persisted.statePath) {
return persisted.statePath;
}
// First run with TTY -- prompt the user
const rl = readline.createInterface({
input: process.stdin,
output: process.stderr, // CRITICAL: output to stderr, not stdout
});
return new Promise((resolve) => {
rl.question(`\nFirst run detected. Where should cogent-bridge store its data?\n` +
` Default: ${defaultPath}\n` +
` Press Enter to accept, or type a custom path: `, async (answer) => {
rl.close();
const chosen = answer.trim() || defaultPath;
await savePersistedConfig({ statePath: chosen });
resolve(chosen);
});
});
}
// ---------------------------------------------------------------------------
// Validation helpers
// ---------------------------------------------------------------------------
async function validateStateDir(statePath) {
await fs.mkdir(statePath, { recursive: true });
const testFile = path.join(statePath, ".write-test");
try {
await fs.writeFile(testFile, "test", "utf-8");
await fs.unlink(testFile);
}
catch {
throw new BridgeError(BridgeErrorCode.STARTUP_FAILED, `Cannot write to state directory: ${statePath}`, "Check permissions or set COGENT_STATE_PATH to a writable directory");
}
}
async function checkClaudeCli(claudePath) {
try {
const { stdout } = await execFileAsync(claudePath, ["--version"], {
timeout: 5000,
});
logger.info(`Claude CLI detected: ${stdout.trim()}`);
}
catch {
logger.warn(`CLI_NOT_FOUND: '${claudePath}' not found on PATH. ` +
`cogent_send_message will fail until Claude Code is installed. ` +
`Set COGENT_CLAUDE_PATH if installed in a non-standard location.`);
}
}
// ---------------------------------------------------------------------------
// Automatic re-registration after server eviction (stale cleanup)
// ---------------------------------------------------------------------------
let reRegistrationInProgress = false;
/**
* Re-register a peer after it was evicted by the server's stale cleanup.
* Called by both HeartbeatService (on 404) and CloudWsClient (on WS upgrade 404).
* Debounced: only one re-registration can run at a time.
*/
export async function triggerReRegistration() {
if (reRegistrationInProgress)
return;
reRegistrationInProgress = true;
try {
const peerState = autoRelay.getLocalPeer();
if (!peerState) {
logger.error("Cannot re-register: no local peer info available");
return;
}
logger.warn(`Re-registering peer '${peerState.peerId}' after server eviction...`);
const backend = getBackend();
// Preserve the operator-supplied label and mode captured at original
// register-peer time. Pre-3.2.2 passed peerState.peerId as label, which
// silently normalized human labels like "Cogent_Master" -> "cogent-master"
// on auto-reconnect. CLIENT_VERSION is also re-supplied so PeerInfo stays
// consistent with the original tool call.
// Slice 5 / Story 8.6: re-derive + re-advertise the durable identity so a
// peer keeps the SAME workspaceId/threadId across an eviction/WS-cycle
// reconnect (durability is the whole point). channelSessionId/capabilities
// aren't available from getLocalPeer() so they remain a pre-existing gap.
await backend.registerPeer(peerState.peerId, peerState.sessionId, peerState.cwd, peerState.label, CLIENT_VERSION, peerState.mode, undefined, // channelSessionId (not tracked on re-register)
undefined, // capabilities (not tracked on re-register)
deriveWorkspaceId(peerState.cwd), peerState.sessionId);
logger.info(`Re-registered peer '${peerState.peerId}' (label: '${peerState.label}', mode: '${peerState.mode}') successfully`);
// Reconnect WS
if (cloudWsClient) {
cloudWsClient.setPeerId(peerState.peerId);
}
// Restart heartbeat (it stopped itself on eviction detection)
heartbeat.restart();
}
catch (err) {
logger.error(`Re-registration failed: ${err}`);
}
finally {
reRegistrationInProgress = false;
}
}
/**
* Evaluate this agent's PRESENCE on its channel and, when it is `offline` with a peer it
* owns, bring it back `online`. See services/peer-presence.ts for the state vocabulary,
* how an agent ends up offline, and why restoring it is safe.
*
* Fire-and-forget from index.ts AFTER the transport connects — it performs network I/O
* and must never precede the `initialize` handshake (scripts/startup-nonblocking-test.mjs).
*/
/**
* The exact positional argument tuple for a presence restore's registerPeer call.
*
* 🔴 EXTRACTED SO IT CAN BE TESTED. registerPeer's trailing parameters are OPTIONAL, and
* per the TypeScript vault (concepts/Optional_Parameters -> sources/.../0281) omitting one
* "will provide the function with an `undefined` value parameter" — silently. Measured:
* deleting `peerSecret` from the call site left BOTH `tsc --noEmit` AND the presence unit
* tests at exit 0, because those tests cover the module, not this wiring. A dropped
* ownership proof means a peer owned by an earlier token can never be reclaimed, and the
* agent stays offline. The type system cannot see it; this tuple + its test can.
*/
export function presenceRegisterArgs(p) {
return [
p.peerId,
p.sessionId,
p.cwd,
p.label,
CLIENT_VERSION,
"agent",
undefined, // channelSessionId — not tracked on a presence restore
undefined, // capabilities — likewise
deriveWorkspaceId(p.cwd),
p.sessionId, // threadId
p.peerSecret, // AUD-003 ownership proof — reclaims a peer owned by an earlier token
];
}
let presenceRetry = null;
/** Stop a pending presence retry (shutdown, tests). */
export function cancelPresenceRetry() {
if (presenceRetry)
clearTimeout(presenceRetry);
presenceRetry = null;
}
/**
* 🔴 A failed restore used to be final: the bridge stayed offline until someone restarted the
* agent (klaire-frontend, 2026-09-30 — refused at startup, then absent for a day after the cause
* was fixed). Retry with backoff until it is online; a manual register in between stops it.
*/
function presenceRetryDelayMs(attempt) {
const base = Number(process.env.COGENT_PRESENCE_RETRY_MS);
return Math.min((Number.isFinite(base) && base > 0 ? base : 30_000) * 2 ** attempt, 300_000);
}
export async function restorePeerPresence(attempt = 0) {
const outcome = await restorePresence({
// The endpoint being a relay URL is what makes presence meaningful. We deliberately do
// NOT gate on the active backend being the cloud one: at startup it is still the
// deferred FileBackend placeholder, and gating on it made this whole path inert
// (measured: "skipped: not-cloud-mode" with valid credentials on disk).
isCloudConfigured: () => {
const ep = getConfig().COGENT_ENDPOINT;
return !!ep && isCloudEndpoint(ep);
},
alreadyRegisteredInProcess: () => autoRelay.getDiagnostics().registered,
peerAlreadyServed: (peerId) => {
try {
return listLiveBridges(process.cwd(), peerId).some((b) => b.pid !== process.pid);
}
catch {
return false; // unreadable registry ⇒ no proof of competition ⇒ proceed
}
},
loadCredentials: () => loadCredentials(),
cwd: () => process.cwd(),
goOnline: async ({ peerId, endpoint, sessionId, token, cwd, label, peerSecret }) => {
// Swap the deferred placeholder for a real cloud backend the supported way — the
// same call join-session uses. Building a second backend here instead would leave
// the tools on the placeholder and the presence path on another: a split brain.
await reinitCloudBackend(endpoint, sessionId, token);
const backend = getBackend();
await backend.registerPeer(...presenceRegisterArgs({ peerId, sessionId, cwd, label, peerSecret }));
// Wiring auto-relay is the POINT, not a side effect: setLocalPeer() is what calls
// announceBridge() and reapStaleBridges(), so restoring presence is also what makes
// leftover bridges for this checkout reapable again.
autoRelay.setLocalPeer(peerId, sessionId, cwd, "agent", label, sessionId);
if (cloudWsClient)
cloudWsClient.setPeerId(peerId);
heartbeat.restart();
},
});
setPresence(outcome);
logPresence(outcome);
if (outcome.state === "offline" && outcome.detail.startsWith("presence restore failed")) {
const delay = presenceRetryDelayMs(attempt);
cancelPresenceRetry();
presenceRetry = setTimeout(() => {
presenceRetry = null;
void restorePeerPresence(attempt + 1).catch(() => { });
}, delay);
presenceRetry.unref?.();
logger.warn(`presence: restore failed — retrying in ${Math.round(delay / 1000)}s`);
}
return outcome;
}
// ---------------------------------------------------------------------------
// Helper to create a CloudWsClient with standard callbacks
// ---------------------------------------------------------------------------
function createCloudWsClient(endpoint, sessionId, token, http, inbox, pollIntervalMs) {
const client = new CloudWsClient({
endpoint,
sessionId,
peerId: "", // Will be set after first registerPeer call via setPeerId()
token,
http,
onMessage: (msg) => {
const isNew = inbox.addMessage(msg);
if (isNew) {
autoRelay.handleIncoming(msg).catch((err) => {
logger.error(`Auto-relay failed: ${err}`);
});
}
},
onMessages: (msgs) => {
for (const msg of msgs) {
const isNew = inbox.addMessage(msg);
if (isNew) {
autoRelay.handleIncoming(msg).catch((err) => {
logger.error(`Auto-relay failed: ${err}`);
});
}
}
},
onPeerConnected: (peer) => { logger.info(`Peer connected: ${peer.peerId} (${peer.name})`); },
onPeerDisconnected: (id) => { logger.info(`Peer disconnected: ${id}`); },
onPeersSnapshot: (peers) => { logger.debug(`Peers snapshot: ${peers.length} peers`); },
onStateChange: (state) => { logger.info(`WebSocket state: ${state}`); },
onError: (err) => { logger.warn(`WebSocket error frame: ${err.code}: ${err.message}`); },
onDeliveryAck: (payload) => {
// 🔴 AUD-002: the messageId used to be DISCARDED here and the ack resolved by peer alone,
// so any ack settled any outstanding send to that peer. The frame has always carried it.
autoRelay.resolveDeliveryAck(payload.peerId, payload.messageId);
},
onPeerEvicted: () => {
triggerReRegistration().catch((err) => {
logger.error(`Re-registration from WS eviction failed: ${err}`);
});
},
pollIntervalMs,
});
// Wire delivery ack sending function on autoRelay
autoRelay.setSendDeliveryAck((messageId, fromPeerId, toPeerId) => {
client.sendDeliveryAck(messageId, fromPeerId, toPeerId);
});
return client;
}
// ---------------------------------------------------------------------------
// Reinitialize cloud backend at runtime
// ---------------------------------------------------------------------------
/**
* Reinitialize the cloud backend with new credentials at runtime.
* Called by create-session, join-session, and register-peer auto-create
* after obtaining new session credentials.
*
* This function:
* 1. Creates a new HttpClient with the new token
* 2. Creates a new HttpBackend and replaces the current backend
* 3. Creates or updates CloudWsClient and MessageInbox
*/
/**
* A `COGENT_SESSION_ID` label that still needs a network round-trip to become a
* UUID. Set by runStartup(), consumed by resolvePendingLabel() AFTER the MCP
* transport connects — never before (see the note at the assignment site).
*/
let pendingLabelResolve = null;
/** Test seam: is a label still waiting to be resolved post-connect? */
export function hasPendingLabelResolve() {
return pendingLabelResolve !== null;
}
/**
* Resolve a `COGENT_SESSION_ID` label to a session UUID, AFTER the transport is
* connected, and upgrade the deferred backend if we also hold a matching token.
*
* Fire-and-forget by design (index.ts calls it with `void`), exactly like
* runPreflights(): the server is already answering `initialize`, so a slow or
* dead relay costs the user nothing. Never throws.
*
* When no matching persisted token exists we stay deferred and say so — the
* user's first cogent_join_session / cogent_create_session completes the wiring.
*/
export async function resolvePendingLabel() {
const pending = pendingLabelResolve;
pendingLabelResolve = null;
if (!pending)
return;
let resolvedId;
try {
const resp = await fetch(`${pending.endpoint}/api/sessions/resolve/${encodeURIComponent(pending.label)}`, { signal: AbortSignal.timeout(2500) });
if (!resp.ok) {
logger.warn(`Failed to resolve session label "${pending.label}": HTTP ${resp.status}. Staying in deferred mode — use cogent_join_session.`);
return;
}
resolvedId = (await resp.json()).sessionId;
}
catch (err) {
logger.warn(`Failed to resolve session label "${pending.label}": ${err}. Staying in deferred mode — use cogent_join_session.`);
return;
}
logger.info(`Resolved session label "${pending.label}" to UUID ${resolvedId}`);
try {
const persisted = await loadCredentials();
if (persisted && persisted.sessionId === resolvedId && persisted.endpoint === pending.endpoint) {
await reinitCloudBackend(pending.endpoint, resolvedId, persisted.token);
}
else {
logger.info(`No persisted token for session ${resolvedId}; staying deferred until cogent_join_session provides one.`);
}
}
catch (err) {
logger.warn(`Post-connect label upgrade failed (non-fatal): ${err}`);
}
}
export async function reinitCloudBackend(endpoint, sessionId, token) {
const config = getConfig();
// 1. Create new HttpClient with new token
cloudHttpClient = new HttpClient(endpoint, token);
// 2. Create new HttpBackend and replace current backend
const newBackend = new HttpBackend(cloudHttpClient, sessionId);
resetBackend(); // Clear the existing backend reference
initBackend(newBackend); // Set the new one
logger.info(`Cloud backend reinitialized for session ${sessionId}`);
// 3. Create or update CloudWsClient
if (cloudWsClient) {
// Update existing client's credentials for reconnection.
// ENDPOINT FIRST: a free->Team re-join changes the HOST, and updateToken/updateSessionId can
// each trigger a reconnect. Moving the host first means any reconnect they cause already dials
// the right relay instead of 401ing against the old one (marvin-mobile, 2026-09-05).
cloudWsClient.updateEndpoint(endpoint);
cloudWsClient.updateToken(token);
cloudWsClient.updateSessionId(sessionId);
cloudWsClient.updateHttp(cloudHttpClient);
// Don't call connect() here -- peerId may still be empty.
// connect() will happen when register-peer calls setPeerId().
}
else {
// First time -- create inbox and WS client
if (!cloudInbox) {
cloudInbox = new MessageInbox();
}
bindInboxNotifier(); // the inbox may be brand new — rebind the host notifier
cloudWsClient = createCloudWsClient(endpoint, sessionId, token, cloudHttpClient, cloudInbox, config.COGENT_POLL_INTERVAL_MS);
logger.info("Cloud WebSocket client created (will connect after peer registration)");
}
}
// ---------------------------------------------------------------------------
// Main startup flow
// ---------------------------------------------------------------------------
export async function runStartup() {
// Determine state path: env var takes precedence, then first-run prompt
const envStatePath = process.env.COGENT_STATE_PATH;
let statePath;
if (envStatePath) {
statePath = envStatePath;
}
else {
const defaultPath = path.join(os.homedir(), ".cogent");
statePath = await firstRunPrompt(defaultPath);
}
// If the prompt chose a different path, set it in env for loadConfig
if (!envStatePath && statePath !== path.join(os.homedir(), ".cogent")) {
process.env.COGENT_STATE_PATH = statePath;
}
else if (!envStatePath) {
// Ensure the default or persisted path is available to loadConfig
process.env.COGENT_STATE_PATH = statePath;
}
// Load and freeze config
loadConfig();
const config = getConfig();
// ---------------------------------------------------------------------------
// Cloud credential resolution
// ---------------------------------------------------------------------------
let effectiveSessionId = config.COGENT_SESSION_ID;
let effectiveToken;
const effectiveEndpoint = config.COGENT_ENDPOINT;
const isCloud = effectiveEndpoint && isCloudEndpoint(effectiveEndpoint);
cloudModeActive = Boolean(isCloud);
// ---------------------------------------------------------------------------
// Resolve label-based COGENT_SESSION_ID to UUID
// ---------------------------------------------------------------------------
if (isCloud && effectiveSessionId && !UUID_V4_RE.test(effectiveSessionId)) {
// Check persisted credentials first -- if the label matches, use stored UUID
// UNGATED: user-supplied label resolution explicitly opts into persisted state.
// The user typed COGENT_SESSION_ID=<label> in their env, so consulting the
// local credential store to map that label -> UUID is exactly what they asked for.
const persisted = await loadCredentials();
if (persisted && persisted.label === effectiveSessionId && persisted.endpoint === effectiveEndpoint) {
logger.info(`Resolved session label "${effectiveSessionId}" to UUID ${persisted.sessionId} from persisted credentials`);
effectiveSessionId = persisted.sessionId;
effectiveToken = persisted.token;
}
else {
// 🔴 DO NOT resolve the label over the network HERE.
//
// runStartup() is awaited BEFORE `server.connect(transport)` (src/index.ts),
// so every millisecond spent here is dead air the MCP client counts against
// its `initialize` timeout. This used to `await fetch(...)` with a 2500 ms
// AbortSignal and a comment claiming the timeout kept the handshake safe —
// but BOUNDING a blocking call does not make it non-blocking, it only caps
// the stall. Measured: an unreachable relay added a reproducible +2437 ms to
// `initialize` (scripts/startup-nonblocking-test.mjs, controlled A/B).
//
// Instead we enter deferred mode immediately — the SAME state a failed
// resolve produced before, which is a fully supported path — and hand the
// resolve to resolvePendingLabel(), which index.ts fires AFTER connect. On
// success it upgrades the placeholder backend via reinitCloudBackend(),
// exactly as cogent_join_session does.
pendingLabelResolve = { label: effectiveSessionId, endpoint: effectiveEndpoint };
effectiveSessionId = undefined;
}
}
if (isCloud) {
// One-shot legacy-credentials warning. Pre-3.1.8 stored creds at
// ~/.cogent-credentials.json (a single global file shared across every CC
// session on the host -- the cross-cwd "credential bleed" bug). 3.1.8 now
// uses ~/.cogent/credentials/<sha-prefix>.json per cwd. The legacy file is
// no longer read; tell the operator they can delete it.
if (!legacyWarnEmitted) {
legacyWarnEmitted = true;
if (await legacyGlobalCredentialsExist()) {
logger.warn("Legacy credentials file detected at ~/.cogent-credentials.json. " +
"cogent-bridge 3.1.8 now stores credentials per-cwd at " +
"~/.cogent/credentials/<hash>.json. The legacy file is no longer " +
"read; you can delete it.");
}
}
// Gate persisted-credentials fallbacks on an explicit in-process join/create
// (or operator opt-in via COGENT_TRUST_PERSISTED_CREDENTIALS=1). Without this
// gate, a fresh process in a fresh cwd could silently inherit another cwd's
// token -- the very bug 3.1.8 closes.
const trustPersisted = () => hasJoinedInProcess() ||
process.env.COGENT_TRUST_PERSISTED_CREDENTIALS === "1";
// Cloud mode: resolve credentials
// Priority: env vars (highest) > persisted credentials (lowest)
if (config.COGENT_SECRET && effectiveSessionId) {
// Full env var config -- need to join session to get a token
// This happens later when register-peer or join-session is called
// For now, try persisted credentials for the token
const persisted = trustPersisted() ? await loadCredentials() : null;
if (persisted && persisted.sessionId === effectiveSessionId && persisted.endpoint === effectiveEndpoint) {
effectiveToken = persisted.token;
}
}
else if (effectiveSessionId) {
// Have session ID but no secret -- try persisted token
const persisted = trustPersisted() ? await loadCredentials() : null;
if (persisted && persisted.sessionId === effectiveSessionId && persisted.endpoint === effectiveEndpoint) {
effectiveToken = persisted.token;
}
}
else {
// No session ID at all -- try persisted credentials
const persisted = trustPersisted() ? await loadCredentials() : null;
if (persisted && persisted.endpoint === effectiveEndpoint) {
effectiveSessionId = persisted.sessionId;
effectiveToken = persisted.token;
logger.info(`Loaded persisted cloud session ${effectiveSessionId}`);
}
// If no persisted creds either, cloud mode will be "deferred" --
// user needs to call create-session or join-session first
}
}
// ---------------------------------------------------------------------------
// Initialize transport backend
// ---------------------------------------------------------------------------
if (isCloud && effectiveSessionId && effectiveToken) {
// Cloud mode with full credentials -- create HttpBackend directly
const backend = createBackend(effectiveEndpoint, effectiveSessionId, effectiveToken);
initBackend(backend);
}
else if (isCloud) {
// Cloud mode but deferred (no credentials yet) -- create local FileBackend as placeholder.
// reinitCloudBackend() will replace it when create-session/join-session provides credentials.
const backend = createBackend(undefined);
initBackend(backend);
}
else {
// Local mode
const backend = createBackend(config.COGENT_ENDPOINT);
initBackend(backend);
}
// Initialize full logger with file outputs
initLogger(config.COGENT_LOG_LEVEL, path.join(config.COGENT_STATE_PATH, "logs"));
// ---------------------------------------------------------------------------
// Cloud mode: set up real-time connection infrastructure
// ---------------------------------------------------------------------------
if (isCloud && effectiveSessionId && effectiveToken) {
// Cloud mode with full credentials -- set up real-time connection
cloudInbox = new MessageInbox();
bindInboxNotifier();
cloudHttpClient = new HttpClient(effectiveEndpoint, effectiveToken);
cloudWsClient = createCloudWsClient(effectiveEndpoint, effectiveSessionId, effectiveToken, cloudHttpClient, cloudInbox, config.COGENT_POLL_INTERVAL_MS);
// Note: WS connect requires a registered peerId. Don't connect yet --
// the peerId is empty and connect() guards against this.
// WS connect will be triggered when register-peer calls cloudWsClient.setPeerId(peerId).
// Per Pitfall 4 in RESEARCH: WS connect must happen AFTER peer registration.
logger.info(`Cloud mode initialized. Session: ${effectiveSessionId}. WebSocket will connect after peer registration.`);
}
else if (isCloud) {
logger.info("Cloud mode endpoint configured but no session credentials. Use cogent_create_session or cogent_join_session.");
}
// ---------------------------------------------------------------------------
// Local-mode-only validation
// ---------------------------------------------------------------------------
if (!isCloud) {
// Validate state directory writability (only needed in local mode)
await validateStateDir(config.COGENT_STATE_PATH);
// Check for claude CLI (warn-only, only needed in local mode)
await checkClaudeCli(config.COGENT_CLAUDE_PATH);
}
const mode = isCloud ? "cloud" : "local";
logger.info(`Startup complete. Mode: ${mode}, State: ${config.COGENT_STATE_PATH}, Log level: ${config.COGENT_LOG_LEVEL}`);
// NOTE: the platform CLI preflight (which SPAWNS the vendor CLI) is deliberately
// NOT run here. index.ts invokes runPreflights() AFTER the MCP transport
// connects, so a slow `codex/claude --help` can never delay the `initialize`
// handshake (that stall closed the MCP connection on cold hosts).
}
/**
* Platform CLI flag + OS-sandbox preflight (audit gap 5). SPAWNS the vendor CLI
* (`codex exec resume --help` / `claude --help`, each capped at 10s) and probes
* the sandbox — so it MUST run AFTER `server.connect(transport)`, never before,
* or a slow spawn on a cold host delays the MCP `initialize` response and the
* client closes the connection ("connection closed: initialize response").
*
* Everything here is warn-only diagnostics (verifies the binary resolves and its
* --help documents every flag auto-relay emits — catching codex's historical
* exit-2 on --color/--sandbox/--full-auto or a renamed claude flag) plus the
* COGENT_CODEX_SANDBOX_FALLBACK recovery. All of it is consulted only later, at
* wake/auto-relay time, so deferring it a few hundred ms past connect is safe.
* Runs in BOTH cloud and local modes (auto-relay drives the agent CLI in cloud
* mode too). Never throws.
*/
export async function runPreflights() {
const config = getConfig();
if (config.COGENT_PLATFORM === "codex") {
await preflightCodex(config.COGENT_CODEX_PATH);
// Flag a standalone [mcp_servers.cogent] in ~/.codex/config.toml — it collides
// by name with the plugin's own `cogent` MCP server and breaks codex MCP
// startup. Never throws; warns so the fix is visible even to plugin-only users.
await preflightCodexServerCollision();
// On a PROVABLY sandbox-blocked host with auto-recovery on (default), switch
// the effective sandbox to bypass so tasks complete instead of failing on
// "bwrap: setting up uid map". Only "blocked" engages it — never "ok"/"unknown".
const sandbox = await preflightCodexSandbox(config);
if (sandbox.status === "blocked" && config.COGENT_CODEX_SANDBOX_FALLBACK) {
setSandboxOverride("bypass");
}
}
else {
await preflightClaude(config.COGENT_CLAUDE_PATH);
}
}
//# sourceMappingURL=startup.js.map