Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 8 additions & 3 deletions packages/connect/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,15 +7,20 @@ or a preinstalled skill:
Run this for me: `npx -y @agent-relay/connect join <link>`
```

The command reuses a live Agent Relay Desktop probe at version 2026.10.4 or
newer when its pointer and socket are owned by the current user and the pointer
is not group- or world-writable. Unsafe pointer or socket metadata fails closed.
The command reuses a live Agent Relay Desktop probe at version 2026.10.5 or
newer on macOS, or 2026.10.4 or newer on Linux, when its pointer and socket are
owned by the current user and the pointer is not group- or world-writable.
Unsafe pointer or socket metadata fails closed.
If an existing socket or Relay process may be restarting, the command retries
liveness for up to 15 seconds. When no eligible probe is available, it
downloads the current release from `AgentWorkforce/relay-desktop-releases`,
verifies the adjacent SHA-256 file, and starts the probe without signing in.
A responsive old Linux probe instead fails with an update-required error so the
CLI never starts a second headless probe beside it.
If a Linux 2026.10.4 probe cannot identify the calling session during join,
send, status, or leave, the CLI tells the user to update Agent Relay rather than retrying the
same request. A live Linux headless probe cannot be replaced by this CLI while
it is running; update the running service separately.

- Linux x64 and arm64 use the relocatable tarball under
`~/.local/lib/agent-relay/current`, a symlink at
Expand Down
38 changes: 31 additions & 7 deletions packages/connect/src/cli.js
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,12 @@

import { readFile } from 'node:fs/promises';
import os from 'node:os';
import { ensureProbe, InstallError, requireExistingProbe as findExistingProbe } from './install.js';
import {
ensureProbe,
InstallError,
linuxProbeSessionUpdateHint,
requireExistingProbe as findExistingProbe,
} from './install.js';
import { requestJson, requireOk, SocketResponseError } from './http.js';

const USAGE = `Usage:
Expand Down Expand Up @@ -126,6 +131,17 @@ async function requireExistingProbe() {
return existing;
}

function requireSessionOk(response, probe) {
const hint = linuxProbeSessionUpdateHint(probe, response);
if (hint) {
const error = new InstallError(hint, 6);
error.code = 'probe_update_required';
error.response = { ...response, error: { ...response.error, message: hint } };
throw error;
}
return requireOk(response);
Comment thread
miyaontherelay marked this conversation as resolved.
}

function joinSummary(response) {
const data = response.data || {};
const hostAgent = safeText(data.host?.agent_name);
Expand Down Expand Up @@ -195,13 +211,14 @@ async function run(options) {
if (!claim) throw new UsageError('--host-claim-stdin requires a claim on stdin.');
body.host_claim = claim;
}
const response = requireOk(
const response = requireSessionOk(
await requestJson(probe.socketPath, {
method: 'POST',
path: '/connect/join',
body: JSON.stringify(body),
headers: { 'content-type': 'application/json' },
})
}),
probe
);
delete body.host_claim;

Expand Down Expand Up @@ -232,12 +249,13 @@ async function run(options) {
const message = await stdinText();
if (!message) throw new UsageError('send requires message text on stdin.');
const suffix = options.to ? `?to=${encodeURIComponent(options.to)}` : '';
const response = requireOk(
const response = requireSessionOk(
await requestJson(probe.socketPath, {
method: 'POST',
path: `/connect/send${suffix}`,
body: message,
})
}),
probe
);
if (options.json) printJson(response);
else {
Expand All @@ -248,13 +266,19 @@ async function run(options) {
}

if (options.command === 'status') {
const response = requireOk(await requestJson(probe.socketPath, { path: '/connect/status' }));
const response = requireSessionOk(
await requestJson(probe.socketPath, { path: '/connect/status' }),
probe
);
if (options.json) printJson(response);
else process.stdout.write(`${statusSummary(response)}\n`);
return;
}

const response = requireOk(await requestJson(probe.socketPath, { method: 'POST', path: '/connect/leave' }));
const response = requireSessionOk(
await requestJson(probe.socketPath, { method: 'POST', path: '/connect/leave' }),
probe
);
if (options.json) printJson(response);
else process.stdout.write(`Left Relay Connect ${safeText(response.data?.connect_id)}.\n`);
}
Expand Down
58 changes: 43 additions & 15 deletions packages/connect/src/install.js
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ import { requestJson } from './http.js';

const RELEASE = 'https://github.com/AgentWorkforce/relay-desktop-releases/releases/latest/download';
const MINIMUM_PROBE_VERSION = '2026.10.4';
const MINIMUM_MAC_PROBE_VERSION = '2026.10.5';
const RECOVERY_TIMEOUT_MS = 15_000;
const INSTALL_LOCK_TIMEOUT_MS = 90_000;
const INSTALL_LOCK_STALE_MS = 15 * 60_000;
Expand All @@ -36,9 +37,9 @@ export class InstallError extends Error {
}

