relay-companion 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,37 @@
1
+ const { contextBridge, ipcRenderer } = require("electron");
2
+
3
+ contextBridge.exposeInMainWorld("relay", {
4
+ // streams from main -> renderer
5
+ onInbox: (cb) => ipcRenderer.on("inbox", (_e, payload) => cb(payload)),
6
+ onNewRelay: (cb) => ipcRenderer.on("newRelay", (_e, relay) => cb(relay)),
7
+ onOpenDone: (cb) => ipcRenderer.on("openDone", (_e, id) => cb(id)),
8
+
9
+ // pull the full payload on demand
10
+ refresh: () => ipcRenderer.invoke("relay:get"),
11
+
12
+ // Relay rows
13
+ open: (id) => ipcRenderer.send("relay:open", id),
14
+ ack: (id) => ipcRenderer.send("relay:ack", id),
15
+ openUrl: (url) => ipcRenderer.send("relay:openUrl", url),
16
+
17
+ // task mutations (return { ok, error?, conflict? })
18
+ accept: (taskId, participantId) => ipcRenderer.invoke("relay:accept", taskId, participantId),
19
+ reject: (taskId, participantId) => ipcRenderer.invoke("relay:reject", taskId, participantId),
20
+ approve: (taskId, approvalId) => ipcRenderer.invoke("relay:approve", taskId, approvalId),
21
+ decline: (taskId, approvalId) => ipcRenderer.invoke("relay:decline", taskId, approvalId),
22
+ answer: (taskId, messageId, text) => ipcRenderer.invoke("relay:answer", taskId, messageId, text),
23
+
24
+ // tasks view
25
+ refreshTasks: () => ipcRenderer.invoke("relay:refreshTasks"),
26
+ openTask: (taskId) => ipcRenderer.send("relay:openTask", taskId),
27
+
28
+ // contacts
29
+ contacts: () => ipcRenderer.invoke("relay:contacts"),
30
+ contactSave: (input) => ipcRenderer.invoke("relay:contactSave", input),
31
+
32
+ // window plumbing
33
+ setFocusable: (v) => ipcRenderer.send("relay:setFocusable", v),
34
+ interactive: (v) => ipcRenderer.send("relay:interactive", v),
35
+ setPos: (x, y) => ipcRenderer.send("relay:setPos", x, y),
36
+ soundBytes: (name) => ipcRenderer.invoke("relay:soundBytes", name),
37
+ });
Binary file
package/package.json ADDED
@@ -0,0 +1,25 @@
1
+ {
2
+ "name": "relay-companion",
3
+ "version": "0.1.0",
4
+ "description": "Relay companion: connects local coding agents to Relay tasks, approvals, and connector tools.",
5
+ "type": "module",
6
+ "bin": {
7
+ "relay": "bin/relay.js"
8
+ },
9
+ "main": "bin/relay.js",
10
+ "scripts": {
11
+ "mcp": "node src/mcp.js",
12
+ "daemon": "node src/task-daemon.js",
13
+ "test": "node --test test/*.test.mjs"
14
+ },
15
+ "dependencies": {
16
+ "@anthropic-ai/claude-agent-sdk": "^0.3.195",
17
+ "@modelcontextprotocol/sdk": "^1.0.4",
18
+ "electron": "^32.0.0"
19
+ },
20
+ "files": [
21
+ "bin",
22
+ "src",
23
+ "overlay"
24
+ ]
25
+ }
@@ -0,0 +1,85 @@
1
+ // Claude materializer. Ported faithfully from
2
+ // granular/tools/relay-companion/src/claude-materializer.js. Writes the Claude
3
+ // Code / Cowork fallback markdown artifact and (for Claude Code targets) forges
4
+ // the native Claude Desktop session via writeClaudeNativeSession.
5
+ //
6
+ // Adaptations (input side only): the input is a cloud companion row, not a
7
+ // granular packet. Title/body come from ./relay-briefing.js. For the open path
8
+ // the overlay always passes --host claude, so forceClaudeCode is set by openRelay.
9
+
10
+ import fs from "node:fs";
11
+ import os from "node:os";
12
+ import path from "node:path";
13
+ import { renderRelayRowBriefing, renderRelayRowSeed } from "./relay-briefing.js";
14
+ import { writeClaudeNativeSession } from "./claude-session-writer.js";
15
+ import { claudeDesktopConfigPath, storeDir } from "./host-paths.js";
16
+
17
+ export function materializeRowForClaude(row, { cwd = process.cwd(), forceClaudeCode = false } = {}) {
18
+ const body = renderClaudeHandoffMarkdown(row);
19
+ const fileName = `${safeFileStem(row.createdAt)}-${safeFileStem(row.id)}.md`;
20
+ const wantsClaudeCode = forceClaudeCode || rowTargetsSurface(row, "claude_code");
21
+ const wantsCowork = rowTargetsSurface(row, "claude_cowork");
22
+ const targets = [];
23
+ if (wantsClaudeCode) targets.push(path.join(storeDir(), "claude-inbox"));
24
+ if (wantsCowork) targets.push(coworkRelayInboxDir());
25
+ const paths = [];
26
+ for (const target of targets) {
27
+ fs.mkdirSync(target, { recursive: true });
28
+ const filePath = path.join(target, fileName);
29
+ fs.writeFileSync(filePath, body);
30
+ paths.push(filePath);
31
+ }
32
+ const nativeSession =
33
+ wantsClaudeCode && shouldMaterializeClaudeNative()
34
+ ? writeClaudeNativeSession({ row, cwd, seed: renderRelayRowSeed(row) })
35
+ : null;
36
+ return {
37
+ paths,
38
+ nativeSession,
39
+ surfaces: {
40
+ claudeCode: wantsClaudeCode,
41
+ claudeCowork: wantsCowork,
42
+ },
43
+ };
44
+ }
45
+
46
+ export function renderClaudeHandoffMarkdown(row) {
47
+ return `${renderRelayRowBriefing(row)}
48
+
49
+ ## Claude Code / Cowork
50
+
51
+ This is a fallback/debug copy of the same Relay content. For Claude Code targets, Relay also writes a native Claude Code transcript and asks Claude Desktop to import it through \`claude://resume?session=...\`. For Cowork targets, this file is the Cowork-facing inbox artifact until Claude exposes a stable native Cowork import surface.
52
+ `;
53
+ }
54
+
55
+ export function coworkRelayInboxDir() {
56
+ const configPath = claudeDesktopConfigPath();
57
+ let userFilesPath = path.join(os.homedir(), "Claude");
58
+ try {
59
+ if (fs.existsSync(configPath)) {
60
+ const config = JSON.parse(fs.readFileSync(configPath, "utf8"));
61
+ userFilesPath = config.coworkUserFilesPath || userFilesPath;
62
+ }
63
+ } catch {
64
+ // Fall back to ~/Claude; a malformed Claude config should not block Relay materialization.
65
+ }
66
+ return path.join(userFilesPath, "Relay Inbox");
67
+ }
68
+
69
+ // The cloud row carries delivery.targetSurfaces when its snapshot packet is read;
70
+ // the staged row defaults both Codex + Claude Code. Treat a missing list as "all".
71
+ function rowTargetsSurface(row, surface) {
72
+ const list = row?.delivery?.targetSurfaces || row?.targetSurfaces || null;
73
+ if (!Array.isArray(list) || !list.length) return true;
74
+ return list
75
+ .map((value) => String(value || "").trim().toLowerCase().replace(/[\s-]+/g, "_"))
76
+ .includes(surface);
77
+ }
78
+
79
+ function safeFileStem(value) {
80
+ return String(value || new Date().toISOString()).replace(/[^0-9A-Za-z._-]+/g, "-").replace(/^-|-$/g, "");
81
+ }
82
+
83
+ function shouldMaterializeClaudeNative() {
84
+ return process.env.RELAY_MATERIALIZE_CLAUDE !== "0";
85
+ }