UNPKG

relay-companion

Version:

Install Relay for Claude Code, Cowork, and Codex, then sign in from the Relay pill.

257 lines (240 loc) • 10.4 kB
// `relay channel` — EXPERIMENTAL Claude Code channel server. // // This is the real fix for "Open in Current Chat must wake an idle chat". // Hook-file injection (claude-inject.cjs) can only deliver when the session // takes a turn on its own; a channel event is the platform's supported way to // PUSH into a running session — an idle session takes a turn on it (docs: // channels-reference, notifications/claude/channel). Sessions opt in at start: // claude --channels server:relay (registered in .mcp.json), or // claude --dangerously-load-development-channels server:relay (dev) // Whether Claude Code Desktop threads can enable channels is still an open // platform question; terminal sessions work today. // // Protocol with the pill/daemon (filesystem, same discipline as claude-inject): // RELAY_HOME/channel-queue/<name>.json — one event per file: // { content: string, meta?: object, createdAt: ISO } // Consume-once via rename-first, so N live channel instances deliver an // event exactly once fleet-wide (first rename wins; losers stay silent). // RELAY_HOME/channel-instances/<pid>.json — presence heartbeat so the pill // knows a wake-capable session exists before preferring this path. // // The event content is an INSTRUCTION built by the same sanitized builders as // hook injection (relay body is never inlined; the model fetches via MCP tools). import fs from "node:fs"; import path from "node:path"; import { Server } from "@modelcontextprotocol/sdk/server/index.js"; import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js"; import { storeDir } from "./host-paths.js"; const QUEUE_DIR = "channel-queue"; const PRESENCE_DIR = "channel-instances"; const POLL_MS = 700; // fs.watch is unreliable across atomic renames; poll cheaply const PRESENCE_MS = 15_000; export function channelQueueDir(homeDir = storeDir()) { return path.join(homeDir, QUEUE_DIR); } export function channelPresenceDir(homeDir = storeDir()) { return path.join(homeDir, PRESENCE_DIR); } // Drop one event for whichever live channel session claims it first. export function enqueueChannelEvent(homeDir, { content, meta = {}, targetCliPid = null } = {}) { const text = String(content || "").trim(); if (!text) throw new Error("enqueueChannelEvent requires content"); const dir = channelQueueDir(homeDir); fs.mkdirSync(dir, { recursive: true, mode: 0o700 }); const file = path.join(dir, `evt-${Date.now()}-${process.pid}-${Math.floor(Math.random() * 1e6)}.json`); const tmp = `${file}.tmp`; // targetCliPid addresses ONE chat: only the pump running inside that CLI // process claims it. Null means "any live session" (the broadcast case). fs.writeFileSync( tmp, `${JSON.stringify({ content: text, meta, targetCliPid: Number(targetCliPid) || null, createdAt: new Date().toISOString() })}\n`, { mode: 0o600 }, ); fs.renameSync(tmp, file); return file; } // How many channel instances heartbeated recently. The pill prefers the wake // path only when EXACTLY ONE live instance exists: queue events are claimed by // whichever instance renames first, so with several live sessions the event // could wake a different chat than the one the user is looking at — hook // staging (session-targeted) stays the correct route until per-session // channel targeting exists. export function countLiveChannelInstances(homeDir = storeDir(), { maxAgeMs = PRESENCE_MS * 3, nowMs = Date.now() } = {}) { let entries = []; try { entries = fs.readdirSync(channelPresenceDir(homeDir)); } catch { return 0; } let live = 0; for (const name of entries) { if (!name.endsWith(".json")) continue; try { const row = JSON.parse(fs.readFileSync(path.join(channelPresenceDir(homeDir), name), "utf8")); if (nowMs - (Number(row.heartbeatAt) || 0) <= maxAgeMs) live += 1; } catch {} } return live; } // True when at least one channel instance heartbeated recently — the pill uses // this to decide whether a wake-capable session exists at all. export function channelInstanceAlive(homeDir = storeDir(), opts = {}) { return countLiveChannelInstances(homeDir, opts) > 0; } // The single live channel instance matching a session's working directory, or // null. "Current chat" means the chat the user is looking at: the wake tier // must fire only when the channel session IS the resolved target, and the only // join available today is cwd (channel presence records its process cwd; hook // rendezvous records the session's). One instance per cwd; ambiguity -> null. export function liveChannelInstanceForCwd(homeDir = storeDir(), cwd, { maxAgeMs = PRESENCE_MS * 3, nowMs = Date.now() } = {}) { const wanted = String(cwd || "").replace(/\/+$/, ""); if (!wanted) return null; let entries = []; try { entries = fs.readdirSync(channelPresenceDir(homeDir)); } catch { return null; } const matches = []; for (const name of entries) { if (!name.endsWith(".json")) continue; try { const row = JSON.parse(fs.readFileSync(path.join(channelPresenceDir(homeDir), name), "utf8")); if (nowMs - (Number(row.heartbeatAt) || 0) > maxAgeMs) continue; if (String(row.cwd || "").replace(/\/+$/, "") === wanted) matches.push(row); } catch {} } return matches.length === 1 ? matches[0] : null; } function consumeEventFile(file) { const claimed = `${file}.${process.pid}.claimed`; try { fs.renameSync(file, claimed); // exactly-once across instances } catch { return null; } let payload = null; try { payload = JSON.parse(fs.readFileSync(claimed, "utf8")); } catch { payload = null; } try { fs.rmSync(claimed, { force: true }); } catch {} return payload && typeof payload === "object" ? payload : null; } // Claim every queued event for this process, oldest first. Consume-once via // rename, so with several live sessions each event wakes exactly one of them // instead of every one — the same discipline the hook injections use. // A pump announces itself by cliPid so the pill can tell, before staging // anything, whether THIS chat can actually be woken. Without a live beacon the // pill must fall back to hook injection (which only lands on the chat's next // turn) rather than promising a wake that will never come. export function writeChannelBeacon(homeDir = storeDir(), cliPid, { nowMs = Date.now() } = {}) { const pid = Number(cliPid) || 0; if (!pid) return null; const dir = channelPresenceDir(homeDir); try { fs.mkdirSync(dir, { recursive: true, mode: 0o700 }); const file = path.join(dir, `cli-${pid}.json`); fs.writeFileSync(file, `${JSON.stringify({ cliPid: pid, pid: process.pid, heartbeatAt: nowMs })}\n`, { mode: 0o600 }); return file; } catch { return null; } } /** Can a channel wake reach the chat owned by `cliPid` right now? */ export function channelWakeAvailable(homeDir = storeDir(), cliPid, { maxAgeMs = 45_000, nowMs = Date.now() } = {}) { const pid = Number(cliPid) || 0; if (!pid) return false; try { const row = JSON.parse(fs.readFileSync(path.join(channelPresenceDir(homeDir), `cli-${pid}.json`), "utf8")); return nowMs - (Number(row.heartbeatAt) || 0) <= maxAgeMs; } catch { return false; } } export function drainChannelEvents(homeDir = storeDir(), { cliPid = null } = {}) { let names = []; try { names = fs.readdirSync(channelQueueDir(homeDir)).filter((n) => n.startsWith("evt-") && n.endsWith(".json")); } catch { return []; } names.sort(); const claimed = []; for (const name of names) { const file = path.join(channelQueueDir(homeDir), name); // Peek before claiming: an event addressed to a DIFFERENT chat must be left // for that chat's pump. Claiming-then-requeueing would race. let addressed = null; try { addressed = Number(JSON.parse(fs.readFileSync(file, "utf8")).targetCliPid) || null; } catch { addressed = null; } if (addressed && cliPid && addressed !== cliPid) continue; const payload = consumeEventFile(file); if (payload) claimed.push(payload); } return claimed; } export async function runChannelServer({ homeDir = storeDir(), log = (m) => console.error(`[relay-channel] ${m}`) } = {}) { const server = new Server( { name: "relay-channel", version: "0.1.0" }, // The experimental channel capability is what lets notifications push into // the session; no tools are exposed (the relay MCP server already serves // tools — this process exists ONLY to wake the session with events). { capabilities: { experimental: { "claude/channel": {} } } }, ); const transport = new StdioServerTransport(); await server.connect(transport); log(`connected; watching ${channelQueueDir(homeDir)}`); const presencePath = path.join(channelPresenceDir(homeDir), `${process.pid}.json`); const heartbeat = () => { try { fs.mkdirSync(channelPresenceDir(homeDir), { recursive: true, mode: 0o700 }); fs.writeFileSync(presencePath, `${JSON.stringify({ pid: process.pid, cwd: process.cwd(), heartbeatAt: Date.now() })}\n`); } catch {} }; heartbeat(); const presenceTimer = setInterval(heartbeat, PRESENCE_MS); if (presenceTimer.unref) presenceTimer.unref(); const pump = async () => { let names = []; try { names = fs.readdirSync(channelQueueDir(homeDir)).filter((n) => n.startsWith("evt-") && n.endsWith(".json")); } catch { return; } names.sort(); // oldest first (timestamp-prefixed names) for (const name of names) { const payload = consumeEventFile(path.join(channelQueueDir(homeDir), name)); if (!payload) continue; try { await server.notification({ method: "notifications/claude/channel", params: { content: String(payload.content || ""), meta: payload.meta || {} }, }); log(`pushed event ${name}`); } catch (error) { log(`push failed for ${name}: ${error && error.message}`); } } }; const pumpTimer = setInterval(() => void pump(), POLL_MS); if (pumpTimer.unref) pumpTimer.unref(); void pump(); const cleanup = () => { try { fs.rmSync(presencePath, { force: true }); } catch {} }; process.on("exit", cleanup); process.on("SIGTERM", () => { cleanup(); process.exit(0); }); return { server, pump }; }