relay-companion
Version:
Install Relay for Claude Code, Cowork, and Codex, then sign in from the Relay pill.
596 lines (566 loc) • 22.1 kB
JavaScript
// Start Claude Code through the official standalone CLI and its supported
// subscription-login lifecycle. Relay never reads, copies, refreshes or stores
// Claude credentials: `claude auth login --claudeai` owns authentication and
// the worker below simply invokes that already-authorized CLI.
import crypto from "node:crypto";
import { spawn as spawnChild } from "node:child_process";
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import { fileURLToPath } from "node:url";
import { adoptClaudeSessionIntoDesktop } from "./claude-session-writer.js";
const liveWorkers = new Map();
const MAX_NATIVE_EVENTS = 4_096;
function recordNativeEvent(state, event) {
if (!state || !event || typeof event !== "object") return event;
state.nativeEventOffset = Number(state.nativeEventOffset || 0) + 1;
const stamped = {
...event,
timestamp: event.timestamp || new Date().toISOString(),
nativeOffset: event.nativeOffset ?? state.nativeEventOffset,
};
state.nativeEvents.push(stamped);
if (state.nativeEvents.length > MAX_NATIVE_EVENTS) {
state.nativeEvents.splice(0, state.nativeEvents.length - MAX_NATIVE_EVENTS);
}
return stamped;
}
function notifyNativeWorker(state, event) {
if (!state) return;
for (const listener of state.nativeListeners) {
try { listener(event || { type: "worker-state", timestamp: new Date().toISOString() }); } catch {}
}
}
const continuationChains = new Map();
function supportedClaudeCliBinary() {
const candidates = [
process.env.CLAUDE_CLI_PATH,
"/opt/homebrew/bin/claude",
"/usr/local/bin/claude",
path.join(os.homedir(), ".local", "bin", "claude"),
].filter(Boolean);
const binary = candidates.find((candidate) => {
try { return fs.statSync(candidate).isFile(); } catch { return false; }
});
if (binary) return binary;
// Preserve PATH installations. spawn() will return the native ENOENT if the
// command is missing, which Settings turns into a precise install message.
return "claude";
}
function transcriptPathFor(cwd, sessionId, homedir = os.homedir()) {
return path.join(homedir, ".claude", "projects", String(cwd).replace(/[^a-zA-Z0-9]/g, "-"), `${sessionId}.jsonl`);
}
function permissionArgs(permissionMode) {
const mode = String(permissionMode || "auto");
return mode === "bypassPermissions"
? ["--permission-mode", mode, "--allow-dangerously-skip-permissions"]
: ["--permission-mode", mode];
}
/**
* Reuse the trusted local Relay server registration for a Task worker. The
* server resolves developer capability from the signed-in Relay account; this
* copy also strips mode flags left behind by older registrations.
*/
export function relayTaskMcpConfig(homedir = os.homedir(), {
execPath = process.execPath,
electron = Boolean(process.versions?.electron),
binPath = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../bin/relay.js"),
env = process.env,
} = {}) {
// The live pill is an Electron process. Reusing its persistent host
// registration here can route a private Claude worker through an updater
// launcher that another runtime is concurrently refreshing. More
// importantly, process.execPath is Electron, not Node: it needs the official
// run-as-Node switch or Claude waits out its MCP deadline before dropping the
// Relay tools. A provider-owned worker should bind to the exact runtime that
// created it and must not rewrite the user's persistent registration.
if (electron) {
const childEnv = { ELECTRON_RUN_AS_NODE: "1" };
for (const key of ["RELAY_API_URL", "RELAY_DEVICE_TOKEN", "RELAY_CONFIG_DIR"]) {
if (env[key]) childEnv[key] = env[key];
}
return JSON.stringify({ mcpServers: { relay: {
type: "stdio",
command: execPath,
args: [binPath, "mcp"],
env: childEnv,
} } });
}
let config;
try {
config = JSON.parse(fs.readFileSync(path.join(homedir, ".claude.json"), "utf8"));
} catch {
return "";
}
const registered = config?.mcpServers?.relay;
if (!registered || typeof registered !== "object" || !registered.command) return "";
const args = (Array.isArray(registered.args) ? registered.args : [])
.map((arg) => String(arg))
.filter((arg) => arg !== "--messages-only" && arg !== "--full");
const relay = {
type: registered.type || "stdio",
command: String(registered.command),
args,
...(registered.env && typeof registered.env === "object" ? { env: registered.env } : {}),
};
return JSON.stringify({ mcpServers: { relay } });
}
function parseStreamLine(line) {
try {
return JSON.parse(String(line || "").trim());
} catch {
return null;
}
}
function eventText(event) {
const content = event?.message?.content ?? event?.content ?? "";
if (typeof content === "string") return content;
if (!Array.isArray(content)) return "";
return content.map((block) => {
if (typeof block === "string") return block;
if (typeof block?.text === "string") return block.text;
if (typeof block?.content === "string") return block.content;
return "";
}).filter(Boolean).join("\n");
}
/** Native Claude Code represents Bash `run_in_background` as a tool result with
* a backgroundTaskId and later emits a task-notification for that id. A result
* event between those two records ends only the model turn, not the task. */
export function claudeBackgroundTaskUpdate(event) {
const started = new Set();
const finished = new Set();
const direct = event?.toolUseResult?.backgroundTaskId
|| event?.tool_use_result?.backgroundTaskId
|| event?.tool_use_result?.background_task_id;
if (direct) started.add(String(direct));
const text = eventText(event);
const startMatch = text.match(/background with ID:\s*([a-zA-Z0-9_-]+)/i);
if (startMatch) started.add(startMatch[1]);
for (const match of text.matchAll(/<task-notification>[\s\S]*?<task-id>([^<]+)<\/task-id>[\s\S]*?<status>(completed|failed|stopped|cancelled)<\/status>[\s\S]*?<\/task-notification>/gi)) {
finished.add(String(match[1]).trim());
}
return { started: [...started], finished: [...finished] };
}
export function claudePendingBackgroundTasksFromTranscript(sessionPath) {
const pending = new Set();
let text = "";
try { text = fs.readFileSync(sessionPath, "utf8"); } catch { return pending; }
for (const line of text.split("\n")) {
const event = parseStreamLine(line);
if (!event) continue;
const update = claudeBackgroundTaskUpdate(event);
for (const id of update.started) pending.add(id);
for (const id of update.finished) pending.delete(id);
}
return pending;
}
function userMessage(sessionId, text) {
return JSON.stringify({
type: "user",
message: { role: "user", content: String(text || "") },
parent_tool_use_id: null,
session_id: sessionId,
});
}
function workerArgs({ sessionId, title, model, effort, permissionMode, resume = false, relayMcpConfig = "" }) {
return [
"--output-format", "stream-json",
"--verbose",
"--input-format", "stream-json",
"--effort", effort,
"--model", model,
"--name", title,
...permissionArgs(permissionMode),
"--include-partial-messages",
"--replay-user-messages",
"--setting-sources=user,project,local",
...(relayMcpConfig ? ["--mcp-config", relayMcpConfig] : []),
...(resume ? ["--resume", sessionId] : ["--session-id", sessionId]),
];
}
async function launchWorker({
sessionId,
cwd,
title,
prompt,
model,
effort,
permissionMode,
resume = false,
command,
spawn,
adopt,
homedir,
initTimeoutMs = 20_000,
displayPrompt = "",
}) {
const runtime = command || supportedClaudeCliBinary();
const relayMcpConfig = relayTaskMcpConfig(homedir);
const env = {
...process.env,
CLAUDE_CODE_DISABLE_CRON: "1",
CLAUDE_CODE_ENABLE_ASK_USER_QUESTION_TOOL: "true",
CLAUDE_CODE_EMIT_TOOL_USE_SUMMARIES: "false",
MCP_CONNECTION_NONBLOCKING: "true",
NODE_USE_SYSTEM_CA: "1",
};
const child = spawn(runtime, workerArgs({ sessionId, title, model, effort, permissionMode, resume, relayMcpConfig }), {
cwd,
env,
stdio: ["pipe", "pipe", "pipe"],
});
if (!child || !child.stdin || !child.stdout) {
throw new Error("Claude Code did not create its streaming worker.");
}
const sessionPath = transcriptPathFor(cwd, sessionId, homedir);
let resolveClosed;
const closed = new Promise((resolve) => { resolveClosed = resolve; });
let resolveMaterialized;
const materialized = new Promise((resolve) => { resolveMaterialized = resolve; });
const state = {
child,
sessionId,
sessionPath,
cwd,
title,
model,
effort,
permissionMode,
stderr: "",
settled: false,
pendingBackgroundTasks: new Set(),
closed: false,
closedPromise: closed,
materializing: false,
materialized: false,
materialization: null,
materializedPromise: materialized,
turnStartedAt: new Date().toISOString(),
userText: String(displayPrompt || ""),
partialAssistantText: "",
lastStreamAt: null,
nativeEvents: [],
nativeEventOffset: 0,
nativeListeners: new Set(),
};
liveWorkers.set(sessionId, state);
let stdoutBuffer = "";
let sawInit = false;
let sawResult = false;
let resolveInit;
let rejectInit;
const initialized = new Promise((resolve, reject) => { resolveInit = resolve; rejectInit = reject; });
const timer = setTimeout(() => {
if (!sawInit) {
// A timed-out worker must not stay registered and later collide with a
// Retry. Close its input and terminate it; the close handler owns final
// cleanup. This is the old "Claude Code crashed" zombie path.
try { child.stdin?.end?.(); } catch {}
try { child.kill?.("SIGTERM"); } catch {}
rejectInit(new Error("Claude Code did not initialize its native session."));
}
}, initTimeoutMs);
// launchWorker is actively awaiting this deadline. Unref'ing it lets Node
// exit (and cancel the pending test/request) when the child is a silent
// worker with no other referenced handles — exactly the initialization
// failure this timer is responsible for resolving.
child.stderr?.setEncoding?.("utf8");
child.stderr?.on?.("data", (chunk) => { state.stderr = `${state.stderr}${chunk}`.slice(-16_000); });
child.stdout.setEncoding?.("utf8");
child.stdout.on("data", (chunk) => {
stdoutBuffer += String(chunk || "");
const lines = stdoutBuffer.split("\n");
stdoutBuffer = lines.pop() || "";
for (const line of lines) {
const event = parseStreamLine(line);
if (!event) continue;
state.lastStreamAt = new Date().toISOString();
const nativeEvent = recordNativeEvent(state, event);
if (event.type === "stream_event") {
const stream = event.event || {};
if (stream.type === "message_start") state.partialAssistantText = "";
if (stream.type === "content_block_start" && stream.content_block?.type === "text") {
state.partialAssistantText += String(stream.content_block.text || "");
}
if (stream.type === "content_block_delta" && stream.delta?.type === "text_delta") {
state.partialAssistantText += String(stream.delta.text || "");
}
}
if (event.type === "assistant" && Array.isArray(event.message?.content)) {
const text = event.message.content
.filter((block) => block?.type === "text")
.map((block) => String(block.text || ""))
.join("\n");
if (text) state.partialAssistantText = text;
}
if (!sawInit && (event.type === "system" || event.session_id || event.sessionId)) {
sawInit = true;
clearTimeout(timer);
resolveInit(event);
}
const background = claudeBackgroundTaskUpdate(event);
for (const id of background.started) state.pendingBackgroundTasks.add(id);
for (const id of background.finished) state.pendingBackgroundTasks.delete(id);
if (event.type === "result") {
sawResult = true;
// Native task notifications are persisted as queue-operation records
// but are not always replayed on stream-json stdout. Rebuild from the
// canonical transcript before deciding whether this result is final.
// The canonical transcript is authoritative once it exists. During
// the first few streamed records it may not have been flushed yet;
// never replace already-observed stdout state with an empty read of a
// file that does not exist.
if (fs.existsSync(sessionPath)) {
state.pendingBackgroundTasks = claudePendingBackgroundTasksFromTranscript(sessionPath);
}
state.settled = state.pendingBackgroundTasks.size === 0;
// A result closes one model turn, not a native background task. Keep
// the streaming worker (and therefore its child process supervisor)
// alive until Claude receives the terminal task-notification and
// produces the real final turn. Closing here is what truncated the
// 1..15 proof at 12 and leaked a `status=stopped` notification.
if (state.settled) setTimeout(() => child.stdin?.end?.(), 50).unref?.();
}
notifyNativeWorker(state, nativeEvent);
}
});
child.once("error", (error) => {
clearTimeout(timer);
const nativeEvent = recordNativeEvent(state, {
type: "result",
subtype: "error_during_execution",
is_error: true,
error: error?.message || String(error),
});
notifyNativeWorker(state, nativeEvent);
if (!sawInit) rejectInit(error);
});
child.once("close", async (code) => {
clearTimeout(timer);
state.closed = true;
const nativeEvent = recordNativeEvent(state, { type: "worker-state", state: "closed", code });
notifyNativeWorker(state, nativeEvent);
resolveClosed({ code });
if (!sawInit && code !== 0) {
rejectInit(new Error(`Claude Code launch failed: ${state.stderr.trim() || `exit ${code}`}`));
resolveMaterialized({ attempted: false, reason: "launch-failed", code });
if (liveWorkers.get(sessionId) === state) liveWorkers.delete(sessionId);
return;
}
if (!sawResult || code !== 0) {
resolveMaterialized({ attempted: false, reason: !sawResult ? "no-result" : "worker-failed", code });
if (liveWorkers.get(sessionId) === state) liveWorkers.delete(sessionId);
return;
}
state.materializing = true;
try {
// Materialize only after Relay's external streaming worker has exited.
// Importing here fires claude://resume and silently starts a competing
// Desktop worker. The UI performs that import only on an explicit Open.
state.materialization = await adopt({
sessionId,
title,
cwd,
model,
effort,
sessionPath,
importIntoDesktop: false,
});
state.materialized = state.materialization?.attempted === true;
resolveMaterialized(state.materialization);
} catch (error) {
state.materialization = { attempted: false, reason: "materialization-failed", error: error?.message || String(error) };
resolveMaterialized(state.materialization);
} finally {
state.materializing = false;
if (liveWorkers.get(sessionId) === state) liveWorkers.delete(sessionId);
}
});
child.stdin.write(`${userMessage(sessionId, prompt)}\n`);
await initialized;
return state;
}
export async function createClaudeDesktopCodeSession({
cwd,
title,
content,
model = "claude-opus-5",
effort = "high",
permissionMode = "auto",
command = "",
spawn = spawnChild,
adopt = adoptClaudeSessionIntoDesktop,
homedir = os.homedir(),
sessionId = crypto.randomUUID(),
initTimeoutMs,
} = {}) {
const resolvedCwd = path.resolve(String(cwd || homedir));
const resolvedTitle = String(title || "Relay Task");
const prompt = [resolvedTitle, String(content || "")].filter(Boolean).join("\n\n");
const worker = await launchWorker({
sessionId,
cwd: resolvedCwd,
title: resolvedTitle,
prompt,
model,
effort,
permissionMode,
command,
spawn,
adopt,
homedir,
initTimeoutMs,
displayPrompt: String(content || ""),
});
return {
sessionId,
desktopSessionId: `local_${sessionId}`,
sessionPath: worker.sessionPath,
cwd: resolvedCwd,
title: resolvedTitle,
model,
effort,
permissionMode,
};
}
async function waitForRetiringWorker(worker, timeoutMs = 5_000) {
if (!worker) return;
if (worker.closed) {
await waitForWorkerMaterialization(worker, timeoutMs);
return;
}
let timeout;
const expired = new Promise((resolve) => {
timeout = setTimeout(() => resolve(false), timeoutMs);
timeout.unref?.();
});
const closed = await Promise.race([worker.closedPromise.then(() => true), expired]);
clearTimeout(timeout);
if (closed) {
await waitForWorkerMaterialization(worker, timeoutMs);
return;
}
try { worker.child?.stdin?.end?.(); } catch {}
try { worker.child?.kill?.("SIGTERM"); } catch {}
throw new Error("Claude Code's previous turn did not close cleanly. Try the follow-up again.");
}
async function waitForWorkerMaterialization(worker, timeoutMs = 30_000) {
if (!worker?.materializedPromise) return null;
let timeout;
const expired = new Promise((_, reject) => {
timeout = setTimeout(() => reject(new Error("Claude Code session materialization timed out.")), timeoutMs);
timeout.unref?.();
});
try {
return await Promise.race([worker.materializedPromise, expired]);
} finally {
clearTimeout(timeout);
}
}
async function continueClaudeDesktopCodeSessionOnce({
sessionId,
cwd,
title = "Relay Task",
content,
model = "claude-opus-5",
effort = "high",
permissionMode = "auto",
command = "",
spawn = spawnChild,
adopt = adoptClaudeSessionIntoDesktop,
homedir = os.homedir(),
initTimeoutMs,
} = {}) {
if (!sessionId) throw new Error("Claude session id is required.");
const existing = liveWorkers.get(String(sessionId));
if (existing && !existing.settled && !existing.closed && existing.child?.stdin?.writable && !existing.child.stdin.writableEnded && !existing.child.stdin.destroyed) {
existing.child.stdin.write(`${userMessage(String(sessionId), content)}\n`);
existing.settled = false;
return { sessionId: String(sessionId), resumed: false, live: true };
}
// The result event arrives just before stdin is closed. A fast Steer used to
// write into that retiring worker during the 50ms window, after which its
// scheduled end swallowed the message or made Desktop mark the session as
// crashed. Wait for a settled worker to exit before starting --resume.
if (existing) await waitForRetiringWorker(existing);
const resolvedCwd = path.resolve(String(cwd || homedir));
const worker = await launchWorker({
sessionId: String(sessionId),
cwd: resolvedCwd,
title: String(title || "Relay Task"),
prompt: String(content || ""),
model,
effort,
permissionMode,
resume: true,
command,
spawn,
adopt,
homedir,
initTimeoutMs,
displayPrompt: String(content || ""),
});
return { sessionId: String(sessionId), resumed: true, live: true, sessionPath: worker.sessionPath };
}
export async function continueClaudeDesktopCodeSession(options = {}) {
const key = String(options.sessionId || "");
if (!key) throw new Error("Claude session id is required.");
// One native transcript may have only one writer. Double-clicks and two
// quick Steers are serialized onto the same provider session instead of
// spawning competing --resume processes.
const previous = continuationChains.get(key) || Promise.resolve();
const current = previous.catch(() => {}).then(() => continueClaudeDesktopCodeSessionOnce(options));
continuationChains.set(key, current);
try {
return await current;
} finally {
if (continuationChains.get(key) === current) continuationChains.delete(key);
}
}
export function claudeDesktopCodeWorker(sessionId) {
return liveWorkers.get(String(sessionId || "")) || null;
}
// The pill polls this projection while the native worker is alive. It exposes
// conversation text and timing, never process handles, credentials or private
// provider events.
export function claudeDesktopCodeWorkerSnapshot(sessionId) {
const worker = claudeDesktopCodeWorker(sessionId);
if (!worker || worker.closed) return null;
return {
sessionId: worker.sessionId,
startedAt: worker.turnStartedAt,
userText: worker.userText,
assistantText: worker.partialAssistantText,
updatedAt: worker.lastStreamAt,
settled: Boolean(worker.settled),
};
}
// Provider-native Work consumes the exact stream-json rows rather than the
// legacy text projection above. The clone prevents renderer-facing code from
// retaining mutable worker state or process handles.
export function claudeDesktopCodeNativeSnapshot(sessionId) {
const worker = claudeDesktopCodeWorker(sessionId);
if (!worker) return null;
return {
sessionId: worker.sessionId,
transcriptPath: worker.sessionPath,
ownerAlive: !worker.closed,
expectedActive: !worker.settled,
settled: Boolean(worker.settled),
events: worker.nativeEvents.map((event) => structuredClone(event)),
};
}
export function subscribeClaudeDesktopCodeWorker(sessionId, listener) {
const worker = claudeDesktopCodeWorker(sessionId);
if (!worker || typeof listener !== "function") return () => {};
worker.nativeListeners.add(listener);
return () => worker.nativeListeners.delete(listener);
}
export async function waitForClaudeDesktopCodeMaterialization(sessionId, { timeoutMs = 30_000 } = {}) {
const worker = liveWorkers.get(String(sessionId || ""));
if (!worker) return null;
if (!worker.closed) {
throw new Error("Claude Code is still working. Open becomes available after this run settles.");
}
return waitForWorkerMaterialization(worker, timeoutMs);
}