relay-companion 0.1.48 → 0.1.49

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
@@ -8,8 +8,18 @@ import { spawnSync, spawn } from "node:child_process";
8
8
  import readline from "node:readline/promises";
9
9
  import { stdin, stdout } from "node:process";
10
10
  import { RelayClient } from "../src/client.js";
11
- import { writeConfig, readConfig, apiUrl, DEFAULT_API_URL, DEFAULT_WEB_URL } from "../src/config.js";
12
- import { runTaskDaemon, pollTaskRuntimeOnce } from "../src/task-daemon.js";
11
+ import {
12
+ COMPANION_MODE_FULL,
13
+ COMPANION_MODE_MESSAGES_ONLY,
14
+ DEFAULT_COMPANION_MODE,
15
+ writeConfig,
16
+ readConfig,
17
+ apiUrl,
18
+ companionModeCliArgs,
19
+ DEFAULT_API_URL,
20
+ DEFAULT_WEB_URL,
21
+ } from "../src/config.js";
22
+ import { runOrdinaryRelayDaemon, runTaskDaemon, pollTaskRuntimeOnce } from "../src/task-daemon.js";
13
23
  import { runMcpServer } from "../src/mcp.js";
14
24
  import { repairDesktopSurfaces, runSetupInstall, runUninstall } from "../src/install.js";
15
25
  import { runUpdateOnce } from "../src/auto-update.js";
@@ -37,6 +47,21 @@ function parseFlags(argv) {
37
47
  return { flags, positional };
38
48
  }
39
49
 
