UNPKG

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