export class OutdatedProbeError extends InstallError {
constructor(version) {
constructor(version, minimum = MINIMUM_PROBE_VERSION, nextStep = 'update it and retry.') {
super(
`Agent Relay ${version || 'unknown'} is too old for Relay Connect (needs ${MINIMUM_PROBE_VERSION} or newer); update it and retry.`,
`Agent Relay ${version || 'unknown'} is too old for Relay Connect (needs ${minimum} or newer); ${nextStep}`,
9
);
this.name = 'OutdatedProbeError';
Expand Down Expand Up @@ -202,6 +203,28 @@ export function probeVersionSupported(version, minimum = MINIMUM_PROBE_VERSION)
return true;
}

function minimumProbeVersion(platform) {
return platform === 'darwin' ? MINIMUM_MAC_PROBE_VERSION : MINIMUM_PROBE_VERSION;
}

function probeSupportedOnPlatform(existing, platform) {
if (!existing) return false;
return existing.version
? probeVersionSupported(existing.version, minimumProbeVersion(platform))
: existing.supported !== false;
}

export function linuxProbeSessionUpdateHint(probe, response, platform = process.platform) {
if (
platform !== 'linux' ||
response?.error?.code !== 'not_a_relay_session' ||
!probeVersionSupported(probe?.version) ||
probeVersionSupported(probe?.version, MINIMUM_MAC_PROBE_VERSION)
)
return null;
return `Agent Relay ${probe.version} could not identify this session. If this is a live Claude Code or Codex session, update the running Agent Relay probe to ${MINIMUM_MAC_PROBE_VERSION} or newer; otherwise run this from a live session.`;
}

async function liveStatus(socketPath, timeoutMs = 5_000) {
if (!socketPath) return null;
try {
Expand Down Expand Up @@ -241,13 +264,14 @@ export async function waitForLiveSocket({
sleep = delay,
pointer = readPointer,
check = liveStatus,
minimumVersion = MINIMUM_PROBE_VERSION,
} = {}) {
const deadline = now() + timeoutMs;
while (now() < deadline) {
const socketPath = await pointer(home);
const remaining = Math.max(1, deadline - now());
const status = await check(socketPath, Math.min(perRequestTimeoutMs, remaining));
if (status && probeVersionSupported(status?.data?.version)) return { socketPath, status };
if (status && probeVersionSupported(status?.data?.version, minimumVersion)) return { socketPath, status };
const sleepFor = Math.min(1_000, Math.max(0, deadline - now()));
if (sleepFor > 0) await sleep(sleepFor);
}
Expand Down Expand Up @@ -600,8 +624,12 @@ export async function acquireInstallLock({
}
}

function requireSupportedProbe(existing) {
if (existing && existing.supported === false) throw new OutdatedProbeError(existing.version);
function requireSupportedProbe(existing, platform = process.platform) {
if (existing && !probeSupportedOnPlatform(existing, platform)) {
const nextStep =
platform === 'darwin' ? 'run `npx -y @agent-relay/connect install` and retry.' : 'update it and retry.';
throw new OutdatedProbeError(existing.version, minimumProbeVersion(platform), nextStep);
}
return existing;
}

Expand Down Expand Up @@ -660,7 +688,7 @@ export async function installMac({

// Do not delete the existing pointer on macOS: RelayDesktop may reuse it.
await run(stage[2][0], stage[2][1]);
const result = await wait({ home });
const result = await wait({ home, minimumVersion: MINIMUM_MAC_PROBE_VERSION });
ready = true;
await finishSwap(app, appSwap, true);
return result;
Expand Down Expand Up @@ -707,10 +735,10 @@ export async function ensureProbe({
acquire = acquireInstallLock,
} = {}) {
const existing = await findRecoveringProbe({ home, find, run, active, now, sleep });
if (existing?.supported !== false) {
if (existing) return { ...existing, installed: false };
} else if (platform === 'linux') {
requireSupportedProbe(existing);
if (probeSupportedOnPlatform(existing, platform)) {
return { ...existing, installed: false };
} else if (existing && platform === 'linux') {
requireSupportedProbe(existing, platform);
}

if (platform !== 'linux' && platform !== 'darwin') {
Expand All @@ -720,10 +748,10 @@ export async function ensureProbe({
const installLock = await acquire({ home, platform, now, sleep });
try {
const afterLock = await find(home, 1_000);
if (afterLock?.supported !== false) {
if (afterLock) return { ...afterLock, installed: false };
} else if (platform === 'linux') {
requireSupportedProbe(afterLock);
if (probeSupportedOnPlatform(afterLock, platform)) {
return { ...afterLock, installed: false };
} else if (afterLock && platform === 'linux') {
requireSupportedProbe(afterLock, platform);
}

const result =
Expand All @@ -737,5 +765,5 @@ export async function ensureProbe({
}

export async function requireExistingProbe(options = {}) {
return requireSupportedProbe(await findRecoveringProbe(options));
return requireSupportedProbe(await findRecoveringProbe(options), options.platform || process.platform);
}
Loading
Loading