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 +85 -32
- package/overlay/main.cjs +64 -8
- package/overlay/mode-policy.cjs +37 -0
- package/package.json +2 -2
- package/src/auto-update.js +17 -4
- package/src/config.js +22 -0
- package/src/desktop-migration.js +26 -9
- package/src/install.js +81 -29
- package/src/materializer.js +19 -4
- package/src/mcp.js +42 -7
- package/src/notifications.js +1 -1
- package/src/runtime.js +11 -5
- package/src/task-daemon.js +71 -3
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 {
|
|
12
|
-
|
|
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({
|
|
119
|
-
|
|
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
|
-
|
|
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(
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
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
|
|
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]
|
|
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
|
|
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]
|
|
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
|
|
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
|
|
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]
|
|
404
|
-
" relay pill
|
|
405
|
-
" relay mcp
|
|
406
|
-
" relay update
|
|
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(
|
|
1143
|
-
|
|
1144
|
-
|
|
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(
|
|
1221
|
-
|
|
1222
|
-
|
|
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.
|
|
4
|
-
"description": "Relay companion
|
|
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"
|
package/src/auto-update.js
CHANGED
|
@@ -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({
|
|
460
|
-
|
|
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");
|
package/src/desktop-migration.js
CHANGED
|
@@ -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
|
-
|
|
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
|
|
91
|
-
* daemon plist without reloading it (never unload the process
|
|
92
|
-
* then installs/reloads only the pill so Relay.app and
|
|
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 = {
|
|
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
|
-
|
|
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
|
|
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(
|
|
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(
|
|
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(
|
|
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(
|
|
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(
|
|
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(
|
|
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
|
-
{
|
|
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:
|
|
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(
|
|
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
|
-
{
|
|
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:
|
|
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(
|
|
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
|
|
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
|
|
package/src/materializer.js
CHANGED
|
@@ -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)
|
|
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
|
|
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.
|
|
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:
|
|
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);
|
package/src/notifications.js
CHANGED
|
@@ -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 {
|
|
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,
|
package/src/task-daemon.js
CHANGED
|
@@ -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
|
-
|
|
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);
|