relay-companion
Version:
Install Relay for Claude Code, Cowork, and Codex, then sign in from the Relay pill.
107 lines (98 loc) • 4.8 kB
JavaScript
// Claude materializer. Ported faithfully from
// granular/tools/relay-companion/src/claude-materializer.js. Writes the Claude
// Code / Cowork fallback markdown artifact and (for Claude Code targets) forges
// the native Claude Desktop session via writeClaudeNativeSession.
//
// Adaptations (input side only): the input is a cloud companion row, not a
// granular packet. Title/body come from ./relay-briefing.js. For the open path
// the overlay always passes --host claude, so forceClaudeCode is set by openRelay.
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import { renderRelayOpenDocuments, renderRelayOpenSeed } from "./relay-briefing.js";
import { writeClaudeNativeSession } from "./claude-session-writer.js";
import { claudeDesktopConfigPath, storeDir } from "./host-paths.js";
export function materializeRowForClaude(row, { cwd = process.cwd(), forceClaudeCode = false, forceCowork = false, model = "", effort = "" } = {}) {
const body = renderClaudeHandoffMarkdown(row);
const fileName = `${safeFileStem(row.createdAt)}-${safeFileStem(row.id)}.md`;
const wantsClaudeCode = forceClaudeCode || (!forceCowork && rowTargetsSurface(row, "claude_code"));
// THE READER'S CHOICE COUNTS. Cowork was reachable only through the SENDER's
// targetSurfaces, so picking "Claude Cowork" in the route rail did the Claude
// Code thing and then claimed it had opened Cowork — a verb that announced
// success and wrote nothing (David's Cowork inbox: newest artifact hours old
// after the click).
const wantsCowork = forceCowork || (!forceClaudeCode && rowTargetsSurface(row, "claude_cowork"));
const targets = [];
if (wantsCowork) targets.push(coworkRelayInboxDir());
const paths = [];
let openRow = row;
if (wantsClaudeCode) {
const { forHuman: forHumanPath, forAgent: forAgentPath } = materializeRelayOpenDocumentFiles(row, { provider: "claude-inbox" });
paths.push(forHumanPath, forAgentPath);
openRow = {
...row,
relayOpenDocumentPaths: { forHuman: forHumanPath, forAgent: forAgentPath },
};
}
for (const target of targets) {
fs.mkdirSync(target, { recursive: true });
const filePath = path.join(target, fileName);
fs.writeFileSync(filePath, body);
paths.push(filePath);
}
const nativeSession =
wantsClaudeCode && shouldMaterializeClaudeNative()
? writeClaudeNativeSession({ row: openRow, cwd, seed: renderRelayOpenSeed(openRow), model, effort })
: null;
return {
paths,
nativeSession,
surfaces: {
claudeCode: wantsClaudeCode,
claudeCowork: wantsCowork,
},
};
}
export function materializeRelayOpenDocumentFiles(row, { provider = "provider-inbox" } = {}) {
const relayDir = path.join(storeDir(), safeFileStem(provider), safeFileStem(row?.id || row?.createdAt));
fs.mkdirSync(relayDir, { recursive: true });
// Hyphenated, not spaced: a space forces the link renderer to escape the path,
// and only Claude is known to percent-decode it. A space-free name needs no
// escaping in any renderer, so the Open link works on every provider surface.
const forHumanPath = path.join(relayDir, "For-Human.md");
const forAgentPath = path.join(relayDir, "For-Agent.md");
fs.writeFileSync(forHumanPath, `# For Human\n\n${String(row?.forHuman || "").trim()}\n`);
fs.writeFileSync(forAgentPath, `# For Agent\n\n${String(row?.forAgent || "").trim()}\n`);
return { forHuman: forHumanPath, forAgent: forAgentPath };
}
export function renderClaudeHandoffMarkdown(row) {
return `${renderRelayOpenDocuments(row)}\n`;
}
export function coworkRelayInboxDir() {
const configPath = claudeDesktopConfigPath();
let userFilesPath = path.join(os.homedir(), "Claude");
try {
if (fs.existsSync(configPath)) {
const config = JSON.parse(fs.readFileSync(configPath, "utf8"));
userFilesPath = config.coworkUserFilesPath || userFilesPath;
}
} catch {
// Fall back to ~/Claude; a malformed Claude config should not block Relay materialization.
}
return path.join(userFilesPath, "Relay Inbox");
}
// The cloud row carries delivery.targetSurfaces when its snapshot packet is read;
// the staged row defaults both Codex + Claude Code. Treat a missing list as "all".
function rowTargetsSurface(row, surface) {
const list = row?.delivery?.targetSurfaces || row?.targetSurfaces || null;
if (!Array.isArray(list) || !list.length) return true;
return list
.map((value) => String(value || "").trim().toLowerCase().replace(/[\s-]+/g, "_"))
.includes(surface);
}
function safeFileStem(value) {
return String(value || new Date().toISOString()).replace(/[^0-9A-Za-z._-]+/g, "-").replace(/^-|-$/g, "");
}
function shouldMaterializeClaudeNative() {
return process.env.RELAY_MATERIALIZE_CLAUDE !== "0";
}