relay-companion 0.1.3 → 0.1.5
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 +52 -5
- package/overlay/inbox.html +18 -4
- package/package.json +1 -1
- package/src/client.js +4 -0
- package/src/install.js +12 -6
- package/src/mcp.js +16 -1
- package/src/notifications.js +7 -2
- package/src/setup-activation.js +205 -0
- package/src/setup-open.js +83 -0
package/bin/relay.js
CHANGED
|
@@ -11,8 +11,10 @@ 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 { shouldRequireLiveTools } from "../src/setup-activation.js";
|
|
14
15
|
import { openRelay, openTask } from "../src/materializer.js";
|
|
15
16
|
import { resetCompanionStateForAccount } from "../src/notifications.js";
|
|
17
|
+
import { finishSetupOpenRelay, normalizeSetupHost, setupOpenRelayToken, setupOpenStatus } from "../src/setup-open.js";
|
|
16
18
|
|
|
17
19
|
function parseFlags(argv) {
|
|
18
20
|
const flags = {};
|
|
@@ -59,14 +61,35 @@ async function cmdPair(flags, { promptForDefaults = true } = {}) {
|
|
|
59
61
|
console.log(`Paired as ${res.user.name} <${res.user.email}>. This device is now connected to Relay.`);
|
|
60
62
|
}
|
|
61
63
|
|
|
64
|
+
function printActivationStatus(activation) {
|
|
65
|
+
if (!activation) return;
|
|
66
|
+
if (activation.host === "Codex") {
|
|
67
|
+
if (activation.currentSessionReady) {
|
|
68
|
+
console.log("Relay tools are live in this Codex session (`relay_send` verified).");
|
|
69
|
+
return;
|
|
70
|
+
}
|
|
71
|
+
const detail = activation.reason ? ` (${activation.reason})` : "";
|
|
72
|
+
console.log(`Relay was added to Codex, but this open Codex session does not expose relay_send yet${detail}.`);
|
|
73
|
+
return;
|
|
74
|
+
}
|
|
75
|
+
if (activation.host === "Claude Code") {
|
|
76
|
+
if (activation.ok) {
|
|
77
|
+
console.log("Relay was added to Claude Code for new sessions. Existing Claude Code sessions may need their MCP tools reloaded from inside Claude.");
|
|
78
|
+
} else {
|
|
79
|
+
console.log("Relay could not verify the Claude Code MCP registration.");
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
|
|
62
84
|
/** Register the Relay tools into Claude Code + Codex and start the receive daemon. */
|
|
63
|
-
function applyInstall() {
|
|
64
|
-
const { installed, missing, daemon, pill } = runSetupInstall();
|
|
85
|
+
async function applyInstall({ requireLiveTools = false } = {}) {
|
|
86
|
+
const { installed, missing, daemon, pill, activations = [] } = await runSetupInstall();
|
|
65
87
|
if (installed.length) console.log(`Added Relay to ${installed.join(" and ")} on this machine.`);
|
|
66
88
|
for (const m of missing) {
|
|
67
89
|
if (!/registration failed/.test(m)) console.log(`${m} was not found here, so it was skipped.`);
|
|
68
90
|
else console.log(`Could not register ${m}.`);
|
|
69
91
|
}
|
|
92
|
+
for (const activation of activations) printActivationStatus(activation);
|
|
70
93
|
if (daemon.ok) console.log("Relay is running in the background and will start automatically when you log in.");
|
|
71
94
|
else if (daemon.reason === "autostart_unsupported_platform") {
|
|
72
95
|
console.log("Run `relay daemon` to receive relays (background autostart is macOS-only for now).");
|
|
@@ -75,14 +98,37 @@ function applyInstall() {
|
|
|
75
98
|
else if (pill?.reason === "pill_runtime_missing") {
|
|
76
99
|
console.log("Run `relay pill` to open the Relay pill.");
|
|
77
100
|
}
|
|
78
|
-
if (installed.length
|
|
101
|
+
if (requireLiveTools && installed.length && !activations.some((activation) => activation.currentSessionReady)) {
|
|
102
|
+
throw new Error(
|
|
103
|
+
"Relay paired and installed, but this running agent session cannot call relay_send yet. Setup is not ready in the current agent.",
|
|
104
|
+
);
|
|
105
|
+
}
|
|
106
|
+
return { installed, missing, daemon, pill, activations };
|
|
79
107
|
}
|
|
80
108
|
|
|
81
109
|
/** Full setup: pair this device, then install the tools + daemon. */
|
|
82
110
|
async function cmdSetup(flags) {
|
|
83
111
|
await cmdPair(flags, { promptForDefaults: Boolean(flags.interactive) });
|
|
84
112
|
console.log("");
|
|
85
|
-
applyInstall();
|
|
113
|
+
await applyInstall({ requireLiveTools: Boolean(flags["require-live-tools"]) || shouldRequireLiveTools() });
|
|
114
|
+
const token = setupOpenRelayToken(flags);
|
|
115
|
+
if (token) {
|
|
116
|
+
console.log("");
|
|
117
|
+
const host = normalizeSetupHost(flags.host || flags.in);
|
|
118
|
+
const result = await finishSetupOpenRelay({
|
|
119
|
+
token,
|
|
120
|
+
host,
|
|
121
|
+
log: (m) => process.stderr.write(`[relay] ${m}\n`),
|
|
122
|
+
});
|
|
123
|
+
const openStatus = setupOpenStatus(result.opened, openUrl);
|
|
124
|
+
if (openStatus.opened) {
|
|
125
|
+
console.log(`Opened relay ${result.relayId} in ${host === "codex" ? "Codex" : "Claude Code"}.`);
|
|
126
|
+
} else if (openStatus.url) {
|
|
127
|
+
console.log(`Relay ${result.relayId} is ready. Open this URL: ${openStatus.url}`);
|
|
128
|
+
} else {
|
|
129
|
+
console.log(`Relay ${result.relayId} is staged in the Relay pill.`);
|
|
130
|
+
}
|
|
131
|
+
}
|
|
86
132
|
}
|
|
87
133
|
|
|
88
134
|
/** Install the tools + daemon on a device that is already paired. */
|
|
@@ -90,7 +136,7 @@ async function cmdInstall() {
|
|
|
90
136
|
if (!readConfig().deviceToken) {
|
|
91
137
|
throw new Error("This device is not paired yet. Run `relay setup` with a pairing code first.");
|
|
92
138
|
}
|
|
93
|
-
applyInstall();
|
|
139
|
+
await applyInstall({ requireLiveTools: shouldRequireLiveTools() });
|
|
94
140
|
}
|
|
95
141
|
|
|
96
142
|
/** Remove the Relay tools from both agents and stop the background daemon. */
|
|
@@ -251,6 +297,7 @@ async function main() {
|
|
|
251
297
|
"",
|
|
252
298
|
"Usage:",
|
|
253
299
|
" relay setup [--code CODE] [--name NAME] Pair this machine and add Relay to Claude Code + Codex",
|
|
300
|
+
" relay setup --code CODE --open-relay TOKEN --host codex|claude",
|
|
254
301
|
" relay setup --interactive Prompt for API URL and device name during setup",
|
|
255
302
|
" relay install Add Relay to your agents (device already paired)",
|
|
256
303
|
" relay uninstall Remove Relay from your agents and stop the daemon",
|
package/overlay/inbox.html
CHANGED
|
@@ -14,6 +14,7 @@
|
|
|
14
14
|
--ink:#1f1a17; --ink-2:#2c2824;
|
|
15
15
|
--muted:#7c736b; --muted-2:#9a928a; --muted-3:#b7b1a6;
|
|
16
16
|
--accent:#305566; --accent-soft:rgba(48,85,102,.10);
|
|
17
|
+
--brand-anthropic:#D9704E;
|
|
17
18
|
--hair:rgba(31,26,23,.07); /* between relay/task items */
|
|
18
19
|
--hair-2:rgba(31,26,23,.06); /* between contact rows */
|
|
19
20
|
--serif:'Newsreader',Georgia,serif;
|
|
@@ -62,8 +63,10 @@
|
|
|
62
63
|
padding:0 18px; cursor:pointer;
|
|
63
64
|
}
|
|
64
65
|
.mark { display:block; }
|
|
65
|
-
.mark rect {
|
|
66
|
-
.
|
|
66
|
+
.mark rect { transition:fill .25s var(--settle); }
|
|
67
|
+
.mark rect:nth-child(1) { fill:var(--brand-anthropic); }
|
|
68
|
+
.mark rect:nth-child(2) { fill:var(--ink); }
|
|
69
|
+
.mark rect:nth-child(3) { fill:url(#relayCodexGradient); }
|
|
67
70
|
.word { font-family:var(--serif); font-size:19px; font-weight:500; letter-spacing:-.01em; color:var(--ink); line-height:1; }
|
|
68
71
|
.spacer { flex:1 1 auto; }
|
|
69
72
|
.minimize-hint {
|
|
@@ -276,7 +279,6 @@
|
|
|
276
279
|
height:100%; display:flex; flex-direction:column; align-items:center; justify-content:center;
|
|
277
280
|
gap:11px; padding:34px 28px; text-align:center;
|
|
278
281
|
}
|
|
279
|
-
.empty .mark rect { fill:var(--muted-3); }
|
|
280
282
|
.empty .t1 { font-family:var(--serif); font-size:16px; color:#4f4740; }
|
|
281
283
|
.empty .t2 { font-size:12.5px; color:var(--muted-2); max-width:210px; line-height:1.45; }
|
|
282
284
|
.gone { display:none !important; }
|
|
@@ -387,10 +389,22 @@
|
|
|
387
389
|
</style>
|
|
388
390
|
</head>
|
|
389
391
|
<body>
|
|
392
|
+
<svg width="0" height="0" viewBox="0 0 0 0" aria-hidden="true" focusable="false">
|
|
393
|
+
<defs>
|
|
394
|
+
<linearGradient id="relayCodexGradient" x1="0%" y1="0%" x2="100%" y2="100%">
|
|
395
|
+
<stop offset="0%" stop-color="#C1C1FA"/>
|
|
396
|
+
<stop offset="16%" stop-color="#C0B1F9"/>
|
|
397
|
+
<stop offset="38%" stop-color="#7790F7"/>
|
|
398
|
+
<stop offset="58%" stop-color="#5F74F6"/>
|
|
399
|
+
<stop offset="82%" stop-color="#342CF5"/>
|
|
400
|
+
<stop offset="100%" stop-color="#3229F5"/>
|
|
401
|
+
</linearGradient>
|
|
402
|
+
</defs>
|
|
403
|
+
</svg>
|
|
390
404
|
<div class="card" id="card">
|
|
391
405
|
<div class="lockup" id="lockup">
|
|
392
406
|
<svg class="mark" width="17" height="17" viewBox="0 0 16 16" aria-hidden="true">
|
|
393
|
-
<rect
|
|
407
|
+
<rect x="0" y="6" width="4" height="4"/>
|
|
394
408
|
<rect x="6" y="6" width="4" height="4"/>
|
|
395
409
|
<rect x="12" y="6" width="4" height="4"/>
|
|
396
410
|
</svg>
|
package/package.json
CHANGED
package/src/client.js
CHANGED
|
@@ -74,6 +74,10 @@ export class RelayClient {
|
|
|
74
74
|
return this.#req("GET", `/v1/open/${encodeURIComponent(token)}/packet`, undefined, { auth: false });
|
|
75
75
|
}
|
|
76
76
|
|
|
77
|
+
bindOpenRelay(token) {
|
|
78
|
+
return this.#req("POST", `/v1/open/${encodeURIComponent(token)}/bind`, {});
|
|
79
|
+
}
|
|
80
|
+
|
|
77
81
|
createFileUpload(payload) {
|
|
78
82
|
return this.#req("POST", "/v1/files", payload);
|
|
79
83
|
}
|
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. */
|
package/src/mcp.js
CHANGED
|
@@ -29,7 +29,22 @@ export const TOOLS = [
|
|
|
29
29
|
type: "array",
|
|
30
30
|
items: { type: "string", enum: ["codex", "claude_code", "claude_desktop", "claude_cowork"] },
|
|
31
31
|
},
|
|
32
|
-
attachments: {
|
|
32
|
+
attachments: {
|
|
33
|
+
type: "array",
|
|
34
|
+
description:
|
|
35
|
+
"Optional files to send with the relay. Each item must include id, name, contentType, bytes, and optionally sha256. If the recipient may not be on Relay and will receive email fallback, include contentBase64 with the exact file bytes so Relay can attach the files directly to the email; otherwise the API will reject the send instead of emailing a broken partial relay.",
|
|
36
|
+
items: {
|
|
37
|
+
type: "object",
|
|
38
|
+
properties: {
|
|
39
|
+
id: { type: "string" },
|
|
40
|
+
name: { type: "string" },
|
|
41
|
+
contentType: { type: "string" },
|
|
42
|
+
bytes: { type: "number" },
|
|
43
|
+
sha256: { type: "string" },
|
|
44
|
+
contentBase64: { type: "string" },
|
|
45
|
+
},
|
|
46
|
+
},
|
|
47
|
+
},
|
|
33
48
|
idempotencyKey: { type: "string" },
|
|
34
49
|
},
|
|
35
50
|
required: ["recipient", "title", "bodyMarkdown", "idempotencyKey"],
|
package/src/notifications.js
CHANGED
|
@@ -453,7 +453,10 @@ export function defaultStageRelayCompanionItem(notification) {
|
|
|
453
453
|
return stageRelayCompanionItem(notification);
|
|
454
454
|
}
|
|
455
455
|
|
|
456
|
-
export function stagePlainRelayItem(
|
|
456
|
+
export function stagePlainRelayItem(
|
|
457
|
+
{ item, packet, attachmentUrls = {} },
|
|
458
|
+
{ statePath = companionStatePath(), forceUnread = false } = {},
|
|
459
|
+
) {
|
|
457
460
|
if (!item?.relayId) throw new Error("stagePlainRelayItem requires an inbox item with relayId");
|
|
458
461
|
const state = readCompanionState(statePath);
|
|
459
462
|
state.packets ||= {};
|
|
@@ -474,7 +477,8 @@ export function stagePlainRelayItem({ item, packet, attachmentUrls = {} }, { sta
|
|
|
474
477
|
},
|
|
475
478
|
};
|
|
476
479
|
const contentPath = writeNotificationPacketContent({ id: item.relayId }, content, statePath);
|
|
477
|
-
const
|
|
480
|
+
const preserveSetupUnread = existing.setupImportedUnread === true && existing.state === "unread";
|
|
481
|
+
const readState = forceUnread || preserveSetupUnread ? "unread" : existing.state === "read" || item.state === "read" ? "read" : "unread";
|
|
478
482
|
const row = {
|
|
479
483
|
...existing,
|
|
480
484
|
id: item.relayId,
|
|
@@ -504,6 +508,7 @@ export function stagePlainRelayItem({ item, packet, attachmentUrls = {} }, { sta
|
|
|
504
508
|
attachmentUrls,
|
|
505
509
|
materializationDeferredReason: "relay_pill",
|
|
506
510
|
materializedSurfaces: existing.materializedSurfaces || { codex: false, claudeCode: false, claudeCowork: false },
|
|
511
|
+
setupImportedUnread: forceUnread || preserveSetupUnread || undefined,
|
|
507
512
|
};
|
|
508
513
|
state.packets[item.relayId] = row;
|
|
509
514
|
writeCompanionState(statePath, state);
|
|
@@ -0,0 +1,205 @@
|
|
|
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
|
+
class JsonlRpcClient {
|
|
25
|
+
constructor({ command = "codex", args = ["app-server", "proxy"], cwd = process.cwd(), timeoutMs = DEFAULT_TIMEOUT_MS } = {}) {
|
|
26
|
+
this.command = command;
|
|
27
|
+
this.args = args;
|
|
28
|
+
this.cwd = cwd;
|
|
29
|
+
this.timeoutMs = timeoutMs;
|
|
30
|
+
this.nextId = 1;
|
|
31
|
+
this.pending = new Map();
|
|
32
|
+
this.proc = null;
|
|
33
|
+
this.stderr = "";
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
async start() {
|
|
37
|
+
if (this.proc) return;
|
|
38
|
+
this.proc = spawn(this.command, this.args, {
|
|
39
|
+
cwd: this.cwd,
|
|
40
|
+
stdio: ["pipe", "pipe", "pipe"],
|
|
41
|
+
env: { ...process.env },
|
|
42
|
+
});
|
|
43
|
+
this.proc.stderr.on("data", (chunk) => {
|
|
44
|
+
this.stderr += chunk.toString();
|
|
45
|
+
});
|
|
46
|
+
this.proc.on("error", (error) => {
|
|
47
|
+
this.rejectAll(error);
|
|
48
|
+
this.proc = null;
|
|
49
|
+
});
|
|
50
|
+
this.proc.on("exit", (code, signal) => {
|
|
51
|
+
const exitLabel = signal || (code ?? "unknown");
|
|
52
|
+
const err = new Error(`codex app-server proxy exited (${exitLabel})`);
|
|
53
|
+
if (this.stderr.trim()) err.stderr = this.stderr.trim();
|
|
54
|
+
this.rejectAll(err);
|
|
55
|
+
this.proc = null;
|
|
56
|
+
});
|
|
57
|
+
readline.createInterface({ input: this.proc.stdout, crlfDelay: Infinity }).on("line", (line) => {
|
|
58
|
+
if (!line.trim()) return;
|
|
59
|
+
let message;
|
|
60
|
+
try {
|
|
61
|
+
message = JSON.parse(line);
|
|
62
|
+
} catch {
|
|
63
|
+
return;
|
|
64
|
+
}
|
|
65
|
+
if (message.id == null || !this.pending.has(message.id)) return;
|
|
66
|
+
const pending = this.pending.get(message.id);
|
|
67
|
+
this.pending.delete(message.id);
|
|
68
|
+
clearTimeout(pending.timer);
|
|
69
|
+
if (message.error) {
|
|
70
|
+
const err = new Error(message.error.message || `${pending.method} failed`);
|
|
71
|
+
err.code = message.error.code;
|
|
72
|
+
err.data = message.error.data;
|
|
73
|
+
pending.reject(err);
|
|
74
|
+
} else {
|
|
75
|
+
pending.resolve(message.result ?? {});
|
|
76
|
+
}
|
|
77
|
+
});
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
rejectAll(error) {
|
|
81
|
+
for (const pending of this.pending.values()) {
|
|
82
|
+
clearTimeout(pending.timer);
|
|
83
|
+
pending.reject(error);
|
|
84
|
+
}
|
|
85
|
+
this.pending.clear();
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
request(method, params = undefined, timeoutMs = this.timeoutMs) {
|
|
89
|
+
if (!this.proc) return Promise.reject(new Error("codex app-server proxy is not running"));
|
|
90
|
+
const id = this.nextId++;
|
|
91
|
+
const payload = params === undefined ? { method, id } : { method, id, params };
|
|
92
|
+
return new Promise((resolve, reject) => {
|
|
93
|
+
const timer = setTimeout(() => {
|
|
94
|
+
this.pending.delete(id);
|
|
95
|
+
reject(new Error(`${method} timed out after ${timeoutMs}ms`));
|
|
96
|
+
}, timeoutMs);
|
|
97
|
+
this.pending.set(id, { method, resolve, reject, timer });
|
|
98
|
+
this.proc.stdin.write(`${JSON.stringify(payload)}\n`);
|
|
99
|
+
});
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
notify(method, params = {}) {
|
|
103
|
+
if (!this.proc) throw new Error("codex app-server proxy is not running");
|
|
104
|
+
this.proc.stdin.write(`${JSON.stringify({ method, params })}\n`);
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
async stop() {
|
|
108
|
+
if (!this.proc) return;
|
|
109
|
+
this.proc.kill("SIGTERM");
|
|
110
|
+
this.proc = null;
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
function classifyCodexActivationError(error) {
|
|
115
|
+
const text = `${error?.message || ""}\n${error?.stderr || ""}\n${error?.data ? JSON.stringify(error.data) : ""}`;
|
|
116
|
+
if (/app-server-control|control socket|No such file|not running|missing/i.test(text)) {
|
|
117
|
+
return "codex_control_socket_unavailable";
|
|
118
|
+
}
|
|
119
|
+
if (/experimentalApi/i.test(text)) return "codex_experimental_api_rejected";
|
|
120
|
+
return "codex_reload_failed";
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
export async function activateCodexMcp({
|
|
124
|
+
client = null,
|
|
125
|
+
command = "codex",
|
|
126
|
+
cwd = process.cwd(),
|
|
127
|
+
threadId = process.env.CODEX_THREAD_ID,
|
|
128
|
+
timeoutMs = DEFAULT_TIMEOUT_MS,
|
|
129
|
+
} = {}) {
|
|
130
|
+
const rpc = client || new JsonlRpcClient({ command, cwd, timeoutMs });
|
|
131
|
+
try {
|
|
132
|
+
await rpc.start();
|
|
133
|
+
await rpc.request("initialize", {
|
|
134
|
+
clientInfo: {
|
|
135
|
+
name: "relay_setup",
|
|
136
|
+
title: "Relay Setup",
|
|
137
|
+
version: "0.1.0",
|
|
138
|
+
},
|
|
139
|
+
capabilities: {
|
|
140
|
+
experimentalApi: true,
|
|
141
|
+
optOutNotificationMethods: ["item/agentMessage/delta", "command/exec/outputDelta", "process/outputDelta"],
|
|
142
|
+
},
|
|
143
|
+
}, timeoutMs);
|
|
144
|
+
rpc.notify("initialized", {});
|
|
145
|
+
await rpc.request("config/mcpServer/reload", null, timeoutMs);
|
|
146
|
+
|
|
147
|
+
const deadline = Date.now() + timeoutMs;
|
|
148
|
+
let lastStatus = null;
|
|
149
|
+
do {
|
|
150
|
+
lastStatus = await rpc.request("mcpServerStatus/list", {
|
|
151
|
+
detail: "toolsAndAuthOnly",
|
|
152
|
+
limit: 100,
|
|
153
|
+
...(threadId ? { threadId } : {}),
|
|
154
|
+
}, timeoutMs);
|
|
155
|
+
if (hasMcpTool(lastStatus)) {
|
|
156
|
+
return {
|
|
157
|
+
host: "Codex",
|
|
158
|
+
ok: true,
|
|
159
|
+
currentSessionReady: true,
|
|
160
|
+
reason: "relay_send_verified",
|
|
161
|
+
verifiedTool: "relay_send",
|
|
162
|
+
};
|
|
163
|
+
}
|
|
164
|
+
await new Promise((resolve) => setTimeout(resolve, 300));
|
|
165
|
+
} while (Date.now() < deadline);
|
|
166
|
+
|
|
167
|
+
return {
|
|
168
|
+
host: "Codex",
|
|
169
|
+
ok: false,
|
|
170
|
+
currentSessionReady: false,
|
|
171
|
+
reason: "relay_send_not_listed",
|
|
172
|
+
serverNames: serverNames(lastStatus),
|
|
173
|
+
};
|
|
174
|
+
} catch (error) {
|
|
175
|
+
return {
|
|
176
|
+
host: "Codex",
|
|
177
|
+
ok: false,
|
|
178
|
+
currentSessionReady: false,
|
|
179
|
+
reason: classifyCodexActivationError(error),
|
|
180
|
+
detail: error?.stderr || error?.message || String(error),
|
|
181
|
+
};
|
|
182
|
+
} finally {
|
|
183
|
+
await rpc.stop?.();
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
export function verifyClaudeMcpRegistration({ runCommand, command = "claude" } = {}) {
|
|
188
|
+
const run = runCommand || ((cmd, args) => spawnSyncCompat(cmd, args));
|
|
189
|
+
const res = run(command, ["mcp", "get", "relay"]);
|
|
190
|
+
return {
|
|
191
|
+
host: "Claude Code",
|
|
192
|
+
ok: Boolean(res.ok),
|
|
193
|
+
currentSessionReady: false,
|
|
194
|
+
reason: res.ok ? "registered_for_new_sessions" : "registration_not_visible",
|
|
195
|
+
detail: res.out || "",
|
|
196
|
+
};
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
function spawnSyncCompat(cmd, args) {
|
|
200
|
+
const res = spawnSync(cmd, args, { encoding: "utf8", timeout: 10_000 });
|
|
201
|
+
return {
|
|
202
|
+
ok: !res.error && res.status === 0,
|
|
203
|
+
out: `${res.stdout || ""}${res.stderr || ""}`.trim(),
|
|
204
|
+
};
|
|
205
|
+
}
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
import { RelayClient } from "./client.js";
|
|
2
|
+
import { openRelay } from "./materializer.js";
|
|
3
|
+
import { stagePlainRelayItem } from "./notifications.js";
|
|
4
|
+
|
|
5
|
+
export function normalizeSetupHost(host) {
|
|
6
|
+
const clean = String(host || "").trim().toLowerCase();
|
|
7
|
+
return clean === "codex" ? "codex" : "claude";
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export function setupOpenRelayToken(flags = {}) {
|
|
11
|
+
return String(flags["open-relay"] || flags.openRelay || flags.relay || "").trim() || null;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export function setupOpenStatus(opened, openUrlFn = () => false) {
|
|
15
|
+
if (!opened) return { opened: false, fallbackAttempted: false, url: null };
|
|
16
|
+
if (opened.openedInHost || opened.skipExternalOpen) {
|
|
17
|
+
return { opened: true, fallbackAttempted: false, url: opened.url || null };
|
|
18
|
+
}
|
|
19
|
+
if (!opened.url) return { opened: false, fallbackAttempted: false, url: null };
|
|
20
|
+
return { opened: Boolean(openUrlFn(opened.url)), fallbackAttempted: true, url: opened.url };
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
function inboxItemFromOpenRelay(relay) {
|
|
25
|
+
return {
|
|
26
|
+
relayId: relay.relayId,
|
|
27
|
+
state: "delivered",
|
|
28
|
+
createdAt: relay.createdAt,
|
|
29
|
+
updatedAt: relay.createdAt,
|
|
30
|
+
kind: relay.kind,
|
|
31
|
+
title: relay.title,
|
|
32
|
+
displayTitle: relay.title,
|
|
33
|
+
sender: relay.sender,
|
|
34
|
+
preview: relay.preview,
|
|
35
|
+
hasAttachments: Boolean(relay.attachments?.length),
|
|
36
|
+
};
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export async function stageCurrentInbox({ client = new RelayClient(), stage = stagePlainRelayItem, forceUnread = false, log = () => {} } = {}) {
|
|
40
|
+
const inbox = await client.inbox();
|
|
41
|
+
const staged = [];
|
|
42
|
+
for (const item of inbox.items || []) {
|
|
43
|
+
try {
|
|
44
|
+
const fetched = await client.fetchRelay(item.relayId);
|
|
45
|
+
stage({ item, packet: fetched.packet, attachmentUrls: fetched.attachmentUrls || {} }, { forceUnread });
|
|
46
|
+
staged.push(item.relayId);
|
|
47
|
+
} catch (error) {
|
|
48
|
+
log(`could not stage relay ${item.relayId}: ${error instanceof Error ? error.message : String(error)}`);
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
return staged;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export async function finishSetupOpenRelay({
|
|
55
|
+
token,
|
|
56
|
+
host = "claude",
|
|
57
|
+
client = new RelayClient(),
|
|
58
|
+
stage = stagePlainRelayItem,
|
|
59
|
+
openRelayFn = openRelay,
|
|
60
|
+
log = () => {},
|
|
61
|
+
} = {}) {
|
|
62
|
+
const cleanToken = String(token || "").trim();
|
|
63
|
+
if (!cleanToken) return null;
|
|
64
|
+
|
|
65
|
+
const bound = await client.bindOpenRelay(cleanToken);
|
|
66
|
+
const relay = bound?.relay;
|
|
67
|
+
if (!relay?.relayId) {
|
|
68
|
+
throw new Error("Relay setup paired this device, but the relay link could not be attached to the account.");
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
const stagedIds = await stageCurrentInbox({ client, stage, forceUnread: true, log });
|
|
72
|
+
if (!stagedIds.includes(relay.relayId)) {
|
|
73
|
+
const fetched = await client.fetchRelay(relay.relayId);
|
|
74
|
+
stage({
|
|
75
|
+
item: inboxItemFromOpenRelay(relay),
|
|
76
|
+
packet: fetched.packet,
|
|
77
|
+
attachmentUrls: fetched.attachmentUrls || {},
|
|
78
|
+
}, { forceUnread: true });
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
const result = await openRelayFn({ id: relay.relayId, host: normalizeSetupHost(host), log });
|
|
82
|
+
return { relayId: relay.relayId, staged: true, opened: result };
|
|
83
|
+
}
|