@essentialai/cogent-plugin
Advanced tools
| // Pure, IO-free logic for the check-on-stop Stop hook. Node built-ins only. | ||
| // Mirrors src/services/auto-relay.ts HUMAN_ORIGINS and src/cloud/credential-store.ts | ||
| // defaultCredentialPath. Kept separate from the runtime (check-on-stop.mjs) so it is | ||
| // unit-tested (plugin/hooks/check-on-stop.lib.test.mjs, gate-counted via vitest include). | ||
| import crypto from "node:crypto"; | ||
| import path from "node:path"; | ||
| /** MUST equal src/services/auto-relay.ts:629 — human-origin broadcasts only. */ | ||
| export const HUMAN_ORIGINS = new Set(["slack", "gchat", "web"]); | ||
| /** Parse COGENT_CHECK_ON_STOP_SCOPE csv → { directed, humanBroadcast }. */ | ||
| export function parseScope(csv) { | ||
| const toks = String(csv || "") | ||
| .split(",") | ||
| .map((s) => s.trim().toLowerCase()) | ||
| .filter(Boolean); | ||
| return { | ||
| directed: toks.includes("directed"), | ||
| humanBroadcast: toks.includes("human-broadcast"), | ||
| }; | ||
| } | ||
| /** sha256(path.resolve(cwd))[..16] — MUST equal credential-store.defaultCredentialPath's hash. */ | ||
| export function credHashForCwd(cwd) { | ||
| return crypto.createHash("sha256").update(path.resolve(cwd)).digest("hex").slice(0, 16); | ||
| } | ||
| function isForMe(msg, me, scope) { | ||
| if (msg.toPeerId === me) return scope.directed; | ||
| if (msg.toPeerId === "*") { | ||
| return ( | ||
| scope.humanBroadcast && | ||
| msg.originPlatform !== undefined && | ||
| HUMAN_ORIGINS.has(msg.originPlatform) | ||
| ); | ||
| } | ||
| return false; | ||
| } | ||
| /** | ||
| * From the FULL per-me message history (chronological arrival order), compute the | ||
| * messages I have NOT answered, using a per-sender reply queue (conversation | ||
| * turn-counting): | ||
| * - candidate = addressed to me per scope (directed, or human-origin broadcast), | ||
| * not authored by me, not a relay echo. | ||
| * - each candidate is enqueued under its sender; a later message FROM me to that | ||
| * sender (a directed reply or my auto-relay echo) dequeues the sender's OLDEST | ||
| * pending. A broadcast reply from me (toPeerId "*") clears the single oldest | ||
| * pending conversation. | ||
| * - whatever remains queued is genuinely unanswered → returned (chronological). | ||
| * | ||
| * This clears the common real-time exchange (1 in → 1 reply → answered) yet still | ||
| * surfaces the interleaved busy-miss (2 in from a sender, 1 reply → 1 left), and — | ||
| * because it evaluates the WHOLE history each time — attributes each reply to the | ||
| * right message regardless of turn boundaries. It favors never MISSING a message. | ||
| * | ||
| * De-duplication ("don't re-nag about the same pending message") is the runtime's | ||
| * job via a persisted "surfaced" id set, NOT a cursor — a cursor would drop a reply's | ||
| * target out of view and mis-attribute it. Returns { items } (chronological). | ||
| */ | ||
| export function selectUnanswered({ messages, me, scope }) { | ||
| const list = Array.isArray(messages) ? messages : []; | ||
| const pending = new Map(); // senderId -> candidate[] (oldest first) | ||
| const order = []; // senderIds in first-seen order | ||
| const enqueue = (m) => { | ||
| if (!pending.has(m.fromPeerId)) { | ||
| pending.set(m.fromPeerId, []); | ||
| order.push(m.fromPeerId); | ||
| } | ||
| pending.get(m.fromPeerId).push(m); | ||
| }; | ||
| const dequeueFrom = (sender) => { | ||
| const q = pending.get(sender); | ||
| if (q && q.length) q.shift(); | ||
| }; | ||
| // A broadcast reply from me (toPeerId "*") mirrors a broadcast QUESTION | ||
| // (COGENT_REPLY_BROADCAST), so it may only clear the oldest pending BROADCAST | ||
| // candidate — NEVER a directed message (which is cleared solely by a directed | ||
| // reply to its sender). Clearing a directed message here would silently drop an | ||
| // unanswered directed message — the exact busy-miss C exists to prevent. | ||
| const dequeueOldestBroadcast = () => { | ||
| let best = null; | ||
| for (const s of order) { | ||
| const q = pending.get(s); | ||
| if (q && q.length && q[0].toPeerId === "*" && (best === null || q[0]._i < pending.get(best)[0]._i)) { | ||
| best = s; | ||
| } | ||
| } | ||
| if (best !== null) pending.get(best).shift(); | ||
| }; | ||
| let i = 0; | ||
| for (const m of list) { | ||
| const idx = i++; | ||
| if (m.fromPeerId === me) { | ||
| // My message (a reply or my own auto-relay echo) answers a pending item. | ||
| // A directed reply clears that sender's oldest; a broadcast reply clears | ||
| // only the oldest pending BROADCAST (never a directed message). | ||
| if (m.toPeerId === "*") dequeueOldestBroadcast(); | ||
| else dequeueFrom(m.toPeerId); | ||
| continue; | ||
| } | ||
| if (m.isRelayEcho === true) continue; // a peer's echo — never my job | ||
| if (!isForMe(m, me, scope)) continue; | ||
| enqueue({ ...m, _i: idx }); | ||
| } | ||
| const items = []; | ||
| for (const s of order) for (const m of pending.get(s)) items.push(m); | ||
| items.sort((a, b) => a._i - b._i); | ||
| return { items: items.map(({ _i, ...m }) => m) }; | ||
| } | ||
| /** Build the { decision: "block" } reason string handed back to the agent. */ | ||
| export function buildBlockReason(items, { firstUse }) { | ||
| const n = items.length; | ||
| const lines = items.slice(0, 10).map((m) => { | ||
| const snippet = String(m.message || "").replace(/\s+/g, " ").slice(0, 120); | ||
| return ` - from ${m.fromPeerId}${m.toPeerId === "*" ? " (channel)" : ""}: ${snippet}`; | ||
| }); | ||
| let reason = | ||
| `You have ${n} unanswered Cogent message${n === 1 ? "" : "s"} that arrived while you were busy:\n` + | ||
| lines.join("\n") + | ||
| `\n\nRead the channel with cogent_get_history and reply to each with cogent_send_message ` + | ||
| `(or spawn a Task sub-agent to handle them), then continue. Do not repeat work you already did this turn.`; | ||
| if (firstUse) { | ||
| reason += | ||
| `\n\n(Cogent auto-check is on: it catches messages that arrive while you're busy. ` + | ||
| `Disable with COGENT_CHECK_ON_STOP=0.)`; | ||
| } | ||
| return reason; | ||
| } |
| #!/usr/bin/env node | ||
| // Cogent check-on-stop (feature "C"). Runs on the plugin Stop event as a SEPARATE | ||
| // short-lived process (Claude Code spawns it; it is NOT the bridge). Silent unless | ||
| // there are unanswered directed / human-broadcast messages that arrived while the | ||
| // agent was BUSY (so B's real-time wake was missed) — then it prints | ||
| // {"decision":"block","reason":...} so the agent takes another turn and replies. | ||
| // | ||
| // Auth: reads the bridge's per-cwd cloud credentials written by credential-store.ts | ||
| // ({endpoint, sessionId, token, peerId}). No secret on disk; the bearer token is | ||
| // already persisted there by design. Node built-ins only (fs, os, path, fetch). | ||
| import fs from "node:fs"; | ||
| import fsp from "node:fs/promises"; | ||
| import os from "node:os"; | ||
| import path from "node:path"; | ||
| import { | ||
| parseScope, | ||
| selectUnanswered, | ||
| buildBlockReason, | ||
| credHashForCwd, | ||
| } from "./check-on-stop.lib.mjs"; | ||
| const HTTP_TIMEOUT_MS = 5000; // < the 8s hook timeout; only bites on a degraded relay | ||
| async function readStdin() { | ||
| const chunks = []; | ||
| for await (const c of process.stdin) chunks.push(c); | ||
| const raw = Buffer.concat(chunks).toString("utf-8").trim(); | ||
| if (!raw) return {}; | ||
| try { | ||
| return JSON.parse(raw); | ||
| } catch { | ||
| return {}; | ||
| } | ||
| } | ||
| function envOff(v) { | ||
| return v === "false" || v === "0"; | ||
| } | ||
| // Mirror credential-store.ts resolveCredentialPath EXACTLY: COGENT_CREDENTIALS_FILE | ||
| // override, else ~/.cogent/credentials/<sha256(resolve(cwd))[..16]>.json (homedir-based, | ||
| // NOT COGENT_STATE_PATH). | ||
| function credPath(cwd, env) { | ||
| if (env.COGENT_CREDENTIALS_FILE) return env.COGENT_CREDENTIALS_FILE; | ||
| return path.join(os.homedir(), ".cogent", "credentials", `${credHashForCwd(cwd)}.json`); | ||
| } | ||
| // The hook's own state (surfaced ids + primed/notified) — a plain marker under | ||
| // ~/.cogent (or COGENT_STATE_PATH). Keyed by BOTH the channel session id AND the | ||
| // peer id: a channel session is shared by every peer, so two co-located peers in | ||
| // the same channel (same $HOME, different cwd) would otherwise clobber each other's | ||
| // surfaced/primed state. | ||
| function statePath(sessionId, me, env) { | ||
| const base = env.COGENT_STATE_PATH || path.join(os.homedir(), ".cogent"); | ||
| const safe = (s) => String(s).replace(/[^A-Za-z0-9._-]/g, "_"); | ||
| return path.join(base, "check-on-stop", `${safe(sessionId)}.${safe(me)}.json`); | ||
| } | ||
| async function readJson(p) { | ||
| try { | ||
| return JSON.parse(await fsp.readFile(p, "utf-8")); | ||
| } catch { | ||
| return null; | ||
| } | ||
| } | ||
| async function writeJson(p, obj) { | ||
| await fsp.mkdir(path.dirname(p), { recursive: true }); | ||
| await fsp.writeFile(p, JSON.stringify(obj, null, 2) + "\n", "utf-8"); | ||
| } | ||
| // Back-compat when creds lack peerId (created by create/join before a register, or | ||
| // by a pre-3.12.2 client): identify "me" as the state-file peer whose cwd matches. | ||
| // (cwd is the reliable key — the credential file is itself per-cwd. channelSessionId | ||
| // in the state file is the peer's LOCAL session id, not the cloud channel id, so it | ||
| // cannot be matched against creds.sessionId.) | ||
| async function peerIdFromState(env, cwd) { | ||
| const stateFile = path.join( | ||
| env.COGENT_STATE_PATH || path.join(os.homedir(), ".cogent"), | ||
| "cogent-state.json", | ||
| ); | ||
| const state = await readJson(stateFile); | ||
| if (!state || !state.peers) return undefined; | ||
| const abs = path.resolve(cwd); | ||
| const byCwd = Object.values(state.peers).find((p) => p.cwd && path.resolve(p.cwd) === abs); | ||
| return byCwd ? byCwd.peerId : undefined; | ||
| } | ||
| async function main() { | ||
| const input = await readStdin(); | ||
| // Loop guard: if we already caused this continuation, never block again. | ||
| if (input && input.stop_hook_active === true) return process.exit(0); | ||
| const env = process.env; | ||
| if (envOff(env.COGENT_CHECK_ON_STOP)) return process.exit(0); // opt-out | ||
| const cwd = input.cwd || process.cwd(); | ||
| const creds = await readJson(credPath(cwd, env)); | ||
| if (!creds || !creds.endpoint || !creds.sessionId || !creds.token) { | ||
| return process.exit(0); // not registered in cloud mode → nothing to do | ||
| } | ||
| const me = creds.peerId || (await peerIdFromState(env, cwd)); | ||
| if (!me) return process.exit(0); // can't identify self → stay silent | ||
| const scope = parseScope(env.COGENT_CHECK_ON_STOP_SCOPE || "directed,human-broadcast"); | ||
| if (!scope.directed && !scope.humanBroadcast) return process.exit(0); | ||
| const sPath = statePath(creds.sessionId, me, env); | ||
| const st = (await readJson(sPath)) || {}; // { surfaced: string[], primed?, notified? } | ||
| const surfaced = Array.isArray(st.surfaced) ? st.surfaced : []; | ||
| // Silent, bounded poll of the relay for the FULL per-me history (no cursor — the | ||
| // queue model re-attributes replies across turns; a cursor would mis-attribute). | ||
| let messages = []; | ||
| try { | ||
| const url = | ||
| `${creds.endpoint}/api/sessions/${encodeURIComponent(creds.sessionId)}/poll` + | ||
| `?peerId=${encodeURIComponent(me)}`; | ||
| const res = await fetch(url, { | ||
| method: "GET", | ||
| headers: { authorization: `Bearer ${creds.token}` }, | ||
| signal: AbortSignal.timeout(HTTP_TIMEOUT_MS), | ||
| }); | ||
| if (!res.ok) return process.exit(0); // auth/relay error → silent, retry next Stop | ||
| const body = await res.json(); | ||
| messages = Array.isArray(body.messages) ? body.messages : []; | ||
| } catch { | ||
| return process.exit(0); // network/timeout → silent | ||
| } | ||
| const { items } = selectUnanswered({ messages, me, scope }); | ||
| const unansweredIds = items.map((m) => m.id); | ||
| // First run: PRIME — record the current backlog as already-surfaced so C never | ||
| // dumps history on install. It catches messages that arrive AFTER it is active. | ||
| if (!st.primed) { | ||
| await writeJson(sPath, { surfaced: unansweredIds, primed: true, notified: st.notified === true }); | ||
| return process.exit(0); | ||
| } | ||
| // New = unanswered we have not already shown. Prune "surfaced" to only still-unanswered | ||
| // ids (bounded growth): once a message is answered it leaves both sets. | ||
| const fresh = items.filter((m) => !surfaced.includes(m.id)); | ||
| const nextSurfaced = unansweredIds; // union of (already-surfaced ∩ unanswered) and fresh | ||
| if (fresh.length === 0) { | ||
| await writeJson(sPath, { surfaced: nextSurfaced, primed: true, notified: st.notified === true }); | ||
| return process.exit(0); // nothing new → silent (zero noise) | ||
| } | ||
| const firstUse = st.notified !== true; | ||
| await writeJson(sPath, { surfaced: nextSurfaced, primed: true, notified: true }); | ||
| const reason = buildBlockReason(fresh, { firstUse }); | ||
| process.stdout.write(JSON.stringify({ decision: "block", reason })); | ||
| return process.exit(0); | ||
| } | ||
| // Never break the agent's Stop on our account — any unexpected error → silent exit 0. | ||
| main().catch(() => process.exit(0)); |
| { | ||
| "hooks": { | ||
| "Stop": [ | ||
| { | ||
| "matcher": "", | ||
| "hooks": [ | ||
| { | ||
| "type": "command", | ||
| "command": "node \"${CLAUDE_PLUGIN_ROOT}/hooks/check-on-stop.mjs\"", | ||
| "timeout": 8, | ||
| "statusMessage": "Checking Cogent channel…" | ||
| } | ||
| ] | ||
| } | ||
| ] | ||
| } | ||
| } |
| { | ||
| "name": "cogent", | ||
| "version": "3.12.1", | ||
| "version": "3.12.2", | ||
| "description": "Inter-session communication bridge for Claude Code with Slack integration. Enables CC agents and Slack team members to communicate in real time.", | ||
@@ -5,0 +5,0 @@ "author": { |
+1
-1
@@ -5,3 +5,3 @@ { | ||
| "command": "npx", | ||
| "args": ["-y", "@essentialai/cogent-bridge@3.12.1"], | ||
| "args": ["-y", "@essentialai/cogent-bridge@3.12.2"], | ||
| "env": { | ||
@@ -8,0 +8,0 @@ "COGENT_ENDPOINT": "https://cogent.tools", |
+2
-2
| { | ||
| "name": "@essentialai/cogent-plugin", | ||
| "version": "3.12.1", | ||
| "version": "3.12.2", | ||
| "description": "Cogent — Claude Code plugin (skills + slash-commands + MCP server) for the cross-agent comms fabric.", | ||
@@ -10,3 +10,3 @@ "author": { "name": "Essential AI Solutions Ltd.", "url": "https://essentialai.uk" }, | ||
| "publishConfig": { "access": "public" }, | ||
| "files": [".claude-plugin", "commands", "skills", ".mcp.json", "README.md", "LICENSE"], | ||
| "files": [".claude-plugin", "commands", "skills", ".mcp.json", "hooks", "README.md", "LICENSE"], | ||
| "scripts": { | ||
@@ -13,0 +13,0 @@ "sync": "node scripts/sync-from-plugin.mjs", |
Major refactor
Supply chain riskPackage has recently undergone a major refactor. It may be unstable or indicate significant internal changes. Use caution when updating to versions that include significant changes.
Network access
Supply chain riskThis module accesses the network.
Environment variable access
Supply chain riskPackage accesses environment variables, which may be a sign of credential stuffing or data theft.
Filesystem access
Supply chain riskAccesses the file system, and could potentially read sensitive data.
Found 2 instances
36366
53.4%15
25%314
823.53%4
300%2
Infinity%