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