50
+ function modeFromFlags(flags) {
51
+ if (flags["messages-only"] && flags.full) {
52
+ throw new Error("Choose either --messages-only or --full, not both.");
53
+ }
54
+ if (flags["messages-only"]) return COMPANION_MODE_MESSAGES_ONLY;
55
+ if (flags.full) return COMPANION_MODE_FULL;
56
+ return DEFAULT_COMPANION_MODE;
57
+ }
58
+
59
+ function requireTaskFeatures(flags = {}) {
60
+ if (modeFromFlags(flags) === COMPANION_MODE_MESSAGES_ONLY) {
61
+ throw new Error("Relay Tasks are disabled in messages-only mode.");
62
+ }
63
+ }
64
+
40
65
  function companionVersion() {
41
66
  try {
42
67
  const require = createRequire(import.meta.url);
@@ -115,8 +140,12 @@ function statusAreaReopenText() {
115
140
  }
116
141
 
117
142
  /** Register the Relay tools into Claude Code + Codex and start the receive daemon. */
118
- async function applyInstall({ requireLiveTools = false, requiredHosts = requiredLiveHosts() } = {}) {
119
- const { installed, missing, daemon, pill, activations = [], binStable = true } = await runSetupInstall();
143
+ async function applyInstall({
144
+ requireLiveTools = false,
145
+ requiredHosts = requiredLiveHosts(),
146
+ mode = DEFAULT_COMPANION_MODE,
147
+ } = {}) {
148
+ const { installed, missing, daemon, pill, activations = [], binStable = true } = await runSetupInstall({ mode });
120
149
  if (installed.length) console.log(`Added Relay to ${installed.join(" and ")} on this machine.`);
121
150
  if (!binStable) {
122
151
  console.log(
@@ -158,12 +187,18 @@ async function applyInstall({ requireLiveTools = false, requiredHosts = required
158
187
 
159
188
  /** Full setup: pair this device, then install the tools + daemon. */
160
189
  async function cmdSetup(flags) {
190
+ const mode = modeFromFlags(flags);
191
+ writeConfig({ companionMode: mode });
161
192
  await cmdPair(flags, { promptForDefaults: Boolean(flags.interactive) });
162
193
  console.log("");
163
194
  await applyInstall({
164
195
  requireLiveTools: Boolean(flags["require-live-tools"]) || shouldRequireLiveTools(),
165
196
  requiredHosts: requiredLiveHosts(),
197
+ mode,
166
198
  });
199
+ if (mode === COMPANION_MODE_MESSAGES_ONLY) {
200
+ console.log("Relay is installed in ordinary-messages-only mode; Relay Tasks are not polled or exposed to agents.");
201
+ }
167
202
  const token = setupOpenRelayToken(flags);
168
203
  if (token) {
169
204
  console.log("");
@@ -185,16 +220,18 @@ async function cmdSetup(flags) {
185
220
  }
186
221
 
187
222
  /** Install the tools + daemon on a device that is already paired. */
188
- async function cmdInstall() {
223
+ async function cmdInstall(flags = {}) {
189
224
  if (!readConfig().deviceToken) {
190
225
  throw new Error("This device is not paired yet. Run `relay setup` with a pairing code first.");
191
226
  }
192
- await applyInstall({ requireLiveTools: shouldRequireLiveTools(), requiredHosts: requiredLiveHosts() });
227
+ const mode = modeFromFlags(flags);
228
+ writeConfig({ companionMode: mode });
229
+ await applyInstall({ requireLiveTools: shouldRequireLiveTools(), requiredHosts: requiredLiveHosts(), mode });
193
230
  }
194
231
 
195
232
  function cmdRepairDesktop(flags = {}) {
196
233
  const reload = !flags["no-restart"];
197
- const repaired = repairDesktopSurfaces({ reload });
234
+ const repaired = repairDesktopSurfaces({ reload, mode: modeFromFlags(flags) });
198
235
  if (!repaired.ok) {
199
236
  const failures = [
200
237
  !repaired.daemon?.ok && `daemon: ${repaired.daemon?.reason || "install failed"}`,
@@ -225,7 +262,8 @@ async function cmdWhoami() {
225
262
  console.log(`${me.user.name} <${me.user.email}> (api: ${apiUrl()})`);
226
263
  }
227
264
 
228
- async function cmdTasksOnce() {
265
+ async function cmdTasksOnce(flags = {}) {
266
+ requireTaskFeatures(flags);
229
267
  const out = await pollTaskRuntimeOnce({ log: (m) => console.log(`[relay] ${m}`) });
230
268
  console.log(
231
269
  `Processed ${out.sessions.length} task session(s), ${out.messages.length} message(s), ${out.notifications.length} Relay companion item(s).`,
@@ -265,28 +303,36 @@ function openUrl(url) {
265
303
  async function cmdOpen(positional, flags) {
266
304
  const log = (m) => process.stderr.write(`[relay] ${m}\n`);
267
305
  const host = String(flags.host || flags.in || "claude").toLowerCase();
306
+ const mode = modeFromFlags(flags);
268
307
  if (flags.task) {
308
+ requireTaskFeatures(flags);
269
309
  const result = await openTask({ taskId: String(flags.task), host, log });
270
310
  console.log(JSON.stringify(result, null, 2));
271
311
  return;
272
312
  }
273
313
  const id = positional[0] || flags.id;
274
314
  if (!id) throw new Error("Usage: relay open <id> --host claude|codex (or: relay open --task <taskId> --host claude|codex)");
275
- const result = await openRelay({ id, host, log });
315
+ const result = await openRelay({ id, host, log, allowTaskRows: mode === COMPANION_MODE_FULL });
276
316
  console.log(JSON.stringify(result, null, 2));
277
317
  }
278
318
 
279
319
  async function cmdOpenNotification(flags) {
320
+ const mode = modeFromFlags(flags);
321
+ if (flags.task) requireTaskFeatures(flags);
280
322
  let target = flags.url;
281
323
  if (!target && flags.task) target = `/app/tasks/${encodeURIComponent(flags.task)}`;
282
324
  if (!target && flags.message) target = "/app/relays";
283
- target ||= "/app/tasks";
325
+ target ||= mode === COMPANION_MODE_MESSAGES_ONLY ? "/app/relays" : "/app/tasks";
326
+ if (mode === COMPANION_MODE_MESSAGES_ONLY && /(^|\/)tasks(?:[/?#]|$)/.test(String(target))) {
327
+ throw new Error("Relay Tasks are disabled in messages-only mode.");
328
+ }
284
329
  const url = absoluteWebTarget(target);
285
330
  const opened = openUrl(url);
286
331
  console.log(opened ? `Opened ${url}` : `Open this Relay URL: ${url}`);
287
332
  }
288
333
 
289
334
  async function cmdAnswerQuestion(flags) {
335
+ requireTaskFeatures(flags);
290
336
  const taskId = flags.task || flags.taskId;
291
337
  const messageId = flags.message || flags.messageId;
292
338
  let answer = flags.answer;
@@ -303,7 +349,7 @@ async function cmdAnswerQuestion(flags) {
303
349
  }
304
350
 
305
351
  /** Launch (or signal) the desktop Relay companion pill and verify it is visible. */
306
- async function cmdPill() {
352
+ async function cmdPill(flags = {}) {
307
353
  const here = path.dirname(fileURLToPath(import.meta.url));
308
354
  const overlayMain = path.resolve(here, "../overlay/main.cjs");
309
355
  // require("electron") returns the path to the electron binary when resolved under node.
@@ -323,11 +369,15 @@ async function cmdPill() {
323
369
  }
324
370
  const reopenNonce = `cli-${process.pid}-${randomUUID()}`;
325
371
  let spawnError = null;
326
- const child = spawn(electronPath, [overlayMain, "--relay-reopen", reopenNonce], {
327
- detached: true,
328
- stdio: "ignore",
329
- env: { ...process.env },
330
- });
372
+ const child = spawn(
373
+ electronPath,
374
+ [overlayMain, ...companionModeCliArgs(modeFromFlags(flags)), "--relay-reopen", reopenNonce],
375
+ {
376
+ detached: true,
377
+ stdio: "ignore",
378
+ env: { ...process.env },
379
+ },
380
+ );
331
381
  child.on("error", (error) => {
332
382
  spawnError = error;
333
383
  });
@@ -356,7 +406,7 @@ async function main() {
356
406
  case "pair":
357
407
  return cmdPair(flags);
358
408
  case "install":
359
- return cmdInstall();
409
+ return cmdInstall(flags);
360
410
  case "repair-desktop":
361
411
  return cmdRepairDesktop(flags);
362
412
  case "uninstall":
@@ -364,7 +414,7 @@ async function main() {
364
414
  case "whoami":
365
415
  return cmdWhoami();
366
416
  case "tasks":
367
- return cmdTasksOnce();
417
+ return cmdTasksOnce(flags);
368
418
  case "open":
369
419
  return cmdOpen(positional, flags);
370
420
  case "open-notification":
@@ -373,37 +423,40 @@ async function main() {
373
423
  case "answer":
374
424
  return cmdAnswerQuestion(flags);
375
425
  case "daemon":
376
- return runTaskDaemon({ intervalMs: Number(flags.interval) || 4000 });
426
+ return modeFromFlags(flags) === COMPANION_MODE_MESSAGES_ONLY
427
+ ? runOrdinaryRelayDaemon({ intervalMs: Number(flags.interval) || 4000 })
428
+ : runTaskDaemon({ intervalMs: Number(flags.interval) || 4000 });
377
429
  case "pill":
378
- return cmdPill();
430
+ return cmdPill(flags);
379
431
  case "mcp":
380
- return runMcpServer();
432
+ return runMcpServer({ mode: modeFromFlags(flags) });
381
433
  case "update":
382
434
  case "self-update":
383
- return runUpdateOnce().then(() => {});
435
+ return runUpdateOnce({ mode: modeFromFlags(flags) }).then(() => {});
384
436
  default:
385
437
  console.log(
386
438
  [
387
439
  "Relay companion",
388
440
  "",
389
441
  "Usage:",
390
- " relay setup [--code CODE] [--name NAME] Pair this machine and add Relay to Claude Code + Codex",
442
+ " relay setup [--code CODE] [--name NAME] [--messages-only|--full]",
443
+ " Pair and install; ordinary messages only by default",
391
444
  " relay version Print the installed Relay companion version",
392
445
  " relay setup --code CODE --open-relay TOKEN --host codex|claude",
393
446
  " relay setup --interactive Prompt for API URL and device name during setup",
394
- " relay install Add Relay to your agents (device already paired)",
447
+ " relay install [--messages-only|--full] Add Relay to your agents; --full explicitly enables Relay Tasks",
395
448
  " relay uninstall Remove Relay from your agents and stop the daemon",
396
- " relay repair-desktop [--no-restart] Repair Relay.app and background services without changing MCP/account state",
449
+ " relay repair-desktop [--no-restart] [--messages-only|--full] Repair Relay.app and background services without changing MCP/account state",
397
450
  " relay pair [--api URL] [--web URL] [--code CODE] Pair this machine only (no agent install)",
398
- " relay tasks Pull and process task agent runtime once",
451
+ " relay tasks --full Pull and process the opt-in Relay Task runtime once",
399
452
  " relay open <id> --host claude|codex Materialize a staged Relay row into a native agent session",
400
- " relay open --task <taskId> --host claude|codex Materialize a Relay task into a native agent session",
453
+ " relay open --task <taskId> --host claude|codex --full Materialize an opt-in Relay Task into a native agent session",
401
454
  " relay open-notification [--url PATH] [--task ID] Open a Relay companion target in the browser",
402
- " relay answer-question --task ID --message ID --answer TEXT",
403
- " relay daemon [--interval MS] Run the task runtime daemon",
404
- " relay pill Launch the desktop Relay companion pill",
405
- " relay mcp Run the MCP server (for your agent)",
406
- " relay update Update to the latest published companion and restart",
455
+ " relay answer-question --task ID --message ID --answer TEXT --full",
456
+ " relay daemon [--interval MS] [--messages-only|--full] Run ordinary receive by default; Tasks require --full",
457
+ " relay pill [--messages-only|--full] Launch the ordinary pill by default; Tasks require --full",
458
+ " relay mcp [--messages-only|--full] Run ordinary tools by default; Tasks require --full",
459
+ " relay update [--messages-only|--full] Update while preserving only the explicitly selected profile",
407
460
  " relay whoami Show the paired account",
408
461
  ].join("\n"),
409
462
  );
package/overlay/main.cjs CHANGED
@@ -51,6 +51,11 @@ const {
51
51
  showInactiveOnAllSpaces,
52
52
  subscribeActiveSpaceChanges,
53
53
  } = require("./space-presence.cjs");
54
+ const {
55
+ COMPANION_MODE_FULL,
56
+ companionModeFromRuntime,
57
+ taskFeaturesAllowed,
58
+ } = require("./mode-policy.cjs");
54
59
 
55
60
  const RELAY_HOME = process.env.RELAY_HOME || process.env.RELAY_COMPANION_HOME || path.join(os.homedir(), ".relay-companion");
56
61
  const STATE_PATH = path.join(RELAY_HOME, "state.json");
@@ -245,6 +250,17 @@ function readConfigFile() {
245
250
  }
246
251
  }
247
252
 
253
+ // The Granular-linked user profile is deliberately ordinary-Relay-only. Resolve the
254
+ // mode before creating clients or IPC handlers, and reject invalid persisted/env
255
+ // values instead of silently enabling the dormant Relay Tasks surface.
256
+ const COMPANION_MODE = companionModeFromRuntime({
257
+ argv: process.argv,
258
+ env: process.env,
259
+ config: readConfigFile(),
260
+ });
261
+ const TASK_FEATURES_ALLOWED = taskFeaturesAllowed(COMPANION_MODE);
262
+ const COMPANION_MODE_CLI_ARG = COMPANION_MODE === COMPANION_MODE_FULL ? "--full" : "--messages-only";
263
+
248
264
  function deviceToken() {
249
265
  if (process.env.RELAY_DEVICE_TOKEN) return process.env.RELAY_DEVICE_TOKEN;
250
266
  return readConfigFile().deviceToken || "";
@@ -279,6 +295,18 @@ function taskWebUrl(taskId) {
279
295
  return `${webBase()}/app/tasks/${encodeURIComponent(taskId)}`;
280
296
  }
281
297
 
298
+ function isRelayTaskWebTarget(pathOrUrl) {
299
+ const value = String(pathOrUrl || "").trim();
300
+ if (!value) return false;
301
+ try {
302
+ const base = new URL(`${webBase()}/`);
303
+ const target = new URL(value, base);
304
+ return target.origin === base.origin && /^\/app\/tasks(?:\/|$)/.test(target.pathname);
305
+ } catch {
306
+ return /(^|\/)app\/tasks(?:[/?#]|$)/.test(value);
307
+ }
308
+ }
309
+
282
310
  // ---- state.json reading (Relay attention rows) ---------------------------
283
311
 
284
312
  function readStore() {
@@ -335,6 +363,7 @@ function readRelays() {
335
363
  return Object.entries(packets)
336
364
  .map(([id, p]) => ({ id, ...(p || {}) }))
337
365
  .filter((p) => p.direction === "inbound")
366
+ .filter((p) => TASK_FEATURES_ALLOWED || p.relayNotificationKind === "plain_relay")
338
367
  .map((p) => ({
339
368
  id: p.id,
340
369
  direction: "inbound",
@@ -430,6 +459,10 @@ function ensureSentLoaded() {
430
459
  let tasksCache = [];
431
460
  let tasksLoadedOnce = null; // a promise that resolves after the first task load
432
461
  async function refreshTasks() {
462
+ if (!TASK_FEATURES_ALLOWED) {
463
+ tasksCache = [];
464
+ return tasksCache;
465
+ }
433
466
  try {
434
467
  const client = await relayClient();
435
468
  const res = await client.listTasks();
@@ -743,6 +776,9 @@ function approvalIdForRow(row) {
743
776
  }
744
777
 
745
778
  async function runMutation(label, fn) {
779
+ if (!TASK_FEATURES_ALLOWED) {
780
+ return { ok: false, error: "Relay Tasks are disabled in messages-only mode." };
781
+ }
746
782
  try {
747
783
  await fn();
748
784
  await refreshTasks();
@@ -1119,6 +1155,10 @@ async function openPacket(packetId, { sent = false } = {}) {
1119
1155
  if (!sent) ackPacket(packetId);
1120
1156
  if (win && !win.isDestroyed()) win.webContents.send("openDone", packetId); // stop the row spinner
1121
1157
  };
1158
+ if (!TASK_FEATURES_ALLOWED && (row?.taskId || isRelayTaskWebTarget(row?.actionUrl))) {
1159
+ console.error("[overlay] refusing to open a Relay Task in messages-only mode:", packetId);
1160
+ return finish();
1161
+ }
1122
1162
  if (process.env.RELAY_OVERLAY_TEST_NO_HOST_OPEN === "1") return finish();
1123
1163
  if (row && row.relayNotificationKind === "connector_reauth") {
1124
1164
  shell
@@ -1139,10 +1179,14 @@ async function openPacket(packetId, { sent = false } = {}) {
1139
1179
  // then keep the row spinner alive while it does post-import title repair.
1140
1180
  env.RELAY_IMPORT_CLAUDE_DESKTOP = "0";
1141
1181
  }
1142
- const child = spawn(process.execPath, [RELAY_CLI, "open", materializationId, "--host", host], {
1143
- env,
1144
- stdio: ["ignore", "pipe", "pipe"],
1145
- });
1182
+ const child = spawn(
1183
+ process.execPath,
1184
+ [RELAY_CLI, "open", materializationId, "--host", host, COMPANION_MODE_CLI_ARG],
1185
+ {
1186
+ env,
1187
+ stdio: ["ignore", "pipe", "pipe"],
1188
+ },
1189
+ );
1146
1190
  let out = "";
1147
1191
  let err = "";
1148
1192
  child.stdout.on("data", (data) => (out += data));
@@ -1204,6 +1248,7 @@ async function openPacket(packetId, { sent = false } = {}) {
1204
1248
  // rail seeded with the task's objective + state + an instruction to call
1205
1249
  // relay_task_status for live detail. The web view is only used if the CLI fails.
1206
1250
  function openTaskDetail(taskId) {
1251
+ if (!TASK_FEATURES_ALLOWED) return;
1207
1252
  if (!taskId) return;
1208
1253
  frontmostBundleId((bundle) => {
1209
1254
  const host = resolveClickHost(bundle);
@@ -1217,10 +1262,14 @@ function openTaskDetail(taskId) {
1217
1262
  // Let the overlay own the actual deep-link launch (see openPacket).
1218
1263
  env.RELAY_IMPORT_CLAUDE_DESKTOP = "0";
1219
1264
  }
1220
- const child = spawn(process.execPath, [RELAY_CLI, "open", "--task", taskId, "--host", host], {
1221
- env,
1222
- stdio: ["ignore", "pipe", "pipe"],
1223
- });
1265
+ const child = spawn(
1266
+ process.execPath,
1267
+ [RELAY_CLI, "open", "--task", taskId, "--host", host, COMPANION_MODE_CLI_ARG],
1268
+ {
1269
+ env,
1270
+ stdio: ["ignore", "pipe", "pipe"],
1271
+ },
1272
+ );
1224
1273
  let out = "";
1225
1274
  let err = "";
1226
1275
  child.stdout.on("data", (data) => (out += data));
@@ -1271,6 +1320,10 @@ function openTaskDetail(taskId) {
1271
1320
  }
1272
1321
 
1273
1322
  function openUrlTarget(url) {
1323
+ if (!TASK_FEATURES_ALLOWED && isRelayTaskWebTarget(url)) {
1324
+ console.error("[overlay] refusing to open a Relay Task URL in messages-only mode");
1325
+ return;
1326
+ }
1274
1327
  const target = absoluteUrl(url);
1275
1328
  shell.openExternal(target).catch((error) => console.error("[overlay] open failed:", error && error.message));
1276
1329
  }
@@ -1677,6 +1730,9 @@ ipcMain.handle("relay:refreshTasks", async () => {
1677
1730
  // Live task detail for the in-pill task view (GET /v1/tasks/:id, same payload the
1678
1731
  // web detail page renders from). The renderer polls this while the view is open.
1679
1732
  ipcMain.handle("relay:taskStatus", async (_e, taskId) => {
1733
+ if (!TASK_FEATURES_ALLOWED) {
1734
+ return { ok: false, error: "Relay Tasks are disabled in messages-only mode." };
1735
+ }
1680
1736
  if (!taskId) return { ok: false, error: "Missing task id." };
1681
1737
  try {
1682
1738
  const client = await relayClient();
@@ -0,0 +1,37 @@
1
+ "use strict";
2
+
3
+ const COMPANION_MODE_FULL = "full";
4
+ const COMPANION_MODE_MESSAGES_ONLY = "messages-only";
5
+
6
+ function normalizeCompanionMode(value) {
7
+ const mode = String(value || "").trim();
8
+ if (mode === COMPANION_MODE_FULL || mode === COMPANION_MODE_MESSAGES_ONLY) return mode;
9
+ throw new Error(`Unsupported Relay companion mode: ${mode || "(empty)"}`);
10
+ }
11
+
12
+ function companionModeFromRuntime({ argv = [], env = {}, config = {} } = {}) {
13
+ const args = Array.isArray(argv) ? argv.map(String) : [];
14
+ const messagesOnly = args.includes("--messages-only");
15
+ const full = args.includes("--full");
16
+ if (messagesOnly && full) throw new Error("Choose either --messages-only or --full, not both.");
17
+ if (messagesOnly) return COMPANION_MODE_MESSAGES_ONLY;
18
+ if (full) return COMPANION_MODE_FULL;
19
+
20
+ // A stored/environmental full value records an old preference but cannot widen
21
+ // a no-flag launch. Relay Tasks require the explicit --full process argument.
22
+ const configured = env.RELAY_COMPANION_MODE || config.companionMode;
23
+ if (configured) normalizeCompanionMode(configured);
24
+ return COMPANION_MODE_MESSAGES_ONLY;
25
+ }
26
+
27
+ function taskFeaturesAllowed(mode) {
28
+ return normalizeCompanionMode(mode) === COMPANION_MODE_FULL;
29
+ }
30
+
31
+ module.exports = {
32
+ COMPANION_MODE_FULL,
33
+ COMPANION_MODE_MESSAGES_ONLY,
34
+ companionModeFromRuntime,
35
+ normalizeCompanionMode,
36
+ taskFeaturesAllowed,
37
+ };
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "relay-companion",
3
- "version": "0.1.48",
4
- "description": "Relay companion: connects local coding agents to Relay tasks, approvals, and connector tools.",
3
+ "version": "0.1.49",
4
+ "description": "Relay companion for ordinary messages, with dormant coordination features available only by explicit opt-in.",
5
5
  "type": "module",
6
6
  "bin": {
7
7
  "relay": "bin/relay.js"
@@ -17,6 +17,11 @@ import path from "node:path";
17
17
  import fs from "node:fs";
18
18
  import { spawn } from "node:child_process";
19
19
  import { fileURLToPath } from "node:url";
20
+ import {
21
+ DEFAULT_COMPANION_MODE,
22
+ companionModeCliArgs,
23
+ normalizeCompanionMode,
24
+ } from "./config.js";
20
25
 
21
26
  export const PACKAGE_NAME = "relay-companion";
22
27
  const DAEMON_LABEL = "work.relay.companion";
@@ -199,6 +204,7 @@ export function spawnDetachedUpdate({
199
204
  spawnImpl = spawn,
200
205
  log = () => {},
201
206
  ensureDir = true,
207
+ mode = DEFAULT_COMPANION_MODE,
202
208
  } = {}) {
203
209
  if (ensureDir) {
204
210
  try {
@@ -214,6 +220,8 @@ export function spawnDetachedUpdate({
214
220
  const restore = parseVersion(currentVersion) ? currentVersion : "latest";
215
221
  const install = managedInstallInfo(packageRoot, { home });
216
222
  if (!install) throw new Error(`cannot update unmanaged package root: ${packageRoot}`);
223
+ const normalizedMode = normalizeCompanionMode(mode);
224
+ const repairModeArgs = companionModeCliArgs(normalizedMode).map(q).join(" ");
217
225
  const runtimeDir = runtimeNpmDir(execPath);
218
226
  // Prepend the running runtime's bin dir so `node`/`npm` in the script resolve to the
219
227
  // exact toolchain that owns the running install, not whatever a fixed PATH finds.
@@ -285,7 +293,7 @@ export function spawnDetachedUpdate({
285
293
  ` if [ "$VERSION_OK" = 1 ] && [ -x "$ELECTRON" ] && [ -f "$PKG/bin/relay.js" ] && grep -q '"version"' "$PKG/package.json" 2>/dev/null; then`,
286
294
  // Rebuild version-coupled app/plist paths from the NEW package before restart.
287
295
  // --no-restart keeps sequencing here: pill first, daemon (our parent) last.
288
- ` if "$NODE" "$PKG/bin/relay.js" repair-desktop --no-restart >> ${LOG} 2>&1; then DESKTOP_OK=1; else echo "[relay-update] $(date) desktop repair FAILED" >> ${LOG} 2>&1; fi`,
296
+ ` if "$NODE" "$PKG/bin/relay.js" repair-desktop --no-restart${repairModeArgs ? ` ${repairModeArgs}` : ""} >> ${LOG} 2>&1; then DESKTOP_OK=1; else echo "[relay-update] $(date) desktop repair FAILED" >> ${LOG} 2>&1; fi`,
289
297
  ` fi`,
290
298
  ` if [ "$DESKTOP_OK" = 1 ]; then`,
291
299
  ` echo "[relay-update] $(date) install and desktop repair verified; restarting services" >> ${LOG} 2>&1`,
@@ -342,7 +350,9 @@ export function createAutoUpdater({
342
350
  // "always idle" so the pure decision logic stays testable without runtime.
343
351
  hasActiveWork = () => false,
344
352
  log = () => {},
353
+ mode = DEFAULT_COMPANION_MODE,
345
354
  } = {}) {
355
+ const normalizedMode = normalizeCompanionMode(mode);
346
356
  // This is the version of the code loaded in THIS process, not a fresh read of the
347
357
  // package.json on every tick. If npm writes a new tree but launchctl restart fails,
348
358
  // the old daemon must keep retrying rather than see the new on-disk version and
@@ -380,7 +390,7 @@ export function createAutoUpdater({
380
390
  state.updating = true;
381
391
  state.updateStartedAt = t;
382
392
  try {
383
- spawnUpdate({ log, currentVersion: runningVersion, targetVersion: latest, packageRoot });
393
+ spawnUpdate({ log, currentVersion: runningVersion, targetVersion: latest, packageRoot, mode: normalizedMode });
384
394
  } catch (err) {
385
395
  state.updating = false;
386
396
  log(`auto-update launch failed: ${err && err.message ? err.message : String(err)}`);
@@ -456,8 +466,11 @@ export function createAutoUpdater({
456
466
 
457
467
  // One-shot for the `relay update` CLI: check once and, if a newer version exists,
458
468
  // launch the update. Prints a human-readable line. Bypasses the interval gate.
459
- export async function runUpdateOnce({ log = (m) => console.log(`[relay] ${m}`) } = {}) {
460
- const updater = createAutoUpdater({ checkIntervalMs: 0, log });
469
+ export async function runUpdateOnce({
470
+ log = (m) => console.log(`[relay] ${m}`),
471
+ mode = DEFAULT_COMPANION_MODE,
472
+ } = {}) {
473
+ const updater = createAutoUpdater({ checkIntervalMs: 0, log, mode });
461
474
  const result = await updater.tick();
462
475
  switch (result.status) {
463
476
  case "updating":
package/src/config.js CHANGED
@@ -4,6 +4,14 @@ import path from "node:path";
4
4
 
5
5
  export const DEFAULT_API_URL = "https://aia6vj5pgp.us-east-1.awsapprunner.com";
6
6
  export const DEFAULT_WEB_URL = "https://sendrelays.com";
7
+ export const COMPANION_MODE_FULL = "full";
8
+ export const COMPANION_MODE_MESSAGES_ONLY = "messages-only";
9
+ export const DEFAULT_COMPANION_MODE = COMPANION_MODE_MESSAGES_ONLY;
10
+
11
+ export function normalizeCompanionMode(value = DEFAULT_COMPANION_MODE) {
12
+ if (value === COMPANION_MODE_FULL || value === COMPANION_MODE_MESSAGES_ONLY) return value;
13
+ throw new Error(`Invalid Relay companion mode: ${String(value)}`);
14
+ }
7
15
 
8
16
  /**
9
17
  * Companion configuration lives in ~/.relay/config.json. It holds the device
@@ -80,6 +88,20 @@ export function currentUser() {
80
88
  return readConfig().user || null;
81
89
  }
82
90
 
91
+ /**
92
+ * Read the stored profile choice for diagnostics and explicit registration work.
93
+ * Runtime entry points do not use this value to widen a no-flag launch: Tasks still
94
+ * require an explicit --full argument on the process being launched.
95
+ */
96
+ export function companionMode() {
97
+ const configured = process.env.RELAY_COMPANION_MODE || readConfig().companionMode || DEFAULT_COMPANION_MODE;
98
+ return normalizeCompanionMode(configured);
99
+ }
100
+
101
+ export function companionModeCliArgs(mode = DEFAULT_COMPANION_MODE) {
102
+ return normalizeCompanionMode(mode) === COMPANION_MODE_FULL ? ["--full"] : ["--messages-only"];
103
+ }
104
+
83
105
  /** Durable local runtime ledger for agent task sessions. */
84
106
  export function taskLedgerPath() {
85
107
  return path.join(configDir(), "task-ledger.json");
@@ -9,6 +9,7 @@ import {
9
9
  relayBinPath,
10
10
  stableNodePath,
11
11
  } from "./install.js";
12
+ import { DEFAULT_COMPANION_MODE, normalizeCompanionMode } from "./config.js";
12
13
 
13
14
  const DEFAULT_RETRY_DELAYS_MS = [15_000, 60_000, 5 * 60_000];
14
15
 
@@ -52,9 +53,15 @@ function markerSurfacePaths(marker) {
52
53
 
53
54
  export function desktopMigrationMarkerIsCurrent(
54
55
  marker,
55
- { version, packageRoot, existsSync = fs.existsSync } = {},
56
+ { version, packageRoot, mode = DEFAULT_COMPANION_MODE, existsSync = fs.existsSync } = {},
56
57
  ) {
57
- if (!marker || marker.version !== version || path.resolve(String(marker.packageRoot || "")) !== path.resolve(packageRoot)) {
58
+ const normalizedMode = normalizeCompanionMode(mode);
59
+ if (
60
+ !marker ||
61
+ marker.version !== version ||
62
+ marker.mode !== normalizedMode ||
63
+ path.resolve(String(marker.packageRoot || "")) !== path.resolve(packageRoot)
64
+ ) {
58
65
  return false;
59
66
  }
60
67
  const surfaces = markerSurfacePaths(marker);
@@ -87,10 +94,10 @@ function writeMarkerAtomic(markerPath, marker) {
87
94
  *
88
95
  * npm lifecycle scripts are not a reliable migration boundary: recent npm clients
89
96
  * can suppress unapproved postinstall hooks. The newly loaded daemon therefore
90
- * repairs its own desktop surfaces once per package version as well. It writes the
91
- * daemon plist without reloading it (never unload the process executing this code),
92
- * then installs/reloads only the pill so Relay.app and KeepAlive become effective
93
- * immediately.
97
+ * repairs its own desktop surfaces once per package version and companion mode as
98
+ * well. It writes the daemon plist without reloading it (never unload the process
99
+ * executing this code), then installs/reloads only the pill so Relay.app and
100
+ * KeepAlive become effective immediately.
94
101
  */
95
102
  export function runDesktopStartupMigration({
96
103
  platform = process.platform,
@@ -105,20 +112,28 @@ export function runDesktopStartupMigration({
105
112
  installDaemonImpl = installDaemonAutostart,
106
113
  installPillImpl = installPillAutostart,
107
114
  runCommand,
115
+ mode = DEFAULT_COMPANION_MODE,
108
116
  } = {}) {
109
117
  if (platform !== "darwin") return { attempted: false, ok: true, reason: "not-darwin", retryable: false };
110
118
  if (!managedInstallInfo(packageRoot, { home: homeDir })) {
111
119
  return { attempted: false, ok: true, reason: "unmanaged-install", retryable: false };
112
120
  }
113
121
  if (!version) return { attempted: false, ok: false, reason: "version-unavailable", retryable: false };
122
+ const normalizedMode = normalizeCompanionMode(mode);
114
123
 
115
124
  const markerPath = desktopMigrationMarkerPath({ homeDir, version });
116
125
  const marker = readMarkerImpl(markerPath);
117
- if (desktopMigrationMarkerIsCurrent(marker, { version, packageRoot, existsSync })) {
126
+ if (desktopMigrationMarkerIsCurrent(marker, { version, packageRoot, mode: normalizedMode, existsSync })) {
118
127
  return { attempted: false, ok: true, reason: "already-current", markerPath, marker, retryable: false };
119
128
  }
120
129
 
121
- const sharedOptions = { platform, homeDir, reload: false, ...(runCommand ? { runCommand } : {}) };
130
+ const sharedOptions = {
131
+ platform,
132
+ homeDir,
133
+ reload: false,
134
+ mode: normalizedMode,
135
+ ...(runCommand ? { runCommand } : {}),
136
+ };
122
137
  let daemon;
123
138
  try {
124
139
  // Refresh the daemon's on-disk plist first, but NEVER unload/reload the daemon
@@ -175,6 +190,7 @@ export function runDesktopStartupMigration({
175
190
 
176
191
  const nextMarker = {
177
192
  version,
193
+ mode: normalizedMode,
178
194
  packageRoot: path.resolve(packageRoot),
179
195
  appPath: pill.appPath,
180
196
  pillPlistPath: pill.plistPath,
@@ -200,7 +216,8 @@ export function runDesktopStartupMigration({
200
216
 
201
217
  /** Run immediately, then retry a bounded number of transient failures on unref'd timers. */
202
218
  export function startDesktopStartupMigration({
203
- runMigration = () => runDesktopStartupMigration(),
219
+ mode = DEFAULT_COMPANION_MODE,
220
+ runMigration = () => runDesktopStartupMigration({ mode }),
204
221
  retryDelaysMs = DEFAULT_RETRY_DELAYS_MS,
205
222
  setTimeoutImpl = setTimeout,
206
223
  log = () => {},
package/src/install.js CHANGED
@@ -5,12 +5,18 @@ import path from "node:path";
5
5
  import { createRequire } from "node:module";
6
6
  import { fileURLToPath } from "node:url";
7
7
  import { activateCodexMcp, verifyClaudeMcpRegistration } from "./setup-activation.js";
8
+ import {
9
+ DEFAULT_COMPANION_MODE,
10
+ companionModeCliArgs,
11
+ normalizeCompanionMode,
12
+ } from "./config.js";
8
13
 
9
14
  // Persistent install of the Relay companion into the user's local agents.
10
15
  //
11
16
  // After pairing, this registers the Relay MCP server (`relay mcp`) into Claude
12
17
  // Code and Codex at user scope, so EVERY interactive session gets the Relay
13
- // tools + the user's connected provider tools — not just background task runs.
18
+ // ordinary-message tools. The dormant Task/provider surface is registered only
19
+ // when setup was explicitly invoked with --full.
14
20
  // It also installs a launchd agent that keeps the receive daemon running.
15
21
 
16
22
  const DAEMON_LAUNCH_LABEL = "work.relay.companion";
@@ -180,6 +186,7 @@ export function installRelayMacApp({
180
186
  overlayMain,
181
187
  homeDir = os.homedir(),
182
188
  runCommand = run,
189
+ mode = DEFAULT_COMPANION_MODE,
183
190
  } = {}) {
184
191
  if (!electronPath || !overlayMain || !fs.existsSync(electronPath) || !fs.existsSync(overlayMain)) {
185
192
  return { ok: false, reason: "pill_runtime_missing", electronPath, overlayMain };
@@ -208,6 +215,7 @@ export function installRelayMacApp({
208
215
  const logPath = path.join(homeDir, ".relay", "pill.log");
209
216
  const pillPlistPath = path.join(homeDir, "Library", "LaunchAgents", `${PILL_LAUNCH_LABEL}.plist`);
210
217
  const version = packageVersion(packageRoot);
218
+ const overlayModeArgs = companionModeCliArgs(mode);
211
219
 
212
220
  try {
213
221
  fs.rmSync(stagingDir, { recursive: true, force: true });
@@ -253,7 +261,7 @@ export function installRelayMacApp({
253
261
  " /bin/sleep 0.1",
254
262
  ' i=$((i + 1))',
255
263
  "done",
256
- `nohup ${shellSingleQuote(electronPath)} ${shellSingleQuote(overlayMain)} --relay-reopen "$nonce" >> ${shellSingleQuote(logPath)} 2>&1 &`,
264
+ `nohup ${shellSingleQuote(electronPath)} ${shellSingleQuote(overlayMain)}${overlayModeArgs.length ? ` ${overlayModeArgs.map(shellSingleQuote).join(" ")}` : ""} --relay-reopen "$nonce" >> ${shellSingleQuote(logPath)} 2>&1 &`,
257
265
  "exit 0",
258
266
  "",
259
267
  ].join("\n");
@@ -419,7 +427,12 @@ function writeTextAtomic(file, value) {
419
427
  fs.renameSync(tmp, file);
420
428
  }
421
429
 
422
- export function writeClaudeCodeMcpConfig(bin = relayBinPath(), node = stableNodePath(), configPath = claudeCodeConfigPath()) {
430
+ export function writeClaudeCodeMcpConfig(
431
+ bin = relayBinPath(),
432
+ node = stableNodePath(),
433
+ configPath = claudeCodeConfigPath(),
434
+ { mode = DEFAULT_COMPANION_MODE } = {},
435
+ ) {
423
436
  if (!fs.existsSync(configPath)) return { ok: false, reason: "claude_config_missing", configPath };
424
437
  try {
425
438
  const cfg = readJsonObject(configPath);
@@ -432,7 +445,7 @@ export function writeClaudeCodeMcpConfig(bin = relayBinPath(), node = stableNode
432
445
  relay: {
433
446
  type: "stdio",
434
447
  command: node,
435
- args: [bin, "mcp"],
448
+ args: [bin, "mcp", ...companionModeCliArgs(mode)],
436
449
  env: {},
437
450
  },
438
451
  };
@@ -471,11 +484,15 @@ function tomlQuote(value) {
471
484
  return JSON.stringify(String(value));
472
485
  }
473
486
 
474
- export function codexRelayMcpTomlSection(bin = relayBinPath(), node = stableNodePath()) {
487
+ export function codexRelayMcpTomlSection(
488
+ bin = relayBinPath(),
489
+ node = stableNodePath(),
490
+ { mode = DEFAULT_COMPANION_MODE } = {},
491
+ ) {
475
492
  return [
476
493
  "[mcp_servers.relay]",
477
494
  `command = ${tomlQuote(node)}`,
478
- `args = [${[bin, "mcp"].map(tomlQuote).join(", ")}]`,
495
+ `args = [${[bin, "mcp", ...companionModeCliArgs(mode)].map(tomlQuote).join(", ")}]`,
479
496
  "startup_timeout_sec = 30",
480
497
  "tool_timeout_sec = 300",
481
498
  "",
@@ -512,14 +529,22 @@ export function removeTomlTable(text, tableName) {
512
529
  return `${before}${before && after ? "\n\n" : ""}${after}${before || after ? "\n" : ""}`;
513
530
  }
514
531
 
515
- export function writeCodexMcpConfig(bin = relayBinPath(), node = stableNodePath(), configPath = codexConfigPath()) {
532
+ export function writeCodexMcpConfig(
533
+ bin = relayBinPath(),
534
+ node = stableNodePath(),
535
+ configPath = codexConfigPath(),
536
+ { mode = DEFAULT_COMPANION_MODE } = {},
537
+ ) {
516
538
  const configDir = path.dirname(configPath);
517
539
  if (!fs.existsSync(configPath) && !fs.existsSync(configDir)) {
518
540
  return { ok: false, reason: "codex_config_missing", configPath };
519
541
  }
520
542
  try {
521
543
  const existing = fs.existsSync(configPath) ? fs.readFileSync(configPath, "utf8") : "";
522
- writeTextAtomic(configPath, replaceTomlTable(existing, "mcp_servers.relay", codexRelayMcpTomlSection(bin, node)));
544
+ writeTextAtomic(
545
+ configPath,
546
+ replaceTomlTable(existing, "mcp_servers.relay", codexRelayMcpTomlSection(bin, node, { mode })),
547
+ );
523
548
  return { ok: true, method: "config", configPath };
524
549
  } catch (error) {
525
550
  return {
@@ -600,34 +625,44 @@ export function commandExists(cmd) {
600
625
  }
601
626
 
602
627
  /** Register the Relay MCP into Claude Code at user scope (loads in every session). Idempotent. */
603
- export function installClaudeCode(bin = relayBinPath(), node = stableNodePath(), { command = "claude" } = {}) {
628
+ export function installClaudeCode(
629
+ bin = relayBinPath(),
630
+ node = stableNodePath(),
631
+ { command = "claude", mode = DEFAULT_COMPANION_MODE } = {},
632
+ ) {
633
+ const modeArgs = companionModeCliArgs(mode);
604
634
  let cliResult = null;
605
635
  if (commandExists(command)) {
606
636
  run(command, ["mcp", "remove", "-s", "user", "relay"]); // ignore if absent
607
- cliResult = run(command, ["mcp", "add", "-s", "user", "relay", "--", node, bin, "mcp"]);
637
+ cliResult = run(command, ["mcp", "add", "-s", "user", "relay", "--", node, bin, "mcp", ...modeArgs]);
608
638
  if (cliResult.ok || /already exists/i.test(cliResult.out)) {
609
639
  return { ok: true, method: "cli" };
610
640
  }
611
641
  }
612
642
 
613
- const configResult = writeClaudeCodeMcpConfig(bin, node);
643
+ const configResult = writeClaudeCodeMcpConfig(bin, node, claudeCodeConfigPath(), { mode });
614
644
  if (configResult.ok) return configResult;
615
645
  if (cliResult) return { ok: false, reason: "registration_failed", detail: cliResult.out, configPath: configResult.configPath };
616
646
  return { ok: false, reason: "claude_code_not_found", configPath: configResult.configPath };
617
647
  }
618
648
 
619
649
  /** Register the Relay MCP into Codex (loads in every session). Idempotent. */
620
- export function installCodex(bin = relayBinPath(), node = stableNodePath(), { command = "codex" } = {}) {
650
+ export function installCodex(
651
+ bin = relayBinPath(),
652
+ node = stableNodePath(),
653
+ { command = "codex", mode = DEFAULT_COMPANION_MODE } = {},
654
+ ) {
655
+ const modeArgs = companionModeCliArgs(mode);
621
656
  let cliResult = null;
622
657
  if (commandExists(command)) {
623
658
  run(command, ["mcp", "remove", "relay"]); // ignore if absent
624
- cliResult = run(command, ["mcp", "add", "relay", "--", node, bin, "mcp"]);
659
+ cliResult = run(command, ["mcp", "add", "relay", "--", node, bin, "mcp", ...modeArgs]);
625
660
  if (cliResult.ok || /already exists/i.test(cliResult.out)) {
626
661
  return { ok: true, method: "cli" };
627
662
  }
628
663
  }
629
664
 
630
- const configResult = writeCodexMcpConfig(bin, node);
665
+ const configResult = writeCodexMcpConfig(bin, node, codexConfigPath(), { mode });
631
666
  if (configResult.ok) return configResult;
632
667
  if (cliResult) return { ok: false, reason: "registration_failed", detail: cliResult.out, configPath: configResult.configPath };
633
668
  return { ok: false, reason: "codex_not_found", configPath: configResult.configPath };
@@ -637,15 +672,23 @@ export function installCodex(bin = relayBinPath(), node = stableNodePath(), { co
637
672
  export function installDaemonAutostart(
638
673
  bin = relayBinPath(),
639
674
  node = stableNodePath(),
640
- { platform = process.platform, runCommand = run, reload = true, homeDir = os.homedir() } = {},
675
+ {
676
+ platform = process.platform,
677
+ runCommand = run,
678
+ reload = true,
679
+ homeDir = os.homedir(),
680
+ mode = DEFAULT_COMPANION_MODE,
681
+ } = {},
641
682
  ) {
683
+ const normalizedMode = normalizeCompanionMode(mode);
684
+ const daemonArgs = [bin, "daemon", ...companionModeCliArgs(normalizedMode)];
642
685
  if (platform === "win32") {
643
686
  const logPath = path.join(homeDir, ".relay", "daemon.log");
644
687
  fs.mkdirSync(path.dirname(logPath), { recursive: true });
645
688
  const res = installWindowsLogonTask({
646
689
  taskName: WINDOWS_DAEMON_TASK_NAME,
647
690
  executable: node,
648
- args: [bin, "daemon"],
691
+ args: daemonArgs,
649
692
  logPath,
650
693
  runCommand,
651
694
  });
@@ -666,8 +709,7 @@ export function installDaemonAutostart(
666
709
  <key>ProgramArguments</key>
667
710
  <array>
668
711
  <string>${plistEscape(node)}</string>
669
- <string>${plistEscape(bin)}</string>
670
- <string>daemon</string>
712
+ ${daemonArgs.map((argument) => ` <string>${plistEscape(argument)}</string>`).join("\n")}
671
713
  </array>
672
714
  <key>RunAtLoad</key><true/>
673
715
  <key>KeepAlive</key><true/>
@@ -690,13 +732,20 @@ export function installDaemonAutostart(
690
732
  /** Install + load a launchd agent that starts the visible Relay pill (macOS). */
691
733
  export function installPillAutostart(
692
734
  bin = relayBinPath(),
693
- { platform = process.platform, runCommand = run, reload = true, homeDir = os.homedir() } = {},
735
+ {
736
+ platform = process.platform,
737
+ runCommand = run,
738
+ reload = true,
739
+ homeDir = os.homedir(),
740
+ mode = DEFAULT_COMPANION_MODE,
741
+ } = {},
694
742
  ) {
695
743
  if (platform !== "darwin" && platform !== "win32") return { ok: false, reason: "autostart_unsupported_platform" };
696
744
  const packageRoot = packageRootForBin(bin);
697
745
  const electron = ensureElectronRuntime(packageRoot, { runCommand });
698
746
  const electronPath = electron.electronPath;
699
747
  const overlayMain = path.join(packageRoot, "overlay", "main.cjs");
748
+ const pillArgs = [overlayMain, ...companionModeCliArgs(mode)];
700
749
  if (!electron.ok || !electronPath || !fs.existsSync(overlayMain)) {
701
750
  return { ok: false, reason: "pill_runtime_missing", electronPath, overlayMain, electron };
702
751
  }
@@ -706,7 +755,7 @@ export function installPillAutostart(
706
755
  const res = installWindowsLogonTask({
707
756
  taskName: WINDOWS_PILL_TASK_NAME,
708
757
  executable: electronPath,
709
- args: [overlayMain],
758
+ args: pillArgs,
710
759
  logPath,
711
760
  runCommand,
712
761
  });
@@ -718,7 +767,7 @@ export function installPillAutostart(
718
767
  const logPath = path.join(home, ".relay", "pill.log");
719
768
  fs.mkdirSync(path.dirname(plistPath), { recursive: true });
720
769
  fs.mkdirSync(path.dirname(logPath), { recursive: true });
721
- const relayApp = installRelayMacApp({ bin, electronPath, overlayMain, homeDir, runCommand });
770
+ const relayApp = installRelayMacApp({ bin, electronPath, overlayMain, homeDir, runCommand, mode });
722
771
  if (!relayApp.ok) return { ...relayApp, electronPath, overlayMain, logPath };
723
772
  const pathEnv = "/opt/homebrew/bin:/usr/local/bin:/usr/bin:/bin";
724
773
  const plist = `<?xml version="1.0" encoding="UTF-8"?>
@@ -729,7 +778,7 @@ export function installPillAutostart(
729
778
  <key>ProgramArguments</key>
730
779
  <array>
731
780
  <string>${plistEscape(electronPath)}</string>
732
- <string>${plistEscape(overlayMain)}</string>
781
+ ${pillArgs.map((argument) => ` <string>${plistEscape(argument)}</string>`).join("\n")}
733
782
  </array>
734
783
  <key>RunAtLoad</key><true/>
735
784
  <key>KeepAlive</key><true/>
@@ -798,7 +847,8 @@ function installWindowsLogonTask({ taskName, executable, args = [], logPath = nu
798
847
  * Detect Claude Code + Codex, register the Relay MCP into each present, and
799
848
  * start the receive daemon. Returns a summary for the CLI to print.
800
849
  */
801
- export async function runSetupInstall() {
850
+ export async function runSetupInstall({ mode = DEFAULT_COMPANION_MODE } = {}) {
851
+ const normalizedMode = normalizeCompanionMode(mode);
802
852
  const { bin, stable: binStable } = resolveStableBin();
803
853
  // Bake the upgrade-surviving node path into the MCP registrations too, not just the
804
854
  // daemon plist — otherwise a `brew upgrade node` / `nvm uninstall` deletes the
@@ -808,7 +858,7 @@ export async function runSetupInstall() {
808
858
  const installed = [];
809
859
  const missing = [];
810
860
  const activations = [];
811
- const claude = installClaudeCode(bin, node);
861
+ const claude = installClaudeCode(bin, node, { mode: normalizedMode });
812
862
  if (claude.ok) {
813
863
  installed.push("Claude Code");
814
864
  activations.push(verifyClaudeMcpRegistration({ configPath: claude.configPath }));
@@ -817,7 +867,7 @@ export async function runSetupInstall() {
817
867
  } else {
818
868
  missing.push("Claude Code (registration failed)");
819
869
  }
820
- const codex = installCodex(bin, node);
870
+ const codex = installCodex(bin, node, { mode: normalizedMode });
821
871
  if (codex.ok) {
822
872
  installed.push("Codex");
823
873
  activations.push(await activateCodexMcp());
@@ -826,8 +876,8 @@ export async function runSetupInstall() {
826
876
  } else {
827
877
  missing.push("Codex (registration failed)");
828
878
  }
829
- const daemon = installDaemonAutostart(bin);
830
- const pill = installPillAutostart(bin);
879
+ const daemon = installDaemonAutostart(bin, node, { mode: normalizedMode });
880
+ const pill = installPillAutostart(bin, { mode: normalizedMode });
831
881
  return { installed, missing, daemon, pill, activations, binStable };
832
882
  }
833
883
 
@@ -843,12 +893,14 @@ export function repairDesktopSurfaces({
843
893
  runCommand = run,
844
894
  reload = true,
845
895
  homeDir = os.homedir(),
896
+ mode = DEFAULT_COMPANION_MODE,
846
897
  } = {}) {
847
- const pill = installPillAutostart(bin, { platform, runCommand, reload, homeDir });
898
+ const normalizedMode = normalizeCompanionMode(mode);
899
+ const pill = installPillAutostart(bin, { platform, runCommand, reload, homeDir, mode: normalizedMode });
848
900
  // Reload the daemon last. A repair may be invoked by the updater's detached child;
849
901
  // replacing the pill first avoids killing the update-owning daemon before all other
850
902
  // desktop surfaces are ready.
851
- const daemon = installDaemonAutostart(bin, node, { platform, runCommand, reload, homeDir });
903
+ const daemon = installDaemonAutostart(bin, node, { platform, runCommand, reload, homeDir, mode: normalizedMode });
852
904
  return { ok: Boolean(daemon.ok && pill.ok), daemon, pill };
853
905
  }
854
906
 
@@ -89,9 +89,21 @@ function readRowContent(rowState) {
89
89
  // snapshotted packet content, and — when the row points at a task — fetch the
90
90
  // verified task object via the RelayClient so the per-kind seed is grounded in the
91
91
  // real API. Returns a normalized "row" the host writers consume.
92
- async function resolveRow(id, { log = () => {} } = {}) {
92
+ async function resolveRow(id, { log = () => {}, allowTaskRows = false } = {}) {
93
93
  const rowState = getRowState(id);
94
- if (!rowState) return resolvePublicOpenToken(id, { log });
94
+ if (!rowState) {
95
+ const resolved = await resolvePublicOpenToken(id, { log });
96
+ if (!allowTaskRows && resolved?.row?.taskId) {
97
+ throw new Error("Relay Tasks are disabled in messages-only mode.");
98
+ }
99
+ return resolved;
100
+ }
101
+ // A stale row from a prior full-mode launch must not smuggle Task behavior into
102
+ // a no-flag/messages-only open. Reject before reading content or calling
103
+ // GET /v1/tasks/:id; explicit full callers opt in through allowTaskRows.
104
+ if (!allowTaskRows && rowState.taskId) {
105
+ throw new Error("Relay Tasks are disabled in messages-only mode.");
106
+ }
95
107
  const content = readRowContent(rowState);
96
108
  // The staged content packet carries the same fields under different names
97
109
  // (briefingMarkdown/displayTitle/sender). Merge so either source satisfies the
@@ -116,6 +128,9 @@ async function resolveRow(id, { log = () => {} } = {}) {
116
128
  delivery: content?.delivery || rowState.delivery || null,
117
129
  sender: content?.sender || (rowState.senderName ? { name: rowState.senderName } : null),
118
130
  };
131
+ if (!allowTaskRows && row.taskId) {
132
+ throw new Error("Relay Tasks are disabled in messages-only mode.");
133
+ }
119
134
 
120
135
  // When the row points at a task, fetch the verified task object so the per-kind
121
136
  // seed builder can ground the session in the real question / result / objective
@@ -204,10 +219,10 @@ function resolveCwd() {
204
219
  return process.env.RELAY_OPEN_CWD || os.homedir();
205
220
  }
206
221
 
207
- export async function openRelay({ id, host = "claude", log = () => {} } = {}) {
222
+ export async function openRelay({ id, host = "claude", log = () => {}, allowTaskRows = false } = {}) {
208
223
  if (!id) throw new Error("openRelay requires a row id");
209
224
  const cleanHost = String(host || "claude").toLowerCase();
210
- const { row, rowState } = await resolveRow(id, { log });
225
+ const { row, rowState } = await resolveRow(id, { log, allowTaskRows });
211
226
  return materializeRowInHost({ id, row, rowState, host: cleanHost, log });
212
227
  }
213
228
 
package/src/mcp.js CHANGED
@@ -3,12 +3,18 @@ import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js"
3
3
  import { CallToolRequestSchema, ListToolsRequestSchema } from "@modelcontextprotocol/sdk/types.js";
4
4
  import { prepareOrdinaryRelayAttachments, prepareTaskMessageAttachments, prepareTaskResult } from "./attachments.js";
5
5
  import { RelayClient } from "./client.js";
6
+ import {
7
+ COMPANION_MODE_FULL,
8
+ DEFAULT_COMPANION_MODE,
9
+ COMPANION_MODE_MESSAGES_ONLY,
10
+ normalizeCompanionMode,
11
+ } from "./config.js";
6
12
 
7
13
  export const TOOLS = [
8
14
  {
9
15
  name: "relay_send",
10
16
  description:
11
- "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.",
17
+ "Send an ordinary Relay message or handoff. 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'. Granular digital employees appear as managed, workspace-labelled contacts; use relay_contacts_search and pass the exact matching contactId so a user with several workspaces reaches the intended employee. That message is authored by this human and wakes their employee; it is NOT a Relay task or deterministic workflow. Never use task-only tools to contact a Granular employee. For other recipients, an on-Relay message appears in their companion and Relays list; an off-Relay recipient receives an email open link.",
12
18
  inputSchema: {
13
19
  type: "object",
14
20
  properties: {
@@ -62,7 +68,7 @@ export const TOOLS = [
62
68
  {
63
69
  name: "relay_contacts_search",
64
70
  description:
65
- "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.",
71
+ "Search this human's Relay contact book before sending an ordinary relay. For a Granular digital employee, search by the workspace name when known (or 'Granular' to discover the workspace-labelled managed contacts), then pass the exact contactId to relay_send. Use it for human names like 'Sven' too. If several workspaces or people plausibly match, ask which contact they meant rather than guessing.",
66
72
  inputSchema: {
67
73
  type: "object",
68
74
  properties: { query: { type: "string" } },
@@ -444,6 +450,21 @@ export const TOOLS = [
444
450
  },
445
451
  ];
446
452
 
453
+ export const MESSAGE_ONLY_TOOL_NAMES = new Set([
454
+ "relay_send",
455
+ "relay_contacts_search",
456
+ "relay_contact_update",
457
+ "relay_inbox_list",
458
+ "relay_acknowledge",
459
+ ]);
460
+
461
+ export function toolsForMode(mode = DEFAULT_COMPANION_MODE) {
462
+ const normalized = normalizeCompanionMode(mode);
463
+ return normalized === COMPANION_MODE_MESSAGES_ONLY
464
+ ? TOOLS.filter((tool) => MESSAGE_ONLY_TOOL_NAMES.has(tool.name))
465
+ : TOOLS;
466
+ }
467
+
447
468
  function text(obj) {
448
469
  return { content: [{ type: "text", text: typeof obj === "string" ? obj : JSON.stringify(obj, null, 2) }] };
449
470
  }
@@ -463,7 +484,11 @@ function relaySendResultForAgent(result) {
463
484
  };
464
485
  }
465
486
 
466
- export async function handleCall(client, name, args) {
487
+ export async function handleCall(client, name, args, { mode = DEFAULT_COMPANION_MODE } = {}) {
488
+ const normalizedMode = normalizeCompanionMode(mode);
489
+ if (normalizedMode === COMPANION_MODE_MESSAGES_ONLY && !MESSAGE_ONLY_TOOL_NAMES.has(name)) {
490
+ throw new Error(`Tool ${name} is unavailable in the ordinary-messages-only Relay profile`);
491
+ }
467
492
  switch (name) {
468
493
  case "relay_send":
469
494
  return text(
@@ -637,17 +662,18 @@ export async function handleCall(client, name, args) {
637
662
  }
638
663
  }
639
664
 
640
- export async function runMcpServer() {
665
+ export async function runMcpServer({ mode = DEFAULT_COMPANION_MODE } = {}) {
666
+ const normalizedMode = normalizeCompanionMode(mode);
641
667
  const client = new RelayClient();
642
668
  const server = new Server(
643
669
  { name: "relay-companion", version: "0.2.0-agent-protocol" },
644
670
  { capabilities: { tools: {} } },
645
671
  );
646
672
 
647
- server.setRequestHandler(ListToolsRequestSchema, async () => ({ tools: TOOLS }));
673
+ server.setRequestHandler(ListToolsRequestSchema, async () => ({ tools: toolsForMode(normalizedMode) }));
648
674
  server.setRequestHandler(CallToolRequestSchema, async (req) => {
649
675
  try {
650
- return await handleCall(client, req.params.name, req.params.arguments || {});
676
+ return await handleCall(client, req.params.name, req.params.arguments || {}, { mode: normalizedMode });
651
677
  } catch (err) {
652
678
  // Surface the API's validation detail so the calling agent can self-correct
653
679
  // instead of guessing what "invalid_request" meant.
@@ -671,8 +697,17 @@ export async function runMcpServer() {
671
697
  await server.connect(transport);
672
698
  }
673
699
 
700
+ export function mcpModeFromArgv(argv = []) {
701
+ const args = Array.isArray(argv) ? argv.map(String) : [];
702
+ const messagesOnly = args.includes("--messages-only");
703
+ const full = args.includes("--full");
704
+ if (messagesOnly && full) throw new Error("Choose either --messages-only or --full, not both.");
705
+ if (full) return COMPANION_MODE_FULL;
706
+ return DEFAULT_COMPANION_MODE;
707
+ }
708
+
674
709
  if (import.meta.url === `file://${process.argv[1]}`) {
675
- runMcpServer().catch((err) => {
710
+ runMcpServer({ mode: mcpModeFromArgv(process.argv.slice(2)) }).catch((err) => {
676
711
  // eslint-disable-next-line no-console
677
712
  console.error(err);
678
713
  process.exit(1);
@@ -46,7 +46,7 @@ function relayCliCommand(args) {
46
46
  }
47
47
 
48
48
  function relayAnswerCommand(taskId, messageId) {
49
- return relayCliCommand(["answer-question", "--task", taskId, "--message", messageId, "--answer", "..."]);
49
+ return relayCliCommand(["answer-question", "--task", taskId, "--message", messageId, "--answer", "...", "--full"]);
50
50
  }
51
51
 
52
52
  function actionForNotification(notification) {
package/src/runtime.js CHANGED
@@ -5,11 +5,17 @@ import readline from "node:readline";
5
5
  import { randomUUID } from "node:crypto";
6
6
  import { execFileSync, spawn, spawnSync } from "node:child_process";
7
7
  import { fileURLToPath } from "node:url";
8
- import { configDir, taskLedgerPath } from "./config.js";
8
+ import {
9
+ COMPANION_MODE_FULL,
10
+ companionModeCliArgs,
11
+ configDir,
12
+ taskLedgerPath,
13
+ } from "./config.js";
9
14
 
10
15
  const CODEX_APP_SERVER_TIMEOUT_MS = 30_000;
11
16
  const codexAppServerConnections = new Map();
12
17
  const claudeSdkRuns = new Map();
18
+ const TASK_MCP_MODE_ARGS = companionModeCliArgs(COMPANION_MODE_FULL);
13
19
 
14
20
  function now() {
15
21
  return new Date().toISOString();
@@ -126,7 +132,7 @@ function mcpConfigPath(hostKind, relaySessionId) {
126
132
  mcpServers: {
127
133
  relay: {
128
134
  command: process.execPath,
129
- args: [relayBinPath(), "mcp"],
135
+ args: [relayBinPath(), "mcp", ...TASK_MCP_MODE_ARGS],
130
136
  env: {
131
137
  RELAY_API_URL: process.env.RELAY_API_URL || "",
132
138
  RELAY_DEVICE_TOKEN: process.env.RELAY_DEVICE_TOKEN || "",
@@ -145,7 +151,7 @@ function relayCodexConfigArgs(relaySessionId) {
145
151
  "-c",
146
152
  `mcp_servers.relay.command=${JSON.stringify(process.execPath)}`,
147
153
  "-c",
148
- `mcp_servers.relay.args=${JSON.stringify([bin, "mcp"])}`,
154
+ `mcp_servers.relay.args=${JSON.stringify([bin, "mcp", ...TASK_MCP_MODE_ARGS])}`,
149
155
  "-c",
150
156
  `mcp_servers.relay.startup_timeout_sec=30`,
151
157
  "-c",
@@ -167,7 +173,7 @@ function relayCodexAppServerConfig() {
167
173
  mcp_servers: {
168
174
  relay: {
169
175
  command: process.execPath,
170
- args: [relayBinPath(), "mcp"],
176
+ args: [relayBinPath(), "mcp", ...TASK_MCP_MODE_ARGS],
171
177
  env,
172
178
  startup_timeout_sec: 30,
173
179
  tool_timeout_sec: 300,
@@ -185,7 +191,7 @@ function relayClaudeMcpServers() {
185
191
  relay: {
186
192
  type: "stdio",
187
193
  command: process.execPath,
188
- args: [relayBinPath(), "mcp"],
194
+ args: [relayBinPath(), "mcp", ...TASK_MCP_MODE_ARGS],
189
195
  env,
190
196
  timeout: 300_000,
191
197
  alwaysLoad: true,
@@ -21,6 +21,7 @@ import {
21
21
  } from "./notifications.js";
22
22
  import { createAutoUpdater } from "./auto-update.js";
23
23
  import { startDesktopStartupMigration } from "./desktop-migration.js";
24
+ import { COMPANION_MODE_FULL, COMPANION_MODE_MESSAGES_ONLY } from "./config.js";
24
25
 
25
26
  function idempotencyKey(prefix) {
26
27
  return `${prefix}_${randomUUID()}`;
@@ -219,6 +220,23 @@ async function pollHumanNotifications({ client, ledger, stageCompanionItem, stag
219
220
  }
220
221
  }
221
222
 
223
+ /**
224
+ * Poll only the ordinary Relay inbox. This is the complete receive loop used by
225
+ * messages-only installations: it deliberately has no task, task-session,
226
+ * connector, or task-notification client calls.
227
+ */
228
+ export async function pollOrdinaryRelayOnce({
229
+ client = new RelayClient(),
230
+ log = () => {},
231
+ stagePlainRelay = defaultStagePlainRelayItem,
232
+ } = {}) {
233
+ const ledger = readTaskLedger();
234
+ const ordinaryRelays = await pollPlainInbox({ client, ledger, stagePlainRelay, log });
235
+ pruneLedger(ledger);
236
+ writeTaskLedger(ledger);
237
+ return { ordinaryRelays };
238
+ }
239
+
222
240
  export async function pollTaskRuntimeOnce({
223
241
  client = new RelayClient(),
224
242
  log = () => {},
@@ -366,6 +384,52 @@ export function startAutoUpdateLoop({
366
384
  return { run, timer, stop: () => clearIntervalImpl(timer) };
367
385
  }
368
386
 
387
+ export async function runOrdinaryRelayDaemon({ intervalMs = 4000 } = {}) {
388
+ // Keep the messages-only receiver alive through transient staging failures.
389
+ process.on("uncaughtException", (err) => {
390
+ // eslint-disable-next-line no-console
391
+ console.error(`[relay] uncaught exception (continuing): ${err && err.stack ? err.stack : err}`);
392
+ });
393
+ process.on("unhandledRejection", (reason) => {
394
+ // eslint-disable-next-line no-console
395
+ console.error(`[relay] unhandled rejection (continuing): ${reason && reason.stack ? reason.stack : reason}`);
396
+ });
397
+ const log = (m) => console.log(`[relay] ${m}`);
398
+ startDesktopStartupMigration({ log, mode: COMPANION_MODE_MESSAGES_ONLY });
399
+ const autoUpdater = createAutoUpdater({
400
+ log,
401
+ hasActiveWork: () => false,
402
+ mode: COMPANION_MODE_MESSAGES_ONLY,
403
+ });
404
+ startAutoUpdateLoop({ autoUpdater, log });
405
+ const client = new RelayClient();
406
+ let me = null;
407
+ for (let attempt = 1; !me; attempt += 1) {
408
+ try {
409
+ me = await client.me();
410
+ } catch (err) {
411
+ const wait = Math.min(60_000, 2_000 * attempt);
412
+ // eslint-disable-next-line no-console
413
+ console.error(`[relay] startup me() failed (${err && err.message}); retrying in ${Math.round(wait / 1000)}s`);
414
+ await new Promise((resolve) => setTimeout(resolve, wait));
415
+ }
416
+ }
417
+ // eslint-disable-next-line no-console
418
+ console.log(`[relay] ordinary-message receiver for ${me.user.email}; polling every ${intervalMs}ms`);
419
+ for (;;) {
420
+ try {
421
+ const result = await pollOrdinaryRelayOnce({ client, log });
422
+ if (result.ordinaryRelays.length) {
423
+ log(`processed ${result.ordinaryRelays.length} ordinary relay(s)`);
424
+ }
425
+ } catch (err) {
426
+ // eslint-disable-next-line no-console
427
+ console.error(`[relay] ordinary-message receiver error: ${err.message}`);
428
+ }
429
+ await new Promise((resolve) => setTimeout(resolve, intervalMs));
430
+ }
431
+ }
432
+
369
433
  export async function runTaskDaemon({ intervalMs = 4000 } = {}) {
370
434
  // Last-resort safety net: the daemon is always-on and launchd-restarted, but a
371
435
  // restart re-delivers in-flight work and can duplicate agent turns. Keep it alive
@@ -384,8 +448,8 @@ export async function runTaskDaemon({ intervalMs = 4000 } = {}) {
384
448
  // Repair Relay.app + launchd surfaces independently of npm postinstall. This is a
385
449
  // bounded, per-version migration; it reloads only the pill and never unloads this
386
450
  // currently running daemon.
387
- startDesktopStartupMigration({ log });
388
- const autoUpdater = createAutoUpdater({ log, hasActiveWork: hasActiveTurns });
451
+ startDesktopStartupMigration({ log, mode: COMPANION_MODE_FULL });
452
+ const autoUpdater = createAutoUpdater({ log, hasActiveWork: hasActiveTurns, mode: COMPANION_MODE_FULL });
389
453
  startAutoUpdateLoop({ autoUpdater, log });
390
454
  const client = new RelayClient();
391
455
  // Boot resilience: the daemon starts at login, often BEFORE the network is up.
@@ -426,7 +490,11 @@ export async function runTaskDaemon({ intervalMs = 4000 } = {}) {
426
490
  }
427
491
 
428
492
  if (import.meta.url === `file://${process.argv[1]}`) {
429
- runTaskDaemon().catch((err) => {
493
+ const full = process.argv.includes("--full");
494
+ const messagesOnly = process.argv.includes("--messages-only");
495
+ if (full && messagesOnly) throw new Error("Choose either --messages-only or --full, not both.");
496
+ const runDaemon = full ? runTaskDaemon : runOrdinaryRelayDaemon;
497
+ runDaemon().catch((err) => {
430
498
  // eslint-disable-next-line no-console
431
499
  console.error(err);
432
500
  process.exit(1);