Sign In

@essentialai/cogent-plugin

Package Overview
Dependencies
Maintainers
1
Versions
18
Alerts
File Explorer

Advanced tools

Socket logo

Install Socket

Detect and block malicious and high-risk dependencies

Install

@essentialai/cogent-plugin - npm Package Compare versions

Comparing version
3.12.1
to
3.12.2
+134
hooks/check-on-stop.lib.mjs
// 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…"
}
]
}
]
}
}
+1
-1
{
"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": {

@@ -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",

{
"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",