@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.
136 lines • 6.01 kB
JavaScript
import { logger } from "../logger.js";
/**
* Best-effort proactive update check for the cogent-bridge npm client.
*
* On startup the MCP server knows its own SERVER_VERSION but not whether a newer
* version has been published — so users stay on stale clients/plugins indefinitely
* (the recurring "/cogent map still shows the old form" pain). This module fetches
* the latest published version and, if the running build is behind, produces a
* one-line nudge the caller prints to STDERR (never stdout — that's the MCP protocol).
*
* Discipline (mirrors relay-version-cache.ts): never throws, returns null on any
* failure / when current, and is bounded by a short timeout so it never delays the
* server becoming ready. The caller fires it and forgets it.
*/
const NPM_LATEST_URL = "https://registry.npmjs.org/@essentialai/cogent-bridge/latest";
/** Parse a leading `x.y.z` into numbers; null if not a clean 3-part numeric version. */
export function parseVersion(v) {
const m = /^(\d+)\.(\d+)\.(\d+)/.exec(v.trim());
if (!m)
return null;
return [Number(m[1]), Number(m[2]), Number(m[3])];
}
/** True iff `latest` is strictly newer than `current` (major, then minor, then patch). */
export function isNewer(current, latest) {
const c = parseVersion(current);
const l = parseVersion(latest);
if (!c || !l)
return false;
for (let i = 0; i < 3; i++) {
if (l[i] > c[i])
return true;
if (l[i] < c[i])
return false;
}
return false;
}
/**
* Process-lifetime store for the startup update check's result, so other surfaces
* (e.g. cogent_health_check) can show the same nudge without a second npm fetch. The
* MCP server is long-lived and checks once at boot; null means "current or not yet/failed".
*/
let lastNudge = null;
/** Nudge text already handed to a surfacing path, so it is announced ONCE, not on repeat. */
let shownNudge = null;
/**
* Record a check's result (the nudge string, or null when current/unchecked).
*
* A CHANGED nudge re-arms {@link consumeNudgeOnce}: a second release must still be announced even
* though the first one already was. Re-setting the SAME text does not re-arm — that is what keeps
* a periodic check from becoming a periodic nag.
*/
export function setLastNudge(nudge) {
if (nudge !== lastNudge)
shownNudge = null;
lastNudge = nudge;
}
/**
* The pending nudge, returned EXACTLY ONCE per distinct nudge; null afterwards.
*
* 🔴 WHY "ONCE" IS PART OF THE CONTRACT. The surfacing path rides a message the agent is already
* processing (auto-relay), so it costs no extra turn — but that also means it would otherwise fire
* on EVERY inbound message. One line per release is information; the same line on every message is
* noise, and noise is what teaches people to ignore the notice that matters.
*/
export function consumeNudgeOnce() {
if (!lastNudge || lastNudge === shownNudge)
return null;
shownNudge = lastNudge;
return lastNudge;
}
/**
* Re-check for a newer published client on an interval, forever.
*
* 🔴 WHY A ONE-SHOT CHECK WAS NEARLY USELESS. The check ran once, at boot. Every bridge here is
* long-lived — some run for weeks — so a version published after startup was invisible to the
* running process no matter how many releases shipped. 3.21.4 through 3.21.8 all went out inside a
* single day; an agent booted that morning would still have reported itself current that evening.
*
* Checks immediately, then every `intervalMs`. Never throws (checkForUpdate already swallows), and
* the timer is `unref`'d so it can never hold the process open at shutdown. Returns a stop
* function — used by tests, and available to any caller that needs a clean teardown.
*/
export function startUpdateCheckLoop(current, opts = {}) {
const { intervalMs = 6 * 60 * 60 * 1000, ...checkOpts } = opts;
const run = () => {
void checkForUpdate(current, checkOpts).then((nudge) => {
// Only ADOPT a positive result. A failed/timed-out check must not erase a nudge we already
// hold — "we could not reach npm" is not evidence that the client is current.
if (nudge)
setLastNudge(nudge);
});
};
run();
const timer = setInterval(run, intervalMs);
timer.unref?.();
return () => clearInterval(timer);
}
/** The most recent update nudge, or null. Read by health-check to surface it to the user. */
export function getLastNudge() {
return lastNudge;
}
/** Test-only: clear the stored nudge between suites. @internal */
export function __resetLastNudgeForTest() {
lastNudge = null;
shownNudge = null;
}
/**
* Fetch the latest published cogent-bridge version; if newer than `current`, return a
* one-line update nudge, else null. Never throws.
*/
export async function checkForUpdate(current, opts = {}) {
const { timeoutMs = 2500, url = NPM_LATEST_URL, fetchImpl = fetch } = opts;
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), timeoutMs);
try {
const resp = await fetchImpl(url, { signal: controller.signal });
if (!resp.ok)
return null;
const body = (await resp.json());
const latest = body?.version;
if (typeof latest !== "string" || !isNewer(current, latest))
return null;
return (`⚠ Cogent update available: v${current} → v${latest}. ` +
`Update the client (clear ~/.npm/_npx then restart your agent) AND the plugin ` +
`(\`/plugin update cogent@cogent\`, then restart Claude Code) — Team \`/cogent map\` ` +
`needs the latest. https://github.com/eaisdevelopment/cogent`);
}
catch (err) {
logger.debug(`npm-update-check: ${err instanceof Error ? err.message : String(err)}`);
return null;
}
finally {
clearTimeout(timer);
}
}
//# sourceMappingURL=npm-update-check.js.map