@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.
481 lines • 22.3 kB
JavaScript
import { spawn } from "node:child_process";
import fs from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { getConfig } from "../config.js";
import { buildResumeEnv, claudeConfigDir } from "./agent-config.js";
import { learnedRoots, readAgentSession } from "./agent-sessions.js";
/**
* Every transcript directory that could hold this cwd's sessions.
*
* 🔴 WHY THIS IS NOT ONE PATH. Claude Code names a project dir by replacing each
* non-alphanumeric char of the cwd with "-", so EVERY representation of the same
* directory encodes to a DIFFERENT name: a trailing slash, a "." segment, or a
* symlinked path (macOS `/tmp` -> `/private/tmp`, `/var` -> `/private/var`) each
* produce a dir that does not exist. The cwd here is a string the AGENT supplied
* to cogent_register_peer — it is not guaranteed to be the canonical form.
*
* That mismatch is how a fresh dev machine got ZERO candidates from its very
* first register (2026-08-31) and refused every wake thereafter. The Codex rail
* had already canonicalised for this reason (codex-cli.ts `cwdCanonicals`); this
* is the Claude Code rail reaching parity.
*
* Exported so callers can LOG what was probed — a miss used to be indistinguishable
* from an empty dir, which is what made the failure undiagnosable from the logs.
*/
export async function projectDirsFor(cwd) {
const [roots, forms] = await Promise.all([
claudeConfigRoots(),
cwdForms(cwd),
]);
const dirs = roots.flatMap((root) => forms.map((f) => path.join(root, "projects", f.replace(/[^a-zA-Z0-9-]/g, "-"))));
return [...new Set(dirs)];
}
/** Every spelling of this cwd that could have been used to name a project dir. */
async function cwdForms(cwd) {
const resolved = path.resolve(cwd);
const forms = new Set([resolved]);
try {
forms.add(await fs.realpath(resolved));
}
catch {
// dir gone or unreadable → resolved form only
}
return [...forms];
}
/**
* Every config root that could hold this machine's Claude Code transcripts,
* best-first.
*
* 🔴 WHY THIS IS A LIST. `claudeConfigDir()` returns ONE guess — the env var if
* this process happens to see it, otherwise `~/.claude`. A single guess has no
* way to be wrong loudly: it just scans a directory that does not exist and
* reports "no sessions". Users relocate their agent state (small system disks,
* synced or encrypted home dirs, multi-account machines, containers), and the
* env var does not always reach the process that needs it.
*
* Order is deliberate:
* 1. `CLAUDE_CONFIG_DIR` — an explicit instruction always wins.
* 2. LEARNED roots — recovered from transcript paths the agent itself reported
* (agent-sessions.ts). These are the only entries PROVEN to have held a real
* transcript, which is why they outrank the conventional defaults.
* 3. `~/.claude`, then `~/.config/claude` — the conventional locations.
*
* Probing a few extra directories costs one failed `readdir` each; missing the
* user's actual directory costs them the entire auto-wake feature.
*/
export async function claudeConfigRoots() {
const roots = [];
const env = process.env.CLAUDE_CONFIG_DIR?.trim();
if (env)
roots.push(env);
roots.push(...(await learnedRoots("cc")));
roots.push(path.join(os.homedir(), ".claude"));
roots.push(path.join(os.homedir(), ".config", "claude"));
return [...new Set(roots.map((r) => path.resolve(r)))];
}
/**
* The transcript this cwd's agent REPORTED for itself, if the record is still
* valid on disk. This is layer 1 — the authoritative path — and it bypasses both
* the root guess and the cwd-encoding guess entirely.
*
* Returns null when nothing was ever recorded (an agent running without our
* plugin) or when the recorded transcript has since been removed, in which case
* the caller falls back to discovery.
*/
export async function recordedCandidate(cwd) {
const rec = await readAgentSession(cwd, "cc");
if (!rec)
return null;
try {
const st = await fs.stat(rec.transcriptPath);
return { sessionId: rec.sessionId, mtimeMs: st.mtimeMs };
}
catch {
return null;
}
}
/**
* One-line description of the transcript BASE dir and how many project dirs it
* holds. Used only when a resolve finds zero candidates, because it separates the
* two causes that look identical in the logs:
* • base absent / 0 project dirs → the bridge is looking at the WRONG config dir
* entirely (Claude Code's environment lives elsewhere and CLAUDE_CONFIG_DIR did
* not reach this process).
* • base holds N project dirs but none match → the CWD is the wrong one (or a
* representation this build cannot map — see {@link projectDirsFor}).
* Never throws.
*/
export async function transcriptBaseSummary() {
const roots = await claudeConfigRoots();
const parts = await Promise.all(roots.map(async (root) => {
const base = path.join(root, "projects");
try {
return `${base} [${(await fs.readdir(base)).length} project dirs]`;
}
catch {
return `${base} [absent]`;
}
}));
return parts.join(", ");
}
/**
* List all Claude Code session candidates for a cwd as {sessionId, mtimeMs}.
* Scans every {@link projectDirsFor} dir for *.jsonl — honoring CLAUDE_CONFIG_DIR
* (default ~/.claude). Returns [] on any error or an empty/absent dir (the
* resolver treats [] as "no candidates"). Never throws — Vault: Returning-null/
* Option error pattern (typescript-master-vault concepts/Error_Handling). Dirs
* and stats are probed in parallel (Vault: Parallelism); results are deduped by
* sessionId so two cwd forms resolving to one dir cannot inflate the count (a
* false ">1" would make the resolver call it "ambiguous" and refuse).
*/
export async function listCandidates(cwd) {
const dirs = await projectDirsFor(cwd);
const perDir = await Promise.all(dirs.map(async (projectDir) => {
try {
const entries = await fs.readdir(projectDir);
return await Promise.all(entries
.filter((f) => f.endsWith(".jsonl"))
.map(async (f) => ({
sessionId: f.replace(".jsonl", ""),
mtimeMs: (await fs.stat(path.join(projectDir, f))).mtimeMs,
})));
}
catch {
return [];
}
}));
const byId = new Map();
for (const c of perDir.flat()) {
const prev = byId.get(c.sessionId);
if (!prev || c.mtimeMs > prev.mtimeMs)
byId.set(c.sessionId, c);
}
// Layer 1 last, so the agent's OWN report survives even when every derived
// directory missed. Deduped by sessionId, so in the normal case (discovery
// already found it) this adds nothing and cannot inflate the count into a
// false "ambiguous".
const recorded = await recordedCandidate(cwd);
if (recorded && !byId.has(recorded.sessionId)) {
byId.set(recorded.sessionId, recorded);
}
return [...byId.values()];
}
/**
* Detect the most recently active Claude Code session for a given cwd (newest
* mtime wins). Returns null if none. Preserved for existing callers; new code
* should use the confidence-aware resolveSession() (session-resolver.ts).
*/
export async function detectCurrentSession(cwd) {
const candidates = await listCandidates(cwd);
if (candidates.length === 0)
return null;
candidates.sort((a, b) => b.mtimeMs - a.mtimeMs);
return candidates[0].sessionId;
}
/**
* Check whether a Claude Code session file exists on disk.
* Returns false if the file is missing (session ended or ID is wrong).
*
* Probes every {@link projectDirsFor} form — a pin validated under the canonical
* cwd must still validate when the agent registered a symlinked/trailing-slash
* representation of the same directory, or the resolver silently drops the pin
* and falls back to guessing by mtime.
*/
export async function validateSession(sessionId, cwd) {
// Layer 1 first: if the agent reported this exact session, its own path is
// proof — no directory needs to be derived at all.
const recorded = await recordedCandidate(cwd);
if (recorded?.sessionId === sessionId)
return true;
const dirs = await projectDirsFor(cwd);
const hits = await Promise.all(dirs.map(async (dir) => {
try {
await fs.access(path.join(dir, `${sessionId}.jsonl`));
return true;
}
catch {
return false;
}
}));
return hits.some(Boolean);
}
/**
* The config root that actually HOLDS this session's transcript, or null.
*
* 🔴 WHY DISCOVERY ALONE WAS NOT ENOUGH. {@link claudeConfigRoots} lets us FIND a
* session under any of several roots, but `claude --resume <id>` is a separate
* process that searches exactly ONE root — whatever `CLAUDE_CONFIG_DIR` says, or
* `~/.claude`. Finding a session in root B and then resuming against root A fails
* with an empty stdout and "No conversation found with session ID" on stderr,
* which upstream reported as the useless "busy, or produced no output" (shipped
* 3.21.0–3.21.3; measured against a real Claude Code on 2026-09-01).
*
* So resume must be told where discovery looked. The invariant: anything that can
* FIND a session must be able to say WHERE it found it.
*
* Layer 1 (the agent's own reported transcript) wins, exactly as in
* {@link listCandidates} — its root is a recorded fact rather than a search. Only
* then do we probe roots × cwd forms for `<root>/projects/<enc>/<id>.jsonl`.
* Returns null when no root holds it, in which case the caller must NOT invent a
* value — an inherited `CLAUDE_CONFIG_DIR` is still better than a guess.
*/
export async function rootForSession(sessionId, cwd) {
const rec = await readAgentSession(cwd, "cc");
if (rec?.sessionId === sessionId && rec.root) {
try {
await fs.stat(rec.transcriptPath);
return path.resolve(rec.root);
}
catch {
// Record went stale (transcript deleted) → fall through to discovery.
}
}
const [roots, forms] = await Promise.all([
claudeConfigRoots(),
cwdForms(cwd),
]);
// Roots are ordered best-first, so the first root holding this id wins. Forms
// within a root are probed in parallel; roots stay sequential so precedence is
// preserved rather than decided by whichever stat returns first.
for (const root of roots) {
const hits = await Promise.all(forms.map(async (form) => {
const file = path.join(root, "projects", form.replace(/[^a-zA-Z0-9-]/g, "-"), `${sessionId}.jsonl`);
try {
await fs.access(file);
return true;
}
catch {
return false;
}
}));
if (hits.some(Boolean))
return root;
}
return null;
}
export async function execClaude(sessionId, message, cwd, timeoutMs) {
// Resolve BEFORE spawning: the child needs to be told which config root holds
// this session (see rootForSession). Never throws — a failed lookup degrades to
// the inherited environment, which is exactly the pre-3.21.4 behavior.
const resolvedRoot = await rootForSession(sessionId, cwd).catch(() => null);
return new Promise((resolve) => {
const config = getConfig();
const effectiveTimeout = timeoutMs ?? config.COGENT_TIMEOUT_MS;
// Apply character limit: 0 means no truncation
const truncated = config.COGENT_CHAR_LIMIT > 0 &&
message.length > config.COGENT_CHAR_LIMIT
? message.slice(0, config.COGENT_CHAR_LIMIT) + "\n...[truncated]"
: message;
const args = [
"--resume", sessionId,
"--dangerously-skip-permissions",
"--no-session-persistence",
"--strict-mcp-config",
// Slice 3 (Story 8.4): capture a STRUCTURED reply envelope instead of
// scraping raw stdout. `claude -p --output-format json` emits
// {type:"result",subtype:"success",is_error,result:"<final text>",…};
// `.result` is the final-assistant message. Verified to work with
// `--resume` (execClaude's exact flags). NOTE: since this flag is now
// always passed, a claude that HONORS it always emits the envelope (so the
// parse path runs); the raw-stdout fallback in the close handler is for a
// claude too old to know the flag (which prints plain text) — an envelope
// with a non-usable result is treated as a capture failure, never relayed
// as a raw JSON blob. Flag drift is guarded by claude-preflight.ts.
"--output-format", "json",
"-p", truncated,
];
// Strip Claude Code env vars so the subprocess doesn't think it's running
// inside a parent Claude session (which hangs it) — but PRESERVE
// CLAUDE_CONFIG_DIR so a relocated config dir is honored when the child
// resumes the session (buildResumeEnv, agent-config.ts).
const cleanEnv = buildResumeEnv(process.env, "CLAUDE", ["CLAUDE_CONFIG_DIR"]);
// Point the child at the root that ACTUALLY holds this session — but ONLY when
// that root is not the one claude would pick on its own. buildResumeEnv is a pure
// filter (it can only preserve a value this process already had), so without this
// a relocated-config user resumes against ~/.claude and fails.
//
// 🔴 WHY THE "DIFFERS FROM DEFAULT" GUARD IS NOT OPTIONAL (regression, 3.21.4).
// Setting CLAUDE_CONFIG_DIR is NOT equivalent to leaving it unset, even when the
// value is exactly the default path. On macOS the CLI keeps credentials in the
// Keychain when the variable is absent; an explicit value can send it to a
// file-based store that holds none — the resume then dies with an API error:
// exit 1, EMPTY stderr, a JSON envelope on stdout, in ~2s. 3.21.4 injected the
// value unconditionally and took auto-wake down for every default-config macOS
// user, while Linux (credentials in ~/.claude/.credentials.json either way) stayed
// green and hid it from the test suite.
//
// So: say nothing when there is nothing to correct. An unset variable is the most
// compatible value there is, and the fix is only needed when the session genuinely
// lives somewhere else.
if (resolvedRoot && resolvedRoot !== path.resolve(claudeConfigDir())) {
cleanEnv.CLAUDE_CONFIG_DIR = resolvedRoot;
}
// Disable check-on-stop (feature C) inside this wake subprocess: THIS resume
// IS the answer to the incoming message, so the plugin Stop hook must not fire
// here (it would self-block the headless reply on the very message it answers).
// C only guards busy-misses in the INTERACTIVE session.
cleanEnv.COGENT_CHECK_ON_STOP = "0";
const child = spawn(config.COGENT_CLAUDE_PATH, args, {
cwd,
env: cleanEnv,
// stdin ignored: prevents child from blocking on stdin read.
// stdout/stderr piped: we capture the output.
stdio: ["ignore", "pipe", "pipe"],
});
let stdout = "";
let stderr = "";
let killed = false;
// Guard so the timeout path and the close/error events cannot double-resolve.
let resolved = false;
let kill9 = null;
const safeResolve = (result) => {
if (resolved)
return;
resolved = true;
resolve(result);
};
// Enforce timeout manually since spawn has no timeout option. Resolve the
// promise DIRECTLY here (not only via the later "close" event): if the
// child ignores SIGTERM, "close" never fires and the caller would hang
// forever. Escalate to SIGKILL after a short grace so we don't leak an
// orphaned process. (Mirrors codex-cli.ts's direct-resolve-on-timeout.)
const timer = setTimeout(() => {
killed = true;
child.kill("SIGTERM");
kill9 = setTimeout(() => {
try {
child.kill("SIGKILL");
}
catch { /* already gone */ }
}, 2000);
kill9.unref?.();
safeResolve({
stdout: stdout || "",
stderr: `CLI_TIMEOUT: CLI subprocess timed out after ${effectiveTimeout}ms`,
exitCode: null,
});
}, effectiveTimeout);
child.stdout.on("data", (chunk) => {
stdout += chunk.toString();
});
child.stderr.on("data", (chunk) => {
stderr += chunk.toString();
});
child.on("error", (err) => {
clearTimeout(timer);
if (kill9)
clearTimeout(kill9);
if (err.code === "ENOENT") {
safeResolve({
stdout: "",
stderr: `CLI_NOT_FOUND: '${config.COGENT_CLAUDE_PATH}' not found. Install Claude Code or set COGENT_CLAUDE_PATH.`,
exitCode: 127,
});
return;
}
safeResolve({
stdout: stdout || "",
stderr: `CLI_EXEC_FAILED: spawn error: ${err.message}`,
exitCode: 1,
});
});
child.on("close", (code) => {
clearTimeout(timer);
if (kill9)
clearTimeout(kill9);
if (killed) {
// Timeout already settled the promise; this is a no-op that just lets
// the child fully clean up after SIGTERM/SIGKILL.
safeResolve({
stdout: stdout || "",
stderr: `CLI_TIMEOUT: CLI subprocess timed out after ${effectiveTimeout}ms`,
exitCode: null,
});
return;
}
if (code !== 0) {
safeResolve({
stdout: stdout || "",
stderr: `CLI_EXEC_FAILED: claude exited with code ${code}. stderr: ${stderr}`,
exitCode: code,
});
return;
}
// Slice 3: prefer the structured envelope.
// - envelope + usable string result → event_stream capture; `stable` is
// true only when the run also reported success (an error subtype still
// yields the text but is flagged unstable → visible downgrade upstream).
// - envelope + no usable text (e.g. an error subtype with a null result)
// → a genuine capture failure (exit 1), NOT a raw JSON blob relayed.
// - NOT a result envelope at all (an older claude without
// `--output-format json`, which prints plain text) → raw-stdout
// fallback (stdout_compat), flagged so the trace shows the downgrade.
const parsed = parseClaudeJsonReply(stdout);
if (parsed && parsed.result !== null) {
safeResolve({
stdout: parsed.result,
stderr: parsed.isError
? stderr || "STRUCTURED_ERROR: claude reported is_error in the result envelope"
: stderr || "",
exitCode: 0,
captureContract: "event_stream",
stable: !parsed.isError,
});
return;
}
if (parsed) {
// result envelope but no usable string result → capture failure.
safeResolve({
stdout: "",
stderr: stderr || "STRUCTURED_ERROR: claude result envelope carried no text result",
exitCode: 1,
captureContract: "event_stream",
stable: false,
});
return;
}
safeResolve({
stdout: stdout || "",
stderr: stderr || "",
exitCode: 0,
captureContract: "stdout_compat",
stable: false,
});
});
});
}
/**
* Parse a Claude Code `--output-format json` result envelope. Returns the final
* assistant message (`.result`, or `null` when absent/non-string) plus whether
* the run reported an error — or `null` ONLY when the text is not a
* `type:"result"` envelope at all (→ caller uses the raw-stdout fallback).
* Slice 3 (Story 8.4).
*/
export function parseClaudeJsonReply(raw) {
const trimmed = raw.trim();
if (!trimmed)
return null;
let parsed;
try {
parsed = JSON.parse(trimmed);
}
catch {
return null; // not JSON → caller falls back to raw stdout
}
// 🔴 TWO SHAPES, BOTH REAL. Older claude prints ONE result envelope object; Claude Code 2.1.258
// prints a JSON ARRAY of the whole event stream with the result envelope LAST. Parsing only the
// object form made the array fall through to the raw-stdout path, so a peer received the entire
// ~20KB event stream — thinking signatures, usage accounting and all — instead of the single line
// the agent said. Seen live on 2026-09-02: a healthy wake answered "41 x 27 = 1107" and the
// sender got a JSON blob with that answer buried in it. Take the LAST result element, because the
// stream can carry more than one and the final one is the turn's outcome.
const env = Array.isArray(parsed)
? ([...parsed].reverse().find((e) => e?.type === "result"))
: parsed;
if (!env || env.type !== "result")
return null;
const result = typeof env.result === "string" ? env.result : null;
return { result, isError: env.is_error === true || env.subtype !== "success" };
}
//# sourceMappingURL=cc-cli.js.map