Skip to content
Draft
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
69 changes: 53 additions & 16 deletions packages/connect/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,21 +7,58 @@ or a preinstalled skill:
Run this for me: `npx -y @agent-relay/connect join <link>`
```

The command reuses a live Agent Relay 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 standalone probe fails with an update-required error so the
CLI never starts a second headless probe beside it. An installed macOS app keeps
its existing app update path.
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.
Run it from the agent's own shell tool (in Claude Code, a command blocked by a
permission check can be run with the `!` prefix). The command needs no
download and starts no new binary: the package itself is the local relay.

## How it works

`join` names the calling session itself. A Codex session is named by
`CODEX_THREAD_ID`; a Claude Code session is named by `CLAUDE_CODE_SESSION_ID`
when that session has a live record, or else by the nearest parent process
with one. Outside a live session the command stops at once and says what it
checked.

It then uses one local relay for that session:

1. **The relay built into this package**, if it already holds this session's
Connect.
2. **A live Agent Relay desktop probe** at version 2026.10.5 or newer on macOS,
or 2026.10.4 or newer on Linux. Its pointer and socket must be owned by the
current user, and the pointer must not be group- or world-writable; unsafe
pointer or socket metadata is never used.
3. **Otherwise, the relay built into this package.** It runs as a per-user
background Node process under the same Node that runs `npx`.
- Its code is copied to `~/.agentworkforce/connect/runtime/<version>/`.
- Its credential and delivery ledger are kept in
`~/.agentworkforce/connect/state.json`: directory 0700, file 0600.
- It serves a 0600 socket under `~/.agentworkforce/connect/run/`.
- It logs to `~/.agentworkforce/connect/daemon.log`, never with tokens or
message text.
- If it cannot start, `join` prints the reason and the end of that log at
once.

It polls the Connect for new messages and writes each into the session.
- **Claude Code:** through the session's own inbox socket.
- **Codex:** with `codex queue`, confirmed from the session's rollout, or
through the Codex app's IPC socket.

It acknowledges a message only once it is in the session. A message whose
delivery is uncertain is never resent. The relay exits a minute after its
last Connect ends. After a restart, run `status` once to resume delivery.

`AGENT_RELAY_CONNECT_BACKEND=node` or `=probe` forces one relay. `join` no
longer downloads the desktop probe. `connect install` still installs it for
people who want the full desktop relay, as described below.

## `connect install`

`install` reuses a live probe when it answers. If an existing socket or Relay
process may be restarting, it retries liveness for up to 15 seconds. A probe
that answers but cannot tie the command to a live session counts as running.
Otherwise it downloads the current release from
`AgentWorkforce/relay-desktop-releases`, verifies the adjacent SHA-256 file,
and starts the probe without signing in.

- Linux x64 and arm64 use the relocatable tarball under
`~/.local/lib/agent-relay/current`, a symlink at
Expand All @@ -34,7 +71,7 @@ it is running; update the running service separately.
installed but its probe needs updating, the existing signed DMG path updates
that app. An app in `/Applications` that this user cannot replace requires
an administrator update or moving the app to `~/Applications`.
- For Claude Code, starting the probe sets `"crossSessionInbound": "accept"`
- For Claude Code, joining (with either relay) sets `"crossSessionInbound": "accept"`
in `~/.claude/settings.json` so replies can be injected into the live
session. The command never signs the user in.

Expand Down
162 changes: 141 additions & 21 deletions packages/connect/src/cli.js
Original file line number Diff line number Diff line change
Expand Up @@ -2,13 +2,20 @@

import { readFile } from 'node:fs/promises';
import os from 'node:os';
import { randomUUID } from 'node:crypto';
import {
ensureProbe,
findLiveSocket,
InstallError,
linuxProbeSessionUpdateHint,
probeSupportedOnPlatform,
requireSupportedProbe,
requireExistingProbe as findExistingProbe,
} from './install.js';
import { requestJson, requireOk, SocketResponseError } from './http.js';
import { identifyCaller, SessionNotFoundError } from './relay/identify.js';
import { ensureRelay, RelayStartError } from './relay/launch.js';
import { relayPaths } from './relay/paths.js';

const USAGE = `Usage:
connect join <link-or-id> [--name <agent_name>] [--host-claim-stdin] [--json]
Expand Down Expand Up @@ -113,8 +120,28 @@ function errorDetails(error) {
connect_join_timeout: ['Relay Connect join timed out; retry the same join safely.', 8],
connect_unreachable: ['Relay Connect cannot reach Cloud or Relaycast; retry once.', 8],
agent_token_invalid: ['Relay Connect is over; run leave once to clear the local registration.', 3],
not_a_relay_session: ['Run this command from a live Claude Code or Codex session.', 6],
not_a_relay_session: [
"Agent Relay is running but could not match this command to a live Claude Code or Codex session. Run it from the agent's own shell tool, not a separate terminal; if it already is, update Claude Code or Codex and retry.",
6,
],
};
if (code === 'session_file_unreadable') {
return {
message: safeText(
error?.response?.error?.message,
"This session's file cannot be read; resume the session and retry."
),
exitCode: 6,
code,
};
}
if (error instanceof SessionNotFoundError || error instanceof RelayStartError) {
return {
message: safeText(error.message.split('\n')[0]) + tailOf(error.message),
exitCode: error.exitCode,
code,
};
}
if (known[code]) return { message: known[code][0], exitCode: known[code][1], code };
if (error instanceof UsageError || error instanceof InstallError) {
return { message: safeText(error.message, 'Relay Connect failed.'), exitCode: error.exitCode || 1, code };
Expand All @@ -129,6 +156,12 @@ function errorDetails(error) {
};
}

