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.
- package/bin/relay.js +262 -0
- package/overlay/inbox.html +1398 -0
- package/overlay/main.cjs +762 -0
- package/overlay/preload.cjs +37 -0
- package/overlay/sounds/tink.wav +0 -0
- package/package.json +25 -0
- package/src/claude-materializer.js +85 -0
- package/src/claude-session-writer.js +629 -0
- package/src/client.js +168 -0
- package/src/codex-app-server.js +120 -0
- package/src/codex-desktop.js +276 -0
- package/src/codex-session-writer.js +170 -0
- package/src/codex-state.js +114 -0
- package/src/config.js +62 -0
- package/src/host-json.js +14 -0
- package/src/host-paths.js +67 -0
- package/src/install.js +142 -0
- package/src/materializer.js +378 -0
- package/src/mcp.js +419 -0
- package/src/notifications.js +412 -0
- package/src/pinning.js +43 -0
- package/src/relay-briefing.js +344 -0
- package/src/runtime.js +1141 -0
- package/src/task-daemon.js +216 -0
|
@@ -0,0 +1,170 @@
|
|
|
1
|
+
// Codex rollout writer. Ported faithfully from
|
|
2
|
+
// granular/tools/relay-companion/src/codex-session-writer.js (only the codexHome
|
|
3
|
+
// import points at this package's host-paths.js). Appends a visible assistant
|
|
4
|
+
// turn to a thread's .jsonl rollout and inserts the zero-width "index marker"
|
|
5
|
+
// user event so Codex Desktop's default local-thread route will index the thread.
|
|
6
|
+
|
|
7
|
+
import crypto from "node:crypto";
|
|
8
|
+
import fs from "node:fs";
|
|
9
|
+
import path from "node:path";
|
|
10
|
+
import { codexHome } from "./host-paths.js";
|
|
11
|
+
|
|
12
|
+
const CODEX_THREAD_INDEX_MARKER = "\u200b";
|
|
13
|
+
|
|
14
|
+
export function appendVisibleAssistantTurn({
|
|
15
|
+
sessionPath,
|
|
16
|
+
text,
|
|
17
|
+
cwd = process.cwd(),
|
|
18
|
+
currentDate = isoDate(),
|
|
19
|
+
timezone = localTimezone(),
|
|
20
|
+
}) {
|
|
21
|
+
if (!sessionPath) throw new Error("sessionPath is required");
|
|
22
|
+
if (!text || !String(text).trim()) throw new Error("assistant turn text is required");
|
|
23
|
+
const resolvedPath = path.resolve(sessionPath);
|
|
24
|
+
if (!fs.existsSync(resolvedPath)) throw new Error(`Codex session does not exist: ${resolvedPath}`);
|
|
25
|
+
|
|
26
|
+
const timestamp = new Date().toISOString();
|
|
27
|
+
const turnId = crypto.randomUUID();
|
|
28
|
+
const messageId = `msg_relay_${crypto.randomBytes(16).toString("hex")}`;
|
|
29
|
+
const cleanText = String(text);
|
|
30
|
+
const lineEnvelope = {
|
|
31
|
+
timestamp,
|
|
32
|
+
type: "turn_context",
|
|
33
|
+
payload: {
|
|
34
|
+
turn_id: turnId,
|
|
35
|
+
cwd,
|
|
36
|
+
workspace_roots: [cwd],
|
|
37
|
+
current_date: currentDate,
|
|
38
|
+
timezone,
|
|
39
|
+
approval_policy: "never",
|
|
40
|
+
sandbox_policy: { type: "danger-full-access" },
|
|
41
|
+
permission_profile: { type: "disabled" },
|
|
42
|
+
model: "gpt-5.5",
|
|
43
|
+
personality: "pragmatic",
|
|
44
|
+
collaboration_mode: {
|
|
45
|
+
mode: "default",
|
|
46
|
+
settings: {
|
|
47
|
+
model: "gpt-5.5",
|
|
48
|
+
reasoning_effort: "xhigh",
|
|
49
|
+
developer_instructions: null,
|
|
50
|
+
},
|
|
51
|
+
},
|
|
52
|
+
multi_agent_version: "v1",
|
|
53
|
+
realtime_active: false,
|
|
54
|
+
effort: "xhigh",
|
|
55
|
+
summary: "auto",
|
|
56
|
+
},
|
|
57
|
+
};
|
|
58
|
+
const lines = [
|
|
59
|
+
lineEnvelope,
|
|
60
|
+
{
|
|
61
|
+
timestamp,
|
|
62
|
+
type: "event_msg",
|
|
63
|
+
payload: {
|
|
64
|
+
type: "agent_message",
|
|
65
|
+
message: cleanText,
|
|
66
|
+
phase: "final_answer",
|
|
67
|
+
memory_citation: null,
|
|
68
|
+
},
|
|
69
|
+
},
|
|
70
|
+
{
|
|
71
|
+
timestamp,
|
|
72
|
+
type: "response_item",
|
|
73
|
+
payload: {
|
|
74
|
+
type: "message",
|
|
75
|
+
id: messageId,
|
|
76
|
+
role: "assistant",
|
|
77
|
+
content: [{ type: "output_text", text: cleanText }],
|
|
78
|
+
phase: "final_answer",
|
|
79
|
+
metadata: { turn_id: turnId },
|
|
80
|
+
},
|
|
81
|
+
},
|
|
82
|
+
{
|
|
83
|
+
timestamp,
|
|
84
|
+
type: "event_msg",
|
|
85
|
+
payload: {
|
|
86
|
+
type: "task_complete",
|
|
87
|
+
turn_id: turnId,
|
|
88
|
+
last_agent_message: cleanText,
|
|
89
|
+
completed_at: Math.floor(Date.now() / 1000),
|
|
90
|
+
duration_ms: 0,
|
|
91
|
+
time_to_first_token_ms: 0,
|
|
92
|
+
},
|
|
93
|
+
},
|
|
94
|
+
];
|
|
95
|
+
|
|
96
|
+
fs.appendFileSync(resolvedPath, `${lines.map((line) => JSON.stringify(line)).join("\n")}\n`);
|
|
97
|
+
return { sessionPath: resolvedPath, turnId, messageId };
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
export function ensureCodexThreadIndexMarker({ sessionPath, markerId = "" }) {
|
|
101
|
+
if (!sessionPath) throw new Error("sessionPath is required");
|
|
102
|
+
const resolvedPath = path.resolve(sessionPath);
|
|
103
|
+
if (!fs.existsSync(resolvedPath)) throw new Error(`Codex session does not exist: ${resolvedPath}`);
|
|
104
|
+
|
|
105
|
+
const clientId = `relay_index_${safeMarkerId(markerId) || crypto.randomBytes(8).toString("hex")}`;
|
|
106
|
+
const text = fs.readFileSync(resolvedPath, "utf8");
|
|
107
|
+
if (text.includes(`"client_id":"${clientId}"`) || text.includes(`"clientId":"${clientId}"`)) {
|
|
108
|
+
return { sessionPath: resolvedPath, inserted: false, clientId };
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
const lines = text.trimEnd().split("\n").filter(Boolean);
|
|
112
|
+
const insertAt = lines.findIndex((line) => {
|
|
113
|
+
try {
|
|
114
|
+
return JSON.parse(line)?.type === "session_meta";
|
|
115
|
+
} catch {
|
|
116
|
+
return false;
|
|
117
|
+
}
|
|
118
|
+
});
|
|
119
|
+
// Codex Desktop's default local-thread route ignores assistant-only rollouts.
|
|
120
|
+
// This zero-width user event makes the session indexable without making the Relay body user-authored.
|
|
121
|
+
const marker = {
|
|
122
|
+
timestamp: new Date().toISOString(),
|
|
123
|
+
type: "event_msg",
|
|
124
|
+
payload: {
|
|
125
|
+
type: "user_message",
|
|
126
|
+
client_id: clientId,
|
|
127
|
+
message: CODEX_THREAD_INDEX_MARKER,
|
|
128
|
+
images: [],
|
|
129
|
+
local_images: [],
|
|
130
|
+
text_elements: [],
|
|
131
|
+
metadata: { relaySynthetic: true, indexOnly: true },
|
|
132
|
+
},
|
|
133
|
+
};
|
|
134
|
+
|
|
135
|
+
lines.splice(insertAt >= 0 ? insertAt + 1 : 0, 0, JSON.stringify(marker));
|
|
136
|
+
fs.writeFileSync(resolvedPath, `${lines.join("\n")}\n`);
|
|
137
|
+
return { sessionPath: resolvedPath, inserted: true, clientId };
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
export function findCodexSessionPath(threadId, sessionsRoot = path.join(codexHome(), "sessions")) {
|
|
141
|
+
if (!threadId || !fs.existsSync(sessionsRoot)) return null;
|
|
142
|
+
const stack = [sessionsRoot];
|
|
143
|
+
while (stack.length) {
|
|
144
|
+
const current = stack.pop();
|
|
145
|
+
for (const entry of fs.readdirSync(current, { withFileTypes: true })) {
|
|
146
|
+
const entryPath = path.join(current, entry.name);
|
|
147
|
+
if (entry.isDirectory()) {
|
|
148
|
+
stack.push(entryPath);
|
|
149
|
+
} else if (entry.isFile() && entry.name.endsWith(`${threadId}.jsonl`)) {
|
|
150
|
+
return entryPath;
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
return null;
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
function isoDate(date = new Date()) {
|
|
158
|
+
return date.toISOString().slice(0, 10);
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
function localTimezone() {
|
|
162
|
+
return Intl.DateTimeFormat().resolvedOptions().timeZone || "UTC";
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
function safeMarkerId(value) {
|
|
166
|
+
return String(value || "")
|
|
167
|
+
.replace(/[^A-Za-z0-9_-]+/g, "_")
|
|
168
|
+
.replace(/^_+|_+$/g, "")
|
|
169
|
+
.slice(0, 96);
|
|
170
|
+
}
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
// Codex thread state writers. Ported faithfully from
|
|
2
|
+
// granular/tools/relay-companion/src/codex-state.js (paths -> host-paths.js,
|
|
3
|
+
// writeJsonAtomic -> host-json.js). Sets danger-full-access permissions in the
|
|
4
|
+
// Codex global-state atom and writes the threads-table row (title/preview/cwd/
|
|
5
|
+
// recency) into Codex's sqlite state DB so the thread shows in the recents rail.
|
|
6
|
+
|
|
7
|
+
import { spawnSync } from "node:child_process";
|
|
8
|
+
import fs from "node:fs";
|
|
9
|
+
import path from "node:path";
|
|
10
|
+
import { codexGlobalStatePath, codexStateDbPath } from "./host-paths.js";
|
|
11
|
+
import { writeJsonAtomic } from "./host-json.js";
|
|
12
|
+
|
|
13
|
+
const DEFAULT_THREAD_PERMISSIONS = {
|
|
14
|
+
activePermissionProfile: { id: ":danger-full-access", extends: null },
|
|
15
|
+
approvalPolicy: "never",
|
|
16
|
+
approvalsReviewer: "user",
|
|
17
|
+
sandboxPolicy: { type: "dangerFullAccess" },
|
|
18
|
+
};
|
|
19
|
+
|
|
20
|
+
export function finalizeCodexThreadState({ threadId, title, cwd, preview }) {
|
|
21
|
+
if (!threadId) throw new Error("threadId is required");
|
|
22
|
+
const permissions = ensureCodexThreadPermissions(threadId);
|
|
23
|
+
const stateDb = updateCodexThreadStateDb({ threadId, title, cwd, preview });
|
|
24
|
+
return { permissions, stateDb };
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export function ensureCodexThreadPermissions(threadId) {
|
|
28
|
+
const filePath = codexGlobalStatePath();
|
|
29
|
+
fs.mkdirSync(path.dirname(filePath), { recursive: true });
|
|
30
|
+
const state = fs.existsSync(filePath) ? JSON.parse(fs.readFileSync(filePath, "utf8")) : {};
|
|
31
|
+
state["electron-persisted-atom-state"] ||= {};
|
|
32
|
+
state["electron-persisted-atom-state"]["heartbeat-thread-permissions-by-id"] ||= {};
|
|
33
|
+
state["electron-persisted-atom-state"]["heartbeat-thread-permissions-by-id"][threadId] ||= DEFAULT_THREAD_PERMISSIONS;
|
|
34
|
+
writeJsonAtomic(filePath, state);
|
|
35
|
+
return { attempted: true, filePath };
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export function updateCodexThreadStateDb({ threadId, title, cwd, preview }) {
|
|
39
|
+
const dbPath = codexStateDbPath();
|
|
40
|
+
if (!fs.existsSync(dbPath)) return { attempted: false, reason: "missing-state-db", dbPath };
|
|
41
|
+
const cleanTitle = String(title || "").trim();
|
|
42
|
+
const cleanPreview = String(preview || "").trim();
|
|
43
|
+
const cleanCwd = cwd ? path.resolve(String(cwd)) : "";
|
|
44
|
+
const updates = ["updated_at = strftime('%s','now')", "updated_at_ms = CAST(strftime('%s','now') AS INTEGER) * 1000"];
|
|
45
|
+
if (cleanTitle) {
|
|
46
|
+
updates.push(`title = ${sqlString(cleanTitle)}`);
|
|
47
|
+
}
|
|
48
|
+
if (cleanPreview) {
|
|
49
|
+
updates.push(`preview = ${sqlString(cleanPreview)}`);
|
|
50
|
+
}
|
|
51
|
+
if (cleanCwd) {
|
|
52
|
+
updates.push(`cwd = ${sqlString(cleanCwd)}`);
|
|
53
|
+
}
|
|
54
|
+
updates.push("recency_at = strftime('%s','now')");
|
|
55
|
+
updates.push("recency_at_ms = CAST(strftime('%s','now') AS INTEGER) * 1000");
|
|
56
|
+
|
|
57
|
+
const sql = `UPDATE threads SET ${updates.join(", ")} WHERE id = ${sqlString(threadId)};`;
|
|
58
|
+
const result = spawnSync("sqlite3", [dbPath, sql], { encoding: "utf8", timeout: 5000 });
|
|
59
|
+
if (result.status !== 0) {
|
|
60
|
+
return {
|
|
61
|
+
attempted: true,
|
|
62
|
+
ok: false,
|
|
63
|
+
dbPath,
|
|
64
|
+
error: result.stderr || result.stdout || "sqlite3 update failed",
|
|
65
|
+
};
|
|
66
|
+
}
|
|
67
|
+
return { attempted: true, ok: true, dbPath };
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
// thread/start persists the threads row asynchronously after thread/name/set. Callers that stop a
|
|
71
|
+
// short-lived app-server must wait for this row before handing the thread id to the desktop app.
|
|
72
|
+
export function codexThreadRowExists(threadId) {
|
|
73
|
+
const dbPath = codexStateDbPath();
|
|
74
|
+
if (!threadId || !fs.existsSync(dbPath)) return false;
|
|
75
|
+
const result = spawnSync("sqlite3", [dbPath, `SELECT count(*) FROM threads WHERE id = ${sqlString(threadId)};`], {
|
|
76
|
+
encoding: "utf8",
|
|
77
|
+
timeout: 5000,
|
|
78
|
+
});
|
|
79
|
+
return result.status === 0 && String(result.stdout || "").trim() !== "0";
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
// Cloud-row adapter: the original read packet.sender/briefingMarkdown/kind. The
|
|
83
|
+
// cloud companion row carries senderName + bodyMarkdown/briefingMarkdown +
|
|
84
|
+
// (optionally) kind, so derive the same preview line from whichever is present.
|
|
85
|
+
export function relayThreadPreview(row) {
|
|
86
|
+
// Never surface the brand word "Relay" or the placeholder "Someone" as if it were
|
|
87
|
+
// a person; the viewer's own agent is "Your agent".
|
|
88
|
+
const rawSender = String(row.senderName || row.sender?.name || row.sender?.handle || "").trim();
|
|
89
|
+
const sender = rawSender && !/^(relay|someone)$/i.test(rawSender) ? rawSender : "Your agent";
|
|
90
|
+
const body = stripMarkdown(row.bodyMarkdown || row.briefingMarkdown || "");
|
|
91
|
+
const kind = String(row.kind || row.relayNotificationKind || "").toLowerCase();
|
|
92
|
+
const prefix = kind === "message" ? `${sender} sent this Relay message:` : `Relay from ${sender}:`;
|
|
93
|
+
return truncatePreview(`${prefix} ${body}`.trim());
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
function stripMarkdown(value) {
|
|
97
|
+
return String(value || "")
|
|
98
|
+
.replace(/^#+\s+/gm, "")
|
|
99
|
+
.replace(/`([^`]+)`/g, "$1")
|
|
100
|
+
.replace(/\[([^\]]+)\]\([^)]+\)/g, "$1")
|
|
101
|
+
.replace(/[*_~>#-]+/g, " ")
|
|
102
|
+
.replace(/\s+/g, " ")
|
|
103
|
+
.trim();
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
function truncatePreview(value, maxLength = 1200) {
|
|
107
|
+
const clean = String(value || "").trim();
|
|
108
|
+
if (clean.length <= maxLength) return clean;
|
|
109
|
+
return `${clean.slice(0, maxLength - 1).trimEnd()}…`;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
function sqlString(value) {
|
|
113
|
+
return `'${String(value ?? "").replaceAll("'", "''")}'`;
|
|
114
|
+
}
|
package/src/config.js
ADDED
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
import fs from "node:fs";
|
|
2
|
+
import os from "node:os";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Companion configuration lives in ~/.relay/config.json. It holds the device
|
|
7
|
+
* token issued at pairing and the API base URL. Environment variables override
|
|
8
|
+
* the file so a single machine can point at a local or staging API for testing.
|
|
9
|
+
*/
|
|
10
|
+
export function configDir() {
|
|
11
|
+
return process.env.RELAY_CONFIG_DIR || path.join(os.homedir(), ".relay");
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export function configPath() {
|
|
15
|
+
return path.join(configDir(), "config.json");
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export function readConfig() {
|
|
19
|
+
try {
|
|
20
|
+
const raw = fs.readFileSync(configPath(), "utf8");
|
|
21
|
+
return JSON.parse(raw);
|
|
22
|
+
} catch {
|
|
23
|
+
return {};
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export function writeConfig(patch) {
|
|
28
|
+
const dir = configDir();
|
|
29
|
+
fs.mkdirSync(dir, { recursive: true, mode: 0o700 });
|
|
30
|
+
const next = { ...readConfig(), ...patch };
|
|
31
|
+
fs.writeFileSync(configPath(), JSON.stringify(next, null, 2), { mode: 0o600 });
|
|
32
|
+
return next;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export function apiUrl() {
|
|
36
|
+
return (
|
|
37
|
+
process.env.RELAY_API_URL ||
|
|
38
|
+
readConfig().apiUrl ||
|
|
39
|
+
"https://aia6vj5pgp.us-east-1.awsapprunner.com"
|
|
40
|
+
);
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export function webUrl() {
|
|
44
|
+
return (
|
|
45
|
+
process.env.RELAY_WEB_URL ||
|
|
46
|
+
readConfig().webUrl ||
|
|
47
|
+
"http://localhost:3000"
|
|
48
|
+
).replace(/\/$/, "");
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export function deviceToken() {
|
|
52
|
+
return process.env.RELAY_DEVICE_TOKEN || readConfig().deviceToken || "";
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export function currentUser() {
|
|
56
|
+
return readConfig().user || null;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/** Durable local runtime ledger for agent task sessions. */
|
|
60
|
+
export function taskLedgerPath() {
|
|
61
|
+
return path.join(configDir(), "task-ledger.json");
|
|
62
|
+
}
|
package/src/host-json.js
ADDED
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
// Minimal atomic JSON writer used by the lazy-open engine's host-state writers
|
|
2
|
+
// (codex-state, pinning). The original relay-companion imported writeJsonAtomic
|
|
3
|
+
// from its large json-store.js; here we only need the atomic write primitive, so
|
|
4
|
+
// this is the adapter the port note calls for — same temp+rename behavior.
|
|
5
|
+
|
|
6
|
+
import fs from "node:fs";
|
|
7
|
+
import path from "node:path";
|
|
8
|
+
|
|
9
|
+
export function writeJsonAtomic(filePath, value) {
|
|
10
|
+
fs.mkdirSync(path.dirname(filePath), { recursive: true });
|
|
11
|
+
const tmp = `${filePath}.${process.pid}.${Date.now()}.tmp`;
|
|
12
|
+
fs.writeFileSync(tmp, `${JSON.stringify(value, null, 2)}\n`, { mode: 0o600 });
|
|
13
|
+
fs.renameSync(tmp, filePath);
|
|
14
|
+
}
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
// Host filesystem paths used by the lazy-open session-materialization engine.
|
|
2
|
+
//
|
|
3
|
+
// Ported faithfully from granular/tools/relay-companion/src/paths.js (the subset
|
|
4
|
+
// the open path needs: Codex home/state + Claude home/projects/desktop sessions).
|
|
5
|
+
// Same env-var override names, same default locations, same behavior. Named
|
|
6
|
+
// host-paths.js so it doesn't collide with any other path helper in this package.
|
|
7
|
+
|
|
8
|
+
import os from "node:os";
|
|
9
|
+
import path from "node:path";
|
|
10
|
+
|
|
11
|
+
export const DEFAULT_CODEX_HOME = path.join(os.homedir(), ".codex");
|
|
12
|
+
export const DEFAULT_CODEX_GLOBAL_STATE = path.join(DEFAULT_CODEX_HOME, ".codex-global-state.json");
|
|
13
|
+
export const DEFAULT_CODEX_STATE_DB = path.join(DEFAULT_CODEX_HOME, "state_5.sqlite");
|
|
14
|
+
export const DEFAULT_CLAUDE_HOME = path.join(os.homedir(), ".claude");
|
|
15
|
+
export const DEFAULT_CLAUDE_DESKTOP_CONFIG = path.join(
|
|
16
|
+
os.homedir(),
|
|
17
|
+
"Library",
|
|
18
|
+
"Application Support",
|
|
19
|
+
"Claude",
|
|
20
|
+
"claude_desktop_config.json",
|
|
21
|
+
);
|
|
22
|
+
export const DEFAULT_CLAUDE_DESKTOP_SESSIONS = path.join(
|
|
23
|
+
os.homedir(),
|
|
24
|
+
"Library",
|
|
25
|
+
"Application Support",
|
|
26
|
+
"Claude",
|
|
27
|
+
"claude-code-sessions",
|
|
28
|
+
);
|
|
29
|
+
|
|
30
|
+
// Durable companion store dir. The cloud companion stages packets + state under
|
|
31
|
+
// RELAY_HOME/RELAY_COMPANION_HOME (see notifications.companionHome()); the engine
|
|
32
|
+
// writes its own snapshot copies under <store>/packets too.
|
|
33
|
+
export function storeDir() {
|
|
34
|
+
return (
|
|
35
|
+
process.env.RELAY_HOME ||
|
|
36
|
+
process.env.RELAY_COMPANION_HOME ||
|
|
37
|
+
path.join(os.homedir(), ".relay-companion")
|
|
38
|
+
);
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export function codexHome() {
|
|
42
|
+
return process.env.CODEX_HOME || DEFAULT_CODEX_HOME;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export function codexGlobalStatePath() {
|
|
46
|
+
return process.env.CODEX_GLOBAL_STATE || path.join(codexHome(), ".codex-global-state.json");
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export function codexStateDbPath() {
|
|
50
|
+
return process.env.CODEX_STATE_DB || path.join(codexHome(), "state_5.sqlite");
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export function claudeHome() {
|
|
54
|
+
return process.env.CLAUDE_HOME || DEFAULT_CLAUDE_HOME;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export function claudeProjectsDir() {
|
|
58
|
+
return process.env.CLAUDE_PROJECTS_DIR || path.join(claudeHome(), "projects");
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export function claudeDesktopConfigPath() {
|
|
62
|
+
return process.env.CLAUDE_DESKTOP_CONFIG || DEFAULT_CLAUDE_DESKTOP_CONFIG;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
export function claudeDesktopSessionsDir() {
|
|
66
|
+
return process.env.CLAUDE_DESKTOP_SESSIONS_DIR || DEFAULT_CLAUDE_DESKTOP_SESSIONS;
|
|
67
|
+
}
|
package/src/install.js
ADDED
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
import { spawnSync } from "node:child_process";
|
|
2
|
+
import fs from "node:fs";
|
|
3
|
+
import os from "node:os";
|
|
4
|
+
import path from "node:path";
|
|
5
|
+
import { fileURLToPath } from "node:url";
|
|
6
|
+
|
|
7
|
+
// Persistent install of the Relay companion into the user's local agents.
|
|
8
|
+
//
|
|
9
|
+
// After pairing, this registers the Relay MCP server (`relay mcp`) into Claude
|
|
10
|
+
// Code and Codex at user scope, so EVERY interactive session gets the Relay
|
|
11
|
+
// tools + the user's connected provider tools — not just background task runs.
|
|
12
|
+
// It also installs a launchd agent that keeps the receive daemon running.
|
|
13
|
+
|
|
14
|
+
const DAEMON_LAUNCH_LABEL = "work.relay.companion";
|
|
15
|
+
|
|
16
|
+
export const PACKAGE_NAME = "relay-companion";
|
|
17
|
+
|
|
18
|
+
/** Absolute path to this companion's CLI entrypoint (deps resolve from here). */
|
|
19
|
+
export function relayBinPath() {
|
|
20
|
+
return path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../bin/relay.js");
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* A STABLE path to the companion CLI that the agents and the daemon can keep
|
|
25
|
+
* launching. When `setup` runs from npx (an ephemeral cache that gets cleaned),
|
|
26
|
+
* we global-install the package and point at that instead, so the registration
|
|
27
|
+
* doesn't rot. From a dev checkout or an existing global install the running
|
|
28
|
+
* path is already stable, so we use it directly.
|
|
29
|
+
*/
|
|
30
|
+
export function resolveStableBin() {
|
|
31
|
+
const here = relayBinPath();
|
|
32
|
+
const ephemeral = /[\\/](_npx|\.npm[\\/]_npx|npm-cache[\\/]_npx)[\\/]/.test(here);
|
|
33
|
+
if (!ephemeral) return here;
|
|
34
|
+
const install = run("npm", ["install", "-g", `${PACKAGE_NAME}@latest`]);
|
|
35
|
+
if (!install.ok) return here; // fall back; caller still gets a working (if cache-bound) path
|
|
36
|
+
const rootRes = spawnSync("npm", ["root", "-g"], { encoding: "utf8", timeout: 20_000 });
|
|
37
|
+
const root = (rootRes.stdout || "").trim();
|
|
38
|
+
const globalBin = path.join(root, ...PACKAGE_NAME.split("/"), "bin", "relay.js");
|
|
39
|
+
return fs.existsSync(globalBin) ? globalBin : here;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function run(cmd, args) {
|
|
43
|
+
const res = spawnSync(cmd, args, { encoding: "utf8", timeout: 20_000 });
|
|
44
|
+
return {
|
|
45
|
+
ok: !res.error && res.status === 0,
|
|
46
|
+
status: res.status,
|
|
47
|
+
out: `${res.stdout || ""}${res.stderr || ""}`.trim(),
|
|
48
|
+
missing: Boolean(res.error && res.error.code === "ENOENT"),
|
|
49
|
+
};
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/** Whether an agent CLI is on PATH. */
|
|
53
|
+
export function commandExists(cmd) {
|
|
54
|
+
const res = spawnSync(cmd, ["--version"], { encoding: "utf8", timeout: 8000 });
|
|
55
|
+
return !res.error && (res.status === 0 || Boolean((res.stdout || "").trim()));
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/** Register the Relay MCP into Claude Code at user scope (loads in every session). Idempotent. */
|
|
59
|
+
export function installClaudeCode(bin = relayBinPath(), node = process.execPath) {
|
|
60
|
+
run("claude", ["mcp", "remove", "-s", "user", "relay"]); // ignore if absent
|
|
61
|
+
const res = run("claude", ["mcp", "add", "-s", "user", "relay", "--", node, bin, "mcp"]);
|
|
62
|
+
return res.ok || /already exists/i.test(res.out);
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/** Register the Relay MCP into Codex (loads in every session). Idempotent. */
|
|
66
|
+
export function installCodex(bin = relayBinPath(), node = process.execPath) {
|
|
67
|
+
run("codex", ["mcp", "remove", "relay"]); // ignore if absent
|
|
68
|
+
const res = run("codex", ["mcp", "add", "relay", "--", node, bin, "mcp"]);
|
|
69
|
+
return res.ok || /already exists/i.test(res.out);
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/** Install + load a launchd agent that keeps `relay daemon` running (macOS). Idempotent. */
|
|
73
|
+
export function installDaemonAutostart(bin = relayBinPath(), node = process.execPath) {
|
|
74
|
+
if (process.platform !== "darwin") return { ok: false, reason: "autostart_unsupported_platform" };
|
|
75
|
+
const home = os.homedir();
|
|
76
|
+
const plistPath = path.join(home, "Library", "LaunchAgents", `${DAEMON_LAUNCH_LABEL}.plist`);
|
|
77
|
+
const logPath = path.join(home, ".relay", "daemon.log");
|
|
78
|
+
fs.mkdirSync(path.dirname(plistPath), { recursive: true });
|
|
79
|
+
fs.mkdirSync(path.dirname(logPath), { recursive: true });
|
|
80
|
+
const pathEnv = "/opt/homebrew/bin:/usr/local/bin:/usr/bin:/bin";
|
|
81
|
+
const plist = `<?xml version="1.0" encoding="UTF-8"?>
|
|
82
|
+
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
|
83
|
+
<plist version="1.0">
|
|
84
|
+
<dict>
|
|
85
|
+
<key>Label</key><string>${DAEMON_LAUNCH_LABEL}</string>
|
|
86
|
+
<key>ProgramArguments</key>
|
|
87
|
+
<array>
|
|
88
|
+
<string>${node}</string>
|
|
89
|
+
<string>${bin}</string>
|
|
90
|
+
<string>daemon</string>
|
|
91
|
+
</array>
|
|
92
|
+
<key>RunAtLoad</key><true/>
|
|
93
|
+
<key>KeepAlive</key><true/>
|
|
94
|
+
<key>StandardOutPath</key><string>${logPath}</string>
|
|
95
|
+
<key>StandardErrorPath</key><string>${logPath}</string>
|
|
96
|
+
<key>EnvironmentVariables</key>
|
|
97
|
+
<dict>
|
|
98
|
+
<key>PATH</key><string>${pathEnv}</string>
|
|
99
|
+
</dict>
|
|
100
|
+
</dict>
|
|
101
|
+
</plist>
|
|
102
|
+
`;
|
|
103
|
+
fs.writeFileSync(plistPath, plist);
|
|
104
|
+
run("launchctl", ["unload", plistPath]); // ignore if not loaded
|
|
105
|
+
const res = run("launchctl", ["load", plistPath]);
|
|
106
|
+
return { ok: res.ok, plistPath, logPath };
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* Detect Claude Code + Codex, register the Relay MCP into each present, and
|
|
111
|
+
* start the receive daemon. Returns a summary for the CLI to print.
|
|
112
|
+
*/
|
|
113
|
+
export function runSetupInstall() {
|
|
114
|
+
const bin = resolveStableBin();
|
|
115
|
+
const installed = [];
|
|
116
|
+
const missing = [];
|
|
117
|
+
if (commandExists("claude")) {
|
|
118
|
+
if (installClaudeCode(bin)) installed.push("Claude Code");
|
|
119
|
+
else missing.push("Claude Code (registration failed)");
|
|
120
|
+
} else missing.push("Claude Code");
|
|
121
|
+
if (commandExists("codex")) {
|
|
122
|
+
if (installCodex(bin)) installed.push("Codex");
|
|
123
|
+
else missing.push("Codex (registration failed)");
|
|
124
|
+
} else missing.push("Codex");
|
|
125
|
+
const daemon = installDaemonAutostart(bin);
|
|
126
|
+
return { installed, missing, daemon };
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/** Remove the Relay MCP from both agents and stop/clear the background daemon. */
|
|
130
|
+
export function runUninstall() {
|
|
131
|
+
run("claude", ["mcp", "remove", "-s", "user", "relay"]);
|
|
132
|
+
run("codex", ["mcp", "remove", "relay"]);
|
|
133
|
+
if (process.platform === "darwin") {
|
|
134
|
+
const plistPath = path.join(os.homedir(), "Library", "LaunchAgents", `${DAEMON_LAUNCH_LABEL}.plist`);
|
|
135
|
+
run("launchctl", ["unload", plistPath]);
|
|
136
|
+
try {
|
|
137
|
+
fs.unlinkSync(plistPath);
|
|
138
|
+
} catch {
|
|
139
|
+
/* already gone */
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
}
|