relay-companion 0.1.4 → 0.1.6
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 +49 -5
- package/package.json +1 -1
- package/src/install.js +12 -6
- package/src/setup-activation.js +238 -0
package/bin/relay.js
CHANGED
|
@@ -11,6 +11,7 @@ import { writeConfig, readConfig, apiUrl, DEFAULT_API_URL, DEFAULT_WEB_URL } fro
|
|
|
11
11
|
import { runTaskDaemon, pollTaskRuntimeOnce } from "../src/task-daemon.js";
|
|
12
12
|
import { runMcpServer } from "../src/mcp.js";
|
|
13
13
|
import { runSetupInstall, runUninstall } from "../src/install.js";
|
|
14
|
+
import { liveToolRequirement, requiredLiveHosts, shouldRequireLiveTools } from "../src/setup-activation.js";
|
|
14
15
|
import { openRelay, openTask } from "../src/materializer.js";
|
|
15
16
|
import { resetCompanionStateForAccount } from "../src/notifications.js";
|
|
16
17
|
import { finishSetupOpenRelay, normalizeSetupHost, setupOpenRelayToken, setupOpenStatus } from "../src/setup-open.js";
|
|
@@ -60,14 +61,45 @@ async function cmdPair(flags, { promptForDefaults = true } = {}) {
|
|
|
60
61
|
console.log(`Paired as ${res.user.name} <${res.user.email}>. This device is now connected to Relay.`);
|
|
61
62
|
}
|
|
62
63
|
|
|
64
|
+
function printActivationStatus(activation) {
|
|
65
|
+
if (!activation) return;
|
|
66
|
+
if (activation.host === "Codex") {
|
|
67
|
+
if (activation.currentSessionReady) {
|
|
68
|
+
if (activation.readinessMode === "deferred_tool_search") {
|
|
69
|
+
console.log("Relay tools are available in this Codex session through Codex tool search (`relay_send`).");
|
|
70
|
+
return;
|
|
71
|
+
}
|
|
72
|
+
console.log("Relay tools are live in this Codex session (`relay_send` verified).");
|
|
73
|
+
return;
|
|
74
|
+
}
|
|
75
|
+
if (activation.serverCatalogHasTool) {
|
|
76
|
+
console.log(
|
|
77
|
+
"Relay was added to Codex and the MCP server advertises relay_send, but this open Codex session has not exposed relay_send to the agent yet.",
|
|
78
|
+
);
|
|
79
|
+
return;
|
|
80
|
+
}
|
|
81
|
+
const detail = activation.reason ? ` (${activation.reason})` : "";
|
|
82
|
+
console.log(`Relay was added to Codex, but this open Codex session does not expose relay_send yet${detail}.`);
|
|
83
|
+
return;
|
|
84
|
+
}
|
|
85
|
+
if (activation.host === "Claude Code") {
|
|
86
|
+
if (activation.ok) {
|
|
87
|
+
console.log("Relay was added to Claude Code for new sessions. Existing Claude Code sessions may need their MCP tools reloaded from inside Claude.");
|
|
88
|
+
} else {
|
|
89
|
+
console.log("Relay could not verify the Claude Code MCP registration.");
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
|
|
63
94
|
/** Register the Relay tools into Claude Code + Codex and start the receive daemon. */
|
|
64
|
-
function applyInstall() {
|
|
65
|
-
const { installed, missing, daemon, pill } = runSetupInstall();
|
|
95
|
+
async function applyInstall({ requireLiveTools = false, requiredHosts = requiredLiveHosts() } = {}) {
|
|
96
|
+
const { installed, missing, daemon, pill, activations = [] } = await runSetupInstall();
|
|
66
97
|
if (installed.length) console.log(`Added Relay to ${installed.join(" and ")} on this machine.`);
|
|
67
98
|
for (const m of missing) {
|
|
68
99
|
if (!/registration failed/.test(m)) console.log(`${m} was not found here, so it was skipped.`);
|
|
69
100
|
else console.log(`Could not register ${m}.`);
|
|
70
101
|
}
|
|
102
|
+
for (const activation of activations) printActivationStatus(activation);
|
|
71
103
|
if (daemon.ok) console.log("Relay is running in the background and will start automatically when you log in.");
|
|
72
104
|
else if (daemon.reason === "autostart_unsupported_platform") {
|
|
73
105
|
console.log("Run `relay daemon` to receive relays (background autostart is macOS-only for now).");
|
|
@@ -76,14 +108,26 @@ function applyInstall() {
|
|
|
76
108
|
else if (pill?.reason === "pill_runtime_missing") {
|
|
77
109
|
console.log("Run `relay pill` to open the Relay pill.");
|
|
78
110
|
}
|
|
79
|
-
|
|
111
|
+
const liveRequirement = liveToolRequirement({ activations, requiredHosts });
|
|
112
|
+
if (requireLiveTools && installed.length && !liveRequirement.ok) {
|
|
113
|
+
const hostLabel = liveRequirement.missingHosts.length
|
|
114
|
+
? liveRequirement.missingHosts.join(" and ")
|
|
115
|
+
: "this running agent session";
|
|
116
|
+
throw new Error(
|
|
117
|
+
`Relay paired and installed, but ${hostLabel} cannot call relay_send yet. Setup is not ready in the current agent.`,
|
|
118
|
+
);
|
|
119
|
+
}
|
|
120
|
+
return { installed, missing, daemon, pill, activations };
|
|
80
121
|
}
|
|
81
122
|
|
|
82
123
|
/** Full setup: pair this device, then install the tools + daemon. */
|
|
83
124
|
async function cmdSetup(flags) {
|
|
84
125
|
await cmdPair(flags, { promptForDefaults: Boolean(flags.interactive) });
|
|
85
126
|
console.log("");
|
|
86
|
-
applyInstall(
|
|
127
|
+
await applyInstall({
|
|
128
|
+
requireLiveTools: Boolean(flags["require-live-tools"]) || shouldRequireLiveTools(),
|
|
129
|
+
requiredHosts: requiredLiveHosts(),
|
|
130
|
+
});
|
|
87
131
|
const token = setupOpenRelayToken(flags);
|
|
88
132
|
if (token) {
|
|
89
133
|
console.log("");
|
|
@@ -109,7 +153,7 @@ async function cmdInstall() {
|
|
|
109
153
|
if (!readConfig().deviceToken) {
|
|
110
154
|
throw new Error("This device is not paired yet. Run `relay setup` with a pairing code first.");
|
|
111
155
|
}
|
|
112
|
-
applyInstall();
|
|
156
|
+
await applyInstall({ requireLiveTools: shouldRequireLiveTools(), requiredHosts: requiredLiveHosts() });
|
|
113
157
|
}
|
|
114
158
|
|
|
115
159
|
/** Remove the Relay tools from both agents and stop the background daemon. */
|
package/package.json
CHANGED
package/src/install.js
CHANGED
|
@@ -4,6 +4,7 @@ import os from "node:os";
|
|
|
4
4
|
import path from "node:path";
|
|
5
5
|
import { createRequire } from "node:module";
|
|
6
6
|
import { fileURLToPath } from "node:url";
|
|
7
|
+
import { activateCodexMcp, verifyClaudeMcpRegistration } from "./setup-activation.js";
|
|
7
8
|
|
|
8
9
|
// Persistent install of the Relay companion into the user's local agents.
|
|
9
10
|
//
|
|
@@ -175,21 +176,26 @@ export function installPillAutostart(bin = relayBinPath()) {
|
|
|
175
176
|
* Detect Claude Code + Codex, register the Relay MCP into each present, and
|
|
176
177
|
* start the receive daemon. Returns a summary for the CLI to print.
|
|
177
178
|
*/
|
|
178
|
-
export function runSetupInstall() {
|
|
179
|
+
export async function runSetupInstall() {
|
|
179
180
|
const bin = resolveStableBin();
|
|
180
181
|
const installed = [];
|
|
181
182
|
const missing = [];
|
|
183
|
+
const activations = [];
|
|
182
184
|
if (commandExists("claude")) {
|
|
183
|
-
if (installClaudeCode(bin))
|
|
184
|
-
|
|
185
|
+
if (installClaudeCode(bin)) {
|
|
186
|
+
installed.push("Claude Code");
|
|
187
|
+
activations.push(verifyClaudeMcpRegistration());
|
|
188
|
+
} else missing.push("Claude Code (registration failed)");
|
|
185
189
|
} else missing.push("Claude Code");
|
|
186
190
|
if (commandExists("codex")) {
|
|
187
|
-
if (installCodex(bin))
|
|
188
|
-
|
|
191
|
+
if (installCodex(bin)) {
|
|
192
|
+
installed.push("Codex");
|
|
193
|
+
activations.push(await activateCodexMcp());
|
|
194
|
+
} else missing.push("Codex (registration failed)");
|
|
189
195
|
} else missing.push("Codex");
|
|
190
196
|
const daemon = installDaemonAutostart(bin);
|
|
191
197
|
const pill = installPillAutostart(bin);
|
|
192
|
-
return { installed, missing, daemon, pill };
|
|
198
|
+
return { installed, missing, daemon, pill, activations };
|
|
193
199
|
}
|
|
194
200
|
|
|
195
201
|
/** Remove the Relay MCP from both agents and stop/clear the background daemon. */
|
|
@@ -0,0 +1,238 @@
|
|
|
1
|
+
import { spawn, spawnSync } from "node:child_process";
|
|
2
|
+
import readline from "node:readline";
|
|
3
|
+
|
|
4
|
+
const DEFAULT_TIMEOUT_MS = 12_000;
|
|
5
|
+
|
|
6
|
+
export function hasMcpTool(statusResponse, serverName = "relay", toolName = "relay_send") {
|
|
7
|
+
const servers = Array.isArray(statusResponse?.data) ? statusResponse.data : [];
|
|
8
|
+
const server = servers.find((item) => item?.name === serverName);
|
|
9
|
+
if (!server) return false;
|
|
10
|
+
if (Array.isArray(server.tools)) return server.tools.some((tool) => tool?.name === toolName);
|
|
11
|
+
return Boolean(server.tools?.[toolName]);
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export function serverNames(statusResponse) {
|
|
15
|
+
return (Array.isArray(statusResponse?.data) ? statusResponse.data : [])
|
|
16
|
+
.map((item) => item?.name)
|
|
17
|
+
.filter(Boolean);
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export function shouldRequireLiveTools(env = process.env) {
|
|
21
|
+
return Boolean(env.CODEX_THREAD_ID || env.CLAUDECODE || env.CLAUDE_CODE_SESSION_ID);
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export function requiredLiveHosts(env = process.env) {
|
|
25
|
+
const hosts = [];
|
|
26
|
+
if (env.CODEX_THREAD_ID) hosts.push("Codex");
|
|
27
|
+
if (env.CLAUDECODE || env.CLAUDE_CODE_SESSION_ID) hosts.push("Claude Code");
|
|
28
|
+
return hosts;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export function liveToolRequirement({ activations = [], requiredHosts = [] } = {}) {
|
|
32
|
+
const readyHosts = new Set(
|
|
33
|
+
activations
|
|
34
|
+
.filter((activation) => activation?.currentSessionReady)
|
|
35
|
+
.map((activation) => activation.host)
|
|
36
|
+
.filter(Boolean),
|
|
37
|
+
);
|
|
38
|
+
if (requiredHosts.length) {
|
|
39
|
+
const missingHosts = requiredHosts.filter((host) => !readyHosts.has(host));
|
|
40
|
+
return { ok: missingHosts.length === 0, readyHosts: Array.from(readyHosts), missingHosts };
|
|
41
|
+
}
|
|
42
|
+
return { ok: readyHosts.size > 0, readyHosts: Array.from(readyHosts), missingHosts: [] };
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function codexCatalogDetail(statusResponse, serverName = "relay", toolName = "relay_send") {
|
|
46
|
+
const servers = Array.isArray(statusResponse?.data) ? statusResponse.data : [];
|
|
47
|
+
const server = servers.find((item) => item?.name === serverName);
|
|
48
|
+
return {
|
|
49
|
+
serverCatalogReady: Boolean(server),
|
|
50
|
+
serverCatalogHasTool: hasMcpTool(statusResponse, serverName, toolName),
|
|
51
|
+
serverNames: serverNames(statusResponse),
|
|
52
|
+
};
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
class JsonlRpcClient {
|
|
56
|
+
constructor({ command = "codex", args = ["app-server", "proxy"], cwd = process.cwd(), timeoutMs = DEFAULT_TIMEOUT_MS } = {}) {
|
|
57
|
+
this.command = command;
|
|
58
|
+
this.args = args;
|
|
59
|
+
this.cwd = cwd;
|
|
60
|
+
this.timeoutMs = timeoutMs;
|
|
61
|
+
this.nextId = 1;
|
|
62
|
+
this.pending = new Map();
|
|
63
|
+
this.proc = null;
|
|
64
|
+
this.stderr = "";
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
async start() {
|
|
68
|
+
if (this.proc) return;
|
|
69
|
+
this.proc = spawn(this.command, this.args, {
|
|
70
|
+
cwd: this.cwd,
|
|
71
|
+
stdio: ["pipe", "pipe", "pipe"],
|
|
72
|
+
env: { ...process.env },
|
|
73
|
+
});
|
|
74
|
+
this.proc.stderr.on("data", (chunk) => {
|
|
75
|
+
this.stderr += chunk.toString();
|
|
76
|
+
});
|
|
77
|
+
this.proc.on("error", (error) => {
|
|
78
|
+
this.rejectAll(error);
|
|
79
|
+
this.proc = null;
|
|
80
|
+
});
|
|
81
|
+
this.proc.on("exit", (code, signal) => {
|
|
82
|
+
const exitLabel = signal || (code ?? "unknown");
|
|
83
|
+
const err = new Error(`codex app-server proxy exited (${exitLabel})`);
|
|
84
|
+
if (this.stderr.trim()) err.stderr = this.stderr.trim();
|
|
85
|
+
this.rejectAll(err);
|
|
86
|
+
this.proc = null;
|
|
87
|
+
});
|
|
88
|
+
readline.createInterface({ input: this.proc.stdout, crlfDelay: Infinity }).on("line", (line) => {
|
|
89
|
+
if (!line.trim()) return;
|
|
90
|
+
let message;
|
|
91
|
+
try {
|
|
92
|
+
message = JSON.parse(line);
|
|
93
|
+
} catch {
|
|
94
|
+
return;
|
|
95
|
+
}
|
|
96
|
+
if (message.id == null || !this.pending.has(message.id)) return;
|
|
97
|
+
const pending = this.pending.get(message.id);
|
|
98
|
+
this.pending.delete(message.id);
|
|
99
|
+
clearTimeout(pending.timer);
|
|
100
|
+
if (message.error) {
|
|
101
|
+
const err = new Error(message.error.message || `${pending.method} failed`);
|
|
102
|
+
err.code = message.error.code;
|
|
103
|
+
err.data = message.error.data;
|
|
104
|
+
pending.reject(err);
|
|
105
|
+
} else {
|
|
106
|
+
pending.resolve(message.result ?? {});
|
|
107
|
+
}
|
|
108
|
+
});
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
rejectAll(error) {
|
|
112
|
+
for (const pending of this.pending.values()) {
|
|
113
|
+
clearTimeout(pending.timer);
|
|
114
|
+
pending.reject(error);
|
|
115
|
+
}
|
|
116
|
+
this.pending.clear();
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
request(method, params = undefined, timeoutMs = this.timeoutMs) {
|
|
120
|
+
if (!this.proc) return Promise.reject(new Error("codex app-server proxy is not running"));
|
|
121
|
+
const id = this.nextId++;
|
|
122
|
+
const payload = params === undefined ? { method, id } : { method, id, params };
|
|
123
|
+
return new Promise((resolve, reject) => {
|
|
124
|
+
const timer = setTimeout(() => {
|
|
125
|
+
this.pending.delete(id);
|
|
126
|
+
reject(new Error(`${method} timed out after ${timeoutMs}ms`));
|
|
127
|
+
}, timeoutMs);
|
|
128
|
+
this.pending.set(id, { method, resolve, reject, timer });
|
|
129
|
+
this.proc.stdin.write(`${JSON.stringify(payload)}\n`);
|
|
130
|
+
});
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
notify(method, params = {}) {
|
|
134
|
+
if (!this.proc) throw new Error("codex app-server proxy is not running");
|
|
135
|
+
this.proc.stdin.write(`${JSON.stringify({ method, params })}\n`);
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
async stop() {
|
|
139
|
+
if (!this.proc) return;
|
|
140
|
+
this.proc.kill("SIGTERM");
|
|
141
|
+
this.proc = null;
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
function classifyCodexActivationError(error) {
|
|
146
|
+
const text = `${error?.message || ""}\n${error?.stderr || ""}\n${error?.data ? JSON.stringify(error.data) : ""}`;
|
|
147
|
+
if (/app-server-control|control socket|No such file|not running|missing/i.test(text)) {
|
|
148
|
+
return "codex_control_socket_unavailable";
|
|
149
|
+
}
|
|
150
|
+
if (/experimentalApi/i.test(text)) return "codex_experimental_api_rejected";
|
|
151
|
+
return "codex_reload_failed";
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
export async function activateCodexMcp({
|
|
155
|
+
client = null,
|
|
156
|
+
command = "codex",
|
|
157
|
+
cwd = process.cwd(),
|
|
158
|
+
threadId = process.env.CODEX_THREAD_ID,
|
|
159
|
+
timeoutMs = DEFAULT_TIMEOUT_MS,
|
|
160
|
+
} = {}) {
|
|
161
|
+
const rpc = client || new JsonlRpcClient({ command, cwd, timeoutMs });
|
|
162
|
+
try {
|
|
163
|
+
await rpc.start();
|
|
164
|
+
await rpc.request("initialize", {
|
|
165
|
+
clientInfo: {
|
|
166
|
+
name: "relay_setup",
|
|
167
|
+
title: "Relay Setup",
|
|
168
|
+
version: "0.1.0",
|
|
169
|
+
},
|
|
170
|
+
capabilities: {
|
|
171
|
+
experimentalApi: true,
|
|
172
|
+
optOutNotificationMethods: ["item/agentMessage/delta", "command/exec/outputDelta", "process/outputDelta"],
|
|
173
|
+
},
|
|
174
|
+
}, timeoutMs);
|
|
175
|
+
rpc.notify("initialized", {});
|
|
176
|
+
await rpc.request("config/mcpServer/reload", null, timeoutMs);
|
|
177
|
+
|
|
178
|
+
const deadline = Date.now() + timeoutMs;
|
|
179
|
+
let lastStatus = null;
|
|
180
|
+
let catalog = {
|
|
181
|
+
serverCatalogReady: false,
|
|
182
|
+
serverCatalogHasTool: false,
|
|
183
|
+
serverNames: [],
|
|
184
|
+
};
|
|
185
|
+
do {
|
|
186
|
+
lastStatus = await rpc.request("mcpServerStatus/list", {
|
|
187
|
+
detail: "toolsAndAuthOnly",
|
|
188
|
+
limit: 100,
|
|
189
|
+
...(threadId ? { threadId } : {}),
|
|
190
|
+
}, timeoutMs);
|
|
191
|
+
catalog = codexCatalogDetail(lastStatus);
|
|
192
|
+
if (catalog.serverCatalogHasTool) break;
|
|
193
|
+
await new Promise((resolve) => setTimeout(resolve, 300));
|
|
194
|
+
} while (Date.now() < deadline);
|
|
195
|
+
|
|
196
|
+
return {
|
|
197
|
+
host: "Codex",
|
|
198
|
+
ok: catalog.serverCatalogHasTool,
|
|
199
|
+
currentSessionReady: catalog.serverCatalogHasTool,
|
|
200
|
+
reason: catalog.serverCatalogHasTool
|
|
201
|
+
? "relay_send_available_via_tool_search"
|
|
202
|
+
: "relay_send_not_listed",
|
|
203
|
+
readinessMode: catalog.serverCatalogHasTool ? "deferred_tool_search" : undefined,
|
|
204
|
+
verifiedTool: catalog.serverCatalogHasTool ? "relay_send" : undefined,
|
|
205
|
+
...catalog,
|
|
206
|
+
};
|
|
207
|
+
} catch (error) {
|
|
208
|
+
return {
|
|
209
|
+
host: "Codex",
|
|
210
|
+
ok: false,
|
|
211
|
+
currentSessionReady: false,
|
|
212
|
+
reason: classifyCodexActivationError(error),
|
|
213
|
+
detail: error?.stderr || error?.message || String(error),
|
|
214
|
+
};
|
|
215
|
+
} finally {
|
|
216
|
+
await rpc.stop?.();
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
export function verifyClaudeMcpRegistration({ runCommand, command = "claude" } = {}) {
|
|
221
|
+
const run = runCommand || ((cmd, args) => spawnSyncCompat(cmd, args));
|
|
222
|
+
const res = run(command, ["mcp", "get", "relay"]);
|
|
223
|
+
return {
|
|
224
|
+
host: "Claude Code",
|
|
225
|
+
ok: Boolean(res.ok),
|
|
226
|
+
currentSessionReady: false,
|
|
227
|
+
reason: res.ok ? "registered_for_new_sessions" : "registration_not_visible",
|
|
228
|
+
detail: res.out || "",
|
|
229
|
+
};
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
function spawnSyncCompat(cmd, args) {
|
|
233
|
+
const res = spawnSync(cmd, args, { encoding: "utf8", timeout: 10_000 });
|
|
234
|
+
return {
|
|
235
|
+
ok: !res.error && res.status === 0,
|
|
236
|
+
out: `${res.stdout || ""}${res.stderr || ""}`.trim(),
|
|
237
|
+
};
|
|
238
|
+
}
|