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 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) console.log("Open Claude Code or Codex and your Relay tools are ready.");
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",
@@ -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 { fill:var(--ink); transition:fill .25s var(--settle); }
66
- .card.has-unread .sq0 { fill:var(--accent); }
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 class="sq0" x="0" y="6" width="4" height="4"/>
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "relay-companion",
3
- "version": "0.1.3",
3
+ "version": "0.1.5",
4
4
  "description": "Relay companion: connects local coding agents to Relay tasks, approvals, and connector tools.",
5
5
  "type": "module",
6
6
  "bin": {
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)) installed.push("Claude Code");
184
- else missing.push("Claude Code (registration failed)");
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)) installed.push("Codex");
188
- else missing.push("Codex (registration failed)");
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: { type: "array", items: { type: "object" } },
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"],
@@ -453,7 +453,10 @@ export function defaultStageRelayCompanionItem(notification) {
453
453
  return stageRelayCompanionItem(notification);
454
454
  }
455
455
 
456
- export function stagePlainRelayItem({ item, packet, attachmentUrls = {} }, { statePath = companionStatePath() } = {}) {
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 readState = existing.state === "read" || item.state === "read" ? "read" : "unread";
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
+ }