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.

618 lines • 43.6 kB
import crypto from "node:crypto"; import { z } from "zod"; import { getConfig } from "../config.js"; import { getBackend, isCloudEndpoint, isCloudMode, isCloudDeferred } from "../backend/index.js"; import { successResult, errorResult, BridgeError, BridgeErrorCode } from "../errors.js"; import { saveCredentials, loadCredentials, loadPeerRecord, savePeerRecord, peerCredentials, getCurrentCloudConnection, hasJoinedInProcess, hasCloudAttemptInProcess, } from "../cloud/credential-store.js"; import { persistProvisionedMailbox } from "../cloud/mail-credential-store.js"; import { logger } from "../logger.js"; import { heartbeat } from "../services/heartbeat.js"; import { autoRelay } from "../services/auto-relay.js"; import { agentSessionId, checkWakeOwnership, isReservedPeerName, listLiveBridgesInFolder, PROCESS_MARK, registryText } from "../services/wake-ownership.js"; import { anotherAgentSessionAmong, heldInFolder, heldNameAdvice, otherBridgesInFolder, otherBridgeNotice, setPresence } from "../services/peer-presence.js"; import { listProcesses } from "../services/stale-bridge-reaper.js"; import { resolveSession } from "../services/session-resolver.js"; import { transcriptBaseSummary } from "../services/cc-cli.js"; import { deriveWorkspaceId } from "../services/durable-identity.js"; import { CLIENT_VERSION } from "../constants.js"; import { getLastNudge } from "../backend/npm-update-check.js"; /** * Why this resolution cannot produce a wakeable session id, or null when it can. * * 🔴 ASK THE CONDITION, NOT A PROXY. The old guard tested `candidateCount === 0`. An AMBIGUOUS * resolution has candidates but no chosen id (`decideResolution` returns `sessionId: null` when * two transcripts are within the margin), so it slipped straight through the zero-check and the * caller's value was pinned unchecked — during the 2026-09-02 ring exercise that value was a * CHANNEL UUID, which `--resume` can never open, and every subsequent wake failed in ~2s with no * warning ever emitted. The question that decides correctness is "did we end up with an id * `--resume` can open?", not "were there zero candidates?". */ export function unresolvedSessionReason(r) { if (r.sessionId) return null; return r.candidateCount === 0 ? "no-candidates" : "ambiguous"; } /** Upper bound on how long cogent_register_peer waits to reclaim its own name (default 50s). */ function peerReclaimWaitMs() { const v = Number(process.env.COGENT_PEER_RECLAIM_WAIT_MS); // 50s stays under the 60s default MCP tool timeout of codex: a longer wait would surface as a // tool timeout instead of the relay's guidance. return Number.isFinite(v) && v >= 0 ? v : 50_000; } /** * Retry a PEER_NOT_YOURS registration with the SAME peerId, waiting for the relay's * `retryAfterMs` with capped backoff, bounded by {@link peerReclaimWaitMs}. A hold that outlasts * the budget (or an old relay that sends no hint) surfaces the original refusal immediately. * Any other error ends the wait and is thrown as-is. */ export async function retrySamePeerId(first, attempt, budgetMs = peerReclaimWaitMs(), sleep = (ms) => new Promise((r) => setTimeout(r, ms))) { let err = first; const start = Date.now(); let backoff = 1_000; for (;;) { const remaining = budgetMs - (Date.now() - start); const retryAfter = err.retryAfterMs; if (retryAfter === undefined || remaining <= 0 || retryAfter > remaining) throw err; const wait = Math.min(Math.max(retryAfter, backoff), remaining); logger.warn(`register_peer: peerId held by an earlier registration — retrying the SAME id in ${Math.round(wait / 1000)}s`); await sleep(wait); backoff = Math.min(backoff * 2, 15_000); try { return await attempt(); } catch (next) { if (!(next instanceof BridgeError && next.code === BridgeErrorCode.PEER_NOT_YOURS)) throw next; err = next; } } } export function registerRegisterPeerTool(server) { server.registerTool("cogent_register_peer", { title: "Register Peer", description: "Step 3: put this agent online in the channel joined or created in this session. " + "peerId = the peer name you were given (never invent or suffix one); " + "sessionId = this agent session's own resumable id (Claude Code or Codex), not the channel id. " + "Also needs the working directory (cwd) and a human-readable label.", inputSchema: { peerId: z .string() .describe("The peer name you were given, e.g. 'backend' or 'frontend'. Never invent or suffix one."), sessionId: z .string() .describe("This agent session's own resumable id (Claude Code or Codex) — NOT the channel id"), cwd: z .string() .describe("Absolute path to the project working directory"), label: z .string() .describe("Human-readable label, e.g. 'Cogent_Backend' or 'Cogent_Frontend'"), peerSecret: z .string() .optional() .describe("AUD-003 ownership proof. Only needed to RECLAIM a peerId whose registration was " + "made from a different connection — e.g. this agent re-joined the channel and was " + "issued a new token. Normally omitted: the stored credential is replayed automatically."), mode: z .enum(["observer", "agent"]) .optional() .default("agent") .describe("Peer participation mode. \"agent\" (default) auto-responds via execClaude on inbound directed messages. " + "\"observer\" registers a passive watcher that receives WS frames and remains targetable but never spawns a subprocess. " + "Useful for operators using their MCP client to monitor a channel without polluting transcript with phantom replies."), role: z .enum(["implementer", "reviewer", "auditor", "coordinator"]) .optional() .describe("Structured A2A role (Slice 4). Makes multi-agent collaboration explicit rather than noise: " + "\"implementer\" writes code, \"reviewer\"/\"auditor\" check it, \"coordinator\" is the single visible task owner. " + "Advertised on the peer (surfaced in cogent_list_peers); at most one coordinator per channel by convention."), }, annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: true, openWorldHint: false, }, }, async ({ peerId, sessionId, cwd, label, mode, role, peerSecret: argPeerSecret }) => { try { const config = getConfig(); // The bridge registry keeps every running bridge under this name (wake-ownership // PROCESS_MARK). A peer of the same name shared that directory, and any other bridge // starting in the folder then read as its newer owner: it stopped waking (review r5 SEC-4). // 🔴 Not `peerId === PROCESS_MARK`: the name becomes a DIRECTORY, and on a case-insensitive // volume (macOS) `.PROCESS` is the same one. if (isReservedPeerName(peerId)) { return errorResult(new BridgeError(BridgeErrorCode.INVALID_INPUT, `peerId '${PROCESS_MARK}' is reserved by the bridge`, "Register under another peerId")); } // Fail-loud guard (T0.1, shipped 3.12.5): if a cogent_join_session / cogent_create_session // was attempted in THIS process but did not succeed, and there is no explicitly // configured cloud session to fall back to, refuse to register. Otherwise register // silently falls back to the LOCAL FileBackend (COGENT_ENDPOINT unset/local) or // AUTO-CREATES a throwaway FREE session (COGENT_ENDPOINT=free) and returns // success:true — masking a failed Team join (e.g. an idle-swept channel → 404) so // the agent believes it is on the intended channel while being invisible to peers. const hasExplicitCloudSession = !!config.COGENT_SESSION_ID || process.env.COGENT_TRUST_PERSISTED_CREDENTIALS === "1"; if (!hasExplicitCloudSession && hasCloudAttemptInProcess() && !hasJoinedInProcess()) { return errorResult(new BridgeError(BridgeErrorCode.JOIN_NOT_CONFIRMED, "A cogent_join_session/cogent_create_session call was attempted this session but did not succeed, so cogent_register_peer will not fall back to local mode or auto-create a new channel (that would silently put you on the WRONG channel while reporting success)", "Re-run cogent_join_session with the correct channel name + secret (+ Org_ID for Team channels) and confirm it returns success before calling cogent_register_peer; or, if the join answered CHANNEL_NOT_FOUND, cogent_create_session with the same name, secret and Org_ID")); } // 🔴 COG-85 (operator, 2026-10-15): REFUSE, before anything reaches the relay, while another live // bridge in this folder holds this name — unless it is this agent session's own. A restarted codex // agent's old connection lingers 60 s (peer-presence HOLDER_LINGER_SECONDS) and then goes, so a retry after that // succeeds; a second agent's name stays refused for as long as that agent runs (the round-7 takeover). // Replaces "notify, do not refuse" (2026-10-13) for the SAME name only. Both spellings of the folder // are looked up: agents pass `pwd` (a symlink, a subfolder), the holder announced its own (review r7). // A lookup that throws does not refuse — the gate never blocks on its own failure. Cloud only, like // presence restore: a local-mode bridge (COGENT_LOCAL clears the endpoint) has no relay name to hold. if (config.COGENT_ENDPOINT) try { const own = agentSessionId(); // One bridge has an entry per name it announced (its peer AND the process mark): dedupe on both. const seen = new Set(); const entries = [...listLiveBridgesInFolder(cwd), ...listLiveBridgesInFolder(process.cwd())] .filter((e) => !seen.has(`${e.pid}:${e.peerId}`) && seen.add(`${e.pid}:${e.peerId}`)); const holder = heldInFolder(otherBridgesInFolder(entries, { pid: process.pid, sessionId: own }, listProcesses), peerId, own); if (holder) { logger.info(`Register: refused '${peerId}' — held in this folder by bridge pid ${holder.pid}`); return errorResult(new BridgeError(BridgeErrorCode.PEER_HELD_IN_FOLDER, `'${peerId}' is held by another live connection in this folder (bridge pid ${holder.pid}, ` + `version ${registryText(holder.version)}); this bridge has NOT registered`, heldNameAdvice(peerId, holder, { sessionId: own, platform: config.COGENT_PLATFORM }))); } } catch (err) { logger.debug(`Register: could not check this folder for a holder of '${peerId}': ${err.message}`); } // Config-based check for auto-create (needs the endpoint URL). // WS trigger uses isCloudMode() separately (line 192). const hasCloudEndpointConfig = config.COGENT_ENDPOINT && isCloudEndpoint(config.COGENT_ENDPOINT); // Track auto-create state for enriching the response let sessionCreated = false; let cloudSessionId; // Hoisted to handler scope so the post-block deferred guard can tell a // resolved-cloud-token register (auto-create / persisted-adopt) apart from // the leak case (COGENT_SESSION_ID set but NO token obtained). let cloudToken; let autoSecret; if (hasCloudEndpointConfig && config.COGENT_ENDPOINT) { let endpoint = config.COGENT_ENDPOINT; // Check if we have cloud credentials (from env vars or persisted) cloudSessionId = config.COGENT_SESSION_ID; if (!cloudSessionId) { // Gate persisted-credentials fallback: only trust on-disk creds when // cogent_join_session / cogent_create_session has been called in this // process (or operator opt-in via COGENT_TRUST_PERSISTED_CREDENTIALS=1). // This closes the cross-cwd "credential bleed" window in 3.1.8. const trustPersisted = hasJoinedInProcess() || process.env.COGENT_TRUST_PERSISTED_CREDENTIALS === "1"; if (trustPersisted) { const persisted = await loadCredentials(); // Accept creds from THIS process's join against EITHER configured relay. // join_session auto-routes Org_ID joins to the Team relay (COGENT_TEAM_ENDPOINT, // 3.5.1), so the persisted endpoint can be the Team URL while COGENT_ENDPOINT is // still the free default. Comparing only against COGENT_ENDPOINT wrongly fell // through to auto-create and 400'd on the Team relay. Adopt the join's endpoint. if (persisted && (persisted.endpoint === config.COGENT_ENDPOINT || persisted.endpoint === config.COGENT_TEAM_ENDPOINT)) { cloudSessionId = persisted.sessionId; cloudToken = persisted.token; endpoint = persisted.endpoint; } } } if (!cloudSessionId) { // 🔴 RC3 — NEVER HOP CHANNELS. This cwd already has a channel on disk, but this bridge // is not online on it (presence restore skipped/failed, or it is on another relay). The // old code auto-created a brand-new random FREE channel here and reported success: // klaire-backend left its Team channel this way and sat alone for 20 hours (2026-10-05). // Auto-create is for a cwd with NO channel at all; otherwise name the channel and stop. const stored = await loadCredentials(); if (stored?.sessionId) { const channel = stored.label ? `'${stored.label}' (${stored.sessionId})` : `'${stored.sessionId}'`; return errorResult(new BridgeError(BridgeErrorCode.JOIN_NOT_CONFIRMED, `This working directory is already on channel ${channel} at ${stored.endpoint}, but this bridge is not online on it, so cogent_register_peer will NOT auto-create a new channel (that would silently move you off your channel while reporting success)`, `Call cogent_join_session for channel ${channel} (name + secret, + Org_ID for Team channels) and confirm it returns success, then retry cogent_register_peer with the SAME peerId. ` + `If that join reports the channel no longer exists, or you do not know its secret, ask the user which channel this agent belongs on and join it (cogent_join_session) or create it (cogent_create_session) explicitly, then register with the SAME peerId — a channel is never auto-created over a stored one`)); } // Auto-create session: no session configured at all autoSecret = crypto.randomBytes(16).toString("hex"); const resp = await fetch(`${endpoint}/api/sessions`, { method: "POST", headers: { "Content-Type": "application/json" }, // 🔴 NO `label` — let the relay name the channel. // // This used to send `Auto-created by ${label}`, which the relay's own // CreateSessionRequestSchema can NEVER accept: it requires // /^[a-z0-9][a-z0-9-]{1,30}[a-z0-9]$/ (lowercase, digits, hyphens, 3-32 chars). // "Auto-created by ..." has a capital A and spaces, so EVERY auto-create got // HTTP 400 INVALID_INPUT and the zero-config path — register with no channel // configured — could not work at all. Measured against a real relay 2026-09-15: // this body 400s, the same POST without a label returns 201. // // The 400 was visible as far back as 3.5.2 and was read as "don't fall into // auto-create" (see e2e/team-join-live.e2e.test.ts), so the symptom was routed // around and the path itself stayed broken. // // Omitting the label is not a workaround: the relay generates one with // collision-retry scoped to the org (generateSessionLabel + isLabelTaken). A // client-side slug would have to re-implement that and could still collide. body: JSON.stringify({ secret: autoSecret }), }); if (!resp.ok) { return errorResult(new Error(`Failed to auto-create session: HTTP ${resp.status}`)); } const created = (await resp.json()); cloudSessionId = created.sessionId; cloudToken = created.token; sessionCreated = true; // Persist for future restarts (incl. peerId for the check-on-stop hook). await saveCredentials({ endpoint, sessionId: cloudSessionId, token: cloudToken, peerId, // The CHANNEL's label (not this peer's) — the name a human needs to join it. label: created.label, savedAt: new Date().toISOString(), }); // Reinit cloud backend with new credentials // (same dynamic import pattern as create-session/join-session) try { const startupModule = (await import("../startup.js")); if (typeof startupModule.reinitCloudBackend === "function") { await startupModule.reinitCloudBackend(endpoint, cloudSessionId, cloudToken); } } catch { // reinitCloudBackend may not exist yet } logger.info(`Auto-created cloud session ${cloudSessionId}`); } } // SECURITY + fail-loud: cloud-configured, no cloud token resolved, and the // active backend is still the deferred FileBackend placeholder. This is the // leak case — COGENT_SESSION_ID is set so the persisted-adopt/auto-create // paths above were skipped (cloudToken stays undefined), and startup never // obtained a token (relay unreachable / label unresolved at boot). Do NOT // fall through to getPeer/registerPeer on the host-global // ~/.cogent/cogent-state.json — that would read/OVERWRITE ANOTHER local // agent's peer entry (cross-channel) AND silently register us locally while // we believe we're on the cloud channel. Refuse loudly. (When a token WAS // resolved — auto-create or persisted-adopt — the backend is/was reinit'd to // the HttpBackend, so this does not fire on the legitimate join path.) if (!cloudToken && isCloudDeferred(config.COGENT_ENDPOINT)) { return errorResult(new BridgeError(BridgeErrorCode.JOIN_NOT_CONFIRMED, "Cloud endpoint + COGENT_SESSION_ID are configured but this bridge has not joined the channel (no session token — the relay was unreachable or the session label did not resolve at startup). cogent_register_peer will NOT register against local state (that would leave you on no channel while other local agents' data sits on this host).", "Call cogent_join_session with the channel name + secret (+ Org_ID for Team channels) and confirm it returns success (or, if the join answered CHANNEL_NOT_FOUND, cogent_create_session with the same name, secret and Org_ID), then retry cogent_register_peer.")); } // Proceed with normal peer registration via the active backend const backend = getBackend(); const existing = await backend.getPeer(peerId); // Durable-identity rails (LOCAL-ONLY — never sent on the relay wire, // whose register schema is .strict()): // channelSessionId = the cloud/relay session id, kept distinct from // the local .jsonl id the resolver finds (they used to collide). // capabilities = what this peer can do for the reply contract. // Slice 3 (Story 8.4): an agent-mode peer advertises the reply-contract // tier it captures with. Both platforms now use the structured // machine_readable_event_stream (Claude `--output-format json` `.result`; // Codex `-o` last-message file) — the bridge never guesses from raw // stdout; a fallback is surfaced as a visible capture_downgrade. const channelSessionId = sessionId; const capabilities = mode === "observer" ? ["observer"] : ["auto-resume", "reply:machine_readable_event_stream"]; // Slice 4 (Story 8.5): advertise the structured A2A role so multi-agent // collaboration is explicit (surfaced in cogent_list_peers), not noise. if (role) { capabilities.push(`role:${role}`); } // Slice 5 / Story 8.6: derive a DURABLE identity that does not depend on // the mutable, lossy cwd hint. workspaceId = stable SHA-256 of the cwd // (deriveWorkspaceId — single-sourced so re-register derives it the same); // threadId = the session id (the durable conversation thread). Sent on the // wire in cloud mode so the relay can route/resolve on them. const workspaceId = deriveWorkspaceId(cwd); const threadId = sessionId; // Pass our own cogent-bridge npm version so the server (and any // operator inspecting cogent_list_peers) can tell at a glance which // client version each peer is running. Plumbs through to PeerInfo. // 🔴 AUD-003: replay the ownership proof we were issued, if we have one. It only MATTERS // when our token has changed since the peer was created (a re-join) — the relay accepts // the same-token path without it. Best-effort: a credential file we cannot read must never // block registration, it just means we fall back to same-token proof. let storedPeerSecret = argPeerSecret; if (storedPeerSecret === undefined && isCloudMode()) { try { // COG-80: THIS peer's proof. The folder file holds whatever registered last in this // folder — with two agents, the other agent's secret, which proves nothing for this name. const own = await loadPeerRecord(peerId); const folder = own ? null : await loadCredentials(); storedPeerSecret = own?.peerSecret ?? (!folder?.peerId || folder.peerId === peerId ? folder?.peerSecret : undefined); } catch { storedPeerSecret = undefined; } } let peer; try { peer = await backend.registerPeer(peerId, sessionId, cwd, label, CLIENT_VERSION, mode, channelSessionId, capabilities, workspaceId, threadId, storedPeerSecret); } catch (regErr) { // 🔴 SELF-RECOVERY FROM A LOST OWNERSHIP PROOF (AUD-003). // // Clients before 3.23.5 destroyed the peerSecret on every re-join, so an agent can hold // `peerId` with no way to prove it owns it and gets PEER_NOT_YOURS forever. The relay // only lets it back in once the peer is ABANDONED (no live connection AND lastSeenAt past // the 30-minute stale timeout) — correct, but a 30-minute outage. // // The prior token IS a valid ownership proof and 3.23.5+ keeps it as `ownerToken`. Retry // once with it: recovery becomes immediate, and the freshly issued peerSecret is stored so // this cannot recur. Every failure path falls through to the ORIGINAL error — a broken // recovery must never mask why registration actually failed. const msg = regErr instanceof Error ? regErr.message : String(regErr); let recovered; if (/PEER_NOT_YOURS/.test(msg) && isCloudMode()) { try { // COG-80: the owner token of THIS peer — never another agent's from the shared folder file. const folderCreds = await loadCredentials(); const creds = (await loadPeerRecord(peerId)) ?? (!folderCreds?.peerId || folderCreds.peerId === peerId ? folderCreds : null); if (creds?.ownerToken && creds.ownerToken !== creds.token && creds.endpoint && creds.sessionId) { logger.warn(`register_peer: '${peerId}' is owned by an earlier token (its peerSecret was lost by a ` + `pre-3.23.5 re-join). Re-proving ownership with the stored owner token.`); const res = await fetch(`${creds.endpoint}/api/sessions/${creds.sessionId}/peers`, { method: "POST", headers: { "content-type": "application/json", authorization: `Bearer ${creds.ownerToken}` }, body: JSON.stringify({ peerId, cwd, label, type: "agent", clientVersion: CLIENT_VERSION, ...(mode ? { mode } : {}), ...(capabilities ? { capabilities } : {}) }), }); if (res.ok) { recovered = await res.json(); const secret = recovered?.peerSecret; if (secret) { await savePeerRecord({ ...creds, peerId, peerSecret: secret, savedAt: new Date().toISOString() }); logger.info(`register_peer: ownership reclaimed for '${peerId}'; a fresh peerSecret is stored.`); } } } } catch (recoverErr) { logger.debug(`register_peer: ownership recovery failed: ${recoverErr.message}`); } } // 🔴 Item 3: the name is ours but held by an earlier registration. Wait for the relay's // retryAfterMs and retry the SAME peerId — never a renamed one (RC1). if (!recovered && regErr instanceof BridgeError && regErr.code === BridgeErrorCode.PEER_NOT_YOURS) { recovered = await retrySamePeerId(regErr, () => backend.registerPeer(peerId, sessionId, cwd, label, CLIENT_VERSION, mode, channelSessionId, capabilities, workspaceId, threadId, storedPeerSecret)); } if (!recovered) throw regErr; peer = recovered; } // Stamp our peerId into the per-cwd cloud credentials so the check-on-stop // hook (a separate process) can identify "me" without re-deriving it. The // join/create path saved creds before the peerId was known; this fills it // in. Best-effort — a failure here must never break registration. if (isCloudMode()) { try { const creds = await loadCredentials(); // AUD-003: the relay returns a freshly-minted peerSecret EXACTLY ONCE, when the peer // is created or adopted. Losing it means this agent can never reclaim its own peerId // after a re-join, so persist it the moment we see it. const issuedSecret = peer?.peerSecret; const platform = getConfig().COGENT_PLATFORM; // Claude: the bridge's OWN session id — what a restart compares with (an agent may pass // another value as sessionId). Codex and others: the id the agent registered with. const agent = { agentSession: (platform === "cc" ? agentSessionId() : undefined) ?? sessionId, platform }; if (creds) { // This process's own channel connection — not whichever agent joined this folder last. const channel = getCurrentCloudConnection() ?? { endpoint: creds.endpoint, sessionId: creds.sessionId, token: creds.token }; // This peer's saved identity: the top-level fields if they name it (always the newest // write), else its own entry. Never another peer's secret or owner token (COG-80). const mine = peerCredentials(creds, peerId); const sameChannel = !!mine && mine.sessionId === channel.sessionId && mine.endpoint === channel.endpoint; const secret = issuedSecret ?? (sameChannel ? mine.peerSecret : undefined); const ownerToken = sameChannel ? (mine.ownerToken ?? (mine.token !== channel.token ? mine.token : undefined)) : undefined; const label = creds.sessionId === channel.sessionId ? creds.label : sameChannel ? mine.label : undefined; const entry = { ...channel, ...(label ? { label } : {}), ...agent, ...(secret ? { peerSecret: secret } : {}), ...(ownerToken ? { ownerToken } : {}), savedAt: new Date().toISOString(), }; // ONE write: the top-level fields name this peer (the Stop hooks, watch and mail read // them), and this peer's entry — which the other agent's register never touches. await saveCredentials({ ...entry, peerId, peers: { ...(creds.peers ?? {}), [peerId]: entry } }); } } catch (err) { logger.debug(`check-on-stop: could not stamp peerId into credentials: ${err.message}`); } } // Cogent Mail M2.7: if the relay auto-provisioned a mailbox for this peer (Team channel), // persist the one-time credentials to the local 0600 mail store so cogent_send_mail / // cogent_fetch_mail can use them without a manual cogent_setup_mail. Best-effort — a failure // must never break registration — and the password value is never logged. // A Team mailbox that FAILED to provision must be audible here, not only in the relay // log the agent's operator may never read. Without this the next symptom is a bare "no // mailbox is configured" at send time, with nothing connecting it to this registration. if (peer?.mailboxError) { logger.warn(`Cogent Mail: mailbox provisioning FAILED (${peer.mailboxError.code}) — ${peer.mailboxError.message}`); } try { if (await persistProvisionedMailbox(peer?.mailbox)) { logger.info(`Cogent Mail: saved auto-provisioned mailbox ${peer.mailbox?.address}`); } } catch (err) { logger.warn(`Cogent Mail: could not save auto-provisioned mailbox creds: ${err.message}`); } // Set local peer info for auto-relay (cloud mode: incoming messages trigger local execClaude). // Auto-detect the real local CC session ID from the filesystem — the user/agent may // pass the cloud session ID by mistake, which won't work with `claude --resume`. // Confidence-aware resolve + PIN at register. The caller is provably // active right now, so this resolution is trustworthy; the pin is // exact-matched on every later wake instead of re-guessing newest-mtime // (the fix for silent wrong-session drift). const resolution = await resolveSession(getConfig().COGENT_PLATFORM, cwd, sessionId); const localSessionId = resolution.sessionId ?? sessionId; logger.info(`Register: session resolved ${resolution.decision}/${resolution.confidence} ` + `(${resolution.candidateCount} candidate[s]) -> ${localSessionId}`); // 🔴 FAIL LOUD. A live agent session is calling this tool right now, so its // transcript MUST be on disk. Zero candidates therefore is not "no sessions // yet" — it means we scanned the WRONG directory (an agent-supplied cwd that // is not where this agent actually runs, or a relocated CLAUDE_CONFIG_DIR / // CODEX_HOME the bridge process cannot see). Registration still succeeds, but // localSessionId silently falls back to the CLOUD session id, which // `--resume` can never open — so EVERY later wake is refused. That combination // shipped as a mystery on a fresh dev machine (2026-08-31): the log said only // "0 candidate[s]" at INFO and the operator had to report it by hand. // This warns on stderr AND returns the probed paths in the tool result, which // is the only channel the calling agent can actually read. const unresolved = unresolvedSessionReason(resolution); if (unresolved) { // Own try/catch: this block is PURELY informational, so nothing in it may // ever fail a registration that has already succeeded. try { const base = getConfig().COGENT_PLATFORM === "cc" ? await transcriptBaseSummary() : "(codex: scans CODEX_HOME/sessions)"; const cause = unresolved === "ambiguous" ? `${resolution.candidateCount} candidate sessions matched and NONE could be chosen, so the ` + `id you supplied was pinned UNCHECKED — if that is a channel UUID rather than a local ` + `session id, every wake will fail in ~2s` : `NO local ${getConfig().COGENT_PLATFORM} session was found for this cwd`; logger.warn(`Register: ${cause} — auto-wake WILL be refused. ` + `cwd=${cwd}; scanned ${(resolution.probedPaths ?? []).join(", ") || "(unknown)"}; base ${base}. ` + `If the base is missing or empty, this agent's config dir is elsewhere — ` + `set CLAUDE_CONFIG_DIR / CODEX_HOME so the BRIDGE process sees it too. ` + `If the base has project dirs but none match, the registered cwd is not where this agent runs.`); } catch (err) { logger.warn(`Register: unresolved session (${unresolved}) for cwd=${cwd} (auto-wake WILL be refused); ` + `could not describe the transcript base: ${err.message}`); } } // Plumb mode (Phase 17 OBS-03) + the pinned local id so the resolver // exact-matches it on subsequent wakes. autoRelay.setLocalPeer(peerId, localSessionId, cwd, mode, label, localSessionId); // The agent's own view of its presence follows what it just did: a bridge that started // `deferred` or `standby` kept reporting that after an explicit register (review r4 problem 10). if (isCloudMode()) { setPresence({ state: "online", restored: false, detail: `registered as '${peerId}' by cogent_register_peer` }); } // 🔴 NOTIFY, DO NOT REFUSE (operator, 2026-10-13). Credentials are one file per folder, so // this registration just took the folder's identity. If another live bridge in this folder // was registered, the CALLER must learn it from the tool result — the only channel it // reads. Informational: nothing here may fail a registration that has already succeeded. let otherBridges; try { const own = agentSessionId(); const others = otherBridgesInFolder(listLiveBridgesInFolder(cwd), { pid: process.pid, sessionId: own }, listProcesses); if (others.length) { otherBridges = otherBridgeNotice(others, peerId, checkWakeOwnership(cwd, peerId), own); // WARN only for a PROVEN other agent session; an agent's own older bridge is routine. if (anotherAgentSessionAmong(others, own)) logger.warn(`Register: ${otherBridges}`); else logger.info(`Register: ${otherBridges}`); } } catch (err) { logger.debug(`Register: could not look for other bridges in this folder: ${err.message}`); } // Build the result, enriched with auto-create info if applicable const result = { success: true, action: existing ? "updated" : "registered", ...(otherBridges ? { otherBridgeInThisFolder: otherBridges } : {}), peer, sessionResolution: { decision: resolution.decision, confidence: resolution.confidence, candidateCount: resolution.candidateCount, // Only on the broken path — keeps the happy-path result terse. ...(unresolved ? { warning: (unresolved === "ambiguous" ? `${resolution.candidateCount} local session transcripts matched this cwd and none ` + "could be chosen, so the sessionId you supplied was pinned UNCHECKED — if it is a " + "channel UUID rather than a local session id, auto-wake will be REFUSED. " : "No local session transcript found for this cwd — auto-wake will be REFUSED. ") + "Verify the cwd is where this agent actually runs and that CLAUDE_CONFIG_DIR / " + "CODEX_HOME are visible to the bridge process.", scannedPaths: resolution.probedPaths ?? [], } : {}), }, }; // Surface a version-drift warning IN the tool response (not just stderr / // health_check, which the agent never sees) so a session on a stale build learns it // on connect and can tell the human to update+restart. The relay's AUTHORITATIVE // advisory (F2 — it compared our clientVersion to its configured minimum) takes // precedence; otherwise fall back to the client's own npm self-check nudge (F1). const relayAdvisory = peer?.advisory; const updateNudge = (typeof relayAdvisory === "string" && relayAdvisory) || getLastNudge(); if (updateNudge) result.updateAvailable = updateNudge; if (sessionCreated && cloudSessionId && autoSecret) { result.session_created = true; result.credentials = { sessionId: cloudSessionId, secret: autoSecret, }; const ep = config.COGENT_ENDPOINT ?? ""; result.config_snippet = { env_vars: `COGENT_ENDPOINT=${ep}\n` + `COGENT_SESSION_ID=${cloudSessionId}\n` + `COGENT_SECRET=${autoSecret}`, mcp_config: { "cogent": { command: "npx", args: ["@essentialai/cogent-bridge"], env: { COGENT_ENDPOINT: ep, COGENT_SESSION_ID: cloudSessionId, COGENT_SECRET: autoSecret, }, }, }, }; } // After successful peer registration in cloud mode, trigger WebSocket // connection by calling setPeerId() on the CloudWsClient created during // startup. Use dynamic import to avoid circular dependency with startup.ts. if (isCloudMode()) { try { const startup = await import("../startup.js"); if (startup.cloudWsClient) { startup.cloudWsClient.setPeerId(peerId); // setPeerId() internally calls connect() if state is "disconnected" and peerId is non-empty logger.info(`WebSocket connection triggered for peer ${peerId}`); } } catch (err) { // Non-fatal: WS is optional, polling fallback will work logger.warn(`Could not trigger WebSocket connection: ${err}`); } } // Start background heartbeat to keep this peer alive. // Pass triggerReRegistration callback so heartbeat can auto-recover // if the server evicts this peer via stale cleanup (returns 404). let reRegFn; try { const startupMod = await import("../startup.js"); reRegFn = startupMod.triggerReRegistration; } catch { // Non-fatal: re-registration won't be available } heartbeat.start(peerId, server, reRegFn); return successResult(result); } catch (err) { logger.error("register-peer failed", { error: err }); return errorResult(err); } }); } //# sourceMappingURL=register-peer.js.map