relay-companion
Version:
Install Relay for Claude Code, Cowork, and Codex, then sign in from the Relay pill.
494 lines (460 loc) • 19.5 kB
JavaScript
// Open-in-current-chat plumbing for the Codex (ChatGPT desktop) host.
//
// The Claude host stages an injection file that a settings hook consumes from
// inside the live session (src/claude-inject.cjs). Codex has no hook runtime,
// but it has two native levers this module feeds:
//
// 1. The CDP/inspector bridge (src/codex-desktop.js) can resume the user's
// current thread in the primary ChatGPT window and start a real turn in it
// ("start-turn-for-host"). That is the live tier.
// 2. The app's own automations: ~/.codex/automations/<id>/automation.toml
// with kind="heartbeat" + target_thread_id injects a prompt into an
// existing thread when its RRULE fires — proven native behavior. Writing a
// near-now, COUNT=1 heartbeat is the fallback tier when the bridge is
// unavailable or the submit fails.
//
// There is no codex-side rendezvous: the rollout files under
// ~/.codex/sessions/YYYY/MM/DD/rollout-*.jsonl ARE the registry. The newest
// active rollout (recency = max(session_index.jsonl updated_at, rollout mtime),
// within 6h) is the user's "current chat"; its tail says whether a turn is open
// (event_msg task_started with no later task_complete / turn_aborted).
//
// The injection instruction is the SAME builder as the Claude path
// (claude-inject.cjs buildInjectionInstruction): the relay body is never
// inlined — the model fetches it via the Relay MCP tools (installed into
// ~/.codex/config.toml as [mcp_servers.relay]), preserving the
// untrusted-content discipline in relay-briefing.js.
import fs from "node:fs";
import path from "node:path";
import claudeInject from "./claude-inject.cjs";
import { codexHome } from "./host-paths.js";
const { buildInjectionInstruction } = claudeInject;
export const THREAD_MAX_AGE_MS = 6 * 60 * 60 * 1000; // a "current" thread was active within 6h
export const OPEN_AUTOMATION_PREFIX = "relay-open-";
export const OPEN_AUTOMATION_MAX_AGE_MS = 60 * 60 * 1000; // self-clean automations older than 1h
// Exclusive-next-occurrence hazard: the app computes nextRunAt for a NEW
// automation file on its next directory scan via rrule .after(now). If the
// single COUNT=1 occurrence has already passed by that first scan, the
// automation silently never fires. 90s (rounded up to a whole minute) gives the
// scheduler comfortable room; RELAY_CODEX_AUTOMATION_DELAY_MS overrides.
export const DEFAULT_OPEN_AUTOMATION_DELAY_MS = 90 * 1000;
// A single Codex turn can append hundreds of megabytes of tool output. In that
// case the opening task_started row falls outside our bounded tail read even
// though the rollout is still receiving token/progress records. Treat a tail
// with no lifecycle marker as active while the native file is still moving.
// A real task_complete/turn_aborted in the tail always wins immediately.
export const RECENT_ROLLOUT_ACTIVITY_MS = 90 * 1000;
const ROLLOUT_TAIL_BYTES = 256 * 1024;
const ROLLOUT_HEAD_BYTES = 96 * 1024;
const INDEX_TAIL_BYTES = 256 * 1024;
const ROLLOUT_FILE_RE = /^rollout-.*-([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12})\.jsonl$/;
export { buildInjectionInstruction };
function defaultCodexHome() {
return codexHome();
}
// ---- rollout parsing --------------------------------------------------------
function readFileSlice(filePath, { fromEnd = null, fromStart = null }) {
let fd = null;
try {
fd = fs.openSync(filePath, "r");
const size = fs.fstatSync(fd).size;
const length = Math.min(size, fromEnd ?? fromStart ?? size);
if (length <= 0) return { text: "", truncatedHead: false };
const position = fromEnd != null ? size - length : 0;
const buffer = Buffer.alloc(length);
fs.readSync(fd, buffer, 0, length, position);
return { text: buffer.toString("utf8"), truncatedHead: fromEnd != null && size > length };
} catch {
return { text: "", truncatedHead: false };
} finally {
if (fd != null) {
try {
fs.closeSync(fd);
} catch {}
}
}
}
function parseJsonLines(text, { dropFirst = false } = {}) {
const lines = String(text || "").split("\n");
if (dropFirst && lines.length) lines.shift(); // partial first line of a tail read
const rows = [];
for (const line of lines) {
const clean = line.trim();
if (!clean) continue;
try {
rows.push(JSON.parse(clean));
} catch {
// malformed line (mid-write, corruption): carries no signal, skip it
}
}
return rows;
}
// Busy/idle + open turn id from a rollout tail. A turn is OPEN after an
// event_msg task_started with no later task_complete / turn_aborted for the
// same turn. Close events are matched by turn_id when both sides carry one:
// mis-reading "busy" as "idle" would double-submit into a running turn, so an
// unmatched close (different turn_id than the open turn) keeps the turn open.
export function parseRolloutActivity(text, { truncatedHead = false } = {}) {
const rows = parseJsonLines(text, { dropFirst: truncatedHead });
let open = null;
let lastEventAt = 0;
let lifecycleObserved = false;
for (const row of rows) {
if (!row || typeof row !== "object") continue;
const at = Date.parse(row.timestamp);
if (Number.isFinite(at)) lastEventAt = Math.max(lastEventAt, at);
if (row.type !== "event_msg" || !row.payload || typeof row.payload !== "object") continue;
const kind = row.payload.type;
const turnId = typeof row.payload.turn_id === "string" ? row.payload.turn_id : null;
if (kind === "task_started") {
lifecycleObserved = true;
open = { turnId };
} else if (kind === "task_complete" || kind === "turn_aborted") {
lifecycleObserved = true;
if (!open || !turnId || !open.turnId || open.turnId === turnId) open = null;
}
}
return {
busy: Boolean(open),
openTurnId: open ? open.turnId : null,
lastEventAt: lastEventAt || null,
lifecycleObserved,
};
}
export function readRolloutActivity(sessionPath, { tailBytes = ROLLOUT_TAIL_BYTES } = {}) {
const { text, truncatedHead } = readFileSlice(sessionPath, { fromEnd: tailBytes });
return parseRolloutActivity(text, { truncatedHead });
}
// First session_meta line of a rollout: cwd + whether this is a subagent
// rollout (subagent forks share the sessions dir but are not user-facing
// "current chats").
export function readRolloutMeta(sessionPath, { headBytes = ROLLOUT_HEAD_BYTES } = {}) {
const { text } = readFileSlice(sessionPath, { fromStart: headBytes });
for (const row of parseJsonLines(text)) {
if (!row || row.type !== "session_meta" || !row.payload || typeof row.payload !== "object") continue;
const payload = row.payload;
return {
cwd: typeof payload.cwd === "string" ? payload.cwd : "",
subagent: payload.thread_source === "subagent" || Boolean(payload.source && payload.source.subagent),
};
}
return null;
}
// ---- current-thread resolution ---------------------------------------------
// ~/.codex/session_index.jsonl is append-only ({id, thread_name, updated_at});
// re-appends supersede. Read the tail and keep the newest row per id.
export function parseSessionIndex(text) {
const byId = new Map();
for (const row of parseJsonLines(text, { dropFirst: false })) {
if (!row || typeof row !== "object" || typeof row.id !== "string" || !row.id) continue;
const updatedAtMs = Date.parse(row.updated_at) || 0;
const existing = byId.get(row.id);
if (!existing || updatedAtMs >= existing.updatedAtMs) {
byId.set(row.id, { threadName: typeof row.thread_name === "string" ? row.thread_name : "", updatedAtMs });
}
}
return byId;
}
function readSessionIndex(homeDir) {
const { text, truncatedHead } = readFileSlice(path.join(homeDir, "session_index.jsonl"), { fromEnd: INDEX_TAIL_BYTES });
return parseSessionIndex(truncatedHead ? text.slice(text.indexOf("\n") + 1) : text);
}
function dateDirParts(date) {
const pad = (value) => String(value).padStart(2, "0");
return [
// Rollout day-directories are named from local wall-clock time, but clock
// and zone drift make that untrustworthy: cover local AND UTC calendars.
`${date.getFullYear()}/${pad(date.getMonth() + 1)}/${pad(date.getDate())}`,
`${date.getUTCFullYear()}/${pad(date.getUTCMonth() + 1)}/${pad(date.getUTCDate())}`,
];
}
// Newest rollout file per thread id across the last few day-directories. mtime
// is the liveness signal: every rollout event touches the file.
export function listRecentRollouts(sessionsRoot, { nowMs = Date.now(), dayLookback = 3 } = {}) {
const dirs = new Set();
for (let back = 0; back < dayLookback; back += 1) {
for (const part of dateDirParts(new Date(nowMs - back * 24 * 60 * 60 * 1000))) {
dirs.add(path.join(sessionsRoot, part));
}
}
const byId = new Map();
for (const dir of dirs) {
let names = [];
try {
names = fs.readdirSync(dir);
} catch {
continue;
}
for (const name of names) {
const match = ROLLOUT_FILE_RE.exec(name);
if (!match) continue;
const filePath = path.join(dir, name);
let mtimeMs = 0;
try {
mtimeMs = fs.statSync(filePath).mtimeMs;
} catch {
continue;
}
const threadId = match[1];
const existing = byId.get(threadId);
if (!existing || mtimeMs > existing.mtimeMs) byId.set(threadId, { threadId, sessionPath: filePath, mtimeMs });
}
}
return byId;
}
// The user's CURRENT Codex thread: the most recently ACTIVE rollout (recency =
// max(session_index updated_at, rollout mtime)) within maxAgeMs whose rollout
// exists and is not a subagent fork. Returns
// { threadId, sessionPath, cwd, threadName, lastActiveAt, busy, openTurnId }
// or null. Mirrors claude-inject.findCurrentClaudeSession.
export function resolveCurrentCodexThread({
homeDir = defaultCodexHome(),
nowMs = Date.now(),
maxAgeMs = THREAD_MAX_AGE_MS,
} = {}) {
const index = readSessionIndex(homeDir);
const rollouts = listRecentRollouts(path.join(homeDir, "sessions"), { nowMs });
const candidates = [];
for (const rollout of rollouts.values()) {
const indexed = index.get(rollout.threadId);
const lastActiveAt = Math.max(rollout.mtimeMs, indexed ? indexed.updatedAtMs : 0);
if (!(lastActiveAt > 0) || nowMs - lastActiveAt > maxAgeMs) continue;
candidates.push({ ...rollout, threadName: indexed ? indexed.threadName : "", lastActiveAt });
}
candidates.sort((a, b) => b.lastActiveAt - a.lastActiveAt);
for (const candidate of candidates) {
const meta = readRolloutMeta(candidate.sessionPath);
if (!meta || meta.subagent) continue;
const activity = readRolloutActivity(candidate.sessionPath);
return {
threadId: candidate.threadId,
sessionPath: candidate.sessionPath,
cwd: meta.cwd,
threadName: candidate.threadName,
lastActiveAt: candidate.lastActiveAt,
busy: activity.busy,
openTurnId: activity.openTurnId,
};
}
return null;
}
// Poll the rollout tail until the open turn closes (task_complete /
// turn_aborted) or the deadline passes. The conservative busy path queues the
// submit behind idle instead of interrupting the user's running turn.
export async function waitForCodexIdle(sessionPath, { timeoutMs = 30000, pollMs = 1000 } = {}) {
const startedAt = Date.now();
for (;;) {
const activity = readRolloutActivity(sessionPath);
if (!activity.busy) return { idle: true, waitedMs: Date.now() - startedAt };
if (Date.now() - startedAt >= timeoutMs) return { idle: false, waitedMs: Date.now() - startedAt };
await new Promise((resolve) => setTimeout(resolve, pollMs));
}
}
// Post-submit verification for the bridge tier: a landed turn appends events
// to the rollout within moments. Baseline the size before the submit, then
// poll for growth. No growth inside the window means the submit cannot be
// CONFIRMED — the caller treats it as failed and falls back.
export function rolloutSize(sessionPath) {
try {
return fs.statSync(sessionPath).size;
} catch {
return -1;
}
}
export async function waitForRolloutGrowth(sessionPath, baselineSize, { timeoutMs = 8000, pollMs = 500 } = {}) {
const startedAt = Date.now();
for (;;) {
const size = rolloutSize(sessionPath);
if (size > baselineSize) return { grew: true, size, waitedMs: Date.now() - startedAt };
if (Date.now() - startedAt >= timeoutMs) return { grew: false, size, waitedMs: Date.now() - startedAt };
await new Promise((resolve) => setTimeout(resolve, pollMs));
}
}
// ---- near-now heartbeat automation (fallback tier) --------------------------
function slugify(value) {
return String(value || "")
.toLowerCase()
.replace(/[^a-z0-9]+/g, "-")
.replace(/-+/g, "-")
.replace(/^-+|-+$/g, "")
.slice(0, 48)
.replace(/-+$/, "");
}
export function openAutomationId(relayId) {
const slug = slugify(relayId);
return `${OPEN_AUTOMATION_PREFIX}${slug || "relay"}`;
}
// TOML basic-string escaping, byte-for-byte the app's own automation writer
// (backslash, newline, carriage return, tab, double quote), plus a defensive
// sweep of remaining C0 controls which would make the file unparseable.
function tomlString(value) {
const escaped = String(value ?? "")
.replace(/\\/g, "\\\\")
.replace(/\n/g, "\\n")
.replace(/\r/g, "\\r")
.replace(/\t/g, "\\t")
.replace(/"/g, '\\"')
// eslint-disable-next-line no-control-regex
.replace(/[\u0000-\u001f]/g, " ");
return `"${escaped}"`;
}
function localTimeZone() {
try {
return Intl.DateTimeFormat().resolvedOptions().timeZone || "UTC";
} catch {
return "UTC";
}
}
function wallClockParts(epochMs, timeZone) {
const formatter = new Intl.DateTimeFormat("en-CA", {
timeZone,
hour12: false,
year: "numeric",
month: "2-digit",
day: "2-digit",
hour: "2-digit",
minute: "2-digit",
second: "2-digit",
});
const parts = {};
for (const part of formatter.formatToParts(new Date(epochMs))) parts[part.type] = part.value;
const hour = parts.hour === "24" ? "00" : parts.hour; // some ICU builds render midnight as 24
return {
stamp: `${parts.year}${parts.month}${parts.day}T${hour}${parts.minute}${parts.second}`,
hour: Number(hour),
minute: Number(parts.minute),
second: Number(parts.second),
};
}
// One occurrence, delayMs from now, rounded UP to a whole minute (the app's
// own automations fire on whole minutes; BYSECOND=0 matches its scheduler's
// fast paths). Returns the rrule string exactly like the app's writer stores
// it — a real newline between DTSTART and RRULE, escaped by tomlString.
export function buildNearNowHeartbeatRrule({
nowMs = Date.now(),
delayMs = DEFAULT_OPEN_AUTOMATION_DELAY_MS,
timeZone = localTimeZone(),
} = {}) {
const fireAtMs = Math.ceil((nowMs + delayMs) / 60000) * 60000;
const { stamp, hour, minute } = wallClockParts(fireAtMs, timeZone);
return {
fireAtMs,
rrule: `DTSTART;TZID=${timeZone}:${stamp}\nRRULE:FREQ=DAILY;BYHOUR=${hour};BYMINUTE=${minute};BYSECOND=0;COUNT=1`,
};
}
// Exact automation.toml shape (field order + escaping) of the app's own
// heartbeat writer. The id MUST equal the directory name or the app's loader
// rejects the file.
export function buildOpenAutomationToml({ id, name, prompt, rrule, targetThreadId, createdAt, updatedAt }) {
return [
"version = 1",
`id = ${tomlString(id)}`,
`kind = ${tomlString("heartbeat")}`,
`name = ${tomlString(name)}`,
`prompt = ${tomlString(prompt)}`,
`status = ${tomlString("ACTIVE")}`,
`rrule = ${tomlString(rrule)}`,
`target_thread_id = ${tomlString(targetThreadId)}`,
`created_at = ${createdAt}`,
`updated_at = ${updatedAt}`,
"",
].join("\n");
}
function automationsDir(homeDir) {
return path.join(homeDir, "automations");
}
function readAutomationFields(tomlPath) {
let text = "";
try {
text = fs.readFileSync(tomlPath, "utf8");
} catch {
return null;
}
const field = (name) => {
const match = new RegExp(`^${name} = "((?:[^"\\\\]|\\\\.)*)"`, "m").exec(text);
return match ? match[1] : null;
};
const createdMatch = /^created_at = (\d+)/m.exec(text);
return {
targetThreadId: field("target_thread_id"),
createdAtMs: createdMatch ? Number(createdMatch[1]) : null,
};
}
// Remove relay-open-* automation dirs older than maxAgeMs (they fired — or
// missed — long ago and a lingering ACTIVE heartbeat blocks the app from
// creating a real one on the same thread), plus any relay-open-* automation
// already targeting `supersedeThreadId` (newest click wins, mirroring
// stageInjection's overwrite semantics). Never touches non-relay dirs.
export function cleanupStaleOpenAutomations({
homeDir = defaultCodexHome(),
nowMs = Date.now(),
maxAgeMs = OPEN_AUTOMATION_MAX_AGE_MS,
supersedeThreadId = null,
} = {}) {
const dir = automationsDir(homeDir);
let entries = [];
try {
entries = fs.readdirSync(dir, { withFileTypes: true });
} catch {
return [];
}
const removed = [];
for (const entry of entries) {
if (!entry.isDirectory() || !entry.name.startsWith(OPEN_AUTOMATION_PREFIX)) continue;
const automationDir = path.join(dir, entry.name);
const fields = readAutomationFields(path.join(automationDir, "automation.toml"));
let ageMs = null;
if (fields && Number.isFinite(fields.createdAtMs)) {
ageMs = nowMs - fields.createdAtMs;
} else {
try {
ageMs = nowMs - fs.statSync(automationDir).mtimeMs;
} catch {
ageMs = Infinity; // unreadable relay-open dir carries no signal
}
}
const supersedes = Boolean(supersedeThreadId && fields && fields.targetThreadId === supersedeThreadId);
if (ageMs > maxAgeMs || supersedes) {
try {
fs.rmSync(automationDir, { recursive: true, force: true });
removed.push(entry.name);
} catch {}
}
}
return removed;
}
// Stage the fallback tier: a near-now, single-shot heartbeat automation
// targeting the resolved current thread, prompt = the shared injection
// instruction (never the relay body). Returns { id, dir, path, fireAtMs }.
export function writeOpenAutomation({
homeDir = defaultCodexHome(),
relayId,
threadId,
instruction,
nowMs = Date.now(),
delayMs = Number(process.env.RELAY_CODEX_AUTOMATION_DELAY_MS) || DEFAULT_OPEN_AUTOMATION_DELAY_MS,
timeZone = localTimeZone(),
} = {}) {
const cleanThreadId = String(threadId || "").trim();
if (!cleanThreadId) throw new Error("writeOpenAutomation requires a threadId");
const prompt = String(instruction || "").trim() || buildInjectionInstruction({ relayId });
cleanupStaleOpenAutomations({ homeDir, nowMs, supersedeThreadId: cleanThreadId });
const id = openAutomationId(relayId);
const { rrule, fireAtMs } = buildNearNowHeartbeatRrule({ nowMs, delayMs, timeZone });
const toml = buildOpenAutomationToml({
id,
name: `Relay open ${String(relayId || "").trim() || "relay"}`,
prompt,
rrule,
targetThreadId: cleanThreadId,
createdAt: nowMs,
updatedAt: nowMs,
});
const dir = path.join(automationsDir(homeDir), id);
fs.mkdirSync(dir, { recursive: true });
const filePath = path.join(dir, "automation.toml");
const tmp = path.join(dir, `.automation.toml.tmp-${process.pid}-${Date.now()}`);
fs.writeFileSync(tmp, toml, "utf8");
fs.renameSync(tmp, filePath);
return { id, dir, path: filePath, fireAtMs };
}