relay-companion 0.1.0 → 0.1.2

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
@@ -7,11 +7,12 @@ import { spawnSync, spawn } from "node:child_process";
7
7
  import readline from "node:readline/promises";
8
8
  import { stdin, stdout } from "node:process";
9
9
  import { RelayClient } from "../src/client.js";
10
- import { writeConfig, readConfig, apiUrl, webUrl } from "../src/config.js";
10
+ import { writeConfig, readConfig, apiUrl, DEFAULT_API_URL, DEFAULT_WEB_URL } from "../src/config.js";
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
14
  import { openRelay, openTask } from "../src/materializer.js";
15
+ import { resetCompanionStateForAccount } from "../src/notifications.js";
15
16
 
16
17
  function parseFlags(argv) {
17
18
  const flags = {};
@@ -38,22 +39,29 @@ async function prompt(question, fallback = "") {
38
39
  return answer || fallback;
39
40
  }
40
41
 
41
- async function cmdPair(flags) {
42
- const url = flags.api || (await prompt(`Relay API URL [${apiUrl()}]: `, apiUrl()));
43
- const appUrl = flags.web || webUrl();
42
+ async function cmdPair(flags, { promptForDefaults = true } = {}) {
43
+ let url = flags.api || process.env.RELAY_API_URL;
44
+ if (!url) {
45
+ url = promptForDefaults ? await prompt(`Relay API URL [${apiUrl()}]: `, apiUrl()) : DEFAULT_API_URL;
46
+ }
47
+ const appUrl = flags.web || process.env.RELAY_WEB_URL || DEFAULT_WEB_URL;
44
48
  let code = flags.code;
45
49
  if (!code) code = await prompt("Pairing code (from your Relay app): ");
46
- const name = flags.name || (await prompt(`Device name [${os.hostname()}]: `, os.hostname()));
50
+ let name = flags.name;
51
+ if (!name) {
52
+ name = promptForDefaults ? await prompt(`Device name [${os.hostname()}]: `, os.hostname()) : os.hostname();
53
+ }
47
54
  writeConfig({ apiUrl: url, webUrl: appUrl });
48
55
  const client = new RelayClient({ url });
49
56
  const res = await client.registerDevice({ pairingCode: code, name, platform: process.platform });
50
57
  writeConfig({ apiUrl: url, webUrl: appUrl, deviceToken: res.deviceToken, deviceId: res.deviceId, user: res.user });
58
+ resetCompanionStateForAccount({ user: res.user, deviceId: res.deviceId });
51
59
  console.log(`Paired as ${res.user.name} <${res.user.email}>. This device is now connected to Relay.`);
52
60
  }
53
61
 
54
62
  /** Register the Relay tools into Claude Code + Codex and start the receive daemon. */
55
63
  function applyInstall() {
56
- const { installed, missing, daemon } = runSetupInstall();
64
+ const { installed, missing, daemon, pill } = runSetupInstall();
57
65
  if (installed.length) console.log(`Added Relay to ${installed.join(" and ")} on this machine.`);
58
66
  for (const m of missing) {
59
67
  if (!/registration failed/.test(m)) console.log(`${m} was not found here, so it was skipped.`);
@@ -63,12 +71,16 @@ function applyInstall() {
63
71
  else if (daemon.reason === "autostart_unsupported_platform") {
64
72
  console.log("Run `relay daemon` to receive relays (background autostart is macOS-only for now).");
65
73
  }
74
+ if (pill?.ok) console.log("The Relay pill is open and will start automatically when you log in.");
75
+ else if (pill?.reason === "pill_runtime_missing") {
76
+ console.log("Run `relay pill` to open the Relay pill.");
77
+ }
66
78
  if (installed.length) console.log("Open Claude Code or Codex and your Relay tools are ready.");
67
79
  }
68
80
 
69
81
  /** Full setup: pair this device, then install the tools + daemon. */
70
82
  async function cmdSetup(flags) {
71
- await cmdPair(flags);
83
+ await cmdPair(flags, { promptForDefaults: Boolean(flags.interactive) });
72
84
  console.log("");
73
85
  applyInstall();
74
86
  }
@@ -137,7 +149,7 @@ function openUrl(url) {
137
149
  // detail. Prints the identical JSON contract.
138
150
  async function cmdOpen(positional, flags) {
139
151
  const log = (m) => process.stderr.write(`[relay] ${m}\n`);
140
- const host = String(flags.host || "claude").toLowerCase();
152
+ const host = String(flags.host || flags.in || "claude").toLowerCase();
141
153
  if (flags.task) {
142
154
  const result = await openTask({ taskId: String(flags.task), host, log });
143
155
  console.log(JSON.stringify(result, null, 2));
@@ -239,6 +251,7 @@ async function main() {
239
251
  "",
240
252
  "Usage:",
241
253
  " relay setup [--code CODE] [--name NAME] Pair this machine and add Relay to Claude Code + Codex",
254
+ " relay setup --interactive Prompt for API URL and device name during setup",
242
255
  " relay install Add Relay to your agents (device already paired)",
243
256
  " relay uninstall Remove Relay from your agents and stop the daemon",
244
257
  " relay pair [--api URL] [--web URL] [--code CODE] Pair this machine only (no agent install)",
@@ -66,6 +66,21 @@
66
66
  .card.has-unread .sq0 { fill:var(--accent); }
67
67
  .word { font-family:var(--serif); font-size:19px; font-weight:500; letter-spacing:-.01em; color:var(--ink); line-height:1; }
68
68
  .spacer { flex:1 1 auto; }
69
+ .minimize-hint {
70
+ margin-right:10px;
71
+ font-size:11px;
72
+ font-weight:500;
73
+ color:var(--muted-3);
74
+ line-height:1;
75
+ transition:color .18s var(--settle), opacity .18s var(--settle), width .18s var(--settle), margin .18s var(--settle);
76
+ }
77
+ .lockup:hover .minimize-hint { color:var(--muted-2); }
78
+ .card.collapsed .minimize-hint {
79
+ width:0;
80
+ margin-right:0;
81
+ overflow:hidden;
82
+ opacity:0;
83
+ }
69
84
  .count {
70
85
  font-family:var(--mono); font-size:13px; font-weight:500; font-variant-numeric:tabular-nums;
71
86
  color:var(--accent); line-height:1; transition:color .3s var(--settle);
@@ -381,6 +396,7 @@
381
396
  </svg>
382
397
  <span class="word">relay</span>
383
398
  <span class="spacer"></span>
399
+ <span class="minimize-hint">Minimize</span>
384
400
  <span class="count zero" id="count">0</span>
385
401
  </div>
386
402
 
@@ -700,6 +716,7 @@
700
716
  task_cancelled: "Task cancelled",
701
717
  task_failed: "Task failed",
702
718
  connector_reauth: "Reconnect needed",
719
+ plain_relay: "Relay",
703
720
  };
704
721
  function relayKindLabel(kind) { return RELAY_KIND_LABEL[kind] || "Relay"; }
705
722
  // The clean split: Relays shows ONLY the urgent quick-reply (human_question) and
@@ -721,7 +738,7 @@
721
738
  // single-line subjects. Source: HumanResponse.question (protocol.ts), the real question.
722
739
  return firstLine(body, 240) || title || relayKindLabel(kind);
723
740
  }
724
- if (kind === "task_completed" || kind === "result") {
741
+ if (kind === "plain_relay" || kind === "task_completed" || kind === "result") {
725
742
  return title || firstLine(body, 90) || relayKindLabel(kind);
726
743
  }
727
744
  // plain message / notice: subject is the first meaningful body line
package/overlay/main.cjs CHANGED
@@ -69,6 +69,7 @@ async function relayClient() {
69
69
  }
70
70
 
71
71
  app.setName("Relay");
72
+ const gotSingleInstanceLock = app.requestSingleInstanceLock();
72
73
 
73
74
  // ---- config / account ----------------------------------------------------
74
75
 
@@ -121,7 +122,35 @@ function taskWebUrl(taskId) {
121
122
 
122
123
  function readStore() {
123
124
  try {
124
- return JSON.parse(fs.readFileSync(STATE_PATH, "utf8")) || {};
125
+ const store = JSON.parse(fs.readFileSync(STATE_PATH, "utf8")) || {};
126
+ const cfg = readConfigFile();
127
+ const current = {
128
+ userId: (cfg.user && cfg.user.id) || "",
129
+ email: (cfg.user && cfg.user.email) || "",
130
+ deviceId: cfg.deviceId || "",
131
+ };
132
+ if (current.userId || current.email) {
133
+ const account = store.account || {};
134
+ const missingAccount = !account.userId && !account.email;
135
+ const wrongUserId = account.userId && current.userId && account.userId !== current.userId;
136
+ const wrongEmail = account.email && current.email && account.email !== current.email;
137
+ if (missingAccount || wrongUserId || wrongEmail) {
138
+ const next = {
139
+ version: 1,
140
+ account: { ...current, resetAt: new Date().toISOString() },
141
+ profile: { name: "", handle: "", email: "", inboxDir: "", contactCardRoots: [], transport: { type: "relay_api" } },
142
+ contacts: [],
143
+ packets: {},
144
+ meetingNotes: {},
145
+ setup: {},
146
+ emailThreads: {},
147
+ chats: {},
148
+ };
149
+ writeStateAtomic(next);
150
+ return next;
151
+ }
152
+ }
153
+ return store;
125
154
  } catch {
126
155
  return {};
127
156
  }
@@ -294,12 +323,18 @@ function ackPacket(packetId) {
294
323
  if (!packetId) return;
295
324
  const store = readStore();
296
325
  if (!store.packets || !store.packets[packetId]) return;
326
+ const row = store.packets[packetId];
297
327
  store.packets[packetId] = {
298
- ...store.packets[packetId],
328
+ ...row,
299
329
  state: "read",
300
330
  updatedAt: new Date().toISOString(),
301
331
  };
302
332
  writeStateAtomic(store);
333
+ if (row.relayNotificationKind === "plain_relay") {
334
+ relayClient()
335
+ .then((client) => client.markRead(packetId, { idempotencyKey: idempotencyKey("read") }))
336
+ .catch((error) => console.error("[overlay] relay mark-read failed:", error && error.message));
337
+ }
303
338
  pushInbox(true).catch(() => {});
304
339
  }
305
340
 
@@ -752,11 +787,21 @@ ipcMain.handle("relay:soundBytes", (_e, name) => {
752
787
  return null; // gracefully null when no sound is available
753
788
  });
754
789
 
755
- app.whenReady().then(() => {
756
- if (process.platform === "darwin" && app.dock) app.dock.hide(); // accessory: no dock icon, no NC banners
757
- createWindow();
758
- });
790
+ if (!gotSingleInstanceLock) {
791
+ app.quit();
792
+ } else {
793
+ app.on("second-instance", () => {
794
+ if (!win || win.isDestroyed()) return;
795
+ pollHosts();
796
+ maybeShow();
797
+ });
759
798
 
760
- app.on("window-all-closed", () => {
761
- /* keep running as a background agent; relaunched by launchd KeepAlive if it dies */
762
- });
799
+ app.whenReady().then(() => {
800
+ if (process.platform === "darwin" && app.dock) app.dock.hide(); // accessory: no dock icon, no NC banners
801
+ createWindow();
802
+ });
803
+
804
+ app.on("window-all-closed", () => {
805
+ /* keep running as a background agent; relaunched at login by launchd */
806
+ });
807
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "relay-companion",
3
- "version": "0.1.0",
3
+ "version": "0.1.2",
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
@@ -46,6 +46,34 @@ export class RelayClient {
46
46
  return this.#req("GET", "/v1/task-relays");
47
47
  }
48
48
 
49
+ sendRelay(payload) {
50
+ return this.#req("POST", "/v1/relays", payload);
51
+ }
52
+
53
+ inbox() {
54
+ return this.#req("GET", "/v1/inbox");
55
+ }
56
+
57
+ fetchRelay(id) {
58
+ return this.#req("GET", `/v1/relays/${encodeURIComponent(id)}`);
59
+ }
60
+
61
+ acknowledge(id, payload = {}) {
62
+ return this.#req("POST", `/v1/relays/${encodeURIComponent(id)}/ack`, payload);
63
+ }
64
+
65
+ markRead(id, payload = {}) {
66
+ return this.#req("POST", `/v1/relays/${encodeURIComponent(id)}/read`, payload);
67
+ }
68
+
69
+ openRelay(token) {
70
+ return this.#req("GET", `/v1/open/${encodeURIComponent(token)}`, undefined, { auth: false });
71
+ }
72
+
73
+ openRelayPacket(token) {
74
+ return this.#req("GET", `/v1/open/${encodeURIComponent(token)}/packet`, undefined, { auth: false });
75
+ }
76
+
49
77
  createFileUpload(payload) {
50
78
  return this.#req("POST", "/v1/files", payload);
51
79
  }
package/src/config.js CHANGED
@@ -2,6 +2,9 @@ import fs from "node:fs";
2
2
  import os from "node:os";
3
3
  import path from "node:path";
4
4
 
5
+ export const DEFAULT_API_URL = "https://aia6vj5pgp.us-east-1.awsapprunner.com";
6
+ export const DEFAULT_WEB_URL = "https://sendrelays.com";
7
+
5
8
  /**
6
9
  * Companion configuration lives in ~/.relay/config.json. It holds the device
7
10
  * token issued at pairing and the API base URL. Environment variables override
@@ -36,7 +39,7 @@ export function apiUrl() {
36
39
  return (
37
40
  process.env.RELAY_API_URL ||
38
41
  readConfig().apiUrl ||
39
- "https://aia6vj5pgp.us-east-1.awsapprunner.com"
42
+ DEFAULT_API_URL
40
43
  );
41
44
  }
42
45
 
@@ -44,7 +47,7 @@ export function webUrl() {
44
47
  return (
45
48
  process.env.RELAY_WEB_URL ||
46
49
  readConfig().webUrl ||
47
- "http://localhost:3000"
50
+ DEFAULT_WEB_URL
48
51
  ).replace(/\/$/, "");
49
52
  }
50
53
 
package/src/install.js CHANGED
@@ -2,6 +2,7 @@ import { spawnSync } from "node:child_process";
2
2
  import fs from "node:fs";
3
3
  import os from "node:os";
4
4
  import path from "node:path";
5
+ import { createRequire } from "node:module";
5
6
  import { fileURLToPath } from "node:url";
6
7
 
7
8
  // Persistent install of the Relay companion into the user's local agents.
@@ -12,6 +13,7 @@ import { fileURLToPath } from "node:url";
12
13
  // It also installs a launchd agent that keeps the receive daemon running.
13
14
 
14
15
  const DAEMON_LAUNCH_LABEL = "work.relay.companion";
16
+ const PILL_LAUNCH_LABEL = "work.relay.companion.pill";
15
17
 
16
18
  export const PACKAGE_NAME = "relay-companion";
17
19
 
@@ -20,6 +22,17 @@ export function relayBinPath() {
20
22
  return path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../bin/relay.js");
21
23
  }
22
24
 
25
+ function packageRootForBin(bin = relayBinPath()) {
26
+ return path.resolve(path.dirname(bin), "..");
27
+ }
28
+
29
+ function plistEscape(value) {
30
+ return String(value)
31
+ .replaceAll("&", "&amp;")
32
+ .replaceAll("<", "&lt;")
33
+ .replaceAll(">", "&gt;");
34
+ }
35
+
23
36
  /**
24
37
  * A STABLE path to the companion CLI that the agents and the daemon can keep
25
38
  * launching. When `setup` runs from npx (an ephemeral cache that gets cleaned),
@@ -49,6 +62,16 @@ function run(cmd, args) {
49
62
  };
50
63
  }
51
64
 
65
+ function electronPathForPackageRoot(packageRoot) {
66
+ try {
67
+ const require = createRequire(path.join(packageRoot, "package.json"));
68
+ const electronPath = require("electron");
69
+ return typeof electronPath === "string" && electronPath ? electronPath : null;
70
+ } catch {
71
+ return null;
72
+ }
73
+ }
74
+
52
75
  /** Whether an agent CLI is on PATH. */
53
76
  export function commandExists(cmd) {
54
77
  const res = spawnSync(cmd, ["--version"], { encoding: "utf8", timeout: 8000 });
@@ -85,17 +108,59 @@ export function installDaemonAutostart(bin = relayBinPath(), node = process.exec
85
108
  <key>Label</key><string>${DAEMON_LAUNCH_LABEL}</string>
86
109
  <key>ProgramArguments</key>
87
110
  <array>
88
- <string>${node}</string>
89
- <string>${bin}</string>
111
+ <string>${plistEscape(node)}</string>
112
+ <string>${plistEscape(bin)}</string>
90
113
  <string>daemon</string>
91
114
  </array>
92
115
  <key>RunAtLoad</key><true/>
93
116
  <key>KeepAlive</key><true/>
94
- <key>StandardOutPath</key><string>${logPath}</string>
95
- <key>StandardErrorPath</key><string>${logPath}</string>
117
+ <key>StandardOutPath</key><string>${plistEscape(logPath)}</string>
118
+ <key>StandardErrorPath</key><string>${plistEscape(logPath)}</string>
119
+ <key>EnvironmentVariables</key>
120
+ <dict>
121
+ <key>PATH</key><string>${plistEscape(pathEnv)}</string>
122
+ </dict>
123
+ </dict>
124
+ </plist>
125
+ `;
126
+ fs.writeFileSync(plistPath, plist);
127
+ run("launchctl", ["unload", plistPath]); // ignore if not loaded
128
+ const res = run("launchctl", ["load", plistPath]);
129
+ return { ok: res.ok, plistPath, logPath };
130
+ }
131
+
132
+ /** Install + load a launchd agent that starts the visible Relay pill (macOS). */
133
+ export function installPillAutostart(bin = relayBinPath()) {
134
+ if (process.platform !== "darwin") return { ok: false, reason: "autostart_unsupported_platform" };
135
+ const packageRoot = packageRootForBin(bin);
136
+ const electronPath = electronPathForPackageRoot(packageRoot);
137
+ const overlayMain = path.join(packageRoot, "overlay", "main.cjs");
138
+ if (!electronPath || !fs.existsSync(overlayMain)) {
139
+ return { ok: false, reason: "pill_runtime_missing", electronPath, overlayMain };
140
+ }
141
+
142
+ const home = os.homedir();
143
+ const plistPath = path.join(home, "Library", "LaunchAgents", `${PILL_LAUNCH_LABEL}.plist`);
144
+ const logPath = path.join(home, ".relay", "pill.log");
145
+ fs.mkdirSync(path.dirname(plistPath), { recursive: true });
146
+ fs.mkdirSync(path.dirname(logPath), { recursive: true });
147
+ const pathEnv = "/opt/homebrew/bin:/usr/local/bin:/usr/bin:/bin";
148
+ const plist = `<?xml version="1.0" encoding="UTF-8"?>
149
+ <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
150
+ <plist version="1.0">
151
+ <dict>
152
+ <key>Label</key><string>${PILL_LAUNCH_LABEL}</string>
153
+ <key>ProgramArguments</key>
154
+ <array>
155
+ <string>${plistEscape(electronPath)}</string>
156
+ <string>${plistEscape(overlayMain)}</string>
157
+ </array>
158
+ <key>RunAtLoad</key><true/>
159
+ <key>StandardOutPath</key><string>${plistEscape(logPath)}</string>
160
+ <key>StandardErrorPath</key><string>${plistEscape(logPath)}</string>
96
161
  <key>EnvironmentVariables</key>
97
162
  <dict>
98
- <key>PATH</key><string>${pathEnv}</string>
163
+ <key>PATH</key><string>${plistEscape(pathEnv)}</string>
99
164
  </dict>
100
165
  </dict>
101
166
  </plist>
@@ -123,7 +188,8 @@ export function runSetupInstall() {
123
188
  else missing.push("Codex (registration failed)");
124
189
  } else missing.push("Codex");
125
190
  const daemon = installDaemonAutostart(bin);
126
- return { installed, missing, daemon };
191
+ const pill = installPillAutostart(bin);
192
+ return { installed, missing, daemon, pill };
127
193
  }
128
194
 
129
195
  /** Remove the Relay MCP from both agents and stop/clear the background daemon. */
@@ -138,5 +204,12 @@ export function runUninstall() {
138
204
  } catch {
139
205
  /* already gone */
140
206
  }
207
+ const pillPlistPath = path.join(os.homedir(), "Library", "LaunchAgents", `${PILL_LAUNCH_LABEL}.plist`);
208
+ run("launchctl", ["unload", pillPlistPath]);
209
+ try {
210
+ fs.unlinkSync(pillPlistPath);
211
+ } catch {
212
+ /* already gone */
213
+ }
141
214
  }
142
215
  }
@@ -87,7 +87,7 @@ function readRowContent(rowState) {
87
87
  // real API. Returns a normalized "row" the host writers consume.
88
88
  async function resolveRow(id, { log = () => {} } = {}) {
89
89
  const rowState = getRowState(id);
90
- if (!rowState) throw new Error(`Unknown Relay row: ${id}`);
90
+ if (!rowState) return resolvePublicOpenToken(id, { log });
91
91
  const content = readRowContent(rowState);
92
92
  // The staged content packet carries the same fields under different names
93
93
  // (briefingMarkdown/displayTitle/sender). Merge so either source satisfies the
@@ -132,6 +132,48 @@ async function resolveRow(id, { log = () => {} } = {}) {
132
132
  return { row, rowState };
133
133
  }
134
134
 
135
+ async function resolvePublicOpenToken(token, { log = () => {} } = {}) {
136
+ try {
137
+ const { RelayClient } = await import("./client.js");
138
+ const client = new RelayClient();
139
+ const response = await client.openRelayPacket(token);
140
+ const packet = response?.packet;
141
+ if (!packet || typeof packet !== "object") throw new Error("packet missing");
142
+ const rowState = {
143
+ id: token,
144
+ direction: "inbound",
145
+ state: "unread",
146
+ kind: packet.kind || "message",
147
+ relayNotificationKind: "plain_relay",
148
+ senderName: packet.sender?.name || "Someone",
149
+ title: packet.title || "Relay",
150
+ displayTitle: packet.title || "Relay",
151
+ bodyMarkdown: packet.bodyMarkdown || "",
152
+ briefingMarkdown: packet.bodyMarkdown || "",
153
+ createdAt: packet.createdAt || new Date().toISOString(),
154
+ attachments: packet.attachments || [],
155
+ attachmentUrls: response.attachmentUrls || {},
156
+ materializationDeferredReason: "public_open_token",
157
+ materializedSurfaces: { codex: false, claudeCode: false, claudeCowork: false },
158
+ };
159
+ const row = {
160
+ ...packet,
161
+ ...rowState,
162
+ id: token,
163
+ sender: packet.sender || (rowState.senderName ? { name: rowState.senderName } : null),
164
+ recipient: packet.recipient || null,
165
+ displayTitle: rowState.displayTitle,
166
+ title: rowState.title,
167
+ bodyMarkdown: rowState.bodyMarkdown,
168
+ briefingMarkdown: renderRelayRowBriefing(rowState),
169
+ };
170
+ return { row, rowState };
171
+ } catch (error) {
172
+ log(`relay public open failed: ${error instanceof Error ? error.message : String(error)}`);
173
+ throw new Error(`Unknown Relay row or open token: ${token}`);
174
+ }
175
+ }
176
+
135
177
  // Fetch the verified task object for a row that points at a task. The real API
136
178
  // shape of GET /v1/tasks/:taskId (RelayClient.getTask) is `{ task: Task }` — a
137
179
  // wrapper object, never a bare Task — so we unwrap response.task. Returns the
package/src/mcp.js CHANGED
@@ -4,6 +4,66 @@ import { CallToolRequestSchema, ListToolsRequestSchema } from "@modelcontextprot
4
4
  import { RelayClient } from "./client.js";
5
5
 
6
6
  export const TOOLS = [
7
+ {
8
+ name: "relay_send",
9
+ description:
10
+ "Send an ordinary Relay message or handoff to another person. Use this for the normal product flow when the human says things like 'send Sven a relay asking how he is doing' or 'relay this context to Jordan'. This is NOT a task and does not create an agent coordination run. If the recipient has Relay, it appears in their Relay companion pill and Relays list; if they do not have Relay, Relay emails them a public link saying the sender sent a relay, with options to open it in Claude or Codex without creating an account. Do not use task-only tools for this ordinary communication path.",
11
+ inputSchema: {
12
+ type: "object",
13
+ properties: {
14
+ recipient: {
15
+ type: "object",
16
+ description: "Who should receive the relay. Prefer contactId or relayUserId when known; email is fine for someone not on Relay.",
17
+ properties: {
18
+ contactId: { type: "string" },
19
+ relayUserId: { type: "string" },
20
+ email: { type: "string" },
21
+ name: { type: "string" },
22
+ },
23
+ },
24
+ kind: { type: "string", enum: ["message", "handoff"], description: "Use message for notes/questions; handoff for larger context takeovers." },
25
+ title: { type: "string" },
26
+ bodyMarkdown: { type: "string" },
27
+ userInstructions: { type: "string", description: "Optional private instruction for how the recipient's agent should handle a handoff." },
28
+ targetSurfaces: {
29
+ type: "array",
30
+ items: { type: "string", enum: ["codex", "claude_code", "claude_desktop", "claude_cowork"] },
31
+ },
32
+ attachments: { type: "array", items: { type: "object" } },
33
+ idempotencyKey: { type: "string" },
34
+ },
35
+ required: ["recipient", "title", "bodyMarkdown", "idempotencyKey"],
36
+ },
37
+ },
38
+ {
39
+ name: "relay_contacts_search",
40
+ description:
41
+ "Search this human's Relay contact book before sending an ordinary relay. Use it when the human gives a name like 'Sven' and you need the correct person/email. If results are ambiguous, ask the human which contact they meant rather than guessing.",
42
+ inputSchema: {
43
+ type: "object",
44
+ properties: { query: { type: "string" } },
45
+ required: ["query"],
46
+ },
47
+ },
48
+ {
49
+ name: "relay_inbox_list",
50
+ description:
51
+ "List ordinary Relay messages addressed to this human. This does not list task coordination relays; use relay_task_status for task state.",
52
+ inputSchema: { type: "object", properties: {} },
53
+ },
54
+ {
55
+ name: "relay_acknowledge",
56
+ description:
57
+ "Acknowledge an ordinary Relay after it has been handled locally. This removes it from the active ordinary inbox.",
58
+ inputSchema: {
59
+ type: "object",
60
+ properties: {
61
+ relayId: { type: "string" },
62
+ idempotencyKey: { type: "string" },
63
+ },
64
+ required: ["relayId", "idempotencyKey"],
65
+ },
66
+ },
7
67
  {
8
68
  name: "relay_task_create",
9
69
  description:
@@ -260,6 +320,26 @@ function text(obj) {
260
320
 
261
321
  export async function handleCall(client, name, args) {
262
322
  switch (name) {
323
+ case "relay_send":
324
+ return text(
325
+ await client.sendRelay({
326
+ recipient: args.recipient,
327
+ kind: args.kind || "message",
328
+ title: args.title,
329
+ bodyMarkdown: args.bodyMarkdown,
330
+ userInstructions: args.userInstructions || "",
331
+ source: { host: "relay-mcp" },
332
+ targetSurfaces: args.targetSurfaces || [],
333
+ attachments: args.attachments || [],
334
+ idempotencyKey: args.idempotencyKey,
335
+ }),
336
+ );
337
+ case "relay_contacts_search":
338
+ return text(await client.searchContacts(args.query));
339
+ case "relay_inbox_list":
340
+ return text(await client.inbox());
341
+ case "relay_acknowledge":
342
+ return text(await client.acknowledge(args.relayId, { idempotencyKey: args.idempotencyKey }));
263
343
  case "relay_task_create":
264
344
  return text(
265
345
  await client.createTask({
@@ -1,7 +1,7 @@
1
1
  import fs from "node:fs";
2
2
  import os from "node:os";
3
3
  import path from "node:path";
4
- import { webUrl } from "./config.js";
4
+ import { currentUser, readConfig, webUrl } from "./config.js";
5
5
  import { relayBinPath } from "./install.js";
6
6
 
7
7
  const DEFAULT_COMPANION_STATE = {
@@ -224,6 +224,48 @@ function writeCompanionState(statePath, state) {
224
224
  fs.renameSync(tmp, statePath);
225
225
  }
226
226
 
227
+ function accountMarker({ user = currentUser(), deviceId = readConfig().deviceId || "" } = {}) {
228
+ return {
229
+ userId: user?.id || "",
230
+ email: user?.email || "",
231
+ deviceId: deviceId || "",
232
+ };
233
+ }
234
+
235
+ function stateBelongsToAccount(state, marker) {
236
+ if (!marker.userId && !marker.email) return true;
237
+ const account = state.account || {};
238
+ if (!account.userId && !account.email) return false;
239
+ if (account.userId && marker.userId && account.userId !== marker.userId) return false;
240
+ if (account.email && marker.email && account.email !== marker.email) return false;
241
+ return true;
242
+ }
243
+
244
+ export function resetCompanionStateForAccount(
245
+ { user = currentUser(), deviceId = readConfig().deviceId || "", force = false } = {},
246
+ { statePath = companionStatePath() } = {},
247
+ ) {
248
+ const marker = accountMarker({ user, deviceId });
249
+ const existing = readCompanionState(statePath);
250
+ if (!force && stateBelongsToAccount(existing, marker)) {
251
+ if (!existing.account) {
252
+ existing.account = { ...marker, resetAt: null };
253
+ writeCompanionState(statePath, existing);
254
+ }
255
+ return { reset: false, statePath };
256
+ }
257
+ const next = {
258
+ ...structuredClone(DEFAULT_COMPANION_STATE),
259
+ account: { ...marker, resetAt: new Date().toISOString() },
260
+ profile: {
261
+ ...structuredClone(DEFAULT_COMPANION_STATE.profile),
262
+ transport: { type: "relay_api" },
263
+ },
264
+ };
265
+ writeCompanionState(statePath, next);
266
+ return { reset: true, statePath };
267
+ }
268
+
227
269
  function notificationUrgency(kind) {
228
270
  if (kind === "human_question") return "blocking";
229
271
  if (kind === "task_request" || kind === "share_approval") return "action_required";
@@ -410,3 +452,67 @@ export function stageRelayCompanionItem(notification, { statePath = companionSta
410
452
  export function defaultStageRelayCompanionItem(notification) {
411
453
  return stageRelayCompanionItem(notification);
412
454
  }
455
+
456
+ export function stagePlainRelayItem({ item, packet, attachmentUrls = {} }, { statePath = companionStatePath() } = {}) {
457
+ if (!item?.relayId) throw new Error("stagePlainRelayItem requires an inbox item with relayId");
458
+ const state = readCompanionState(statePath);
459
+ state.packets ||= {};
460
+ state.chats ||= {};
461
+ const now = new Date().toISOString();
462
+ const existing = state.packets[item.relayId] || {};
463
+ const content = {
464
+ ...(packet || {}),
465
+ id: item.relayId,
466
+ briefingMarkdown: packet?.bodyMarkdown || item.preview || "",
467
+ attachmentUrls,
468
+ delivery: {
469
+ transport: "relay_api",
470
+ targetSurfaces: packet?.targetSurfaces || ["codex", "claude_code"],
471
+ recipientInboxDir: "",
472
+ outboxPath: null,
473
+ inboxPath: null,
474
+ },
475
+ };
476
+ const contentPath = writeNotificationPacketContent({ id: item.relayId }, content, statePath);
477
+ const readState = existing.state === "read" || item.state === "read" ? "read" : "unread";
478
+ const row = {
479
+ ...existing,
480
+ id: item.relayId,
481
+ direction: "inbound",
482
+ state: readState,
483
+ kind: item.kind || packet?.kind || "message",
484
+ relayNotificationKind: "plain_relay",
485
+ urgency: "normal",
486
+ senderName: item.sender?.name || packet?.sender?.name || "Someone",
487
+ title: item.title || packet?.title || item.preview || "Relay",
488
+ displayTitle: item.title || packet?.title || item.displayTitle || "Relay",
489
+ briefingMarkdown: packet?.bodyMarkdown || item.preview || "",
490
+ bodyMarkdown: packet?.bodyMarkdown || item.preview || "",
491
+ createdAt: item.createdAt || packet?.createdAt || now,
492
+ updatedAt: item.updatedAt || now,
493
+ stagedAt: now,
494
+ actionUrl: "/app/relays",
495
+ action: { type: "open", url: "/app/relays" },
496
+ quickReply: false,
497
+ taskId: null,
498
+ participantId: null,
499
+ messageId: null,
500
+ provider: null,
501
+ contentPath,
502
+ filePath: contentPath,
503
+ attachments: packet?.attachments || [],
504
+ attachmentUrls,
505
+ materializationDeferredReason: "relay_pill",
506
+ materializedSurfaces: existing.materializedSurfaces || { codex: false, claudeCode: false, claudeCowork: false },
507
+ };
508
+ state.packets[item.relayId] = row;
509
+ writeCompanionState(statePath, state);
510
+ return {
511
+ ok: true,
512
+ transport: "relay_companion",
513
+ statePath,
514
+ itemId: item.relayId,
515
+ actionUrl: row.actionUrl,
516
+ contentPath,
517
+ };
518
+ }
@@ -13,6 +13,7 @@ import {
13
13
  defaultStageRelayCompanionItem,
14
14
  freshNotifications,
15
15
  markNotificationsProcessed,
16
+ stagePlainRelayItem as defaultStagePlainRelayItem,
16
17
  } from "./notifications.js";
17
18
 
18
19
  function idempotencyKey(prefix) {
@@ -55,6 +56,26 @@ function markTaskEventsProcessed(ledger, events) {
55
56
  }
56
57
  }
57
58
 
59
+ function freshPlainRelays(ledger, items) {
60
+ ledger.plainRelays ||= {};
61
+ return items.filter((item) => {
62
+ const seen = ledger.plainRelays[item.relayId];
63
+ return !seen || seen.updatedAt !== item.updatedAt || seen.state !== item.state;
64
+ });
65
+ }
66
+
67
+ function markPlainRelaysProcessed(ledger, items) {
68
+ ledger.plainRelays ||= {};
69
+ const processedAt = new Date().toISOString();
70
+ for (const item of items) {
71
+ ledger.plainRelays[item.relayId] = {
72
+ state: item.state,
73
+ updatedAt: item.updatedAt || item.createdAt || "",
74
+ processedAt,
75
+ };
76
+ }
77
+ }
78
+
58
79
  async function pollVisibleTaskEvents({ client, ledger, tasks, log }) {
59
80
  if (typeof client.taskEvents !== "function") return [];
60
81
  const visibleEvents = [];
@@ -76,7 +97,32 @@ async function pollVisibleTaskEvents({ client, ledger, tasks, log }) {
76
97
  return visibleEvents;
77
98
  }
78
99
 
79
- async function pollHumanNotifications({ client, ledger, stageCompanionItem, log }) {
100
+ async function pollPlainInbox({ client, ledger, stagePlainRelay, log }) {
101
+ if (typeof client.inbox !== "function" || typeof client.fetchRelay !== "function") return [];
102
+ try {
103
+ const inbox = await client.inbox();
104
+ const fresh = freshPlainRelays(ledger, inbox.items || []);
105
+ const staged = [];
106
+ for (const item of fresh) {
107
+ try {
108
+ const relay = await client.fetchRelay(item.relayId);
109
+ stagePlainRelay({ item, packet: relay.packet, attachmentUrls: relay.attachmentUrls || {} });
110
+ staged.push(item);
111
+ log(`staged ordinary Relay from ${item.sender?.name || "someone"}: ${item.title || item.relayId}`);
112
+ } catch (err) {
113
+ log(`ordinary Relay staging failed for ${item.relayId}: ${err.message}`);
114
+ }
115
+ }
116
+ if (staged.length) markPlainRelaysProcessed(ledger, staged);
117
+ return staged;
118
+ } catch (err) {
119
+ log(`ordinary Relay inbox polling failed: ${err.message}`);
120
+ return [];
121
+ }
122
+ }
123
+
124
+ async function pollHumanNotifications({ client, ledger, stageCompanionItem, stagePlainRelay, log }) {
125
+ const ordinaryRelays = await pollPlainInbox({ client, ledger, stagePlainRelay, log });
80
126
  try {
81
127
  const [me, tasks, relays, connectors] = await Promise.all([
82
128
  client.me(),
@@ -103,10 +149,10 @@ async function pollHumanNotifications({ client, ledger, stageCompanionItem, log
103
149
  }
104
150
  }
105
151
  if (staged.length) markNotificationsProcessed(ledger, staged);
106
- return { notifications: staged, events };
152
+ return { notifications: staged, ordinaryRelays, events };
107
153
  } catch (err) {
108
154
  log(`Relay companion attention polling failed: ${err.message}`);
109
- return { notifications: [], events: [] };
155
+ return { notifications: [], ordinaryRelays, events: [] };
110
156
  }
111
157
  }
112
158
 
@@ -114,10 +160,16 @@ export async function pollTaskRuntimeOnce({
114
160
  client = new RelayClient(),
115
161
  log = () => {},
116
162
  stageCompanionItem = defaultStageRelayCompanionItem,
163
+ stagePlainRelay = defaultStagePlainRelayItem,
117
164
  adapters,
118
165
  } = {}) {
119
166
  const ledger = readTaskLedger();
120
- const inbox = await client.agentInbox();
167
+ let inbox = { messages: [], sessions: [] };
168
+ try {
169
+ inbox = await client.agentInbox();
170
+ } catch (err) {
171
+ log(`task agent inbox polling failed: ${err.message}`);
172
+ }
121
173
  const fresh = freshMessages(ledger, inbox.messages || []);
122
174
  const processedMessageIds = new Set();
123
175
  const processedMessages = [];
@@ -179,9 +231,15 @@ export async function pollTaskRuntimeOnce({
179
231
  }
180
232
  }
181
233
 
182
- const humanPolling = await pollHumanNotifications({ client, ledger, stageCompanionItem, log });
234
+ const humanPolling = await pollHumanNotifications({ client, ledger, stageCompanionItem, stagePlainRelay, log });
183
235
  writeTaskLedger(ledger);
184
- return { sessions: touched, messages: processedMessages, notifications: humanPolling.notifications, events: humanPolling.events };
236
+ return {
237
+ sessions: touched,
238
+ messages: processedMessages,
239
+ notifications: humanPolling.notifications,
240
+ ordinaryRelays: humanPolling.ordinaryRelays,
241
+ events: humanPolling.events,
242
+ };
185
243
  }
186
244
 
187
245
  export async function runTaskDaemon({ intervalMs = 4000 } = {}) {
@@ -194,9 +252,15 @@ export async function runTaskDaemon({ intervalMs = 4000 } = {}) {
194
252
  for (;;) {
195
253
  try {
196
254
  const result = await pollTaskRuntimeOnce({ client, log });
197
- if (result.sessions.length || result.messages.length || result.notifications.length || result.events.length) {
255
+ if (
256
+ result.sessions.length ||
257
+ result.messages.length ||
258
+ result.notifications.length ||
259
+ result.ordinaryRelays.length ||
260
+ result.events.length
261
+ ) {
198
262
  log(
199
- `processed ${result.sessions.length} session(s), ${result.messages.length} message(s), ${result.notifications.length} Relay companion item(s), ${result.events.length} event(s)`,
263
+ `processed ${result.sessions.length} session(s), ${result.messages.length} message(s), ${result.notifications.length} task attention item(s), ${result.ordinaryRelays.length} ordinary relay(s), ${result.events.length} event(s)`,
200
264
  );
201
265
  }
202
266
  } catch (err) {