// Keeps the log tail a start failure carries, on its own lines.
function tailOf(message) {
const lines = String(message).split('\n').slice(1);
return lines.length ? `\n${lines.map((line) => safeText(line, '')).join('\n')}` : '';
}

async function requireExistingProbe() {
const existing = await findExistingProbe({ home: os.homedir() });
if (!existing)
Expand All @@ -147,8 +180,15 @@ function requireSessionOk(response, probe) {
return requireOk(response);
}

function joinSummary(response) {
function joinSummary(response, backend) {
const data = response.data || {};
const claudeNote =
backend.kind === 'node'
? 'Claude Code note: Relay Connect sets "crossSessionInbound": "accept" in ~/.claude/settings.json so replies can be injected; the previous file is kept as settings.json.relay-desktop-backup.'
: 'Claude Code note: the probe sets "crossSessionInbound": "accept" in ~/.claude/settings.json; undo it by POSTing {"enabled":false} to /setup/direct-delivery over the pointer socket (exact command in the package README).';
const warnings = Array.isArray(data.warnings)
? data.warnings.map((warning) => `Warning: ${safeText(warning)}`)
: [];
const hostAgent = safeText(data.host?.agent_name);
const hostPerson = safeText(data.host?.person, '');
const host = hostPerson && hostPerson !== hostAgent ? `${hostPerson} (${hostAgent})` : hostAgent;
Expand All @@ -163,7 +203,12 @@ function joinSummary(response) {
'- Check membership: npx -y @agent-relay/connect status',
'- Leave: npx -y @agent-relay/connect leave',
'- An ended or expired notice means the Connect is over; stop sending and do not silently rejoin.',
'Claude Code note: the probe sets "crossSessionInbound": "accept" in ~/.claude/settings.json; undo it by POSTing {"enabled":false} to /setup/direct-delivery over the pointer socket (exact command in the package README).',
...(backend.kind === 'node'
? ['- After a restart, run status once to resume delivery: npx -y @agent-relay/connect status']
: []),
claudeNote,
...(backend.note ? [backend.note] : []),
...warnings,
].join('\n');
}

Expand Down Expand Up @@ -207,7 +252,7 @@ async function run(options) {
return;
}

const probe = options.command === 'join' ? await ensureProbe() : await requireExistingProbe();
const backend = await chooseBackend(options.command);
if (options.command === 'join') {
const body = { link: options.positionals[0] };
if (options.name) body.name = options.name;
Expand All @@ -216,33 +261,33 @@ async function run(options) {
if (!claim) throw new UsageError('--host-claim-stdin requires a claim on stdin.');
body.host_claim = claim;
}
const response = requireSessionOk(
await requestJson(probe.socketPath, {
const response = backend.check(
await backend.call({
method: 'POST',
path: '/connect/join',
body: JSON.stringify(body),
headers: { 'content-type': 'application/json' },
}),
probe
timeoutMs: 60_000,
})
);
delete body.host_claim;

if (options.json) printJson(response);
else process.stdout.write(`${joinSummary(response)}\n`);
else process.stdout.write(`${joinSummary(response, backend)}\n`);
return;
}

if (options.command === 'send') {
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 = requireSessionOk(
await requestJson(probe.socketPath, {
const response = backend.check(
await backend.call({
method: 'POST',
path: `/connect/send${suffix}`,
body: message,
}),
probe
headers: { 'x-agent-relay-request': randomUUID() },
})
);
if (options.json) printJson(response);
else {
Expand All @@ -253,23 +298,98 @@ async function run(options) {
}

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

const response = requireSessionOk(
await requestJson(probe.socketPath, { method: 'POST', path: '/connect/leave' }),
probe
);
const response = backend.check(await backend.call({ method: 'POST', path: '/connect/leave' }));
if (options.json) printJson(response);
else process.stdout.write(`Left Relay Connect ${safeText(response.data?.connect_id)}.\n`);
}

async function nodeHoldsSession(home, target) {
try {
const state = JSON.parse(await readFile(relayPaths(home).state, 'utf8'));
return Boolean(state?.sessions?.[`${target.kind}:${target.id}`]);
} catch {
return false;
}
}

/**
* Which local relay serves this command. The Node relay serves any session it
* already holds; otherwise an eligible desktop probe is preferred; otherwise
* `join` starts the Node relay. AGENT_RELAY_CONNECT_BACKEND=node|probe forces one.
*/
async function chooseBackend(command) {
const home = os.homedir();
const forced = process.env.AGENT_RELAY_CONNECT_BACKEND;
const probeBackend = (probe) => ({
kind: 'probe',
call: (request) => requestJson(probe.socketPath, request),
check: (response) => requireSessionOk(response, probe),
});
if (forced === 'probe') {
return probeBackend(command === 'join' ? await ensureProbe() : await requireExistingProbe());
}

let target = null;
let identifyError = null;
try {
target = await identifyCaller({ home });
} catch (error) {
if (!(error instanceof SessionNotFoundError)) throw error;
identifyError = error;
}
const nodeBackend = async (note) => {
const socket = await ensureRelay({ home });
return {
kind: 'node',
note,
call: (request) =>
requestJson(socket, {
...request,
headers: { ...request.headers, 'x-agent-relay-target': `${target.kind}:${target.id}` },
}),
check: (response) => requireOk(response),
};
};
if (forced === 'node') {
if (!target) throw identifyError;
return await nodeBackend();
}
if (target && (await nodeHoldsSession(home, target))) return await nodeBackend();

let probe = null;
try {
probe = await findLiveSocket(home, 2_000);
} catch {
// An unsafe or unreadable probe pointer is never used; the Node relay is.
probe = null;
}
if (probe && !probe.sessionRejected) {
if (probeSupportedOnPlatform(probe)) return probeBackend(probe);
if (command === 'join' && target) {
return await nodeBackend(
`Note: Agent Relay ${safeText(probe.version)} is too old for Relay Connect, so this Connect uses the relay built into this package.`
);
}
requireSupportedProbe(probe);
}
if (!target) {
// The probe knows the session but cannot read its record: its reason is the useful one.
if (probe?.status?.error?.code === 'session_file_unreadable') throw new SocketResponseError(probe.status);
throw identifyError;
}
if (command === 'join') return await nodeBackend();
throw new SocketResponseError({
ok: false,
error: { code: 'connect_not_joined', message: 'This agent session has not joined a Relay Connect.' },
});
}

export async function main(argv = process.argv.slice(2)) {
let options;
try {
Expand Down
Loading