diff --git a/packages/connect/README.md b/packages/connect/README.md
index 222d7ead0..5dcff11df 100644
--- a/packages/connect/README.md
+++ b/packages/connect/README.md
@@ -7,21 +7,58 @@ or a preinstalled skill:
Run this for me: `npx -y @agent-relay/connect join `
```
-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//`.
+ - 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
@@ -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.
diff --git a/packages/connect/src/cli.js b/packages/connect/src/cli.js
index 2974516a6..738a416de 100644
--- a/packages/connect/src/cli.js
+++ b/packages/connect/src/cli.js
@@ -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 [--name ] [--host-claim-stdin] [--json]
@@ -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 };
@@ -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)
@@ -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;
@@ -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');
}
@@ -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;
@@ -216,19 +261,19 @@ 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;
}
@@ -236,13 +281,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 = 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 {
@@ -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 {
diff --git a/packages/connect/src/install.js b/packages/connect/src/install.js
index 1ebb3d50a..e95a620c0 100644
--- a/packages/connect/src/install.js
+++ b/packages/connect/src/install.js
@@ -221,7 +221,7 @@ function minimumProbeVersion(platform) {
return platform === 'darwin' ? MINIMUM_MAC_PROBE_VERSION : MINIMUM_PROBE_VERSION;
}
-function probeSupportedOnPlatform(existing, platform) {
+export function probeSupportedOnPlatform(existing, platform = process.platform) {
if (!existing) return false;
return existing.version
? probeVersionSupported(existing.version, minimumProbeVersion(platform))
@@ -239,6 +239,17 @@ export function linuxProbeSessionUpdateHint(probe, response, platform = process.
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.`;
}
+// A running probe answers every route with one of these when it cannot tie
+// the caller to a live Claude Code or Codex session. The probe is live; only
+// the caller is unidentified, so waiting longer or installing another probe
+// cannot help. Its version is not readable in that state.
+const SESSION_REJECTIONS = new Set(['not_a_relay_session', 'session_file_unreadable']);
+
+export function isSessionRejection(response) {
+ return response?.ok === false && SESSION_REJECTIONS.has(response?.error?.code);
+}
+
+/** Any well-formed probe answer to /setup/status, or null when nothing answered. */
async function liveStatus(socketPath, timeoutMs = 5_000) {
if (!socketPath) return null;
try {
@@ -248,7 +259,7 @@ async function liveStatus(socketPath, timeoutMs = 5_000) {
path: '/setup/status',
timeoutMs,
});
- return response?.ok === true ? response : null;
+ return response && typeof response === 'object' && typeof response.ok === 'boolean' ? response : null;
} catch (error) {
if (error instanceof InstallError) throw error;
return null;
@@ -258,7 +269,10 @@ async function liveStatus(socketPath, timeoutMs = 5_000) {
export async function findLiveSocket(home = os.homedir(), timeoutMs = 5_000) {
const socketPath = await readPointer(home);
const status = await liveStatus(socketPath, timeoutMs);
- if (!status) return null;
+ if (isSessionRejection(status)) {
+ return { socketPath, status, version: undefined, supported: undefined, sessionRejected: true };
+ }
+ if (status?.ok !== true) return null;
const version = status?.data?.version;
return {
socketPath,
@@ -281,15 +295,26 @@ export async function waitForLiveSocket({
minimumVersion = MINIMUM_PROBE_VERSION,
} = {}) {
const deadline = now() + timeoutMs;
+ let lastAnswer = '';
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, minimumVersion)) return { socketPath, status };
+ // The probe is up but cannot identify the caller: return at once so the
+ // session route reports that, instead of timing out and rolling back.
+ if (isSessionRejection(status)) return { socketPath, status, sessionRejected: true };
+ if (status?.ok === true && probeVersionSupported(status?.data?.version, minimumVersion)) {
+ return { socketPath, status };
+ }
+ if (status?.ok === true) lastAnswer = `version ${status?.data?.version || 'unknown'}`;
+ else if (status) lastAnswer = String(status?.error?.code || 'an error');
const sleepFor = Math.min(1_000, Math.max(0, deadline - now()));
if (sleepFor > 0) await sleep(sleepFor);
}
- throw new InstallError(`Timed out waiting for the Agent Relay probe after ${timeoutMs} ms.`);
+ const detail = lastAnswer
+ ? `its last answer was ${lastAnswer.replace(/[^\w .-]/g, '')}`
+ : 'it never answered; see ~/.agentworkforce/desktop/headless.log';
+ throw new InstallError(`Timed out waiting for the Agent Relay probe after ${timeoutMs} ms (${detail}).`);
}
async function findProbe(directory) {
@@ -660,7 +685,7 @@ export async function acquireInstallLock({
}
}
-function requireSupportedProbe(existing, platform = process.platform) {
+export 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.';
diff --git a/packages/connect/src/relay/claude.js b/packages/connect/src/relay/claude.js
new file mode 100644
index 000000000..70ba98ffa
--- /dev/null
+++ b/packages/connect/src/relay/claude.js
@@ -0,0 +1,699 @@
+import { randomUUID } from 'node:crypto';
+import { constants, readdirSync, realpathSync, statSync } from 'node:fs';
+import { chmod, lstat, mkdir, open, readdir, readFile, readlink, rename, rm, stat } from 'node:fs/promises';
+import net from 'node:net';
+import os from 'node:os';
+import { basename, dirname, extname, join } from 'node:path';
+import { ancestors, claudeStartMatches, MAX_ANCESTRY, processInfo } from './processes.js';
+
+// Claude Code terminal sessions and the inbox route into them, ported from
+// relay-desktop probe/src/relay/claude.rs and claude_settings.rs.
+//
+// Claude Code registers every session in `/sessions/.json`
+// and opens an inbox socket for cross-session messaging, authenticated by the
+// token in the matching `..key`. A message is two JSON lines on
+// that socket: the auth line, then the user message.
+
+/** The inbox wire version this client speaks (claude.rs KNOWN_PEER_PROTOCOL). */
+export const KNOWN_PEER_PROTOCOL = 1;
+/** Claude Code refuses an inbox line past about a million characters. */
+export const MAX_LINE_BYTES = 1_000_000;
+const SOCKET_TIMEOUT_MS = 5_000;
+const DRAIN_TIMEOUT_MS = 1_000;
+
+export const INBOUND_KEY = 'crossSessionInbound';
+const ACCEPT = 'accept';
+export const BACKUP_SUFFIX = '.relay-desktop-backup';
+export const MANAGED_SETTINGS = {
+ darwin: '/Library/Application Support/ClaudeCode/managed-settings.json',
+ other: '/etc/claude-code/managed-settings.json',
+};
+
+const UUID = /^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$/;
+
+/** store.rs `is_uuid`: 36 characters, hex with dashes at 8, 13, 18 and 23. */
+export function isUuid(value) {
+ return typeof value === 'string' && UUID.test(value);
+}
+
+function outcome(kind, detail) {
+ return { outcome: kind, detail };
+}
+
+function expandTilde(path, home) {
+ if (path === '~') return home;
+ if (path.startsWith('~/')) return join(home, path.slice(2));
+ return path;
+}
+
+function setting(env, key) {
+ const value = env?.[key];
+ if (typeof value !== 'string') return null;
+ const trimmed = value.trim();
+ return trimmed === '' ? null : trimmed;
+}
+
+/** `CLAUDE_CONFIG_DIR`, else `~/.claude`. */
+export function claudeHome({ home = os.homedir(), env = process.env } = {}) {
+ const configured = setting(env, 'CLAUDE_CONFIG_DIR');
+ return configured ? expandTilde(configured, home) : join(home, '.claude');
+}
+
+async function sortedEntries(directory) {
+ const names = await readdir(directory).catch(() => []);
+ return names.sort().map((name) => join(directory, name));
+}
+
+function isDirectorySync(path) {
+ try {
+ return statSync(path).isDirectory();
+ } catch {
+ return false;
+ }
+}
+
+/**
+ * Every Claude login of this person's (claude.rs `registries`): the
+ * configured home, then each sibling `.claude` or `.claude-` folder
+ * that has a `sessions/` registry, each once by the real path of its
+ * registry, so a login whose `sessions/` links to another's is read once.
+ */
+export function claudeHomes({ home = os.homedir(), env = process.env } = {}) {
+ const configured = claudeHome({ home, env });
+ const parent = dirname(configured);
+ let names = [];
+ try {
+ names = readdirSync(parent);
+ } catch {
+ names = [];
+ }
+ const siblings = names
+ .filter((name) => name === '.claude' || name.startsWith('.claude-'))
+ .map((name) => join(parent, name))
+ .filter((folder) => isDirectorySync(join(folder, 'sessions')))
+ .sort();
+ const seen = new Set();
+ const homes = [];
+ for (const folder of [configured, ...siblings]) {
+ const registry = join(folder, 'sessions');
+ let key = registry;
+ try {
+ key = realpathSync(registry);
+ } catch {
+ // Not there (yet): compared by its own path.
+ }
+ if (seen.has(key)) continue;
+ seen.add(key);
+ homes.push(folder);
+ }
+ return homes;
+}
+
+/**
+ * The first JSON value in `text`, ignoring whatever follows it (claude.rs
+ * `first_value`). Claude Code rewrites its registry files in place, so an
+ * interrupted write can leave stray bytes after a complete record.
+ */
+export function firstJsonValue(text) {
+ const start = text.search(/\S/);
+ if (start < 0) throw new SyntaxError('empty JSON');
+ const opening = text[start];
+ if (opening !== '{' && opening !== '[') {
+ try {
+ return JSON.parse(text);
+ } catch {
+ const token = text.slice(start).match(/^("(?:[^"\\]|\\.)*"|[^\s,\]}]+)/);
+ if (!token) throw new SyntaxError('no JSON value');
+ return JSON.parse(token[0]);
+ }
+ }
+ let depth = 0;
+ let inString = false;
+ for (let index = start; index < text.length; index += 1) {
+ const char = text[index];
+ if (inString) {
+ if (char === '\\') index += 1;
+ else if (char === '"') inString = false;
+ continue;
+ }
+ if (char === '"') inString = true;
+ else if (char === '{' || char === '[') depth += 1;
+ else if (char === '}' || char === ']') {
+ depth -= 1;
+ if (depth === 0) return JSON.parse(text.slice(start, index + 1));
+ }
+ }
+ throw new SyntaxError('unterminated JSON value');
+}
+
+/** `procStart` as text: a string on macOS, a string or a number of ticks on Linux. */
+function recordedStart(value) {
+ if (typeof value === 'string') return value;
+ if (typeof value === 'number' && Number.isFinite(value)) return String(value);
+ return null;
+}
+
+/**
+ * One `.json` record (claude.rs `TerminalSession::decode_value`), or
+ * `null` without what a delivery needs: a UUID session id, a pid and a
+ * socket path.
+ */
+export function decodeSessionRecord(record) {
+ if (!record || typeof record !== 'object' || Array.isArray(record)) return null;
+ const { sessionId, pid, messagingSocketPath } = record;
+ if (!isUuid(sessionId)) return null;
+ if (!Number.isInteger(pid) || pid <= 0 || pid > 0x7fffffff) return null;
+ if (typeof messagingSocketPath !== 'string' || messagingSocketPath === '') return null;
+ const text = (key) => (typeof record[key] === 'string' ? record[key] : null);
+ return {
+ sessionId,
+ pid,
+ messagingSocketPath,
+ peerProtocol: Number.isInteger(record.peerProtocol) ? record.peerProtocol : 0,
+ procStart: recordedStart(record.procStart),
+ cwd: text('cwd'),
+ name: text('name'),
+ status: text('status'),
+ updatedAtMs: null,
+ registryDir: null,
+ };
+}
+
+/** Reads `pid` once per pass, so one registry read asks `ps` once per pid. */
+function infoReader() {
+ const cache = new Map();
+ return (pid) => {
+ if (!cache.has(pid)) cache.set(pid, processInfo(pid));
+ return cache.get(pid);
+ };
+}
+
+/**
+ * Whether the record belongs to a process running now (claude.rs
+ * `is_alive`): the pid is alive and its start time matches `procStart`. A
+ * record without a start is taken on its pid alone.
+ */
+async function isLive(record, info) {
+ const running = await info(record.pid);
+ if (!running) return false;
+ return record.procStart === null || claudeStartMatches(record.procStart, running.start);
+}
+
+async function keyRecords(registryDir, pid) {
+ const prefix = `${pid}.`;
+ const records = [];
+ for (const path of await sortedEntries(registryDir)) {
+ if (extname(path) !== '.key' || !basename(path).startsWith(prefix)) continue;
+ try {
+ records.push(firstJsonValue(await readFile(path, 'utf8')));
+ } catch {
+ // An unreadable key is no key.
+ }
+ }
+ return records;
+}
+
+async function keyNamesRunningProcess(registryDir, pid, info) {
+ const running = await info(pid);
+ if (!running) return false;
+ return (await keyRecords(registryDir, pid)).some((record) => {
+ const start = recordedStart(record?.procStart);
+ return start !== null && claudeStartMatches(start, running.start);
+ });
+}
+
+/**
+ * Claude Code's session registry across every login (claude.rs `registry`):
+ * live sessions newest first, and the `.json` files that do not parse
+ * although a key of the same pid names the running process. `want` narrows
+ * the read before any process is looked up; it never changes which of the
+ * wanted records are live.
+ */
+async function readRegistry({ home, env, want = () => true, wantPid = () => true }) {
+ const info = infoReader();
+ const sessions = [];
+ const unreadable = [];
+ for (const claude of claudeHomes({ home, env })) {
+ const registryDir = join(claude, 'sessions');
+ for (const path of await sortedEntries(registryDir)) {
+ if (extname(path) !== '.json') continue;
+ let record;
+ let error;
+ try {
+ record = firstJsonValue(await readFile(path, 'utf8'));
+ } catch (caught) {
+ error = caught;
+ }
+ if (error) {
+ const stem = basename(path, '.json');
+ const pid = /^\d+$/.test(stem) ? Number.parseInt(stem, 10) : 0;
+ if (pid > 0 && wantPid(pid) && (await keyNamesRunningProcess(registryDir, pid, info))) {
+ unreadable.push({ path, pid, error: String(error?.message ?? error) });
+ }
+ continue;
+ }
+ const session = decodeSessionRecord(record);
+ if (!session || !want(session)) continue;
+ session.registryDir = registryDir;
+ session.updatedAtMs = (await stat(path).catch(() => null))?.mtimeMs ?? null;
+ if (!(await isLive(session, info))) continue;
+ // One entry per process: a start-matched record wins over a stale one
+ // without a start, in whichever login it is.
+ const index = sessions.findIndex((other) => other.pid === session.pid);
+ if (index < 0) sessions.push(session);
+ else if (sessions[index].procStart === null && session.procStart !== null) {
+ sessions[index] = session;
+ }
+ }
+ }
+ sessions.sort((a, b) => (b.updatedAtMs ?? -Infinity) - (a.updatedAtMs ?? -Infinity) || a.pid - b.pid);
+ return { sessions, unreadable, info };
+}
+
+/**
+ * The live session with this id, looked up afresh (its pid and socket change
+ * when it is resumed in a new process), or `null`.
+ */
+export async function findClaudeSession({ home = os.homedir(), env = process.env, sessionId }) {
+ if (!isUuid(sessionId)) return null;
+ const { sessions } = await readRegistry({
+ home,
+ env,
+ want: (session) => session.sessionId === sessionId,
+ });
+ return sessions.find((session) => session.sessionId === sessionId) ?? null;
+}
+
+/**
+ * The Claude Code session the process `pid` runs inside (local.rs
+ * `caller_target`): the nearest ancestor, `pid` itself included, of this
+ * user's that is a live registered session with a recorded start time that
+ * matches. A running session whose own `.json` does not parse ends the
+ * walk, so an outer session is never named instead. Returns the session id
+ * or `null`.
+ */
+export async function claudeCallerSession({ home = os.homedir(), env = process.env, pid }) {
+ const uid = process.getuid?.();
+ const chain = await ancestors(pid, MAX_ANCESTRY);
+ const pids = new Set(chain.map((entry) => entry.pid));
+ const { sessions, unreadable } = await readRegistry({
+ home,
+ env,
+ want: (session) => pids.has(session.pid),
+ wantPid: (candidate) => pids.has(candidate),
+ });
+ for (const entry of chain) {
+ const own = uid === undefined || entry.uid === uid;
+ const session = sessions.find(
+ (candidate) =>
+ own &&
+ candidate.pid === entry.pid &&
+ candidate.procStart !== null &&
+ claudeStartMatches(candidate.procStart, entry.start)
+ );
+ if (session) {
+ // Still the same process after the registry was read.
+ const again = await processInfo(entry.pid);
+ const same =
+ again !== null &&
+ again.start === entry.start &&
+ again.parent === entry.parent &&
+ again.uid === entry.uid;
+ return same ? session.sessionId : null;
+ }
+ // A running session whose own registry file does not parse is the
+ // caller's nearest session: the walk ends here.
+ if (own && unreadable.some((file) => file.pid === entry.pid)) return null;
+ }
+ return null;
+}
+
+/**
+ * The inbox token from the session's `..key` whose recorded
+ * start matches the session's (claude.rs `peer_token`). A key that records
+ * no start is used only when it is the pid's one key. Never logged.
+ */
+export async function peerToken(session) {
+ const keys = [];
+ for (const record of await keyRecords(session.registryDir, session.pid)) {
+ const token = record?.peerToken;
+ if (typeof token !== 'string' || token === '') continue;
+ keys.push({ token, start: recordedStart(record.procStart) });
+ }
+ if (session.procStart !== null) {
+ const matching = keys.find((key) => key.start === session.procStart);
+ if (matching) return matching.token;
+ }
+ if (keys.length === 1) {
+ const [only] = keys;
+ if (only.start === null) return only.token;
+ if (session.procStart === null) return only.token;
+ }
+ return null;
+}
+
+/**
+ * The two NDJSON lines of one delivery (claude.rs `peer_frames`), keys in
+ * the order serde_json writes them:
+ * ```text
+ * {"token":"","type":"auth"}
+ * {"message":{"content":"…","role":"user"},"msg_id":…,"priority":"next","type":"user","uuid":…}
+ * ```
+ */
+export function peerFrames(token, messageId, text) {
+ const auth = Buffer.from(`${JSON.stringify({ token, type: 'auth' })}\n`);
+ const message = Buffer.from(
+ `${JSON.stringify({
+ message: { content: text, role: 'user' },
+ msg_id: messageId,
+ priority: 'next',
+ type: 'user',
+ uuid: randomUUID().toUpperCase(),
+ })}\n`
+ );
+ if (auth.length > MAX_LINE_BYTES) {
+ return { error: "the session's inbox key is too long to send" };
+ }
+ if (message.length > MAX_LINE_BYTES) {
+ return {
+ error: `the message is ${message.length} bytes, past the ${MAX_LINE_BYTES} a session accepts`,
+ };
+ }
+ return { auth, message };
+}
+
+function connectSocket(path) {
+ return net.createConnection(path);
+}
+
+/** Writes `bytes`; resolves `null` once handed to the kernel, else the error. */
+function writeAll(socket, bytes, state) {
+ return new Promise((resolve) => {
+ let settled = false;
+ const finish = (error) => {
+ if (settled) return;
+ settled = true;
+ clearTimeout(timer);
+ socket.off('error', onError);
+ socket.off('close', onClose);
+ resolve(error ?? null);
+ };
+ const onError = (error) => finish(error);
+ const onClose = () => finish(state.error ?? new Error('the connection closed'));
+ const timer = setTimeout(() => finish(new Error('timed out writing to the socket')), SOCKET_TIMEOUT_MS);
+ if (state.error || socket.destroyed) {
+ finish(state.error ?? new Error('the connection closed'));
+ return;
+ }
+ socket.on('error', onError);
+ socket.on('close', onClose);
+ try {
+ socket.write(bytes, (error) => finish(error ?? null));
+ } catch (error) {
+ finish(error);
+ }
+ });
+}
+
+function connected(socket) {
+ return new Promise((resolve) => {
+ const timer = setTimeout(() => done(new Error('timed out connecting')), SOCKET_TIMEOUT_MS);
+ const done = (error) => {
+ clearTimeout(timer);
+ socket.off('connect', onConnect);
+ socket.off('error', onError);
+ resolve(error ?? null);
+ };
+ const onConnect = () => done(null);
+ const onError = (error) => done(error);
+ socket.once('connect', onConnect);
+ socket.once('error', onError);
+ });
+}
+
+/** Lets the session read both lines before the connection closes. */
+function drain(socket) {
+ return new Promise((resolve) => {
+ const done = () => {
+ clearTimeout(timer);
+ resolve();
+ };
+ const timer = setTimeout(done, DRAIN_TIMEOUT_MS);
+ socket.once('data', done);
+ socket.once('end', done);
+ socket.once('close', done);
+ socket.once('error', done);
+ });
+}
+
+async function writeInbox(socketPath, frames, connect) {
+ let socket;
+ try {
+ socket = connect(socketPath);
+ } catch (error) {
+ return outcome('retry', `could not reach the session's inbox: ${error?.message ?? error}`);
+ }
+ const state = { error: null };
+ socket.on('error', (error) => {
+ state.error = error;
+ });
+ try {
+ const refused = await connected(socket);
+ if (refused) {
+ return outcome('retry', `could not reach the session's inbox: ${refused.message}`);
+ }
+ // A write can fail after part of it went out, so only a failure before
+ // any of the message line was handed to the kernel is certain.
+ const authError = await writeAll(socket, frames.auth, state);
+ if (authError) {
+ return outcome('retry', `could not reach the session's inbox: ${authError.message}`);
+ }
+ const messageError = await writeAll(socket, frames.message, state);
+ if (messageError) {
+ return outcome(
+ 'in_doubt',
+ `the session's inbox connection broke while sending: ${messageError.message}`
+ );
+ }
+ socket.end();
+ await drain(socket);
+ return outcome('delivered', "handed to the Claude Code session's inbox");
+ } finally {
+ socket.destroy();
+ }
+}
+
+/**
+ * Writes `text` into the Claude Code session's inbox (claude.rs
+ * `send_to_terminal`). `delivered` means the bytes were handed over, not
+ * that Claude read or ran them. Claude's inbox gives no read-back, so a
+ * repeat after a crash is possible; `msg_id` is the Relaycast message id in
+ * case Claude dedupes on it.
+ */
+export async function injectClaude({
+ home = os.homedir(),
+ env = process.env,
+ sessionId,
+ messageId,
+ text,
+ connect = connectSocket,
+}) {
+ if (!isUuid(sessionId)) return outcome('refused', 'invalid session id');
+ const session = await findClaudeSession({ home, env, sessionId });
+ if (!session) return outcome('waiting', 'that Claude Code session is not running');
+ if (session.peerProtocol !== KNOWN_PEER_PROTOCOL) {
+ return outcome(
+ 'retry',
+ `that Claude Code session speaks peer protocol ${session.peerProtocol}, which this app does not know`
+ );
+ }
+ const token = await peerToken(session);
+ if (!token) return outcome('retry', "could not read that Claude Code session's inbox key");
+ const frames = peerFrames(token, messageId, text);
+ if (frames.error) return outcome('retry', frames.error);
+ return writeInbox(session.messagingSocketPath, frames, connect);
+}
+
+/**
+ * Whether the session can take a message now, without writing to its
+ * socket: the record is live, it speaks peer protocol 1 and its key is
+ * readable.
+ */
+export async function claudePreflight({ home = os.homedir(), env = process.env, sessionId }) {
+ if (!isUuid(sessionId)) return { ok: false, reason: 'invalid session id' };
+ const session = await findClaudeSession({ home, env, sessionId });
+ if (!session) return { ok: false, reason: 'that Claude Code session is not running' };
+ if (session.peerProtocol !== KNOWN_PEER_PROTOCOL) {
+ return {
+ ok: false,
+ reason: `that Claude Code session speaks peer protocol ${session.peerProtocol}, which this app does not know`,
+ };
+ }
+ if (!(await peerToken(session))) {
+ return { ok: false, reason: "could not read that Claude Code session's inbox key" };
+ }
+ return { ok: true, reason: "the Claude Code session's inbox is ready" };
+}
+
+// MARK: crossSessionInbound
+
+/** `crossSessionInbound` in a settings file's text, or `null`. */
+export function inboundValue(text) {
+ if (text === null || text === undefined) return null;
+ try {
+ const value = JSON.parse(text)?.[INBOUND_KEY];
+ return typeof value === 'string' ? value : null;
+ } catch {
+ return null;
+ }
+}
+
+function unreadableSettings(path) {
+ const error = new Error(
+ `Your Claude settings file is not valid JSON, so it was left unchanged. Fix it or add "${INBOUND_KEY}": "${ACCEPT}" by hand.`
+ );
+ error.code = 'claude_settings_unreadable';
+ error.path = path;
+ return error;
+}
+
+/**
+ * The settings text with `crossSessionInbound` set to `value`, every other
+ * key kept (claude_settings.rs `with_inbound`); an absent or blank file
+ * starts empty, and anything but a JSON object is refused.
+ */
+export function withInbound(value, text, path = 'settings.json') {
+ let object = {};
+ if (text !== null && text !== undefined && text.trim() !== '') {
+ try {
+ object = JSON.parse(text);
+ } catch {
+ throw unreadableSettings(path);
+ }
+ if (!object || typeof object !== 'object' || Array.isArray(object)) {
+ throw unreadableSettings(path);
+ }
+ }
+ object[INBOUND_KEY] = value;
+ return `${JSON.stringify(object, null, 2)}\n`;
+}
+
+/** `path` with every symlink followed, also when the target does not exist yet. */
+async function resolveSymlinks(path) {
+ let current = path;
+ for (let index = 0; index < 40; index += 1) {
+ const info = await lstat(current).catch(() => null);
+ if (!info?.isSymbolicLink()) break;
+ const target = await readlink(current).catch(() => null);
+ if (target === null) break;
+ current = target.startsWith('/') ? target : join(dirname(current), target);
+ }
+ return current;
+}
+
+async function readOptional(path) {
+ try {
+ return await readFile(path, 'utf8');
+ } catch (error) {
+ if (error?.code === 'ENOENT') return null;
+ throw error;
+ }
+}
+
+/**
+ * Replaces `path` atomically with `updated` (claude_settings.rs
+ * `write_keeping_file`): the file's mode is kept (0600 for a new file), the
+ * parent is created private, and the original is copied once beside it.
+ */
+async function writeKeepingFile(path, existing, updated) {
+ const parent = dirname(path);
+ await mkdir(parent, { recursive: true, mode: 0o700 });
+ const mode = ((await stat(path).catch(() => null))?.mode ?? 0o600) & 0o777;
+ if (existing !== null) {
+ const backup = `${path}${BACKUP_SUFFIX}`;
+ if (!(await lstat(backup).catch(() => null))) {
+ const handle = await open(backup, constants.O_WRONLY | constants.O_CREAT | constants.O_EXCL, mode);
+ try {
+ await handle.writeFile(existing);
+ } finally {
+ await handle.close();
+ }
+ }
+ }
+ const temporary = join(parent, `.settings.${process.pid}.${randomUUID()}`);
+ const handle = await open(temporary, constants.O_WRONLY | constants.O_CREAT | constants.O_EXCL, 0o600);
+ try {
+ await handle.writeFile(updated);
+ await handle.sync();
+ } finally {
+ await handle.close();
+ }
+ try {
+ await chmod(temporary, mode);
+ await rename(temporary, path);
+ } catch (error) {
+ await rm(temporary, { force: true });
+ throw error;
+ }
+}
+
+function managedError(file, value) {
+ const error = new Error(
+ `Managed Claude settings do not allow direct delivery: ${file} sets "${INBOUND_KEY}" to ${JSON.stringify(value)}, so replies cannot be injected; ask your admin.`
+ );
+ error.code = 'claude_inbound_managed';
+ error.path = file;
+ return error;
+}
+
+/**
+ * Turns direct delivery on for the Claude login (claude_settings.rs
+ * `set_own_direct_delivery(true)`): `"crossSessionInbound": "accept"` in
+ * its `settings.json`, every other key kept, the original backed up once to
+ * `settings.json.relay-desktop-backup`, a symlinked file rewritten at its
+ * target. Nothing is written when managed settings or the user's own
+ * already accept. Managed settings that pin another value throw an Error
+ * with code `claude_inbound_managed` naming the managed file.
+ *
+ * `claudeHome` picks a login other than `CLAUDE_CONFIG_DIR`/`~/.claude`
+ * (the caller session's `registryDir/..`); `managedSettingsPath` replaces
+ * the system path (tests).
+ */
+export async function ensureClaudeDirectDelivery({
+ home = os.homedir(),
+ env = process.env,
+ platform = process.platform,
+ claudeHome: login,
+ managedSettingsPath,
+} = {}) {
+ const managedFile =
+ managedSettingsPath ?? (platform === 'darwin' ? MANAGED_SETTINGS.darwin : MANAGED_SETTINGS.other);
+ const settingsFile = join(login ?? claudeHome({ home, env }), 'settings.json');
+ const managed = async () => inboundValue(await readFile(managedFile, 'utf8').catch(() => null));
+ const user = async () => inboundValue(await readFile(settingsFile, 'utf8').catch(() => null));
+ const delivers = async () => ((await managed()) ?? (await user())) === ACCEPT;
+
+ const pinned = await managed();
+ if (pinned !== null && pinned !== ACCEPT) throw managedError(managedFile, pinned);
+ if (await delivers()) return { changed: false, path: settingsFile };
+
+ const path = await resolveSymlinks(settingsFile);
+ let existing;
+ try {
+ existing = await readOptional(path);
+ } catch (error) {
+ const failed = new Error(`Could not save your Claude settings: ${error?.message ?? error}`);
+ failed.code = 'claude_settings_failed';
+ throw failed;
+ }
+ const updated = withInbound(ACCEPT, existing, settingsFile);
+ try {
+ await writeKeepingFile(path, existing, updated);
+ } catch (error) {
+ const failed = new Error(`Could not save your Claude settings: ${error?.message ?? error}`);
+ failed.code = 'claude_settings_failed';
+ throw failed;
+ }
+ if (!(await delivers())) throw managedError(managedFile, await managed());
+ return { changed: true, path: settingsFile };
+}
diff --git a/packages/connect/src/relay/cloud.js b/packages/connect/src/relay/cloud.js
new file mode 100644
index 000000000..1119b47de
--- /dev/null
+++ b/packages/connect/src/relay/cloud.js
@@ -0,0 +1,347 @@
+import { readFileSync } from 'node:fs';
+
+// Cloud and Relaycast calls for a Connect participant, matching the desktop
+// probe (relay-desktop probe/src/relay/connect.rs, client.rs, sdk_gaps.rs).
+// Errors carry an HTTP status (null when nothing answered), a code, and a
+// credential-free cause for the log.
+
+const JOIN_TIMEOUT_MS = 45_000;
+const CLOUD_TIMEOUT_MS = 12_000;
+const RELAYCAST_TIMEOUT_MS = 20_000;
+const MAX_BODY_BYTES = 4 * 1024 * 1024;
+
+let packageVersion = '0.0.0';
+try {
+ packageVersion = JSON.parse(readFileSync(new URL('../../package.json', import.meta.url), 'utf8')).version;
+} catch {
+ // A runtime copy without package.json still works; the version is only a header.
+}
+export const VERSION = packageVersion;
+
+export class RelayHttpError extends Error {
+ constructor({ status = null, code, message, cause, requestId }) {
+ super(message || code);
+ this.name = 'RelayHttpError';
+ this.status = status;
+ this.code = code;
+ this.logCause = cause || (status ? `http_${status}:${code}` : 'transport');
+ this.requestId = requestId;
+ }
+}
+
+/** https, or http on loopback: the only places a credential may be sent. */
+export function sendable(url) {
+ try {
+ const parsed = new URL(url);
+ if (parsed.username || parsed.password) return false;
+ if (parsed.protocol === 'https:') return true;
+ return parsed.protocol === 'http:' && ['localhost', '127.0.0.1', '[::1]'].includes(parsed.hostname);
+ } catch {
+ return false;
+ }
+}
+
+/** The Cloud base: agentrelay.com, or a loopback http override for local end-to-end tests. */
+export function cloudBase(env = process.env) {
+ const override = env.AGENT_RELAY_CONNECT_CLOUD_URL;
+ if (override) {
+ try {
+ const url = new URL(override);
+ const loopback = ['localhost', '127.0.0.1', '[::1]'].includes(url.hostname);
+ if ((url.protocol === 'http:' && loopback) || url.protocol === 'https:') {
+ return override.replace(/\/+$/, '');
+ }
+ } catch {
+ // Fall through to the shipped destination.
+ }
+ }
+ return 'https://agentrelay.com';
+}
+
+/** The Connect id in a link or bare id (connect.rs `connect_id`). */
+export function connectIdFrom(link) {
+ let id;
+ if (typeof link !== 'string') return null;
+ if (link.includes('://')) {
+ let url;
+ try {
+ url = new URL(link);
+ } catch {
+ return null;
+ }
+ if (url.protocol !== 'https:' || url.hostname !== 'agentrelay.com') return null;
+ const parts = url.pathname.split('/').filter(Boolean);
+ id = parts.at(-1) === 'join' ? parts.at(-2) : parts.at(-1);
+ } else {
+ id = link.trim();
+ }
+ return id && id.length <= 128 && /^[A-Za-z0-9_-]+$/.test(id) ? id : null;
+}
+
+function transportCause(error) {
+ if (error?.name === 'TimeoutError' || error?.name === 'AbortError') return 'timeout';
+ const code = error?.cause?.code || error?.code;
+ if (code === 'ENOTFOUND' || code === 'EAI_AGAIN') return 'dns';
+ if (code === 'ECONNREFUSED' || code === 'ECONNRESET' || code === 'EHOSTUNREACH' || code === 'ENETUNREACH') {
+ return 'connect';
+ }
+ if (typeof code === 'string' && code.startsWith('ERR_TLS')) return 'tls';
+ if (typeof code === 'string' && /CERT|SSL/.test(code)) return 'tls';
+ return 'transport';
+}
+
+async function readBody(response) {
+ const reader = response.body?.getReader();
+ if (!reader) return '';
+ const chunks = [];
+ let size = 0;
+ for (;;) {
+ const { done, value } = await reader.read();
+ if (done) break;
+ size += value.byteLength;
+ if (size > MAX_BODY_BYTES) {
+ await reader.cancel().catch(() => {});
+ throw Object.assign(new Error('oversized'), { code: 'oversized' });
+ }
+ chunks.push(Buffer.from(value));
+ }
+ return Buffer.concat(chunks).toString('utf8');
+}
+
+/**
+ * One JSON request. Resolves `{status, body}` for any HTTP answer; rejects a
+ * RelayHttpError with `status: null` when nothing usable answered.
+ */
+export async function requestJson(
+ url,
+ { method = 'GET', token, headers = {}, body, timeoutMs, fetchImpl = fetch } = {}
+) {
+ if (!sendable(url)) {
+ throw new RelayHttpError({
+ code: 'connect_unreachable',
+ message: 'Refusing to send to this address.',
+ cause: 'unsendable_url',
+ });
+ }
+ const finalHeaders = {
+ accept: 'application/json',
+ 'user-agent': `AgentRelayConnect/${VERSION}`,
+ ...headers,
+ };
+ if (token) finalHeaders.authorization = `Bearer ${token}`;
+ if (body !== undefined) finalHeaders['content-type'] = 'application/json';
+ let response;
+ try {
+ response = await fetchImpl(url, {
+ method,
+ headers: finalHeaders,
+ body: body === undefined ? undefined : JSON.stringify(body),
+ redirect: 'manual',
+ signal: AbortSignal.timeout(timeoutMs),
+ });
+ } catch (error) {
+ const cause = transportCause(error);
+ throw new RelayHttpError({
+ code: cause === 'timeout' ? 'timeout' : 'connect_unreachable',
+ message: cause === 'timeout' ? 'The request timed out.' : 'Could not be reached.',
+ cause,
+ });
+ }
+ let text;
+ try {
+ text = await readBody(response);
+ } catch (error) {
+ throw new RelayHttpError({
+ code: 'connect_unreachable',
+ message: 'The answer could not be read.',
+ cause:
+ error?.code === 'oversized' ? 'oversized' : transportCause(error) === 'timeout' ? 'timeout' : 'read',
+ });
+ }
+ let parsed = null;
+ if (text.trim()) {
+ try {
+ parsed = JSON.parse(text);
+ } catch {
+ parsed = null;
+ }
+ }
+ return {
+ status: response.status,
+ body: parsed,
+ requestId: response.headers.get('x-request-id') || undefined,
+ };
+}
+
+function answerError(answer, fallbackCode, fallbackMessage) {
+ const error = answer.body?.error || {};
+ const code = typeof error.code === 'string' && error.code ? error.code : fallbackCode;
+ const message =
+ typeof error.message === 'string' &&
+ error.message &&
+ error.message.length <= 300 &&
+ !error.message.includes('\n')
+ ? error.message
+ : fallbackMessage;
+ return new RelayHttpError({ status: answer.status, code, message, requestId: answer.requestId });
+}
+
+function ok(answer) {
+ return answer.status >= 200 && answer.status < 300;
+}
+
+function inner(body) {
+ return body && typeof body === 'object' && body.data !== undefined && body.data !== null ? body.data : body;
+}
+
+// Cloud -------------------------------------------------------------------------------
+
+export async function cloudJoin({ connectId, name, hostClaim, idempotencyKey, env, fetchImpl }) {
+ const body = {};
+ if (name) body.agent_name = name;
+ if (hostClaim) body.host_claim = hostClaim;
+ let answer;
+ try {
+ answer = await requestJson(`${cloudBase(env)}/cloud/connect/${connectId}/join`, {
+ method: 'POST',
+ headers: { 'idempotency-key': idempotencyKey },
+ body,
+ timeoutMs: JOIN_TIMEOUT_MS,
+ fetchImpl,
+ });
+ } catch (error) {
+ if (error.code === 'timeout') {
+ throw new RelayHttpError({
+ status: 504,
+ code: 'connect_join_timeout',
+ message: 'Relay Connect join timed out. Retry the same join; it is safe to retry.',
+ cause: 'timeout',
+ });
+ }
+ throw new RelayHttpError({
+ status: 503,
+ code: 'connect_unreachable',
+ message: 'Relay Connect could not be reached. Retry the same join; it is safe to retry.',
+ cause: error.logCause,
+ });
+ }
+ if (!ok(answer)) throw answerError(answer, 'connect_join_failed', 'Could not join this Connect.');
+ const data = inner(answer.body);
+ const participant = data?.participant;
+ const valid =
+ data &&
+ typeof data.connect_id === 'string' &&
+ typeof data.expires_at === 'string' &&
+ !Number.isNaN(Date.parse(data.expires_at)) &&
+ participant &&
+ typeof participant.token === 'string' &&
+ participant.token &&
+ typeof participant.agent_id === 'string' &&
+ participant.agent_id &&
+ typeof participant.agent_name === 'string' &&
+ sendable(participant.relay_base_url || '') &&
+ Array.isArray(data.participants) &&
+ data.participants.some((entry) => entry?.agent_name === participant.agent_name);
+ if (!valid) {
+ throw new RelayHttpError({
+ status: 502,
+ code: 'invalid_join_response',
+ message: 'Relay Connect returned an incomplete join response.',
+ cause: 'invalid_join_response',
+ });
+ }
+ return data;
+}
+
+/** Whether Cloud still lists the Connect: `{active:true}`, `{gone:{code,message}}`, or throws. */
+export async function cloudAvailability({ connectId, env, fetchImpl }) {
+ let answer;
+ try {
+ answer = await requestJson(`${cloudBase(env)}/connect/${connectId}`, {
+ timeoutMs: CLOUD_TIMEOUT_MS,
+ fetchImpl,
+ });
+ } catch (error) {
+ throw new RelayHttpError({
+ status: 503,
+ code: 'connect_unreachable',
+ message: 'Relay Connect could not be reached to check whether this Connect is still open. Retry once.',
+ cause: error.logCause,
+ });
+ }
+ if (ok(answer)) return { active: true };
+ const fallback = answer.status === 404 ? 'connect_not_found' : answer.status === 410 ? 'connect_ended' : '';
+ const code = answer.body?.error?.code || fallback;
+ if (
+ [404, 410].includes(answer.status) &&
+ ['connect_not_found', 'connect_ended', 'connect_expired'].includes(code)
+ ) {
+ const messages = {
+ connect_expired: 'This Connect has expired.',
+ connect_not_found: 'This Connect was not found.',
+ connect_ended: 'This Connect has ended.',
+ };
+ return { gone: { code, message: answerError(answer, code, messages[code]).message } };
+ }
+ throw new RelayHttpError({
+ status: 502,
+ code: 'connect_upstream_error',
+ message: `Relay Connect answered HTTP ${answer.status} when checking whether this Connect is still open. Retry once.`,
+ cause: code ? `http_${answer.status}:${code}` : `http_${answer.status}`,
+ });
+}
+
+/** Tells Cloud this participant left, freeing its name. Best effort; resolves the answer. */
+export async function cloudLeave({ connectId, token, env, fetchImpl }) {
+ const answer = await requestJson(`${cloudBase(env)}/cloud/connect/${connectId}/leave`, {
+ method: 'POST',
+ token,
+ timeoutMs: CLOUD_TIMEOUT_MS,
+ fetchImpl,
+ });
+ if (ok(answer)) return inner(answer.body);
+ throw answerError(answer, 'connect_leave_failed', 'Relay Connect could not record the leave.');
+}
+
+// Relaycast, as the participant ------------------------------------------------------------
+
+export function relaycast({ baseUrl, token, fetchImpl }) {
+ const base = baseUrl.replace(/\/+$/, '');
+ const call = async (method, path, { body, headers, timeoutMs = RELAYCAST_TIMEOUT_MS } = {}) => {
+ const answer = await requestJson(`${base}${path}`, {
+ method,
+ token,
+ body,
+ headers: {
+ 'x-relaycast-origin-client': 'agent-relay-connect',
+ 'x-relaycast-origin-version': VERSION,
+ ...headers,
+ },
+ timeoutMs,
+ fetchImpl,
+ });
+ if (!ok(answer))
+ throw answerError(answer, 'relaycast_error', `Relaycast answered HTTP ${answer.status}.`);
+ return inner(answer.body);
+ };
+ return {
+ dm: (to, text, idempotencyKey) =>
+ call('POST', '/v1/dm', {
+ body: { to, text, mode: 'wait' },
+ headers: idempotencyKey ? { 'idempotency-key': idempotencyKey } : {},
+ }),
+ agents: () => call('GET', '/v1/agents?status=all'),
+ deliveries: () => call('GET', '/v1/deliveries?limit=50'),
+ ack: (id) => call('POST', `/v1/deliveries/${encodeURIComponent(id)}/ack`),
+ fail: (id, error) =>
+ call('POST', `/v1/deliveries/${encodeURIComponent(id)}/fail`, {
+ body: { error: String(error).slice(0, 500), retryable: false },
+ }),
+ heartbeat: () => call('POST', '/v1/agents/heartbeat', { body: {} }),
+ disconnect: () => call('POST', '/v1/agents/disconnect', { body: {} }),
+ };
+}
+
+export function isAuthRejection(error) {
+ return error?.status === 401 || error?.status === 403;
+}
diff --git a/packages/connect/src/relay/codex.js b/packages/connect/src/relay/codex.js
new file mode 100644
index 000000000..2be16d7a4
--- /dev/null
+++ b/packages/connect/src/relay/codex.js
@@ -0,0 +1,1191 @@
+import { spawn } from 'node:child_process';
+import { createHash, randomUUID } from 'node:crypto';
+import { accessSync, constants, readdirSync, realpathSync, statSync } from 'node:fs';
+import { open, readdir } from 'node:fs/promises';
+import net from 'node:net';
+import os from 'node:os';
+import { join } from 'node:path';
+import { argvOf, isTrustedCodex, listProcesses, openFiles } from './processes.js';
+import { queryReadOnly, sqliteBackend } from './sqlite.js';
+
+// Codex threads and the routes a message takes into them, ported from
+// relay-desktop probe/src/relay/codex.rs and codex/{router,queue,rollout,
+// ipc,processes,threads}.rs.
+//
+// A message is delivered when the thread's rollout records it, or when a
+// Codex app window that owns the thread confirms the turn. A live terminal
+// Codex gets every message through its own queue (`codex queue`); that
+// handoff is not delivery and stays accepted until the rollout records it.
+
+const UUID = /^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$/;
+
+/** queue.rs `is_thread_id` (store.rs `is_uuid`). */
+export function isThreadId(value) {
+ return typeof value === 'string' && UUID.test(value);
+}
+
+function outcome(kind, detail) {
+ return { outcome: kind, detail };
+}
+
+// MARK: Message reference (delivery/policy.rs MessageRef)
+
+/** The first 12 hex digits of sha256(message id). */
+export function messageRef(messageId) {
+ return createHash('sha256').update(String(messageId), 'utf8').digest('hex').slice(0, 12);
+}
+
+const REF = /^[0-9a-f]{12}$/;
+
+/**
+ * The reference carried by `text`, if its first line is a relay attribution
+ * (`[via Agent Relay …]` or `[Relay Connect — from · ref []`).
+ */
+export function messageRefInText(text) {
+ if (typeof text !== 'string') return null;
+ if (!(text.startsWith('[via Agent Relay') || text.startsWith('[Relay Connect '))) return null;
+ let first = text.split('\n', 1)[0];
+ if (first.endsWith('\r')) first = first.slice(0, -1);
+ if (!first.endsWith(']')) return null;
+ const inner = first.slice(0, -1);
+ const marker = inner.lastIndexOf('ref ');
+ if (marker < 0) return null;
+ const candidate = inner.slice(marker + 4);
+ return REF.test(candidate) ? candidate : null;
+}
+
+// MARK: Paths and the codex binary (codex.rs)
+
+function expand(value, home) {
+ if (value === '~') return home;
+ if (value.startsWith('~/')) return join(home, value.slice(2));
+ return value;
+}
+
+function setting(env, key) {
+ const value = env?.[key];
+ if (typeof value !== 'string') return null;
+ const trimmed = value.trim();
+ return trimmed === '' ? null : trimmed;
+}
+
+/** `{codexHome, sqliteHome, ipcSocket}`: `CODEX_HOME` else `~/.codex`; `CODEX_SQLITE_HOME` else Codex home. */
+export function codexPaths({ home = os.homedir(), env = process.env } = {}) {
+ const configured = setting(env, 'CODEX_HOME');
+ const codexHome = configured ? expand(configured, home) : join(home, '.codex');
+ const sqlite = setting(env, 'CODEX_SQLITE_HOME');
+ return {
+ codexHome,
+ sqliteHome: sqlite ? expand(sqlite, home) : codexHome,
+ ipcSocket: join(codexHome, 'ipc/ipc.sock'),
+ };
+}
+
+function isExecutableFile(path) {
+ try {
+ if (!statSync(path).isFile()) return false;
+ accessSync(path, constants.X_OK);
+ return true;
+ } catch {
+ return false;
+ }
+}
+
+/** A version manager's shim re-resolves `codex` per directory; only a real install is used. */
+function isShim(path) {
+ const shim = (value) => value.includes('/mise/shims/');
+ if (shim(path)) return true;
+ try {
+ return shim(realpathSync(path));
+ } catch {
+ return false;
+ }
+}
+
+function versionKey(version) {
+ const trimmed = (version ?? '').replace(/^v+/, '');
+ const dash = trimmed.indexOf('-');
+ const release = dash < 0 ? trimmed : trimmed.slice(0, dash);
+ const prerelease = dash < 0 ? '' : trimmed.slice(dash + 1);
+ return {
+ numbers: release.split('.').map((part) => (/^\d+$/.test(part) ? Number(part) : 0)),
+ isRelease: prerelease === '',
+ prerelease,
+ };
+}
+
+function compareVersionKeys(a, b) {
+ const length = Math.max(a.numbers.length, b.numbers.length);
+ for (let index = 0; index < length; index += 1) {
+ // Rust compares Vec lexicographically: a shorter prefix is smaller.
+ if (index >= a.numbers.length) return -1;
+ if (index >= b.numbers.length) return 1;
+ if (a.numbers[index] !== b.numbers[index]) return a.numbers[index] - b.numbers[index];
+ }
+ if (a.isRelease !== b.isRelease) return a.isRelease ? 1 : -1;
+ return a.prerelease < b.prerelease ? -1 : a.prerelease > b.prerelease ? 1 : 0;
+}
+
+/** `//bin/codex` for every version directory, newest first. */
+function versions(root) {
+ let names = [];
+ try {
+ names = readdirSync(root);
+ } catch {
+ return [];
+ }
+ return names
+ .map((name) => ({ name, key: versionKey(name) }))
+ .sort((a, b) => compareVersionKeys(b.key, a.key))
+ .map(({ name }) => join(root, name, 'bin/codex'));
+}
+
+const SYSTEM_CODEX = {
+ darwin: [
+ '/Applications/ChatGPT.app/Contents/Resources/codex',
+ '/Applications/Codex.app/Contents/Resources/codex',
+ ],
+ shared: ['/opt/homebrew/bin/codex', '/usr/local/bin/codex'],
+};
+
+/**
+ * Where a `codex` may be installed, in order (codex.rs
+ * `executable_candidates`): the Codex desktop app's own binary, mise, the
+ * user's bin, nvm installs, Homebrew, then `PATH`; never a shim.
+ * `system` replaces the fixed system locations (tests).
+ */
+export function codexCandidates({
+ home = os.homedir(),
+ env = process.env,
+ platform = process.platform,
+ system,
+} = {}) {
+ const apps = system ? [] : platform === 'darwin' ? SYSTEM_CODEX.darwin : [];
+ const tail = system ?? SYSTEM_CODEX.shared;
+ const mise = join(home, '.local/share/mise/installs/codex');
+ const candidates = [
+ ...apps,
+ join(mise, 'latest/bin/codex'),
+ join(home, '.local/bin/codex'),
+ ...versions(mise),
+ ...versions(join(home, '.nvm/versions/node')),
+ ...tail,
+ ];
+ const path = env?.PATH;
+ if (typeof path === 'string') {
+ for (const directory of path.split(':')) {
+ if (directory.startsWith('/')) candidates.push(join(directory, 'codex'));
+ }
+ }
+ return candidates.filter((candidate) => !isShim(candidate));
+}
+
+/**
+ * The `codex` to run: `RELAY_DESKTOP_CODEX` (exactly that file, or none),
+ * else the first installed candidate.
+ */
+export function codexExecutable(options = {}) {
+ const { home = os.homedir(), env = process.env } = options;
+ const explicit = setting(env, 'RELAY_DESKTOP_CODEX');
+ if (explicit) {
+ const path = expand(explicit, home);
+ return isExecutableFile(path) && !isShim(path) ? path : null;
+ }
+ return codexCandidates(options).find((candidate) => isExecutableFile(candidate)) ?? null;
+}
+
+// MARK: SQLite reads (threads.rs, queue.rs)
+
+/**
+ * The thread's row id in Codex's spelling (threads.rs `read_one`): missing,
+ * archived and sub-agent threads are not found. `undefined` when the
+ * catalog cannot be read at all.
+ */
+async function readThreadId(paths, id) {
+ if (!isThreadId(id)) return null;
+ const file = join(paths.sqliteHome, 'state_5.sqlite');
+ const columns = await queryReadOnly(file, 'pragma table_info(threads)');
+ if (columns.status !== 'ok') return undefined;
+ const names = new Set(columns.rows.map((row) => row.name));
+ if (!['id', 'title', 'archived', 'source'].every((column) => names.has(column))) return undefined;
+ const found = await queryReadOnly(
+ file,
+ "select id from threads where id = ? collate nocase and archived = 0 and source in ('cli', 'vscode', 'exec') limit 1",
+ [id]
+ );
+ if (found.status !== 'ok') return undefined;
+ return typeof found.rows[0]?.id === 'string' ? found.rows[0].id : null;
+}
+
+/** The thread's recorded rollout path (threads.rs `rollout_column`). */
+async function rolloutColumn(paths, id) {
+ const found = await queryReadOnly(
+ join(paths.sqliteHome, 'state_5.sqlite'),
+ 'select rollout_path as rollout_path from threads where id = ? collate nocase',
+ [id]
+ );
+ if (found.status !== 'ok') return null;
+ const path = found.rows[0]?.rollout_path;
+ return typeof path === 'string' && path !== '' ? path : null;
+}
+
+/** The reference of a relay-created queue item (queue.rs `owned_ref`). */
+export function ownedRef(payloadJson) {
+ const find = (value) => {
+ if (typeof value === 'string') return messageRefInText(value);
+ if (Array.isArray(value)) {
+ for (const item of value) {
+ const found = find(item);
+ if (found) return found;
+ }
+ return null;
+ }
+ if (value && typeof value === 'object') {
+ for (const item of Object.values(value)) {
+ const found = find(item);
+ if (found) return found;
+ }
+ }
+ return null;
+ };
+ try {
+ return find(JSON.parse(payloadJson));
+ } catch {
+ return messageRefInText(payloadJson);
+ }
+}
+
+/**
+ * The relay's items `sql` selects as `(id, payload_json)`: `false`/`true`
+ * for whether any matches `keep`, `null` when the queue exists but cannot
+ * be read. No queue database means nothing is queued; no SQLite reader on
+ * this computer reads as nothing queued too, leaving the rollout and the
+ * 45 s rule to decide (docs/connect-node-local-relay.md, "SQLite").
+ */
+async function queueHas(paths, sql, params, keep) {
+ const result = await queryReadOnly(join(paths.sqliteHome, 'queue_1.sqlite'), sql, params);
+ if (result.status === 'missing' || result.status === 'unavailable') return false;
+ if (result.status !== 'ok') return null;
+ return result.rows.some((row) => {
+ if (typeof row.payload_json !== 'string') return false;
+ const owned = ownedRef(row.payload_json);
+ return owned !== null && keep(owned);
+ });
+}
+
+/** Whether the relay's queue item carrying `ref` still waits (queue.rs `is_waiting`). */
+function queueWaiting(paths, thread, ref) {
+ return queueHas(
+ paths,
+ 'select id, payload_json from queued_items where thread_id = ? collate nocase and instr(payload_json, ?) > 0',
+ [thread, `ref ${ref}]`],
+ (owned) => owned === ref
+ );
+}
+
+/** Whether any relay message still waits in the thread's queue (queue.rs `relay_waiting`). */
+function relayWaiting(paths, thread) {
+ return queueHas(
+ paths,
+ 'select id, payload_json from queued_items where thread_id = ? collate nocase',
+ [thread],
+ () => true
+ );
+}
+
+// MARK: Rollout (rollout.rs)
+
+const MARKERS = [
+ '"task_started"',
+ '"task_complete"',
+ '"turn_aborted"',
+ '"client_id"',
+ '"item_completed"',
+].map((marker) => Buffer.from(marker));
+
+class RolloutSummary {
+ constructor() {
+ this.activeTurn = null;
+ this.outcomes = new Map();
+ this.turnByClientMessage = new Map();
+ this.turnByMessageRef = new Map();
+ this.relayTurns = new Set();
+ }
+
+ clone() {
+ const copy = new RolloutSummary();
+ copy.activeTurn = this.activeTurn;
+ copy.outcomes = new Map(this.outcomes);
+ copy.turnByClientMessage = new Map(this.turnByClientMessage);
+ copy.turnByMessageRef = new Map(this.turnByMessageRef);
+ copy.relayTurns = new Set(this.relayTurns);
+ return copy;
+ }
+
+ consume(line) {
+ if (!MARKERS.some((marker) => line.includes(marker))) return;
+ let row;
+ try {
+ row = JSON.parse(line.toString('utf8'));
+ } catch {
+ return;
+ }
+ if (row?.type !== 'event_msg') return;
+ const payload = row.payload ?? {};
+ const turn = payload.turn_id;
+ if (typeof turn !== 'string') return;
+ const item = payload.item ?? {};
+ switch (payload.type) {
+ case 'task_started':
+ this.activeTurn = turn;
+ break;
+ case 'task_complete':
+ this.finish(turn, { completed: true, reply: payload.last_agent_message ?? null });
+ break;
+ case 'turn_aborted':
+ this.finish(turn, { completed: false, reason: payload.reason ?? null });
+ break;
+ case 'item_completed':
+ if (item.type === 'UserMessage') {
+ if (typeof item.client_id === 'string') this.turnByClientMessage.set(item.client_id, turn);
+ const contents = Array.isArray(item.content) ? item.content : [];
+ const reference = contents
+ .map((content) => content?.text)
+ .filter((text) => typeof text === 'string')
+ .map(messageRefInText)
+ .find(Boolean);
+ if (reference) {
+ this.relayTurns.add(turn);
+ this.turnByMessageRef.set(reference, turn);
+ }
+ } else if (item.type === 'HookPrompt') {
+ const fragments = Array.isArray(item.fragments) ? item.fragments : [];
+ const reference = fragments
+ .map((fragment) => fragment?.text)
+ .filter((text) => typeof text === 'string')
+ .map(messageRefInText)
+ .find(Boolean);
+ if (reference) this.turnByMessageRef.set(reference, turn);
+ }
+ break;
+ default:
+ break;
+ }
+ }
+
+ finish(turn, result) {
+ if (this.activeTurn === turn) this.activeTurn = null;
+ this.outcomes.set(turn, result);
+ }
+}
+
+const CACHED_ROLLOUTS = 256;
+const rollouts = new Map();
+
+function lines(buffer) {
+ const found = [];
+ let start = 0;
+ for (let index = buffer.indexOf(10); index >= 0; index = buffer.indexOf(10, start)) {
+ found.push(buffer.subarray(start, index));
+ start = index + 1;
+ }
+ return found;
+}
+
+/**
+ * The summary of the rollout at `path` (rollout.rs `RolloutSummary::load`).
+ * Codex only appends, so what was read before is kept and only the bytes
+ * written since are parsed; a replaced or shortened file is read again.
+ */
+async function loadRollout(path) {
+ const handle = await open(path, 'r');
+ try {
+ const info = await handle.stat();
+ const cached = rollouts.get(path);
+ rollouts.delete(path);
+ const entry =
+ cached && cached.dev === info.dev && cached.ino === info.ino && cached.offset <= info.size
+ ? cached
+ : { dev: info.dev, ino: info.ino, offset: 0, summary: new RolloutSummary() };
+ const length = Math.max(0, info.size - entry.offset);
+ const added = Buffer.alloc(length);
+ let read = 0;
+ while (read < length) {
+ const { bytesRead } = await handle.read(added, read, length - read, entry.offset + read);
+ if (bytesRead === 0) break;
+ read += bytesRead;
+ }
+ const bytes = added.subarray(0, read);
+ const complete = bytes.lastIndexOf(10) + 1;
+ for (const line of lines(bytes.subarray(0, complete))) entry.summary.consume(line);
+ entry.offset += complete;
+ const summary = entry.summary.clone();
+ // A line still being written counts for this answer only.
+ summary.consume(bytes.subarray(complete));
+ if (rollouts.size >= CACHED_ROLLOUTS) rollouts.clear();
+ rollouts.set(path, entry);
+ return summary;
+ } finally {
+ await handle.close();
+ }
+}
+
+const SESSION_DEPTH = 4;
+
+async function findRollout(directory, suffix, depth) {
+ if (depth > SESSION_DEPTH) return null;
+ let entries;
+ try {
+ entries = await readdir(directory, { withFileTypes: true });
+ } catch {
+ return null;
+ }
+ const directories = [];
+ for (const entry of entries) {
+ if (entry.isFile() && entry.name.startsWith('rollout-') && entry.name.toLowerCase().endsWith(suffix)) {
+ return join(directory, entry.name);
+ }
+ if (entry.isDirectory()) directories.push(join(directory, entry.name));
+ }
+ directories.sort().reverse();
+ for (const child of directories) {
+ const found = await findRollout(child, suffix, depth + 1);
+ if (found) return found;
+ }
+ return null;
+}
+
+/**
+ * Where Codex keeps the thread's rollout: the thread row's `rollout_path`,
+ * else `sessions/YYYY/MM/DD/rollout-*-.jsonl` (rollout.rs
+ * `rollout_path`).
+ */
+export async function rolloutPath(paths, thread) {
+ if (!isThreadId(thread)) return null;
+ const column = await rolloutColumn(paths, thread);
+ if (column) return column;
+ return findRollout(join(paths.codexHome, 'sessions'), `-${thread.toLowerCase()}.jsonl`, 0);
+}
+
+async function recorded(paths, thread, ref) {
+ const path = await rolloutPath(paths, thread);
+ if (!path) return false;
+ try {
+ return (await loadRollout(path)).turnByMessageRef.has(ref);
+ } catch {
+ return false;
+ }
+}
+
+/** No turn running. A thread with no rollout yet is idle; one that cannot be read is not. */
+async function idle(paths, thread) {
+ const path = await rolloutPath(paths, thread);
+ if (!path) return true;
+ try {
+ return (await loadRollout(path)).activeTurn === null;
+ } catch (error) {
+ return error?.code === 'ENOENT';
+ }
+}
+
+// MARK: Caller identification
+
+/**
+ * The Codex thread the CLI runs in, from its own `CODEX_THREAD_ID`: a UUID
+ * whose thread exists under Codex home (a catalog row, or a rollout file).
+ * Returns `{threadId}` (lowercase), or `{threadId: null, reason}`.
+ */
+export async function codexCallerThread({ env = process.env, pid: _pid, home = os.homedir() } = {}) {
+ const raw = typeof env?.CODEX_THREAD_ID === 'string' ? env.CODEX_THREAD_ID.trim() : '';
+ if (raw === '') return { threadId: null, reason: 'no Codex thread id in env' };
+ const thread = raw.toLowerCase();
+ if (!isThreadId(thread)) return { threadId: null, reason: 'CODEX_THREAD_ID is not a thread id' };
+ const paths = codexPaths({ home, env });
+ if (await readThreadId(paths, thread)) return { threadId: thread };
+ if (await rolloutPath(paths, thread)) return { threadId: thread };
+ return {
+ threadId: null,
+ reason: `Codex has no thread ${thread} under ${paths.codexHome}`,
+ };
+}
+
+// MARK: Live terminal (codex/processes.rs terminal_thread_is_live)
+
+const LOCK = /(?:^|\/)thread-writer-locks\/([0-9a-fA-F-]{36})\.lock$/;
+
+/** The thread after the last `resume` in `argv`. */
+function resumeArgument(argv) {
+ const index = argv.lastIndexOf('resume');
+ return index < 0 ? null : (argv[index + 1] ?? null);
+}
+
+/**
+ * Whether an interactive Codex TUI of this user, the one that drains
+ * `codex queue`, runs `thread` now: it holds the thread's writer lock or
+ * runs `resume `. Conductor's and Paseo's app-servers also drain
+ * the queue in the probe; neither is a guest surface, so they are not
+ * looked for here.
+ */
+export async function terminalThreadIsLive(thread) {
+ const wanted = String(thread).toLowerCase();
+ if (!isThreadId(wanted)) return false;
+ const uid = process.getuid?.();
+ for (const candidate of await listProcesses()) {
+ if (uid !== undefined && candidate.uid !== uid) continue;
+ if (!(await isTrustedCodex(candidate.executable))) continue;
+ const argv = await argvOf(candidate.pid);
+ if (argv.some((argument) => argument === 'app-server' || argument === 'exec' || argument === 'queue')) {
+ continue;
+ }
+ if (resumeArgument(argv)?.toLowerCase() === wanted) return true;
+ const files = await openFiles(candidate.pid);
+ if (files.some((file) => file.match(LOCK)?.[1]?.toLowerCase() === wanted)) return true;
+ }
+ return false;
+}
+
+// MARK: Running codex (codex/runner.rs)
+
+const OUTPUT_LIMIT = 64 * 1024;
+
+/**
+ * Runs `file args` from the temporary directory with no input, in its own
+ * process group, stopping the group at `timeoutMs`: `{code, stdout,
+ * stderr}`, `{timedOut: true}` or `{notStarted: reason}`.
+ */
+export function runCommand(file, args, { timeoutMs = 20_000, env = {} } = {}) {
+ return new Promise((resolve) => {
+ let child;
+ try {
+ child = spawn(file, args, {
+ cwd: os.tmpdir(),
+ env: { ...process.env, ...env },
+ stdio: ['ignore', 'pipe', 'pipe'],
+ detached: true,
+ });
+ } catch (error) {
+ resolve({ notStarted: String(error?.message ?? error) });
+ return;
+ }
+ const collect = (stream) => {
+ const chunks = [];
+ let size = 0;
+ stream.on('data', (chunk) => {
+ if (size >= OUTPUT_LIMIT) return;
+ chunks.push(chunk.subarray(0, OUTPUT_LIMIT - size));
+ size += chunk.length;
+ });
+ return () => Buffer.concat(chunks).toString('utf8');
+ };
+ const stdout = collect(child.stdout);
+ const stderr = collect(child.stderr);
+ let timedOut = false;
+ const group = (signal) => {
+ try {
+ process.kill(-child.pid, signal);
+ } catch {
+ // The group is gone.
+ }
+ };
+ const timer = setTimeout(() => {
+ timedOut = true;
+ group('SIGTERM');
+ setTimeout(() => group('SIGKILL'), 5_000).unref();
+ }, timeoutMs);
+ child.once('error', (error) => {
+ clearTimeout(timer);
+ resolve({ notStarted: String(error?.message ?? error) });
+ });
+ child.once('close', (code) => {
+ clearTimeout(timer);
+ if (timedOut) resolve({ timedOut: true });
+ else resolve({ code: code ?? -1, stdout: stdout(), stderr: stderr() });
+ });
+ });
+}
+
+/** Only exit code zero and Codex's own confirmation count as queued (queue.rs `outcome`). */
+export function queueOutcome(code, stdout = '', stderr = '') {
+ const CONFIRMATION = 'Queued message ';
+ if (code === 0) {
+ const at = stdout.indexOf(CONFIRMATION);
+ if (at >= 0) {
+ const item = stdout.slice(at + CONFIRMATION.length).match(/^\S*/)[0];
+ return { queued: true, item: item === '' ? null : item };
+ }
+ }
+ const source = stderr === '' ? stdout : stderr;
+ const line = source
+ .split('\n')
+ .map((value) => value.trim())
+ .find((value) => value !== '');
+ if (!line) return { queued: false, reason: `codex exited with code ${code}` };
+ return { queued: false, reason: [...line].slice(0, 300).join('') };
+}
+
+// MARK: Codex app IPC (codex/ipc.rs)
+
+export const MAX_FRAME_BYTES = 268_435_456;
+const CLIENT_TYPE = 'agent-relay-desktop';
+const DISCOVERY_TIMEOUT_MS = 12_000;
+const INITIALIZE_TIMEOUT_MS = 5_000;
+const START_TURN_TIMEOUT_MS = 30_000;
+const METHOD_VERSIONS = {
+ initialize: 0,
+ 'thread-owner-discovery': 1,
+ 'thread-follower-start-turn': 2,
+};
+
+class IpcError extends Error {
+ constructor(kind, reason) {
+ const text = {
+ not_running: 'The Codex app is not running.',
+ timed_out: 'The Codex app did not answer in time.',
+ rejected: `The Codex app refused the message: ${reason}`,
+ io: `Could not talk to the Codex app: ${reason}`,
+ }[kind];
+ super(text);
+ this.kind = kind;
+ this.reason = reason;
+ }
+}
+
+/** A 4-byte little-endian length, then the payload. */
+export function encodeFrame(payload) {
+ const bytes = Buffer.isBuffer(payload) ? payload : Buffer.from(payload);
+ if (bytes.length === 0 || bytes.length > MAX_FRAME_BYTES) {
+ throw new IpcError('io', `a ${bytes.length}-byte frame`);
+ }
+ const header = Buffer.alloc(4);
+ header.writeUInt32LE(bytes.length, 0);
+ return Buffer.concat([header, bytes]);
+}
+
+/** Accumulates socket bytes and hands back whole frame payloads. */
+export class FrameBuffer {
+ constructor() {
+ this.pending = Buffer.alloc(0);
+ }
+
+ append(data) {
+ this.pending = Buffer.concat([this.pending, data]);
+ const payloads = [];
+ while (this.pending.length >= 4) {
+ const length = this.pending.readUInt32LE(0);
+ if (length === 0 || length > MAX_FRAME_BYTES) throw new Error(`a ${length}-byte frame`);
+ if (this.pending.length < 4 + length) break;
+ payloads.push(this.pending.subarray(4, 4 + length));
+ this.pending = this.pending.subarray(4 + length);
+ }
+ return payloads;
+ }
+}
+
+function ipcRequest(method, params, source, requestId, target, timeoutMs) {
+ const request = {
+ type: 'request',
+ requestId,
+ sourceClientId: source,
+ version: METHOD_VERSIONS[method],
+ method,
+ params,
+ };
+ if (target) request.targetClientId = target;
+ if (timeoutMs !== undefined) request.timeoutMs = timeoutMs;
+ return request;
+}
+
+function decodeIncoming(payload) {
+ let value;
+ try {
+ value = JSON.parse(payload.toString('utf8'));
+ } catch {
+ return null;
+ }
+ if (typeof value?.type !== 'string') return null;
+ const text = (item) => (typeof item === 'string' ? item : null);
+ return {
+ kind: value.type,
+ requestId: text(value.requestId),
+ method: text(value.method),
+ resultType: text(value.resultType),
+ error: text(value.error),
+ handledByClient: text(value.handledByClientId),
+ clientId: text(value.result?.clientId),
+ turnId: text(value.result?.result?.turn?.id),
+ };
+}
+
+/** One registered connection to the Codex app's router. */
+class IpcSession {
+ constructor(socket) {
+ this.socket = socket;
+ this.buffer = new FrameBuffer();
+ this.queued = [];
+ this.failure = null;
+ this.wake = null;
+ this.clientId = '';
+ socket.on('data', (data) => {
+ try {
+ this.queued.push(...this.buffer.append(data));
+ } catch {
+ this.failure = new IpcError('io', 'received a malformed frame');
+ }
+ this.notify();
+ });
+ const closed = () => {
+ this.failure ??= new IpcError('io', 'the Codex app closed the connection');
+ this.notify();
+ };
+ socket.on('end', closed);
+ socket.on('close', closed);
+ socket.on('error', (error) => {
+ this.failure ??= new IpcError('io', error?.message ?? String(error));
+ this.notify();
+ });
+ }
+
+ static connect(path, connect = (socketPath) => net.createConnection(socketPath)) {
+ return new Promise((resolve, reject) => {
+ let socket;
+ try {
+ socket = connect(path);
+ } catch (error) {
+ reject(new IpcError('io', error?.message ?? String(error)));
+ return;
+ }
+ const onError = (error) => {
+ socket.destroy();
+ reject(
+ error?.code === 'ENOENT' || error?.code === 'ECONNREFUSED'
+ ? new IpcError('not_running')
+ : new IpcError('io', error?.message ?? String(error))
+ );
+ };
+ socket.once('error', onError);
+ socket.once('connect', () => {
+ socket.off('error', onError);
+ const session = new IpcSession(socket);
+ session.register().then(
+ () => resolve(session),
+ (error) => {
+ session.close();
+ reject(error);
+ }
+ );
+ });
+ });
+ }
+
+ notify() {
+ const wake = this.wake;
+ this.wake = null;
+ wake?.();
+ }
+
+ close() {
+ this.socket.destroy();
+ }
+
+ send(message) {
+ const frame = encodeFrame(Buffer.from(JSON.stringify(message)));
+ return new Promise((resolve, reject) => {
+ if (this.failure) {
+ reject(this.failure);
+ return;
+ }
+ this.socket.write(frame, (error) => (error ? reject(new IpcError('io', error.message)) : resolve()));
+ });
+ }
+
+ /** The next message, or `null` once `deadline` passes; client discovery is declined here. */
+ async nextMessage(deadline) {
+ for (;;) {
+ while (this.queued.length > 0) {
+ const message = decodeIncoming(this.queued.shift());
+ if (!message) continue;
+ if (message.kind === 'client-discovery-request' && message.requestId) {
+ await this.send({
+ type: 'client-discovery-response',
+ requestId: message.requestId,
+ response: { canHandle: false },
+ });
+ continue;
+ }
+ return message;
+ }
+ if (this.failure) throw this.failure;
+ const remaining = deadline - Date.now();
+ if (remaining <= 0) return null;
+ await new Promise((resolve) => {
+ const timer = setTimeout(() => {
+ this.wake = null;
+ resolve();
+ }, remaining);
+ this.wake = () => {
+ clearTimeout(timer);
+ resolve();
+ };
+ });
+ }
+ }
+
+ async request(method, params, source, target, timeoutMs, waitMs) {
+ const id = randomUUID();
+ await this.send(ipcRequest(method, params, source, id, target, timeoutMs));
+ const deadline = Date.now() + waitMs;
+ for (;;) {
+ const message = await this.nextMessage(deadline);
+ if (!message) throw new IpcError('timed_out');
+ if (message.kind === 'response' && message.requestId === id) return message;
+ }
+ }
+
+ async register() {
+ const response = await this.request(
+ 'initialize',
+ { clientType: CLIENT_TYPE },
+ 'initializing-client',
+ null,
+ undefined,
+ INITIALIZE_TIMEOUT_MS
+ );
+ if (response.resultType === 'success' && response.clientId) {
+ this.clientId = response.clientId;
+ return;
+ }
+ throw new IpcError('rejected', response.error ?? 'initialize failed');
+ }
+
+ /** The window that owns each of `threads`; a thread no window has open is left out. */
+ async owners(threads) {
+ const byRequest = new Map();
+ for (const thread of threads) {
+ const id = randomUUID();
+ await this.send(
+ ipcRequest('thread-owner-discovery', { hostId: 'local', conversationId: thread }, this.clientId, id)
+ );
+ byRequest.set(id, thread);
+ }
+ const owners = new Map();
+ const deadline = Date.now() + DISCOVERY_TIMEOUT_MS;
+ while (byRequest.size > 0) {
+ const message = await this.nextMessage(deadline);
+ if (!message) break;
+ if (message.kind !== 'response' || !byRequest.has(message.requestId)) continue;
+ const thread = byRequest.get(message.requestId);
+ byRequest.delete(message.requestId);
+ if (message.resultType === 'success' && message.handledByClient) {
+ owners.set(thread, message.handledByClient);
+ }
+ }
+ return owners;
+ }
+
+ async startTurn(thread, text, messageId, owner) {
+ const started = await this.request(
+ 'thread-follower-start-turn',
+ {
+ conversationId: thread,
+ turnStart: {
+ request: {
+ threadId: thread,
+ clientUserMessageId: messageId,
+ input: [{ type: 'text', text, text_elements: [] }],
+ },
+ context: {},
+ },
+ },
+ this.clientId,
+ owner,
+ START_TURN_TIMEOUT_MS,
+ START_TURN_TIMEOUT_MS + 2_000
+ );
+ if (started.resultType !== 'success') {
+ throw new IpcError('rejected', started.error ?? 'unknown error');
+ }
+ return started.turnId;
+ }
+}
+
+/**
+ * A turn through the Codex app window that owns `thread` (router.rs
+ * `window_turn`): `{sent: false}` when the app is not running or no window
+ * owns it, `{sent: true}`, `{refused: reason}` when the app refused, or
+ * `{inDoubt: reason}` for any other failure after the turn was asked for.
+ */
+export async function windowTurn(socketPath, thread, text, messageId, connect) {
+ let attempted = false;
+ let session;
+ try {
+ session = await IpcSession.connect(socketPath, connect);
+ const owners = await session.owners([thread]);
+ const owner = owners.get(thread);
+ if (!owner) return { sent: false };
+ attempted = true;
+ await session.startTurn(thread, text, messageId, owner);
+ return { sent: true };
+ } catch (error) {
+ if (error instanceof IpcError && error.kind === 'rejected') return { refused: error.reason };
+ if (attempted) return { inDoubt: error?.message ?? String(error) };
+ return { sent: false };
+ } finally {
+ session?.close();
+ }
+}
+
+// MARK: Router (codex/router.rs)
+
+export const NOT_RUNNING = 'Waiting — session not running';
+export const NOT_CONSUMED = 'Waiting — terminal session has not consumed the message';
+export const BEHIND_TURN = "Waiting — queued behind the session's active turn";
+export const HANDED_NOT_RUNNING = 'Waiting — handed off; session not running';
+export const RECORDING = 'Waiting — Codex is recording the message';
+export const BUSY = 'Waiting — session busy';
+export const QUEUE_UNREADABLE = 'Waiting — Codex queue state is unavailable';
+
+/** How long a handoff is given to show up in the rollout. */
+export const HANDOFF_GRACE_MS = 45_000;
+/** How long a handoff is remembered at all. */
+const HANDOFF_MEMORY_MS = 60 * 60 * 1_000;
+const QUEUE_TIMEOUT_MS = 20_000;
+const SUPPORT_TIMEOUT_MS = 10_000;
+
+/**
+ * Delivers into Codex threads, remembering its handoffs in memory.
+ *
+ * Options: `home`, `env` (CODEX_HOME, CODEX_SQLITE_HOME, RELAY_DESKTOP_CODEX,
+ * PATH), `platform`, `run(file, args, {timeoutMs, env})` returning
+ * `{code, stdout, stderr}` / `{timedOut}` / `{notStarted}`, `now()` in ms,
+ * and for tests `systemCodex` (replaces the fixed install locations),
+ * `terminalLive(thread)` and `connectIpc(path)`.
+ */
+export function createCodexRouter({
+ home = os.homedir(),
+ env = process.env,
+ platform = process.platform,
+ run = runCommand,
+ now = Date.now,
+ systemCodex,
+ terminalLive = terminalThreadIsLive,
+ connectIpc,
+} = {}) {
+ const paths = codexPaths({ home, env });
+ const handedOff = new Map();
+ const queueSupport = new Map();
+
+ const runIn = async (file, args, timeoutMs) => {
+ try {
+ const result = await run(file, args, {
+ timeoutMs,
+ env: { CODEX_HOME: paths.codexHome, CODEX_SQLITE_HOME: paths.sqliteHome },
+ });
+ return result ?? { notStarted: 'no result' };
+ } catch (error) {
+ return { notStarted: String(error?.message ?? error) };
+ }
+ };
+
+ const executable = () => codexExecutable({ home, env, platform, system: systemCodex });
+
+ /** Asks the binary itself (`codex queue --help`), once per build. */
+ const supportsQueue = async (file) => {
+ let modified = null;
+ try {
+ modified = statSync(file).mtimeMs;
+ } catch {
+ // Asked anyway; not remembered under a stale key.
+ }
+ const key = `${file}\0${modified}`;
+ if (queueSupport.has(key)) return queueSupport.get(key);
+ const result = await runIn(file, ['queue', '--help'], SUPPORT_TIMEOUT_MS);
+ if (typeof result.code !== 'number') return false;
+ queueSupport.set(key, result.code === 0);
+ return result.code === 0;
+ };
+
+ /** The thread id as Codex's own database spells it. */
+ const spelled = async (thread) => (await readThreadId(paths, thread)) || thread;
+
+ const prune = (at) => {
+ for (const [id, seen] of handedOff) {
+ if (at - seen >= HANDOFF_MEMORY_MS) handedOff.delete(id);
+ }
+ };
+
+ const remember = (messageId) => {
+ const at = now();
+ handedOff.set(messageId, at);
+ prune(at);
+ };
+
+ const rememberOnce = (messageId) => {
+ const at = now();
+ if (!handedOff.has(messageId)) handedOff.set(messageId, at);
+ prune(at);
+ };
+
+ const recentlyHandedOff = (messageId) => {
+ const at = handedOff.get(messageId);
+ return at !== undefined && now() - at < HANDOFF_GRACE_MS;
+ };
+
+ const missingHandoffOutcome = (messageId) => {
+ const at = now();
+ const seen = handedOff.get(messageId);
+ if (seen !== undefined && at - seen >= HANDOFF_GRACE_MS) {
+ handedOff.delete(messageId);
+ return outcome(
+ 'in_doubt',
+ 'Codex accepted this handoff, but it is no longer queued and was not recorded'
+ );
+ }
+ if (seen === undefined) {
+ handedOff.set(messageId, at);
+ prune(at);
+ }
+ return outcome('accepted', RECORDING);
+ };
+
+ const delivered = (messageId, detail) => {
+ handedOff.delete(messageId);
+ // TODO(phase 2): the probe also removes the relay's own queue copy of a
+ // message that arrived another way (queue.rs `remove_owned`). This
+ // client opens Codex's databases read-only, so that copy is left for
+ // Codex; the rollout, not the queue, decides delivery.
+ return outcome('delivered', detail);
+ };
+
+ const handed = async (thread, messageId) => {
+ remember(messageId);
+ if (!(await terminalLive(thread))) return outcome('accepted', HANDED_NOT_RUNNING);
+ if (await idle(paths, thread)) return outcome('accepted', NOT_CONSUMED);
+ return outcome('waiting_for_turn', BEHIND_TURN);
+ };
+
+ const queueSend = async (thread, text) => {
+ if (!isThreadId(thread)) return { error: 'failed', reason: 'not a Codex thread id' };
+ // TODO: the probe queues a Conductor chat's thread with Conductor's own
+ // bundled Codex (processes.rs `conductor_executable`); not ported.
+ const file = executable();
+ if (!file || !(await supportsQueue(file))) return { error: 'not_installed' };
+ const result = await runIn(
+ file,
+ ['queue', '--thread', await spelled(thread), `--message=${text}`],
+ QUEUE_TIMEOUT_MS
+ );
+ if (result.timedOut) return { error: 'timed_out' };
+ if (typeof result.code !== 'number') return { error: 'not_installed' };
+ const parsed = queueOutcome(result.code, result.stdout ?? '', result.stderr ?? '');
+ return parsed.queued ? { ok: true } : { error: 'failed', reason: parsed.reason };
+ };
+
+ const handoffToTerminal = async (thread, messageId, text, ref, queued) => {
+ // The queue cannot say whether an earlier handoff is still there:
+ // another one could run the message twice.
+ if (queued === null) return outcome('waiting', QUEUE_UNREADABLE);
+ const sent = await queueSend(thread, text);
+ if (sent.ok) return handed(thread, messageId);
+ if (sent.error === 'timed_out') {
+ if ((await queueWaiting(paths, thread, ref)) === true) return handed(thread, messageId);
+ return outcome('in_doubt', 'Codex queue did not answer in time');
+ }
+ if (sent.error === 'not_installed') return outcome('retry', 'the codex command line is not installed');
+ return outcome('retry', sent.reason);
+ };
+
+ return {
+ paths,
+
+ /** Puts `text` into the thread (router.rs `inject`). */
+ async inject({ threadId, messageId, text }) {
+ if (!isThreadId(threadId)) return outcome('refused', 'not a Codex thread id');
+ const thread = threadId;
+ const ref = messageRef(messageId);
+ if (await recorded(paths, thread, ref)) {
+ return delivered(messageId, "recorded in the Codex thread's rollout");
+ }
+ const queued = await queueWaiting(paths, thread, ref);
+ if (queued === true) return handed(thread, messageId);
+ if (recentlyHandedOff(messageId)) return outcome('accepted', RECORDING);
+ // TODO: the probe's Conductor Stop-hook hold (router.rs
+ // `stop_hook_route`) would go here; it needs an installed hook binary
+ // and is not ported (docs/connect-node-local-relay.md, "Not ported").
+ //
+ // A live TUI owns its turn scheduling: hand it the message now, and
+ // only through its queue, which Codex runs in order.
+ if (await terminalLive(thread)) {
+ return handoffToTerminal(thread, messageId, text, ref, queued);
+ }
+ // A turn through another host would overtake relay messages still in
+ // the queue: join that queue behind them instead.
+ const waiting = await relayWaiting(paths, thread);
+ if (waiting === null) return outcome('waiting', QUEUE_UNREADABLE);
+ if (waiting === true) return handoffToTerminal(thread, messageId, text, ref, queued);
+ // TODO(phase 2): the shared app-server route (`codex app-server
+ // proxy`, `thread/loaded/list` then `turn/start`) comes before the
+ // window route in the probe.
+ const window = await windowTurn(paths.ipcSocket, await spelled(thread), text, messageId, connectIpc);
+ if (window.sent) return delivered(messageId, 'the Codex app started the turn');
+ if (window.refused !== undefined) return outcome('retry', window.refused);
+ if (window.inDoubt !== undefined) return outcome('in_doubt', window.inDoubt);
+ if (!(await idle(paths, thread))) return outcome('waiting', BUSY);
+ return outcome('waiting', NOT_RUNNING);
+ },
+
+ /**
+ * Observes a handoff a prior attempt already proved accepted, never
+ * writing another queue item (router.rs `observe`).
+ */
+ async observe({ threadId, messageId }) {
+ if (!isThreadId(threadId)) return outcome('refused', 'not a Codex thread id');
+ const ref = messageRef(messageId);
+ if (await recorded(paths, threadId, ref)) {
+ return delivered(messageId, "recorded in the Codex thread's rollout");
+ }
+ const queued = await queueWaiting(paths, threadId, ref);
+ if (queued === true) return handed(threadId, messageId);
+ if (queued === false) return missingHandoffOutcome(messageId);
+ // An unreadable queue cannot prove the handoff vanished: keep its
+ // first observation time so a later readable absence can cross the
+ // grace without ever resending.
+ rememberOnce(messageId);
+ return outcome('accepted', QUEUE_UNREADABLE);
+ },
+
+ /** Whether the thread can take a message now, without sending one. */
+ async preflight({ threadId } = {}) {
+ if (!isThreadId(threadId)) return { ok: false, reason: 'not a Codex thread id' };
+ const file = executable();
+ if (file && (await supportsQueue(file))) {
+ return { ok: true, reason: `${file} supports codex queue` };
+ }
+ try {
+ if (statSync(paths.ipcSocket)) {
+ return { ok: true, reason: `the Codex app is listening on ${paths.ipcSocket}` };
+ }
+ } catch {
+ // No app socket.
+ }
+ if (!file) {
+ return {
+ ok: false,
+ reason:
+ 'Codex cannot receive injected messages: no codex command line is installed and the Codex app is not running.',
+ };
+ }
+ const version = await runIn(file, ['--version'], SUPPORT_TIMEOUT_MS);
+ const named =
+ typeof version.stdout === 'string' ? version.stdout.match(/\d+\.\d+\.\d+[^\s]*/)?.[0] : null;
+ return {
+ ok: false,
+ reason: `Codex ${named ?? '(unknown version)'} (${file}) cannot receive injected messages; update Codex.`,
+ };
+ },
+ };
+}
+
+/** Which SQLite reader this process uses (`node:sqlite`, `sqlite3` or none). */
+export async function codexSqliteBackend() {
+ return (await sqliteBackend()).kind;
+}
diff --git a/packages/connect/src/relay/connect.js b/packages/connect/src/relay/connect.js
new file mode 100644
index 000000000..526b928e7
--- /dev/null
+++ b/packages/connect/src/relay/connect.js
@@ -0,0 +1,776 @@
+import { createHash, randomUUID } from 'node:crypto';
+import {
+ cloudAvailability,
+ cloudJoin,
+ cloudLeave,
+ connectIdFrom,
+ isAuthRejection,
+ relaycast,
+ RelayHttpError,
+} from './cloud.js';
+import { handoffTtlMs } from './state.js';
+
+// The Connect half of the Node relay: the local join/send/status/leave
+// routes and the poll → inject → settle pass, ported from the desktop probe
+// (relay-desktop probe/src/relay/connect.rs and outbound.rs). Every rule that
+// decides when a delivery is acked, failed, retried or left alone matches the
+// probe's ledger; see docs/connect-node-local-relay.md.
+
+export const HEARTBEAT_MS = 30_000;
+const BACKOFF_MIN_MS = 2_000;
+const BACKOFF_MAX_MS = 15 * 60_000;
+const NOTICE_TTL_MS = 60 * 60_000;
+const INJECTED_REASONS = new Set(['dm', 'mention', 'thread-reply']);
+
+export class ConnectError extends Error {
+ constructor(status, code, message) {
+ super(message);
+ this.name = 'ConnectError';
+ this.status = status;
+ this.code = code;
+ }
+}
+
+export function messageRef(messageId) {
+ return createHash('sha256').update(messageId).digest('hex').slice(0, 12);
+}
+
+/** One line, no brackets: the probe's `policy::one_line`. */
+export function oneLine(value) {
+ return String(value)
+ .replace(/\[/g, '(')
+ .replace(/\]/g, ')')
+ .replace(/[\u0000-\u001f\u007f-\u009f\u2028\u2029\u200b-\u200f\u202a-\u202e\u2060-\u2064\ufeff]/g, ' ')
+ .replace(/\s+/g, ' ')
+ .trim();
+}
+
+export function joinRequestHash(connectId, name, hostClaim) {
+ const request = JSON.stringify(['relay-connect-join:v1', connectId, name ?? null, hostClaim ?? null]);
+ return createHash('sha256').update(request).digest('hex');
+}
+
+function str(value) {
+ return typeof value === 'string' && value.length > 0 ? value : null;
+}
+
+/** A `GET /v1/deliveries` item, as the probe's `Delivery::from_queue_item` reads it. */
+export function parseDelivery(item) {
+ const id = str(item?.id);
+ const messageId = str(item?.message_id);
+ if (!id || !messageId) return null;
+ const message = item.message || {};
+ const attachments = [
+ ...(Array.isArray(message.attachments) ? message.attachments : []),
+ ...(Array.isArray(item.attachments) ? item.attachments : []),
+ ];
+ return {
+ id,
+ messageId,
+ status: typeof item.status === 'string' ? item.status : '',
+ reason: str(item.reason),
+ channelId: str(item.channel_id),
+ sender: str(message.agent_name),
+ senderAddress: str(message.agent_address),
+ text: typeof message.text === 'string' ? message.text : null,
+ threadId: str(message.thread_id),
+ relayfilePath: str(message.metadata?.relayfile?.path),
+ attachments,
+ };
+}
+
+export function decide(delivery) {
+ if (!delivery.reason || !INJECTED_REASONS.has(delivery.reason)) {
+ return { inject: false, reason: `not addressed to this agent (${delivery.reason || 'unknown'})` };
+ }
+ const text = delivery.text && delivery.text.trim() ? delivery.text : null;
+ if (!text && delivery.attachments.length === 0) return { inject: false, reason: 'the message has no text' };
+ return { inject: true };
+}
+
+function withAttachments(body, attachments) {
+ const lines = [];
+ const seen = new Set();
+ for (const attachment of attachments) {
+ const name = oneLine(attachment?.filename || attachment?.name || attachment?.file_name || 'file');
+ const where =
+ str(attachment?.url) ||
+ str(attachment?.download_url) ||
+ str(attachment?.file_id) ||
+ str(attachment?.id);
+ const key = `${name}|${where}`;
+ if (seen.has(key)) continue;
+ seen.add(key);
+ const type = str(attachment?.content_type) || str(attachment?.mime_type);
+ lines.push(`- ${name}${type ? ` (${oneLine(type)})` : ''}${where ? ` ${oneLine(where)}` : ''}`);
+ }
+ if (lines.length === 0) return body;
+ return `${body}${body ? '\n\n' : ''}Attachments:\n${lines.join('\n')}`;
+}
+
+export function injectedText(delivery, hint) {
+ const sender = oneLine(delivery.sender || 'participant');
+ let body = delivery.text || '';
+ if (delivery.relayfilePath) {
+ const line = `Relayfile path: ${delivery.relayfilePath}`;
+ if (!body.includes(line)) body = `${body}\n\n${line}`;
+ }
+ body = withAttachments(body, delivery.attachments);
+ return `[Relay Connect — from ${sender} · ref ${messageRef(delivery.messageId)}]\n${hint || ''}\n\n${body}`;
+}
+
+function goneError(code, message) {
+ return new ConnectError(code === 'connect_not_found' ? 404 : 410, code, message);
+}
+
+function expired(session, now) {
+ const at = Date.parse(session.expires_at);
+ return Number.isNaN(at) || at <= now;
+}
+
+export function joinPublic(session) {
+ return {
+ connect_id: session.connect_id,
+ agent_name: session.agent_name,
+ role: session.role,
+ task: session.task,
+ expires_at: session.expires_at,
+ host: session.host,
+ participants: session.participants.map((p) => ({ agent_name: p.agent_name, role: p.role })),
+ };
+}
+
+export function participantNames(session, roster) {
+ const names = [...session.participants.map((p) => p.agent_name), ...session.observed_participants];
+ for (const agent of roster || []) {
+ const name = agent?.name;
+ if (typeof name === 'string' && name && !names.includes(name)) names.push(name);
+ }
+ return names;
+}
+
+export function statusPublic(session, roster) {
+ const roles = new Map(session.participants.map((p) => [p.agent_name, p.role]));
+ return {
+ connect_id: session.connect_id,
+ agent_name: session.agent_name,
+ role: session.role,
+ task: session.task,
+ expires_at: session.expires_at,
+ participants: participantNames(session, roster).map((name) => ({
+ agent_name: name,
+ role: roles.get(name) ?? null,
+ online: roster
+ ? roster.some((agent) => agent?.name === name && ['active', 'online'].includes(agent?.status))
+ : null,
+ })),
+ };
+}
+
+function closeNotice(session, code) {
+ const ended = code !== 'connect_expired';
+ return {
+ id: `connect-${ended ? 'ended' : 'expired'}-${session.connect_id}`,
+ target: session.target,
+ text: ended
+ ? '[Relay Connect] This Connect ended. Its local credential was removed.'
+ : '[Relay Connect] This Connect expired. Its local credential was removed.',
+ };
+}
+
+/**
+ * The Connect service. `injector` routes text into a session:
+ * `inject(target, {messageId, text})`, `observe(...)`, `preflight(target)`,
+ * `prepare(target)` (Claude Code's direct-delivery setting), all resolving
+ * `{outcome, detail}` where an outcome applies.
+ */
+export class ConnectService {
+ constructor({
+ state,
+ injector,
+ log = () => {},
+ env = process.env,
+ now = Date.now,
+ fetchImpl,
+ replyHint = () => '',
+ onChange = () => {},
+ }) {
+ this.state = state;
+ this.injector = injector;
+ this.log = log;
+ this.env = env;
+ this.now = now;
+ this.fetchImpl = fetchImpl;
+ this.replyHint = replyHint;
+ this.onChange = onChange;
+ this.locks = new Map();
+ this.lastHeartbeat = new Map();
+ this.backoff = new Map();
+ }
+
+ client(session) {
+ return relaycast({ baseUrl: session.base_url, token: session.token, fetchImpl: this.fetchImpl });
+ }
+
+ logFailure(operation, connectId, code, cause) {
+ this.log(
+ `relay connect ${operation} failed code=${code} connect_ref=${messageRef(connectId)} cause=${cause}`
+ );
+ }
+
+ /** Serializes one credential's passes and route calls, like the probe's agent locks. */
+ async withLock(key, task) {
+ const previous = this.locks.get(key) || Promise.resolve();
+ let release;
+ const current = new Promise((resolve) => {
+ release = resolve;
+ });
+ const chained = previous.then(() => current);
+ this.locks.set(key, chained);
+ await previous;
+ try {
+ return await task();
+ } finally {
+ release();
+ if (this.locks.get(key) === chained) this.locks.delete(key);
+ }
+ }
+
+ async close(session, code) {
+ const removed = await this.state.removeSession(session.target, session.agent_id);
+ if (removed) {
+ await this.state.queueNotice({ ...closeNotice(session, code), queued_at_ms: this.now() });
+ this.onChange();
+ }
+ return removed;
+ }
+
+ async availability(session) {
+ try {
+ return await cloudAvailability({
+ connectId: session.connect_id,
+ env: this.env,
+ fetchImpl: this.fetchImpl,
+ });
+ } catch (error) {
+ this.logFailure('availability', session.connect_id, error.code, error.logCause);
+ throw new ConnectError(error.status || 503, error.code, error.message);
+ }
+ }
+
+ async current(target) {
+ const session = this.state.session(target);
+ if (!session)
+ throw new ConnectError(404, 'connect_not_joined', 'This agent session has not joined a Relay Connect.');
+ if (expired(session, this.now())) {
+ await this.close(session, 'connect_expired');
+ throw new ConnectError(410, 'connect_expired', 'This Connect has expired.');
+ }
+ return session;
+ }
+
+ // Routes -------------------------------------------------------------------------------
+
+ async join(target, fields) {
+ const extra = Object.keys(fields || {}).filter((key) => !['link', 'name', 'host_claim'].includes(key));
+ if (extra.length)
+ throw new ConnectError(400, 'invalid_request', 'Use link, optional name and optional host_claim.');
+ const link = typeof fields.link === 'string' ? fields.link.trim() : '';
+ if (!link) throw new ConnectError(400, 'invalid_request', 'A Relay Connect link or id is required.');
+ const requestedId = connectIdFrom(link);
+ if (!requestedId) throw new ConnectError(400, 'invalid_link', 'Use a Relay Connect link or id.');
+ let name = null;
+ if (fields.name !== undefined && fields.name !== null) {
+ name = typeof fields.name === 'string' ? fields.name.trim().toLowerCase() : '';
+ if (!/^[a-z0-9]+(?:-[a-z0-9]+)*$/.test(name) || name.length < 2 || name.length > 48) {
+ throw new ConnectError(
+ 400,
+ 'invalid_name',
+ 'Names are 2-48 lowercase letters, numbers, or single hyphens.'
+ );
+ }
+ }
+ let claim = null;
+ if (fields.host_claim !== undefined) {
+ if (typeof fields.host_claim !== 'string' || !fields.host_claim) {
+ throw new ConnectError(400, 'invalid_request', 'host_claim must be a non-empty string.');
+ }
+ claim = fields.host_claim;
+ }
+
+ const existing = this.state.session(target);
+ if (existing) {
+ if (expired(existing, this.now())) await this.close(existing, 'connect_expired');
+ else if (existing.connect_id === requestedId) return { ...joinPublic(existing), warnings: [] };
+ else
+ throw new ConnectError(
+ 409,
+ 'connect_already_joined',
+ 'This agent session is already in a different Relay Connect.'
+ );
+ }
+
+ // Before Cloud: a session that cannot receive replies should not join.
+ if (this.injector.prepare) {
+ try {
+ await this.injector.prepare(target);
+ } catch (error) {
+ throw new ConnectError(409, error.code || 'session_not_injectable', error.message);
+ }
+ }
+
+ const idempotencyKey = await this.state.beginJoinAttempt(
+ target,
+ joinRequestHash(requestedId, name, claim),
+ `node-v1-${randomUUID()}`
+ );
+ if (!idempotencyKey) {
+ throw new ConnectError(
+ 409,
+ 'connect_join_pending',
+ 'A previous join has an unknown outcome. Retry with the same link, name, and host claim.'
+ );
+ }
+ let joined;
+ try {
+ joined = await cloudJoin({
+ connectId: requestedId,
+ name,
+ hostClaim: claim,
+ idempotencyKey,
+ env: this.env,
+ fetchImpl: this.fetchImpl,
+ });
+ } catch (error) {
+ const status = error.status ?? 503;
+ if (status >= 500 || error.code === 'connect_join_timeout')
+ this.logFailure('join', requestedId, error.code, error.logCause);
+ if (status < 500 && status !== 429) await this.state.clearJoinAttempt(target, idempotencyKey);
+ throw new ConnectError(status, error.code, error.message);
+ }
+ if (joined.connect_id !== requestedId) {
+ throw new ConnectError(502, 'invalid_join_response', 'Relay Connect returned a different Connect.');
+ }
+ const participant = joined.participant;
+ const session = {
+ target,
+ connect_id: joined.connect_id,
+ task: typeof joined.task === 'string' ? joined.task : '',
+ expires_at: joined.expires_at,
+ host: joined.host || {},
+ agent_name: participant.agent_name,
+ agent_id: participant.agent_id,
+ role: participant.role || 'guest',
+ token: participant.token,
+ base_url: participant.relay_base_url,
+ participants: joined.participants
+ .filter((p) => typeof p?.agent_name === 'string')
+ .map((p) => ({ agent_name: p.agent_name, role: typeof p.role === 'string' ? p.role : null })),
+ observed_participants: [],
+ joined_at_ms: this.now(),
+ };
+ if (expired(session, this.now()))
+ throw new ConnectError(410, 'connect_expired', 'This Connect has expired.');
+ try {
+ await this.state.insertSession(session);
+ } catch {
+ throw new ConnectError(500, 'connect_save_failed', 'This Connect could not be saved locally.');
+ }
+ this.onChange();
+ const warnings = [];
+ if (this.injector.preflight) {
+ const check = await this.injector
+ .preflight(target)
+ .catch((error) => ({ ok: false, reason: error.message }));
+ if (!check.ok) warnings.push(`Replies may not reach this session yet: ${check.reason}`);
+ }
+ return { ...joinPublic(session), warnings };
+ }
+
+ async send(target, text, { to, requestKey } = {}) {
+ if (typeof text !== 'string' || !text)
+ throw new ConnectError(400, 'invalid_text', 'The message must be non-empty UTF-8 text.');
+ const session = await this.current(target);
+ const roster = await this.roster(session);
+ const names = participantNames(session, roster);
+ let recipients;
+ if (to !== undefined && to !== null) {
+ if (to === session.agent_name)
+ throw new ConnectError(400, 'invalid_recipient', 'Cannot send to this session itself.');
+ if (!names.includes(to))
+ throw new ConnectError(404, 'participant_not_found', 'That participant is not in this Connect.');
+ recipients = [to];
+ } else {
+ recipients = names.filter((name) => name !== session.agent_name);
+ }
+ const key = requestKey || randomUUID();
+ const sent = [];
+ const client = this.client(session);
+ for (const name of recipients) {
+ let result;
+ try {
+ result = await client.dm(name, text, `connect-send:${key}:${name}`);
+ } catch (error) {
+ const failure = await this.sendFailure(session, name, error);
+ if (sent.length)
+ failure.message = `${failure.message} Already sent to ${sent.map((s) => s.to).join(', ')}.`;
+ throw failure;
+ }
+ sent.push({ to: name, message_id: str(result?.message?.id) || str(result?.id) });
+ }
+ return { sent };
+ }
+
+ async sendFailure(session, name, error) {
+ if (error.status === 404 && ['agent_not_found', 'address_not_found'].includes(error.code)) {
+ return new ConnectError(404, error.code, error.message);
+ }
+ if (error.status === 404 || error.status === 410) {
+ await this.close(session, 'connect_ended');
+ return new ConnectError(410, 'connect_ended', 'This Connect expired or ended.');
+ }
+ if (isAuthRejection(error)) {
+ let availability;
+ try {
+ availability = await this.availability(session);
+ } catch (availabilityError) {
+ return availabilityError;
+ }
+ if (availability.gone) {
+ await this.close(session, availability.gone.code);
+ return goneError(availability.gone.code, availability.gone.message);
+ }
+ this.logFailure('send', session.connect_id, 'connect_auth_rejected', error.logCause);
+ return new ConnectError(
+ 503,
+ 'connect_auth_rejected',
+ "Relay Connect refused this session's credential although the Connect is still open. This can happen briefly after the host changes; retry once."
+ );
+ }
+ if (error.status === null || error.status === undefined) {
+ this.logFailure('send', session.connect_id, 'connect_unreachable', error.logCause);
+ return new ConnectError(
+ 503,
+ 'connect_unreachable',
+ `Relay Connect could not be reached; the message to ${name} may or may not have been delivered. Ask before resending.`
+ );
+ }
+ if (error.status >= 500) {
+ this.logFailure('send', session.connect_id, 'connect_upstream_error', error.logCause);
+ return new ConnectError(
+ 502,
+ 'connect_upstream_error',
+ `Relay Connect's server failed (HTTP ${error.status}); the message to ${name} may already have been delivered. Ask before resending.`
+ );
+ }
+ return new ConnectError(error.status, error.code, error.message);
+ }
+
+ /** The Relaycast roster, or null when it cannot be read; closes a gone Connect. */
+ async roster(session) {
+ try {
+ const agents = await this.client(session).agents();
+ return Array.isArray(agents) ? agents : Array.isArray(agents?.agents) ? agents.agents : null;
+ } catch (error) {
+ if (error.status === 404 || error.status === 410) {
+ await this.close(session, 'connect_ended');
+ throw new ConnectError(410, 'connect_ended', 'This Connect expired or ended.');
+ }
+ if (isAuthRejection(error)) {
+ const availability = await this.availability(session).catch(() => null);
+ if (availability?.gone) {
+ await this.close(session, availability.gone.code);
+ throw goneError(availability.gone.code, availability.gone.message);
+ }
+ }
+ return null;
+ }
+ }
+
+ async status(target) {
+ const session = await this.current(target);
+ const roster = await this.roster(session);
+ const status = statusPublic(session, roster);
+ const delivery = this.state.deliveryPublic(session);
+ if (delivery) status.delivery = delivery;
+ return status;
+ }
+
+ async leave(target) {
+ const session = await this.current(target);
+ return await this.withLock(`connect:${session.agent_id}`, async () => {
+ // Cloud's leave frees the name and deletes this participant's Relaycast
+ // agent. Without it (an older Cloud, or a failure), at least mark the
+ // agent offline. Both are best effort: leaving locally always succeeds.
+ let cloudLeft = false;
+ try {
+ await cloudLeave({
+ connectId: session.connect_id,
+ token: session.token,
+ env: this.env,
+ fetchImpl: this.fetchImpl,
+ });
+ cloudLeft = true;
+ } catch (error) {
+ this.logFailure(
+ 'leave',
+ session.connect_id,
+ error.code || 'connect_leave_failed',
+ error.logCause || 'unknown'
+ );
+ }
+ if (!cloudLeft) {
+ try {
+ await this.client(session).disconnect();
+ } catch (error) {
+ this.logFailure(
+ 'disconnect',
+ session.connect_id,
+ error.code || 'relaycast_error',
+ error.logCause || 'unknown'
+ );
+ }
+ }
+ await this.state.removeSession(session.target, session.agent_id);
+ this.onChange();
+ return { left: true, connect_id: session.connect_id };
+ });
+ }
+
+ // The poll pass --------------------------------------------------------------------------
+
+ gate(connectId) {
+ const entry = this.backoff.get(connectId);
+ return !entry || entry.until <= this.now();
+ }
+
+ observe(connectId, error) {
+ const limited = error?.status === 429 || ['rate_limit_exceeded', 'workspace_busy'].includes(error?.code);
+ if (!limited) {
+ if (!error) this.backoff.delete(connectId);
+ return;
+ }
+ const previous = this.backoff.get(connectId);
+ const delay = previous ? Math.min(BACKOFF_MAX_MS, previous.delay * 2) : BACKOFF_MIN_MS;
+ this.backoff.set(connectId, { delay, until: this.now() + delay });
+ }
+
+ async pollAll() {
+ await this.deliverNotices();
+ for (const session of this.state.sessions()) {
+ await this.withLock(`connect:${session.agent_id}`, () => this.pollOne(session));
+ }
+ }
+
+ async deliverNotices() {
+ for (const notice of [...this.state.data.notices]) {
+ let result;
+ try {
+ result = await this.injector.inject(notice.target, { messageId: notice.id, text: notice.text });
+ } catch (error) {
+ result = { outcome: 'in_doubt', detail: error.message };
+ }
+ const keep =
+ ['retry', 'waiting'].includes(result.outcome) &&
+ this.now() - (notice.queued_at_ms || 0) < NOTICE_TTL_MS;
+ if (!keep) await this.state.dropNotice(notice.id);
+ }
+ }
+
+ async settle(client, connectId, delivery, settled) {
+ try {
+ if (settled.kind === 'handled' && settled.outcome === 'delivered') await client.ack(delivery.id);
+ else
+ await client.fail(
+ delivery.id,
+ settled.detail || (settled.kind === 'handled' ? 'in doubt, not resent' : 'refused by the session')
+ );
+ this.observe(connectId, null);
+ } catch (error) {
+ this.observe(connectId, error);
+ }
+ }
+
+ async ack(client, connectId, deliveryId) {
+ try {
+ await client.ack(deliveryId);
+ } catch (error) {
+ this.observe(connectId, error);
+ }
+ }
+
+ async fail(client, connectId, deliveryId, detail) {
+ try {
+ await client.fail(deliveryId, detail);
+ } catch (error) {
+ this.observe(connectId, error);
+ }
+ }
+
+ async pollOne(snapshot) {
+ const session = this.state.session(snapshot.target);
+ if (!session || session.agent_id !== snapshot.agent_id) return;
+ if (expired(session, this.now())) {
+ await this.close(session, 'connect_expired');
+ return;
+ }
+ const client = this.client(session);
+ const connectId = session.connect_id;
+
+ const last = this.lastHeartbeat.get(session.agent_id);
+ if ((last === undefined || this.now() - last >= HEARTBEAT_MS) && this.gate(connectId)) {
+ this.lastHeartbeat.set(session.agent_id, this.now());
+ try {
+ await client.heartbeat();
+ this.observe(connectId, null);
+ } catch (error) {
+ this.observe(connectId, error);
+ if (error.status === 404 || error.status === 410) {
+ await this.close(session, 'connect_ended');
+ return;
+ }
+ if (isAuthRejection(error)) {
+ const availability = await this.availability(session).catch(() => null);
+ if (availability?.gone) {
+ await this.close(session, availability.gone.code);
+ return;
+ }
+ }
+ }
+ }
+ if (!this.gate(connectId)) return;
+
+ let items;
+ try {
+ items = await client.deliveries();
+ this.observe(connectId, null);
+ } catch (error) {
+ this.observe(connectId, error);
+ if (error.status === 404 || error.status === 410) {
+ await this.close(session, 'connect_ended');
+ return;
+ }
+ if (isAuthRejection(error)) {
+ const availability = await this.availability(session).catch(() => null);
+ if (availability?.gone) await this.close(session, availability.gone.code);
+ }
+ return;
+ }
+ const deliveries = (
+ Array.isArray(items) ? items : Array.isArray(items?.deliveries) ? items.deliveries : []
+ )
+ .map(parseDelivery)
+ .filter((delivery) => delivery && ['queued', 'delivered'].includes(delivery.status));
+ const ttlMs = handoffTtlMs(this.env.AGENT_RELAY_CONNECT_HANDOFF_TIMEOUT_SECONDS);
+
+ for (const delivery of deliveries) {
+ const current = this.state.session(session.target);
+ if (!current || current.agent_id !== session.agent_id) break;
+ const ledger = `${session.agent_id}:${delivery.id}`;
+ const settled = this.state.settled(ledger);
+ if (settled) {
+ await this.settle(client, connectId, delivery, settled);
+ continue;
+ }
+ if (delivery.sender === session.agent_name) {
+ await this.ack(client, connectId, delivery.id);
+ continue;
+ }
+ if (delivery.sender) await this.state.observeParticipant(session, delivery.sender);
+ if (!decide(delivery).inject) {
+ await this.ack(client, connectId, delivery.id);
+ continue;
+ }
+ try {
+ await this.state.rememberPending(ledger, delivery.messageId);
+ } catch (error) {
+ this.log(
+ `relay connect ledger write failed connect_ref=${messageRef(connectId)} cause=${error.code || 'io'}`
+ );
+ break;
+ }
+ const action = this.state.pendingAction(ledger, ttlMs);
+ const alreadyAccepted = this.state.pendingWasAccepted(ledger);
+ if (action.wait) {
+ // One target is an ordered stream: a backing-off head holds later
+ // items, unless the provider's queue already holds it.
+ if (alreadyAccepted) continue;
+ break;
+ }
+ const hint = alreadyAccepted || !delivery.sender ? '' : this.replyHint(delivery.sender, session.target);
+ const text = injectedText(delivery, hint);
+ let result;
+ try {
+ result = alreadyAccepted
+ ? await this.injector.observe(session.target, { messageId: delivery.messageId, text })
+ : await this.injector.inject(session.target, { messageId: delivery.messageId, text });
+ } catch (error) {
+ result = { outcome: 'crashed', detail: error.message };
+ }
+ const stop = await this.record(client, connectId, ledger, delivery, result, action.finalAttempt);
+ if (stop) break;
+ }
+ }
+
+ /** Persists one handoff outcome, then settles it. Returns true when the pass must stop. */
+ async record(client, connectId, ledger, delivery, result, finalAttempt) {
+ const reason = result?.detail || 'no detail';
+ try {
+ switch (result?.outcome) {
+ case 'delivered':
+ await this.state.rememberHandled(
+ ledger,
+ delivery.messageId,
+ 'delivered',
+ 'Injected into the session.'
+ );
+ await this.ack(client, connectId, delivery.id);
+ return false;
+ case 'in_doubt': {
+ const detail = `In doubt; not resent — ${reason}`;
+ await this.state.rememberHandled(ledger, delivery.messageId, 'in_doubt', detail);
+ await this.fail(client, connectId, delivery.id, detail);
+ return false;
+ }
+ case 'refused': {
+ const detail = `Refused by the session — ${reason}`;
+ await this.state.rememberFailed(ledger, delivery.messageId, 'refused', detail);
+ await this.fail(client, connectId, delivery.id, detail);
+ return false;
+ }
+ case 'retry':
+ case 'waiting': {
+ const detail = await this.state.rememberUnacceptedAttempt(
+ ledger,
+ delivery.messageId,
+ result.outcome,
+ reason,
+ finalAttempt
+ );
+ if (detail) {
+ await this.fail(client, connectId, delivery.id, detail);
+ return false;
+ }
+ return true;
+ }
+ case 'accepted':
+ case 'waiting_for_turn':
+ await this.state.rememberAccepted(ledger, result.outcome === 'waiting_for_turn', reason);
+ return false;
+ default: {
+ const detail = 'In doubt; the local session handoff stopped unexpectedly and was not resent.';
+ await this.state.rememberHandled(ledger, delivery.messageId, 'in_doubt', detail);
+ await this.fail(client, connectId, delivery.id, detail);
+ return false;
+ }
+ }
+ } catch (error) {
+ this.log(
+ `relay connect ledger write failed connect_ref=${messageRef(connectId)} cause=${error.code || 'io'}`
+ );
+ return true;
+ }
+ }
+}
+
+export { RelayHttpError };
diff --git a/packages/connect/src/relay/daemon.js b/packages/connect/src/relay/daemon.js
new file mode 100644
index 000000000..296529133
--- /dev/null
+++ b/packages/connect/src/relay/daemon.js
@@ -0,0 +1,300 @@
+#!/usr/bin/env node
+// The Node relay daemon: one per user, started detached by `connect join`
+// under the Node that is already running npx. It serves the /connect/*
+// routes on a 0600 Unix socket, polls Relaycast for each joined Connect,
+// injects into the session, and exits once no Connect is left.
+
+import { constants } from 'node:fs';
+import { chmod, open, readFile, rm, stat } from 'node:fs/promises';
+import http from 'node:http';
+import os from 'node:os';
+import { fileURLToPath } from 'node:url';
+import { ConnectError, ConnectService } from './connect.js';
+import { VERSION } from './cloud.js';
+import { privateDirectory, relayPaths } from './paths.js';
+import { RelayState } from './state.js';
+
+export const POLL_MS = 3_000;
+export const IDLE_AFTER_LAST_MS = 60_000;
+export const IDLE_NEVER_USED_MS = 15 * 60_000;
+const MAX_BODY_BYTES = 1_000_000;
+const TARGET = /^(claude|codex):([A-Za-z0-9][A-Za-z0-9_-]{7,63})$/;
+
+function log(line) {
+ process.stderr.write(`${new Date().toISOString()} ${line}\n`);
+}
+
+function shellQuote(value) {
+ return /^[A-Za-z0-9_./:=@%+-]+$/.test(value) ? value : `'${String(value).replace(/'/g, `'\\''`)}'`;
+}
+
+/** The reply command shown above an injected message: this runtime's CLI, under this Node. */
+export function replyHintFor(
+ sender,
+ nodePath = process.execPath,
+ cliPath = fileURLToPath(new URL('../cli.js', import.meta.url))
+) {
+ return `[reply: printf '%s' 'your reply' | ${shellQuote(nodePath)} ${shellQuote(cliPath)} send --to ${shellQuote(sender)}]`;
+}
+
+function respond(response, status, payload) {
+ const body = JSON.stringify(payload);
+ response.writeHead(status, {
+ 'content-type': 'application/json',
+ 'content-length': Buffer.byteLength(body),
+ });
+ response.end(body);
+}
+
+function readBody(request) {
+ return new Promise((resolve, reject) => {
+ const chunks = [];
+ let size = 0;
+ request.on('data', (chunk) => {
+ size += chunk.length;
+ if (size > MAX_BODY_BYTES) {
+ reject(new ConnectError(413, 'too_large', 'The request is too large.'));
+ request.destroy();
+ return;
+ }
+ chunks.push(chunk);
+ });
+ request.on('end', () => resolve(Buffer.concat(chunks)));
+ request.on('error', reject);
+ });
+}
+
+function targetOf(request) {
+ const match = TARGET.exec(String(request.headers['x-agent-relay-target'] || ''));
+ if (!match) {
+ throw new ConnectError(
+ 403,
+ 'not_a_relay_session',
+ 'This command is not running inside a supported live Agent Relay session.'
+ );
+ }
+ return { kind: match[1], id: match[2] };
+}
+
+/** Takes the single-instance lock, replacing a stale one. Resolves a release function or null. */
+async function acquireLock(path) {
+ for (let attempt = 0; attempt < 2; attempt += 1) {
+ try {
+ const handle = await open(path, constants.O_WRONLY | constants.O_CREAT | constants.O_EXCL, 0o600);
+ await handle.writeFile(`${process.pid}\n`);
+ await handle.close();
+ return async () => {
+ const owner = await readFile(path, 'utf8').catch(() => '');
+ if (owner.trim() === String(process.pid)) await rm(path, { force: true });
+ };
+ } catch (error) {
+ if (error?.code !== 'EEXIST') throw error;
+ const owner = Number.parseInt(await readFile(path, 'utf8').catch(() => ''), 10);
+ let alive = false;
+ if (Number.isInteger(owner) && owner > 0) {
+ try {
+ process.kill(owner, 0);
+ alive = true;
+ } catch (killError) {
+ alive = killError?.code === 'EPERM';
+ }
+ }
+ if (alive) return null;
+ await rm(path, { force: true });
+ }
+ }
+ return null;
+}
+
+export async function startDaemon({
+ home = os.homedir(),
+ env = process.env,
+ injector,
+ fetchImpl,
+ now = Date.now,
+} = {}) {
+ const paths = relayPaths(home);
+ await privateDirectory(paths.root);
+ await privateDirectory(paths.run);
+ const release = await acquireLock(paths.lock);
+ if (!release) {
+ const error = new Error('Another Agent Relay Connect relay is already running for this user.');
+ error.code = 'already_running';
+ throw error;
+ }
+
+ const state = await RelayState.load(paths.state, { now });
+ let everUsed = state.sessions().length > 0;
+ let lastActiveAt = now();
+ const service = new ConnectService({
+ state,
+ injector:
+ injector || (await import('./injector.js')).createInjector({ home, env, platform: process.platform }),
+ log,
+ env,
+ now,
+ fetchImpl,
+ replyHint: (sender) => replyHintFor(sender),
+ onChange: () => {
+ if (state.sessions().length) everUsed = true;
+ schedule(0);
+ },
+ });
+
+ let timer = null;
+ let polling = false;
+ let stopped = false;
+ const schedule = (delay = POLL_MS) => {
+ if (stopped) return;
+ if (timer) clearTimeout(timer);
+ timer = setTimeout(tick, delay);
+ timer.unref?.();
+ };
+ const busy = () => state.sessions().length > 0 || state.data.notices.length > 0;
+ async function tick() {
+ timer = null;
+ if (stopped) return;
+ if (!polling) {
+ polling = true;
+ try {
+ await service.pollAll();
+ } catch (error) {
+ log(`relay connect poll failed cause=${error?.code || error?.name || 'error'}`);
+ } finally {
+ polling = false;
+ }
+ }
+ if (busy()) {
+ lastActiveAt = now();
+ } else if (now() - lastActiveAt >= (everUsed ? IDLE_AFTER_LAST_MS : IDLE_NEVER_USED_MS)) {
+ log('relay connect idle; exiting');
+ await stop();
+ return;
+ }
+ schedule();
+ }
+
+ const server = http.createServer(async (request, response) => {
+ try {
+ const url = new URL(request.url || '/', 'http://relay');
+ const route = `${request.method} ${url.pathname}`;
+ if (route === 'GET /health') {
+ respond(response, 200, {
+ ok: true,
+ data: { version: VERSION, pid: process.pid, sessions: state.sessions().length },
+ });
+ return;
+ }
+ if (route === 'POST /shutdown') {
+ if (busy() && url.searchParams.get('force') !== '1') {
+ throw new ConnectError(409, 'relay_busy', 'The relay still has an active Connect.');
+ }
+ respond(response, 200, { ok: true, data: { stopping: true } });
+ setImmediate(() => stop());
+ return;
+ }
+ const target = targetOf(request);
+ const body = await readBody(request);
+ let data;
+ if (route === 'POST /connect/join') {
+ let fields;
+ try {
+ fields = JSON.parse(body.toString('utf8') || '{}');
+ } catch {
+ throw new ConnectError(400, 'invalid_request', 'The body must be JSON.');
+ }
+ if (!fields || typeof fields !== 'object' || Array.isArray(fields)) {
+ throw new ConnectError(400, 'invalid_request', 'The body must be a JSON object.');
+ }
+ data = await service.join(target, fields);
+ } else if (route === 'POST /connect/send') {
+ const to = url.searchParams.getAll('to');
+ if (to.length > 1) throw new ConnectError(400, 'invalid_request', 'Use at most one to.');
+ let text;
+ try {
+ text = new TextDecoder('utf-8', { fatal: true }).decode(body);
+ } catch {
+ throw new ConnectError(400, 'invalid_text', 'The message must be non-empty UTF-8 text.');
+ }
+ data = await service.send(target, text, {
+ to: to[0],
+ requestKey: String(request.headers['x-agent-relay-request'] || '') || undefined,
+ });
+ } else if (route === 'GET /connect/status') {
+ data = await service.status(target);
+ } else if (route === 'POST /connect/leave') {
+ data = await service.leave(target);
+ } else {
+ throw new ConnectError(404, 'not_found', 'Unknown route.');
+ }
+ lastActiveAt = now();
+ respond(response, 200, { ok: true, data });
+ } catch (error) {
+ const status = error instanceof ConnectError ? error.status : 500;
+ const code = error instanceof ConnectError ? error.code : 'relay_error';
+ const message =
+ error instanceof ConnectError ? error.message : 'The relay could not handle this request.';
+ if (!(error instanceof ConnectError))
+ log(`relay connect route failed cause=${error?.code || error?.name || 'error'}`);
+ respond(response, status, { ok: false, error: { code, message } });
+ }
+ });
+
+ // A socket left by a relay that died is replaced; the lock says it is ours.
+ await rm(paths.socket, { force: true });
+ await new Promise((resolve, reject) => {
+ server.once('error', reject);
+ server.listen(paths.socket, resolve);
+ });
+ await chmod(paths.socket, 0o600);
+ const socketInfo = await stat(paths.socket);
+
+ async function stop() {
+ if (stopped) return;
+ stopped = true;
+ if (timer) clearTimeout(timer);
+ await new Promise((resolve) => server.close(() => resolve()));
+ try {
+ const current = await stat(paths.socket);
+ if (current.ino === socketInfo.ino) await rm(paths.socket, { force: true });
+ } catch {
+ // Already gone.
+ }
+ await release();
+ stopResolve();
+ }
+ let stopResolve;
+ const stopped$ = new Promise((resolve) => {
+ stopResolve = resolve;
+ });
+
+ log(`relay connect started version=${VERSION} pid=${process.pid} sessions=${state.sessions().length}`);
+ schedule(0);
+ return { socket: paths.socket, stop, stopped: stopped$, service, state };
+}
+
+async function main() {
+ let daemon;
+ try {
+ daemon = await startDaemon();
+ } catch (error) {
+ const message = error?.message || String(error);
+ log(`relay connect failed to start: ${message}`);
+ if (process.send) process.send({ type: 'error', code: error?.code || 'start_failed', message });
+ process.exitCode = error?.code === 'already_running' ? 0 : 1;
+ process.disconnect?.();
+ return;
+ }
+ if (process.send) {
+ process.send({ type: 'ready', version: VERSION, socket: daemon.socket, pid: process.pid });
+ process.disconnect?.();
+ }
+ for (const signal of ['SIGTERM', 'SIGINT', 'SIGHUP']) {
+ process.on(signal, () => daemon.stop());
+ }
+ await daemon.stopped;
+}
+
+if (process.argv[1] && fileURLToPath(import.meta.url) === process.argv[1]) {
+ await main();
+}
diff --git a/packages/connect/src/relay/identify.js b/packages/connect/src/relay/identify.js
new file mode 100644
index 000000000..b4b024d25
--- /dev/null
+++ b/packages/connect/src/relay/identify.js
@@ -0,0 +1,41 @@
+import os from 'node:os';
+import { claudeCallerSession, findClaudeSession } from './claude.js';
+import { codexCallerThread } from './codex.js';
+
+// The CLI runs inside the session that called it, so it names that session
+// itself: Codex by CODEX_THREAD_ID; Claude Code by CLAUDE_CODE_SESSION_ID when
+// that session has a live record, else by the nearest ancestor with a live
+// session record (the desktop probe's `caller_target` rules).
+
+const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
+
+export class SessionNotFoundError extends Error {
+ constructor(checks) {
+ super(
+ `Run this from a live Claude Code or Codex session (${checks.join('; ')}). In Claude Code, if a permission check blocks the command, run it yourself with the ! prefix.`
+ );
+ this.name = 'SessionNotFoundError';
+ this.code = 'not_a_relay_session';
+ this.exitCode = 6;
+ }
+}
+
+export async function identifyCaller({ home = os.homedir(), env = process.env, pid = process.pid } = {}) {
+ const checks = [];
+ if (env.CODEX_THREAD_ID) {
+ const codex = await codexCallerThread({ env, pid, home });
+ if (codex?.threadId) return { kind: 'codex', id: codex.threadId };
+ checks.push(`CODEX_THREAD_ID is set but ${codex?.reason || 'its thread was not found'}`);
+ } else {
+ checks.push('no Codex thread id in the environment');
+ }
+ const declared = env.CLAUDE_CODE_SESSION_ID;
+ if (declared && UUID.test(declared)) {
+ const record = await findClaudeSession({ home, env, sessionId: declared }).catch(() => null);
+ if (record) return { kind: 'claude', id: record.sessionId || declared };
+ }
+ const claude = await claudeCallerSession({ home, env, pid });
+ if (claude) return { kind: 'claude', id: typeof claude === 'string' ? claude : claude.sessionId };
+ checks.push('no Claude Code session record for any parent process');
+ throw new SessionNotFoundError(checks);
+}
diff --git a/packages/connect/src/relay/injector.js b/packages/connect/src/relay/injector.js
new file mode 100644
index 000000000..d64a33e69
--- /dev/null
+++ b/packages/connect/src/relay/injector.js
@@ -0,0 +1,27 @@
+import { claudePreflight, ensureClaudeDirectDelivery, injectClaude } from './claude.js';
+import { createCodexRouter } from './codex.js';
+
+/** Routes Connect text into the joined Claude Code or Codex session. */
+export function createInjector({ home, env, platform = process.platform, run }) {
+ const codex = createCodexRouter({ home, env, platform, run });
+ return {
+ async prepare(target) {
+ if (target.kind === 'claude') await ensureClaudeDirectDelivery({ home, env, platform });
+ },
+ async inject(target, { messageId, text }) {
+ if (target.kind === 'claude')
+ return await injectClaude({ home, env, sessionId: target.id, messageId, text });
+ return await codex.inject({ threadId: target.id, messageId, text });
+ },
+ async observe(target, { messageId, text }) {
+ // Only a provider queue (Codex) ever accepts without delivering.
+ if (target.kind === 'claude')
+ return { outcome: 'in_doubt', detail: 'Claude Code reported no acceptance to observe.' };
+ return await codex.observe({ threadId: target.id, messageId, text });
+ },
+ async preflight(target) {
+ if (target.kind === 'claude') return await claudePreflight({ home, env, sessionId: target.id });
+ return await codex.preflight({ threadId: target.id });
+ },
+ };
+}
diff --git a/packages/connect/src/relay/launch.js b/packages/connect/src/relay/launch.js
new file mode 100644
index 000000000..531d38462
--- /dev/null
+++ b/packages/connect/src/relay/launch.js
@@ -0,0 +1,181 @@
+import { spawn } from 'node:child_process';
+import { constants } from 'node:fs';
+import { access, cp, open, readFile, rename, rm, stat } from 'node:fs/promises';
+import os from 'node:os';
+import { basename, dirname, join } from 'node:path';
+import { fileURLToPath } from 'node:url';
+import { requestJson } from '../http.js';
+import { VERSION } from './cloud.js';
+import { privateDirectory, relayPaths } from './paths.js';
+
+// Starts the Node relay daemon for `connect join`, or finds the running one.
+// The daemon runs from a copy of this package under ~/.agentworkforce so an
+// npx cache cleanup cannot remove its code, under the Node already running
+// this CLI: nothing is downloaded and no new binary is launched.
+
+const HANDSHAKE_TIMEOUT_MS = 10_000;
+
+export class RelayStartError extends Error {
+ constructor(message, exitCode = 1) {
+ super(message);
+ this.name = 'RelayStartError';
+ this.exitCode = exitCode;
+ this.code = 'relay_start_failed';
+ }
+}
+
+function versionParts(value) {
+ return String(value || '')
+ .split(/[.-]/)
+ .slice(0, 3)
+ .map((part) => Number.parseInt(part, 10) || 0);
+}
+
+export function olderVersion(a, b) {
+ const left = versionParts(a);
+ const right = versionParts(b);
+ for (let index = 0; index < 3; index += 1) {
+ if (left[index] !== right[index]) return left[index] < right[index];
+ }
+ return false;
+}
+
+/** The running daemon's health, or null. */
+export async function relayHealth(socket, timeoutMs = 2_000) {
+ try {
+ const info = await stat(socket);
+ if (!info.isSocket()) return null;
+ const uid = process.getuid?.();
+ if (uid !== undefined && info.uid !== uid) return null;
+ const response = await requestJson(socket, { path: '/health', timeoutMs });
+ return response?.ok === true ? response.data : null;
+ } catch {
+ return null;
+ }
+}
+
+/** Copies this package's code to runtime// once; returns the daemon entry path. */
+export async function stageRuntime(paths, version = VERSION) {
+ const codeDir = dirname(dirname(fileURLToPath(import.meta.url)));
+ const packageRoot = dirname(codeDir);
+ const destination = join(paths.runtime, version);
+ const entry = join(destination, basename(codeDir), 'relay/daemon.js');
+ try {
+ await access(entry, constants.R_OK);
+ return entry;
+ } catch {
+ // Not staged yet.
+ }
+ await privateDirectory(paths.runtime);
+ const staging = `${destination}.${process.pid}.tmp`;
+ await rm(staging, { recursive: true, force: true });
+ await cp(codeDir, join(staging, basename(codeDir)), { recursive: true });
+ await cp(join(packageRoot, 'package.json'), join(staging, 'package.json'));
+ try {
+ await rename(staging, destination);
+ } catch (error) {
+ await rm(staging, { recursive: true, force: true });
+ // Another CLI staged the same version first.
+ if (!['EEXIST', 'ENOTEMPTY'].includes(error?.code)) throw error;
+ }
+ return entry;
+}
+
+async function logTail(path, lines = 12) {
+ try {
+ const text = await readFile(path, 'utf8');
+ return text.trimEnd().split('\n').slice(-lines).join('\n');
+ } catch {
+ return '';
+ }
+}
+
+function handshake(child, timeoutMs) {
+ return new Promise((resolve) => {
+ let settled = false;
+ const finish = (result) => {
+ if (settled) return;
+ settled = true;
+ clearTimeout(timer);
+ resolve(result);
+ };
+ const timer = setTimeout(() => finish({ type: 'timeout' }), timeoutMs);
+ child.once('message', (message) =>
+ finish(message && typeof message === 'object' ? message : { type: 'invalid' })
+ );
+ child.once('error', (error) => finish({ type: 'error', message: error.message }));
+ child.once('exit', (code, signal) => finish({ type: 'exit', code, signal }));
+ });
+}
+
+/**
+ * Resolves the daemon socket, starting the daemon when none answers. Failures
+ * reject with the daemon's own reason and the end of its log.
+ */
+export async function ensureRelay({
+ home = os.homedir(),
+ env = process.env,
+ timeoutMs = HANDSHAKE_TIMEOUT_MS,
+ spawnProcess = spawn,
+} = {}) {
+ const paths = relayPaths(home);
+ const running = await relayHealth(paths.socket);
+ if (running) {
+ // An older idle relay steps aside for this version; a busy one keeps serving.
+ if (!olderVersion(running.version, VERSION) || running.sessions > 0) return paths.socket;
+ await requestJson(paths.socket, { method: 'POST', path: '/shutdown', timeoutMs: 2_000 }).catch(
+ () => null
+ );
+ for (let attempt = 0; attempt < 20 && (await relayHealth(paths.socket, 500)); attempt += 1) {
+ await new Promise((resolve) => setTimeout(resolve, 100));
+ }
+ }
+
+ await privateDirectory(paths.root);
+ const entry = await stageRuntime(paths);
+ const log = await open(paths.log, 'a', 0o600);
+ let child;
+ let handshakeResult;
+ try {
+ child = spawnProcess(process.execPath, [entry], {
+ detached: true,
+ stdio: ['ignore', log.fd, log.fd, 'ipc'],
+ env: { ...env, HOME: home },
+ });
+ // Listen before anything else awaits: a daemon that fails at once must not be missed.
+ handshakeResult = handshake(child, timeoutMs);
+ } finally {
+ await log.close();
+ }
+ const result = await handshakeResult;
+ if (child.connected) child.disconnect();
+ child.unref();
+
+ if (result.type === 'ready') return result.socket || paths.socket;
+ // Two CLIs can race to start it; the loser finds the winner here.
+ if (result.type === 'error' && result.code === 'already_running') {
+ for (let attempt = 0; attempt < 20; attempt += 1) {
+ if (await relayHealth(paths.socket, 500)) return paths.socket;
+ await new Promise((resolve) => setTimeout(resolve, 100));
+ }
+ }
+ if (result.type === 'timeout' && child.pid) {
+ try {
+ process.kill(child.pid, 'SIGTERM');
+ } catch {
+ // It already exited.
+ }
+ }
+ const reason =
+ result.type === 'error'
+ ? result.message
+ : result.type === 'exit'
+ ? `it exited${result.signal ? ` on ${result.signal}` : ` with code ${result.code}`} before it was ready`
+ : result.type === 'timeout'
+ ? `it did not report ready within ${Math.round(timeoutMs / 1000)} s`
+ : `it could not be started (${result.message || 'unknown error'})`;
+ const tail = await logTail(paths.log);
+ throw new RelayStartError(
+ `The Agent Relay Connect relay could not start: ${reason}.${tail ? `\nLast lines of ${paths.log}:\n${tail}` : ''}`
+ );
+}
diff --git a/packages/connect/src/relay/paths.js b/packages/connect/src/relay/paths.js
new file mode 100644
index 000000000..e8a2f0528
--- /dev/null
+++ b/packages/connect/src/relay/paths.js
@@ -0,0 +1,54 @@
+import { constants } from 'node:fs';
+import { chmod, lstat, mkdir, open, rename, rm } from 'node:fs/promises';
+import os from 'node:os';
+import { dirname, join } from 'node:path';
+
+/** Where the Node relay keeps its state, socket, log and runtime copy. */
+export function relayPaths(home = os.homedir()) {
+ const root = join(home, '.agentworkforce/connect');
+ return {
+ root,
+ state: join(root, 'state.json'),
+ run: join(root, 'run'),
+ socket: join(root, 'run/connect.sock'),
+ lock: join(root, 'run/daemon.lock'),
+ log: join(root, 'daemon.log'),
+ runtime: join(root, 'runtime'),
+ };
+}
+
+/** Creates `path` as a 0700 directory this user owns, or refuses. */
+export async function privateDirectory(path) {
+ await mkdir(path, { recursive: true, mode: 0o700 });
+ const info = await lstat(path);
+ const uid = process.getuid?.();
+ if (!info.isDirectory() || (uid !== undefined && info.uid !== uid)) {
+ throw new Error(`${path} must be a directory this user owns.`);
+ }
+ if ((info.mode & 0o077) !== 0) await chmod(path, 0o700);
+}
+
+/** Writes `text` to `path` with mode 0600: temp file, fsync, rename, fsync the directory. */
+export async function writeAtomic(path, text) {
+ const directory = dirname(path);
+ await privateDirectory(directory);
+ const temporary = join(directory, `.${process.pid}.${Date.now()}.tmp`);
+ const handle = await open(temporary, constants.O_WRONLY | constants.O_CREAT | constants.O_EXCL, 0o600);
+ try {
+ await handle.writeFile(text);
+ await handle.sync();
+ } finally {
+ await handle.close();
+ }
+ try {
+ await rename(temporary, path);
+ } catch (error) {
+ await rm(temporary, { force: true });
+ throw error;
+ }
+ const directoryHandle = await open(directory, constants.O_RDONLY).catch(() => null);
+ if (directoryHandle) {
+ await directoryHandle.sync().catch(() => {});
+ await directoryHandle.close();
+ }
+}
diff --git a/packages/connect/src/relay/processes.js b/packages/connect/src/relay/processes.js
new file mode 100644
index 000000000..c27cfcdc1
--- /dev/null
+++ b/packages/connect/src/relay/processes.js
@@ -0,0 +1,268 @@
+import { execFile } from 'node:child_process';
+import { readdir, readFile, readlink, realpath, stat } from 'node:fs/promises';
+import { basename } from 'node:path';
+
+// The kernel's view of this computer's processes, ported from relay-desktop
+// probe/src/relay/process.rs: parent, owner and start time, executable,
+// arguments and open files. Linux reads /proc; macOS asks `ps` and `lsof`,
+// since Node has no libproc. Every read is best effort: a process that is
+// gone or unreadable is `null` / empty, never an exception.
+
+/** How far up a process tree a caller is followed (process.rs MAX_ANCESTRY). */
+export const MAX_ANCESTRY = 64;
+
+const LINUX = process.platform === 'linux';
+const COMMAND_TIMEOUT_MS = 10_000;
+
+/** Runs a read-only system command; `null` when it fails or exits non-zero. */
+function run(file, args) {
+ return new Promise((resolve) => {
+ execFile(
+ file,
+ args,
+ {
+ // `lstart` must be UTC and in English to compare with Claude's
+ // recorded `procStart`.
+ env: { ...process.env, TZ: 'UTC', LC_ALL: 'C' },
+ timeout: COMMAND_TIMEOUT_MS,
+ maxBuffer: 16 * 1024 * 1024,
+ },
+ (error, stdout) => resolve(error ? null : String(stdout))
+ );
+ });
+}
+
+function validPid(pid) {
+ return Number.isInteger(pid) && pid > 0;
+}
+
+/**
+ * The fields of `/proc//stat` after the command name, which may itself
+ * contain spaces and parentheses: index 1 is the parent, 19 the start time.
+ */
+export function statFields(stat) {
+ const end = stat.lastIndexOf(')');
+ return end < 0
+ ? []
+ : stat
+ .slice(end + 1)
+ .trim()
+ .split(/\s+/);
+}
+
+async function linuxInfo(pid) {
+ try {
+ const [text, info] = await Promise.all([readFile(`/proc/${pid}/stat`, 'utf8'), stat(`/proc/${pid}`)]);
+ const fields = statFields(text);
+ const parent = Number.parseInt(fields[1] ?? '', 10);
+ const start = fields[19];
+ if (!Number.isInteger(parent) || !start || !/^\d+$/.test(start)) return null;
+ return { pid, parent, uid: info.uid, start };
+ } catch {
+ return null;
+ }
+}
+
+async function macInfo(pid) {
+ const output = await run('ps', ['-o', 'ppid=,uid=,lstart=', '-p', String(pid)]);
+ const match = output?.trim().match(/^(\d+)\s+(\d+)\s+(.+)$/);
+ if (!match) return null;
+ return {
+ pid,
+ parent: Number.parseInt(match[1], 10),
+ uid: Number.parseInt(match[2], 10),
+ start: match[3].trim().replace(/\s+/g, ' '),
+ };
+}
+
+/**
+ * One process as the kernel reports it: `{pid, parent, uid, start}`, or
+ * `null` when it is gone. `start` is clock ticks since boot on Linux
+ * (`/proc//stat` field 22) and the UTC `ctime`-style start
+ * (`Mon Oct 12 15:22:47 2026`, whole seconds) on macOS: the two forms
+ * Claude Code records as `procStart`. Two reads with equal values are the
+ * same process.
+ */
+export async function processInfo(pid) {
+ if (!validPid(pid)) return null;
+ return LINUX ? linuxInfo(pid) : macInfo(pid);
+}
+
+/** The parent pid, or `null`. */
+export async function parentOf(pid) {
+ return (await processInfo(pid))?.parent ?? null;
+}
+
+/** The start time as Claude Code records it (see `processInfo`), or `null`. */
+export async function processStart(pid) {
+ return (await processInfo(pid))?.start ?? null;
+}
+
+const MONTHS = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec'];
+
+/**
+ * Claude Code's macOS `procStart`, `Wed Sep 30 10:00:00 2026` in UTC, as
+ * whole seconds since the epoch (process.rs `claude_start_seconds`).
+ */
+export function claudeStartSeconds(text) {
+ const parts = String(text).trim().split(/\s+/);
+ if (parts.length !== 5) return null;
+ const [, monthName, dayText, time, yearText] = parts;
+ const month = MONTHS.indexOf(monthName);
+ if (month < 0 || !/^\d+$/.test(dayText) || !/^-?\d+$/.test(yearText)) return null;
+ const clock = time.split(':');
+ if (clock.length !== 3 || clock.some((part) => !/^\d+$/.test(part))) return null;
+ const [hour, minute, second] = clock.map((part) => Number.parseInt(part, 10));
+ const day = Number.parseInt(dayText, 10);
+ const year = Number.parseInt(yearText, 10);
+ if (hour > 23 || minute > 59 || second > 59) return null;
+ const at = Date.UTC(year, month, day, hour, minute, second);
+ const date = new Date(at);
+ // Reject what chrono would: 31 Feb does not roll into March.
+ if (date.getUTCDate() !== day || date.getUTCMonth() !== month || at < 0) return null;
+ return at / 1000;
+}
+
+/**
+ * Whether Claude Code's recorded `procStart` names the process whose
+ * `processStart` is `start`, so a recycled pid is never mistaken for the
+ * session (process.rs `claude_start_matches`): equal ticks on Linux, the
+ * same whole second on macOS.
+ */
+export function claudeStartMatches(recorded, start, platform = process.platform) {
+ if (recorded == null || start == null) return false;
+ if (platform === 'linux') return String(recorded).trim() === String(start).trim();
+ const seconds = claudeStartSeconds(recorded);
+ return seconds !== null && seconds === claudeStartSeconds(start);
+}
+
+/**
+ * `pid` itself, then its parent, grandparent and so on, nearest first:
+ * at most `max` entries, stopping before pid 1 or at a process that is
+ * gone. Each entry is `processInfo`'s record.
+ */
+export async function ancestors(pid, max = MAX_ANCESTRY) {
+ const chain = [];
+ let current = pid;
+ for (let index = 0; index < max; index += 1) {
+ if (!validPid(current) || current <= 1) break;
+ const info = await processInfo(current);
+ if (!info) break;
+ chain.push(info);
+ current = info.parent;
+ }
+ return chain;
+}
+
+/** The absolute paths of the files `pid` has open. */
+export async function openFiles(pid) {
+ if (!validPid(pid)) return [];
+ if (LINUX) {
+ const directory = `/proc/${pid}/fd`;
+ const entries = await readdir(directory).catch(() => []);
+ const targets = await Promise.all(
+ entries.map((entry) => readlink(`${directory}/${entry}`).catch(() => null))
+ );
+ return targets.filter((target) => typeof target === 'string' && target.startsWith('/'));
+ }
+ const output = await run('lsof', ['-w', '-p', String(pid), '-Fn']);
+ if (!output) return [];
+ return output
+ .split('\n')
+ .filter((line) => line.startsWith('n/'))
+ .map((line) => line.slice(1));
+}
+
+/** Whether `pid` has `path` open. */
+export async function holdsFile(pid, path) {
+ const files = await openFiles(pid);
+ if (files.includes(path)) return true;
+ // macOS reports `/private/var/...` for `/var/...` and the like.
+ const real = await realpath(path).catch(() => null);
+ return real !== null && files.includes(real);
+}
+
+/**
+ * The process's arguments. Exact on Linux (`/proc//cmdline`); on
+ * macOS `ps -o args=` joins them with spaces, so they are split on
+ * whitespace and an argument containing a space is split too.
+ */
+export async function argvOf(pid) {
+ if (!validPid(pid)) return [];
+ if (LINUX) {
+ const bytes = await readFile(`/proc/${pid}/cmdline`).catch(() => null);
+ if (!bytes) return [];
+ return bytes
+ .toString('utf8')
+ .split('\0')
+ .filter((part) => part.length > 0);
+ }
+ const output = await run('ps', ['-o', 'args=', '-p', String(pid)]);
+ return output ? output.trim().split(/\s+/).filter(Boolean) : [];
+}
+
+/** Whether a process with this pid exists (it may belong to another user). */
+export function isAlive(pid) {
+ if (!validPid(pid)) return false;
+ try {
+ process.kill(pid, 0);
+ return true;
+ } catch (error) {
+ return error?.code === 'EPERM';
+ }
+}
+
+/**
+ * Every process as `{pid, uid, executable}`: the kernel's executable path
+ * on Linux (`/proc//exe`), the full command path `ps -o comm=`
+ * reports on macOS.
+ */
+export async function listProcesses() {
+ if (LINUX) {
+ const entries = await readdir('/proc').catch(() => []);
+ const found = await Promise.all(
+ entries
+ .filter((entry) => /^\d+$/.test(entry))
+ .map(async (entry) => {
+ const pid = Number.parseInt(entry, 10);
+ try {
+ const [executable, info] = await Promise.all([
+ readlink(`/proc/${pid}/exe`),
+ stat(`/proc/${pid}`),
+ ]);
+ return { pid, uid: info.uid, executable: executable.replace(/ \(deleted\)$/, '') };
+ } catch {
+ return null;
+ }
+ })
+ );
+ return found.filter(Boolean);
+ }
+ const output = await run('ps', ['-axo', 'pid=,uid=,comm=']);
+ if (!output) return [];
+ return output
+ .split('\n')
+ .map((line) => line.trim().match(/^(\d+)\s+(\d+)\s+(.+)$/))
+ .filter(Boolean)
+ .map((match) => ({
+ pid: Number.parseInt(match[1], 10),
+ uid: Number.parseInt(match[2], 10),
+ executable: match[3],
+ }));
+}
+
+/**
+ * Whether `executable` is a Codex this user may run as theirs: named
+ * `codex`, and on Linux owned by root or this user and writable by no one
+ * else (process.rs `is_trusted_codex`). The macOS OpenAI code-signature
+ * check is deliberately not ported (docs/connect-node-local-relay.md,
+ * "Not ported").
+ */
+export async function isTrustedCodex(executable) {
+ if (basename(executable) !== 'codex') return false;
+ if (!LINUX) return true;
+ const info = await stat(executable).catch(() => null);
+ if (!info?.isFile()) return false;
+ const uid = process.getuid?.();
+ return (info.uid === 0 || info.uid === uid) && (info.mode & 0o022) === 0;
+}
diff --git a/packages/connect/src/relay/sqlite.js b/packages/connect/src/relay/sqlite.js
new file mode 100644
index 000000000..c2e2e3656
--- /dev/null
+++ b/packages/connect/src/relay/sqlite.js
@@ -0,0 +1,129 @@
+import { execFile } from 'node:child_process';
+import { stat } from 'node:fs/promises';
+
+// Read-only access to Codex's SQLite files (queue_1.sqlite, state_5.sqlite)
+// without an npm dependency: `node:sqlite` when this Node has it (22.13+,
+// 23.4+ without a flag), else the `sqlite3` command line when it is on PATH,
+// else nothing, and callers degrade (docs/connect-node-local-relay.md,
+// "SQLite").
+
+const BUSY_TIMEOUT_MS = 2_000;
+
+let backend;
+
+async function loadNodeSqlite() {
+ // node:sqlite prints an ExperimentalWarning when it loads; it is noise for
+ // a guest's terminal, so only that warning is held back, only here.
+ const original = process.emitWarning;
+ process.emitWarning = function emitWarning(warning, ...rest) {
+ const text = typeof warning === 'string' ? warning : (warning?.message ?? '');
+ if (/sqlite/i.test(text)) return undefined;
+ return original.call(this, warning, ...rest);
+ };
+ try {
+ const module = await import('node:sqlite');
+ return typeof module.DatabaseSync === 'function' ? module.DatabaseSync : null;
+ } catch {
+ return null;
+ } finally {
+ process.emitWarning = original;
+ }
+}
+
+function run(file, args, timeout = 10_000) {
+ return new Promise((resolve) => {
+ execFile(file, args, { timeout, maxBuffer: 16 * 1024 * 1024 }, (error, stdout) =>
+ resolve(error ? null : String(stdout))
+ );
+ });
+}
+
+/**
+ * Which reader this process uses: `{kind: 'node:sqlite', DatabaseSync}`,
+ * `{kind: 'sqlite3'}` or `{kind: null}`. Decided once.
+ */
+export async function sqliteBackend() {
+ if (backend) return backend;
+ const forced = process.env.AGENT_RELAY_CONNECT_SQLITE;
+ const DatabaseSync = forced === 'sqlite3' || forced === 'none' ? null : await loadNodeSqlite();
+ if (DatabaseSync) {
+ backend = { kind: 'node:sqlite', DatabaseSync };
+ } else if (forced !== 'none' && (await run('sqlite3', ['-version'])) !== null) {
+ backend = { kind: 'sqlite3' };
+ } else {
+ backend = { kind: null };
+ }
+ return backend;
+}
+
+/** Forgets the chosen backend (tests). */
+export function resetSqliteBackend() {
+ backend = undefined;
+}
+
+function literal(value) {
+ if (value === null || value === undefined) return 'null';
+ if (typeof value === 'number' && Number.isFinite(value)) return String(value);
+ return `'${String(value).replace(/'/g, "''")}'`;
+}
+
+/** `sql` with each `?` replaced by a quoted literal, for the sqlite3 CLI. */
+function inline(sql, params) {
+ let index = 0;
+ return sql.replace(/\?/g, () => literal(params[index++]));
+}
+
+/**
+ * Runs one read-only query against `file`:
+ * - `{status: 'missing'}` when there is no such file;
+ * - `{status: 'unavailable'}` when no SQLite reader exists on this computer;
+ * - `{status: 'unreadable'}` when it exists but cannot be read now;
+ * - `{status: 'ok', rows}` with each row as a plain object by column name.
+ *
+ * Statements come from this package only; `params` are bound (node:sqlite)
+ * or quoted (sqlite3 CLI).
+ */
+export async function queryReadOnly(file, sql, params = []) {
+ try {
+ await stat(file);
+ } catch (error) {
+ return error?.code === 'ENOENT' ? { status: 'missing' } : { status: 'unreadable' };
+ }
+ const reader = await sqliteBackend();
+ if (reader.kind === 'node:sqlite') {
+ let database;
+ try {
+ database = new reader.DatabaseSync(file, { readOnly: true });
+ database.exec(`pragma busy_timeout = ${BUSY_TIMEOUT_MS}`);
+ const rows = database.prepare(sql).all(...params);
+ return { status: 'ok', rows: rows.map((row) => ({ ...row })) };
+ } catch {
+ return { status: 'unreadable' };
+ } finally {
+ try {
+ database?.close();
+ } catch {
+ // Already closed or never opened.
+ }
+ }
+ }
+ if (reader.kind === 'sqlite3') {
+ const output = await run('sqlite3', [
+ '-readonly',
+ '-json',
+ '-cmd',
+ `.timeout ${BUSY_TIMEOUT_MS}`,
+ file,
+ inline(sql, params),
+ ]);
+ if (output === null) return { status: 'unreadable' };
+ if (output.trim() === '') return { status: 'ok', rows: [] };
+ try {
+ const rows = JSON.parse(output);
+ return Array.isArray(rows) ? { status: 'ok', rows } : { status: 'unreadable' };
+ } catch {
+ return { status: 'unreadable' };
+ }
+ }
+ return { status: 'unavailable' };
+}
diff --git a/packages/connect/src/relay/state.js b/packages/connect/src/relay/state.js
new file mode 100644
index 000000000..b40ea16db
--- /dev/null
+++ b/packages/connect/src/relay/state.js
@@ -0,0 +1,276 @@
+import { readFile } from 'node:fs/promises';
+import { writeAtomic } from './paths.js';
+
+// The ledger mirrors the desktop probe's connect-sessions.json (relay-desktop
+// probe/src/relay/connect.rs): one Connect per session target, and per
+// delivery a pending, handled (delivered or in doubt) or failed record keyed
+// by `agent_id:delivery_id`. Only the daemon writes it.
+
+export const RETRY_MIN_MS = 5_000;
+export const RETRY_MAX_MS = 30_000;
+export const HANDOFF_TTL_MS = 60 * 60 * 1_000;
+export const JOIN_ATTEMPT_TTL_MS = 15 * 60 * 1_000;
+const OUTCOMES_KEPT = 64;
+
+export function handoffTtlMs(value) {
+ const seconds = Number.parseInt(value ?? '', 10);
+ return Number.isInteger(seconds) && seconds >= 60 && seconds <= 24 * 60 * 60
+ ? seconds * 1_000
+ : HANDOFF_TTL_MS;
+}
+
+export function targetKey(target) {
+ return `${target.kind}:${target.id}`;
+}
+
+function empty() {
+ return {
+ version: 1,
+ sessions: {},
+ join_attempts: {},
+ pending: {},
+ pending_delivery: {},
+ handled: {},
+ failures: {},
+ notices: [],
+ outcomes: [],
+ };
+}
+
+export class RelayState {
+ constructor(path, data = empty(), { now = Date.now, write = writeAtomic } = {}) {
+ this.path = path;
+ this.data = data;
+ this.now = now;
+ this.write = write;
+ }
+
+ static async load(path, options = {}) {
+ let data = empty();
+ try {
+ const parsed = JSON.parse(await readFile(path, 'utf8'));
+ if (parsed && parsed.version === 1) data = { ...empty(), ...parsed };
+ } catch (error) {
+ if (error?.code !== 'ENOENT') throw new Error(`Could not read ${path}: ${error.message}`);
+ }
+ return new RelayState(path, data, options);
+ }
+
+ async persist() {
+ await this.write(this.path, `${JSON.stringify(this.data, null, 2)}\n`);
+ }
+
+ // Sessions -----------------------------------------------------------------
+
+ session(target) {
+ return this.data.sessions[targetKey(target)] || null;
+ }
+
+ sessions() {
+ return Object.values(this.data.sessions);
+ }
+
+ async insertSession(session) {
+ const key = targetKey(session.target);
+ const previous = this.data.sessions[key];
+ const previousAttempt = this.data.join_attempts[key];
+ this.data.sessions[key] = session;
+ delete this.data.join_attempts[key];
+ try {
+ await this.persist();
+ } catch (error) {
+ if (previous) this.data.sessions[key] = previous;
+ else delete this.data.sessions[key];
+ if (previousAttempt) this.data.join_attempts[key] = previousAttempt;
+ throw error;
+ }
+ }
+
+ /** Removes the session and every ledger entry for its credential. */
+ async removeSession(target, agentId) {
+ const key = targetKey(target);
+ const current = this.data.sessions[key];
+ if (!current || (agentId && current.agent_id !== agentId)) return null;
+ delete this.data.sessions[key];
+ const prefix = `${current.agent_id}:`;
+ for (const map of ['pending', 'pending_delivery', 'handled', 'failures']) {
+ for (const ledger of Object.keys(this.data[map])) {
+ if (ledger.startsWith(prefix)) delete this.data[map][ledger];
+ }
+ }
+ await this.persist();
+ return current;
+ }
+
+ async observeParticipant(session, name) {
+ const current = this.session(session.target);
+ if (!current || current.agent_id !== session.agent_id) return;
+ const known = current.participants.some((p) => p.agent_name === name);
+ if (known || current.observed_participants.includes(name) || name === current.agent_name) return;
+ current.observed_participants.push(name);
+ await this.persist();
+ }
+
+ // Join attempts --------------------------------------------------------------
+
+ /** The idempotency key for this join, reusing a live attempt with the same request. */
+ async beginJoinAttempt(target, requestHash, newKey) {
+ const key = targetKey(target);
+ const now = this.now();
+ const current = this.data.join_attempts[key];
+ if (current && current.expires_at_ms > now) {
+ if (current.request_hash !== requestHash) return null;
+ return current.idempotency_key;
+ }
+ this.data.join_attempts[key] = {
+ request_hash: requestHash,
+ idempotency_key: newKey,
+ expires_at_ms: now + JOIN_ATTEMPT_TTL_MS,
+ };
+ await this.persist();
+ return newKey;
+ }
+
+ async clearJoinAttempt(target, idempotencyKey) {
+ const key = targetKey(target);
+ if (this.data.join_attempts[key]?.idempotency_key !== idempotencyKey) return;
+ delete this.data.join_attempts[key];
+ await this.persist();
+ }
+
+ // Delivery ledger ----------------------------------------------------------------
+
+ settled(ledger) {
+ if (this.data.handled[ledger]) return { kind: 'handled', ...this.data.handled[ledger] };
+ if (this.data.failures[ledger]) return { kind: 'failed', ...this.data.failures[ledger] };
+ return null;
+ }
+
+ async rememberPending(ledger, messageId) {
+ if (!this.data.pending[ledger]) this.data.pending[ledger] = messageId;
+ if (!this.data.pending_delivery[ledger]) {
+ this.data.pending_delivery[ledger] = {
+ kind: 'waiting',
+ detail: 'Queued for local delivery.',
+ first_seen_ms: this.now(),
+ last_attempt_ms: 0,
+ next_attempt_ms: 0,
+ attempts: 0,
+ accepted: false,
+ behind_turn: false,
+ };
+ }
+ await this.persist();
+ }
+
+ /** `{wait:true}`, or `{wait:false, finalAttempt}` once the handoff TTL has passed. */
+ pendingAction(ledger, ttlMs) {
+ const pending = this.data.pending_delivery[ledger];
+ if (!pending) return { wait: false, finalAttempt: false };
+ const now = this.now();
+ if (!pending.accepted && now - pending.first_seen_ms >= ttlMs) return { wait: false, finalAttempt: true };
+ if (pending.next_attempt_ms > now) return { wait: true };
+ return { wait: false, finalAttempt: false };
+ }
+
+ pendingWasAccepted(ledger) {
+ return this.data.pending_delivery[ledger]?.accepted === true;
+ }
+
+ async rememberWaiting(ledger, kind, detail) {
+ const pending = this.data.pending_delivery[ledger];
+ if (!pending) throw new Error('pending delivery disappeared before its outcome was recorded');
+ const now = this.now();
+ pending.kind = kind;
+ pending.accepted = pending.accepted || kind === 'accepted';
+ pending.detail = detail;
+ pending.last_attempt_ms = now;
+ pending.attempts += 1;
+ // Observing an accepted handoff only reads the provider's queue and
+ // transcript, so it never backs off.
+ const shift = pending.accepted ? 0 : Math.min(3, Math.max(0, pending.attempts - 1));
+ pending.next_attempt_ms = now + Math.min(RETRY_MAX_MS, RETRY_MIN_MS * 2 ** shift);
+ await this.persist();
+ }
+
+ /** Returns a failure detail when this was the final attempt, otherwise null after backing off. */
+ async rememberUnacceptedAttempt(ledger, messageId, kind, reason, finalAttempt) {
+ if (finalAttempt) {
+ const detail = `The local session remained unavailable without accepting the handoff — ${reason}`;
+ await this.rememberFailed(ledger, messageId, 'unavailable', detail);
+ return detail;
+ }
+ const detail =
+ kind === 'retry'
+ ? `Local delivery will retry — ${reason}`
+ : `Waiting for the local session — ${reason}`;
+ await this.rememberWaiting(ledger, kind, detail);
+ return null;
+ }
+
+ /** Records that the provider's queue holds the message. Returns whether that changed. */
+ async rememberAccepted(ledger, behindTurn, reason) {
+ const pending = this.data.pending_delivery[ledger];
+ if (!pending) throw new Error('pending delivery disappeared before its outcome was recorded');
+ const changed = !pending.accepted || pending.behind_turn !== behindTurn;
+ pending.behind_turn = behindTurn;
+ pending.accepted_at_ms ??= this.now();
+ await this.rememberWaiting(ledger, 'accepted', `Accepted by the local session queue — ${reason}`);
+ return changed;
+ }
+
+ async rememberHandled(ledger, messageId, outcome, detail) {
+ delete this.data.pending[ledger];
+ delete this.data.pending_delivery[ledger];
+ this.data.handled[ledger] = { outcome, message_id: messageId, detail, at_ms: this.now() };
+ this.recordOutcome(ledger, outcome, detail);
+ await this.persist();
+ }
+
+ async rememberFailed(ledger, messageId, outcome, detail) {
+ delete this.data.pending[ledger];
+ delete this.data.pending_delivery[ledger];
+ this.data.failures[ledger] = { outcome, message_id: messageId, detail, at_ms: this.now() };
+ this.recordOutcome(ledger, outcome, detail);
+ await this.persist();
+ }
+
+ recordOutcome(ledger, outcome, detail) {
+ this.data.outcomes.unshift({ ledger, outcome, detail, at_ms: this.now() });
+ this.data.outcomes.length = Math.min(this.data.outcomes.length, OUTCOMES_KEPT);
+ }
+
+ deliveryPublic(session) {
+ const prefix = `${session.agent_id}:`;
+ const pending = Object.entries(this.data.pending_delivery)
+ .filter(([ledger]) => ledger.startsWith(prefix))
+ .map(([ledger, entry]) => ({
+ delivery_id: ledger.slice(prefix.length),
+ state: entry.kind,
+ detail: entry.detail,
+ attempts: entry.attempts,
+ }));
+ const recent = this.data.outcomes
+ .filter((entry) => entry.ledger.startsWith(prefix))
+ .slice(0, 10)
+ .map((entry) => ({
+ delivery_id: entry.ledger.slice(prefix.length),
+ outcome: entry.outcome,
+ detail: entry.detail,
+ }));
+ return pending.length || recent.length ? { pending, recent } : null;
+ }
+
+ // Notices for sessions whose Connect closed -----------------------------------------
+
+ async queueNotice(notice) {
+ if (this.data.notices.some((queued) => queued.id === notice.id)) return;
+ this.data.notices.push(notice);
+ await this.persist();
+ }
+
+ async dropNotice(id) {
+ this.data.notices = this.data.notices.filter((notice) => notice.id !== id);
+ await this.persist();
+ }
+}
diff --git a/packages/connect/tests/connect.test.ts b/packages/connect/tests/connect.test.ts
index 3b9dffc70..f733917c7 100644
--- a/packages/connect/tests/connect.test.ts
+++ b/packages/connect/tests/connect.test.ts
@@ -86,8 +86,12 @@ async function runCli(home: string, args: string[], input = '') {
if (error.code !== 'EEXIST') throw error;
});
return await new Promise<{ code: number | null; stdout: string; stderr: string }>((resolveRun) => {
+ // Never let the session running these tests be identified as the caller.
+ const env = Object.fromEntries(
+ Object.entries(process.env).filter(([key]) => !/^(CLAUDE|CODEX)/.test(key))
+ );
const child = spawn(process.execPath, [launcher, ...args], {
- env: { ...process.env, HOME: home },
+ env: { ...env, HOME: home },
stdio: ['pipe', 'pipe', 'pipe'],
});
const stdout: Buffer[] = [];
@@ -1225,6 +1229,150 @@ describe('probe installer', () => {
expect(checks).toBe(2);
});
+ it('treats a probe that cannot identify the caller as live at once', async () => {
+ let clock = 0;
+ let checks = 0;
+ const result = await waitForLiveSocket({
+ timeoutMs: 60_000,
+ now: () => clock,
+ sleep: async (milliseconds) => {
+ clock += milliseconds;
+ },
+ pointer: async () => '/tmp/relay.sock',
+ check: async () => {
+ checks += 1;
+ return { ok: false, error: { code: 'not_a_relay_session' } };
+ },
+ });
+ expect(result).toMatchObject({ socketPath: '/tmp/relay.sock', sessionRejected: true });
+ expect(checks).toBe(1);
+ expect(clock).toBe(0);
+ });
+
+ it("names the probe's last answer when readiness times out", async () => {
+ let clock = 0;
+ await expect(
+ waitForLiveSocket({
+ timeoutMs: 2_000,
+ now: () => clock,
+ sleep: async (milliseconds) => {
+ clock += milliseconds;
+ },
+ pointer: async () => '/tmp/relay.sock',
+ check: async () => ({ ok: false, error: { code: 'relay_stopping' } }),
+ })
+ ).rejects.toThrow('after 2000 ms (its last answer was relay_stopping).');
+ clock = 0;
+ await expect(
+ waitForLiveSocket({
+ timeoutMs: 1_000,
+ now: () => clock,
+ sleep: async (milliseconds) => {
+ clock += milliseconds;
+ },
+ pointer: async () => '',
+ check: async () => null,
+ })
+ ).rejects.toThrow('(it never answered; see ~/.agentworkforce/desktop/headless.log).');
+ });
+
+ it('finds a live probe that rejects the caller instead of installing another', async () => {
+ const { home, socketPath } = await listen((_request, response) => {
+ json(response, { ok: false, error: { code: 'not_a_relay_session' } }, 403);
+ });
+ await expect(findLiveSocket(home)).resolves.toMatchObject({
+ socketPath,
+ sessionRejected: true,
+ version: undefined,
+ });
+ });
+
+ it('reports an unidentified session inline, as a fresh-HOME probe answers it', async () => {
+ // A just-started probe that cannot tie the caller to a live session (as on
+ // a fresh HOME) answers every route, /setup/status included, this way.
+ const seen: string[] = [];
+ const { home } = await listen((request, response) => {
+ seen.push(`${request.method} ${request.url}`);
+ json(
+ response,
+ {
+ ok: false,
+ error: {
+ code: 'not_a_relay_session',
+ message: 'This command is not running inside a supported live Agent Relay session.',
+ },
+ },
+ 403
+ );
+ });
+ const started = Date.now();
+ const result = await runCli(home, ['join', 'connect-test']);
+ expect(Date.now() - started).toBeLessThan(5_000);
+ // The CLI names the session itself; with no session and a probe that
+ // cannot place it either, it stops with what it checked.
+ expect(result).toEqual({
+ code: 6,
+ stdout: '',
+ stderr:
+ 'Run this from a live Claude Code or Codex session (no Codex thread id in the environment; no Claude Code session record for any parent process). In Claude Code, if a permission check blocks the command, run it yourself with the ! prefix.\n',
+ });
+ expect(seen).toEqual(['GET /setup/status']);
+ });
+
+ it("passes the probe's explanation through for an unreadable session file", async () => {
+ const { home } = await listen((_request, response) => {
+ json(
+ response,
+ {
+ ok: false,
+ error: {
+ code: 'session_file_unreadable',
+ message: 'This Claude Code session is running, but its session file cannot be read.',
+ },
+ },
+ 409
+ );
+ });
+ expect(await runCli(home, ['status'])).toEqual({
+ code: 6,
+ stdout: '',
+ stderr: 'This Claude Code session is running, but its session file cannot be read.\n',
+ });
+ });
+
+ it('keeps a fresh Linux install whose probe answers but cannot identify the caller', async () => {
+ const root = await mkdtemp(join(os.tmpdir(), 'connect-linux-unidentified-test-'));
+ cleanups.push(async () => rm(root, { recursive: true, force: true }));
+ const home = join(root, 'home');
+ const run = async (file: string, args: string[]) => {
+ if (file === 'tar') {
+ const destination = args[args.indexOf('-C') + 1];
+ const probe = join(destination, 'release/agent_relay/helpers/agent-relay-probe');
+ await mkdir(join(probe, '..'), { recursive: true });
+ await writeFile(probe, '#!/bin/sh\n');
+ await chmod(probe, 0o755);
+ }
+ return { code: 0, stdout: '', stderr: '' };
+ };
+ const result = await installLinux({
+ home,
+ arch: 'x64',
+ run,
+ start: async () => ({ pid: undefined, unref() {} }),
+ wait: (options: Record) =>
+ waitForLiveSocket({
+ ...options,
+ sleep: async () => {},
+ pointer: async () => '/tmp/relay.sock',
+ check: async () => ({ ok: false, error: { code: 'not_a_relay_session' } }),
+ }),
+ });
+ expect(result).toMatchObject({ sessionRejected: true });
+ await expect(readlink(join(home, '.local/bin/agent-relay-probe'))).resolves.toContain(
+ join(home, '.local/lib/agent-relay/current')
+ );
+ });
+
it('enforces a timeout on each Unix-socket request', async () => {
const { socketPath } = await listen(() => {
// Deliberately leave the response open until the client destroys it.
diff --git a/packages/connect/tests/relay-claude.test.ts b/packages/connect/tests/relay-claude.test.ts
new file mode 100644
index 000000000..cd9b32e3c
--- /dev/null
+++ b/packages/connect/tests/relay-claude.test.ts
@@ -0,0 +1,395 @@
+import { spawn } from 'node:child_process';
+import { EventEmitter } from 'node:events';
+import { chmod, mkdir, mkdtemp, readFile, rm, stat, writeFile } from 'node:fs/promises';
+import net from 'node:net';
+import os from 'node:os';
+import { join } from 'node:path';
+import { afterEach, describe, expect, it } from 'vitest';
+import {
+ claudeCallerSession,
+ claudeHomes,
+ claudePreflight,
+ ensureClaudeDirectDelivery,
+ findClaudeSession,
+ firstJsonValue,
+ injectClaude,
+} from '../src/relay/claude.js';
+import { processStart } from '../src/relay/processes.js';
+
+const SESSION = '0f8fad5b-d9cb-469f-a165-70867728950e';
+const OTHER_SESSION = '7c9e6679-7425-40de-944b-e07fc1f90ae7';
+const cleanups: Array<() => Promise | void> = [];
+
+afterEach(async () => {
+ while (cleanups.length) await cleanups.pop()!();
+});
+
+async function tempHome() {
+ // Short, so the inbox socket path stays under the Unix socket limit.
+ const home = await mkdtemp(join(process.env.TMPDIR || os.tmpdir(), 'rc-'));
+ cleanups.push(() => rm(home, { recursive: true, force: true }));
+ const env = { PATH: process.env.PATH ?? '' };
+ const sessions = join(home, '.claude/sessions');
+ await mkdir(sessions, { recursive: true });
+ return { home, env, sessions };
+}
+
+type Record = {
+ pid: number;
+ sessionId?: string;
+ procStart?: string | null;
+ socket: string;
+ peerProtocol?: number;
+};
+
+async function writeRecord(sessions: string, record: Record, trailing = '') {
+ const body = {
+ pid: record.pid,
+ sessionId: record.sessionId ?? SESSION,
+ cwd: '/work/project',
+ procStart: record.procStart,
+ peerProtocol: record.peerProtocol ?? 1,
+ kind: 'interactive',
+ messagingSocketPath: record.socket,
+ name: 'project',
+ status: 'idle',
+ };
+ await writeFile(join(sessions, `${record.pid}.json`), JSON.stringify(body) + trailing);
+}
+
+async function writeKey(
+ sessions: string,
+ pid: number,
+ hash: string,
+ token: string,
+ procStart: string | null
+) {
+ const path = join(sessions, `${pid}.${hash}.key`);
+ await writeFile(path, JSON.stringify({ peerToken: token, procStart }));
+ await chmod(path, 0o600);
+}
+
+/** A fake Claude Code inbox: records every line received on each connection. */
+async function fakeInbox(home: string) {
+ const path = join(home, 'in.sock');
+ const connections: string[][] = [];
+ const server = net.createServer((socket) => {
+ let data = '';
+ socket.on('data', (chunk) => {
+ data += chunk;
+ });
+ socket.on('end', () => {
+ connections.push(data.split('\n').filter(Boolean));
+ socket.end();
+ });
+ });
+ await new Promise((resolve) => server.listen(path, resolve));
+ cleanups.push(() => new Promise((resolve) => server.close(() => resolve())));
+ return { path, connections };
+}
+
+/** A socket stand-in whose `failAt`-th write fails with EPIPE. */
+class FailingSocket extends EventEmitter {
+ writes: Buffer[] = [];
+ destroyed = false;
+ constructor(private failAt: number) {
+ super();
+ setImmediate(() => this.emit('connect'));
+ }
+ write(bytes: Buffer, callback: (error?: Error) => void) {
+ this.writes.push(bytes);
+ if (this.writes.length === this.failAt) {
+ setImmediate(() => {
+ const error = Object.assign(new Error('write EPIPE'), { code: 'EPIPE' });
+ this.emit('error', error);
+ callback(error);
+ });
+ } else {
+ setImmediate(() => callback());
+ }
+ return true;
+ }
+ end() {
+ setImmediate(() => this.emit('end'));
+ }
+ destroy() {
+ this.destroyed = true;
+ }
+}
+
+describe('Claude Code sessions', () => {
+ it('reads the first JSON value of a record and ignores trailing junk', () => {
+ expect(firstJsonValue('{"a":"}{","b":[1,{"c":2}]}\u0000\u0000}garbage')).toEqual({
+ a: '}{',
+ b: [1, { c: 2 }],
+ });
+ expect(firstJsonValue(' {"x":"a\\"b"} {"y":1}')).toEqual({ x: 'a"b' });
+ expect(() => firstJsonValue('{"unterminated":')).toThrow();
+ expect(() => firstJsonValue('')).toThrow();
+ });
+
+ it('lists the configured login and sibling logins once each', async () => {
+ const { home, env } = await tempHome();
+ await mkdir(join(home, '.claude-work/sessions'), { recursive: true });
+ await mkdir(join(home, '.claude-empty'), { recursive: true });
+ expect(claudeHomes({ home, env })).toEqual([join(home, '.claude'), join(home, '.claude-work')]);
+ expect(claudeHomes({ home, env: { CLAUDE_CONFIG_DIR: '~/.claude-work' } })).toEqual([
+ join(home, '.claude-work'),
+ join(home, '.claude'),
+ ]);
+ });
+
+ it('delivers the auth line and the user message with the key matching the start time', async () => {
+ const { home, env, sessions } = await tempHome();
+ const inbox = await fakeInbox(home);
+ const start = (await processStart(process.pid))!;
+ await writeRecord(
+ sessions,
+ { pid: process.pid, procStart: start, socket: inbox.path },
+ '\u0000\u0000"}stray'
+ );
+ await writeKey(sessions, process.pid, 'aaaa', 'stale-token', 'Thu Jan 1 00:00:00 1970');
+ await writeKey(sessions, process.pid, 'bbbb', 'live-token', start);
+
+ const found = await findClaudeSession({ home, env, sessionId: SESSION });
+ expect(found).toMatchObject({
+ sessionId: SESSION,
+ pid: process.pid,
+ peerProtocol: 1,
+ cwd: '/work/project',
+ });
+ expect(await claudePreflight({ home, env, sessionId: SESSION })).toMatchObject({ ok: true });
+
+ const result = await injectClaude({
+ home,
+ env,
+ sessionId: SESSION,
+ messageId: 'msg_1',
+ text: 'hello\nthere',
+ });
+ expect(result.outcome).toBe('delivered');
+ await new Promise((resolve) => setTimeout(resolve, 50));
+ expect(inbox.connections).toHaveLength(1);
+ const [auth, message] = inbox.connections[0];
+ expect(auth).toBe('{"token":"live-token","type":"auth"}');
+ const parsed = JSON.parse(message);
+ expect(Object.keys(parsed)).toEqual(['message', 'msg_id', 'priority', 'type', 'uuid']);
+ expect(parsed).toMatchObject({
+ type: 'user',
+ msg_id: 'msg_1',
+ priority: 'next',
+ message: { role: 'user', content: 'hello\nthere' },
+ });
+ expect(parsed.uuid).toMatch(/^[0-9A-F]{8}-[0-9A-F]{4}-4[0-9A-F]{3}-[89AB][0-9A-F]{3}-[0-9A-F]{12}$/);
+ });
+
+ it('leaves a message waiting when the recorded process is gone', async () => {
+ const { home, env, sessions } = await tempHome();
+ const inbox = await fakeInbox(home);
+ const child = spawn('sleep', ['30'], { stdio: 'ignore' });
+ await new Promise((resolve) => setTimeout(resolve, 200));
+ const start = (await processStart(child.pid!))!;
+ child.kill('SIGKILL');
+ await new Promise((resolve) => child.once('exit', resolve));
+ await writeRecord(sessions, { pid: child.pid!, procStart: start, socket: inbox.path });
+ await writeKey(sessions, child.pid!, 'aaaa', 'token', start);
+
+ expect(await injectClaude({ home, env, sessionId: SESSION, messageId: 'm', text: 'x' })).toEqual({
+ outcome: 'waiting',
+ detail: 'that Claude Code session is not running',
+ });
+ expect((await claudePreflight({ home, env, sessionId: SESSION })).ok).toBe(false);
+ expect(inbox.connections).toHaveLength(0);
+ });
+
+ it('does not take a recycled pid for the session', async () => {
+ const { home, env, sessions } = await tempHome();
+ const inbox = await fakeInbox(home);
+ const recycled = process.platform === 'linux' ? '1' : 'Thu Jan 1 00:00:01 1970';
+ await writeRecord(sessions, { pid: process.pid, procStart: recycled, socket: inbox.path });
+ expect((await injectClaude({ home, env, sessionId: SESSION, messageId: 'm', text: 'x' })).outcome).toBe(
+ 'waiting'
+ );
+ });
+
+ it('retries when the inbox key is missing, and refuses an id that is not a session id', async () => {
+ const { home, env, sessions } = await tempHome();
+ const inbox = await fakeInbox(home);
+ await writeRecord(sessions, {
+ pid: process.pid,
+ procStart: await processStart(process.pid),
+ socket: inbox.path,
+ });
+ expect(await injectClaude({ home, env, sessionId: SESSION, messageId: 'm', text: 'x' })).toEqual({
+ outcome: 'retry',
+ detail: "could not read that Claude Code session's inbox key",
+ });
+ expect(await claudePreflight({ home, env, sessionId: SESSION })).toEqual({
+ ok: false,
+ reason: "could not read that Claude Code session's inbox key",
+ });
+ expect((await injectClaude({ home, env, sessionId: 'nope', messageId: 'm', text: 'x' })).outcome).toBe(
+ 'refused'
+ );
+ expect(inbox.connections).toHaveLength(0);
+ });
+
+ it('retries an unknown peer protocol', async () => {
+ const { home, env, sessions } = await tempHome();
+ const start = (await processStart(process.pid))!;
+ await writeRecord(sessions, {
+ pid: process.pid,
+ procStart: start,
+ socket: join(home, 'x.sock'),
+ peerProtocol: 2,
+ });
+ await writeKey(sessions, process.pid, 'aaaa', 'token', start);
+ const result = await injectClaude({ home, env, sessionId: SESSION, messageId: 'm', text: 'x' });
+ expect(result.outcome).toBe('retry');
+ expect(result.detail).toContain('peer protocol 2');
+ });
+
+ it('retries when the inbox cannot be reached or the auth line fails, and is in doubt mid-message', async () => {
+ const { home, env, sessions } = await tempHome();
+ const start = (await processStart(process.pid))!;
+ await writeRecord(sessions, { pid: process.pid, procStart: start, socket: join(home, 'gone.sock') });
+ await writeKey(sessions, process.pid, 'aaaa', 'token', start);
+
+ const unreachable = await injectClaude({ home, env, sessionId: SESSION, messageId: 'm', text: 'x' });
+ expect(unreachable.outcome).toBe('retry');
+ expect(unreachable.detail).toMatch(/^could not reach the session's inbox: /);
+
+ let authFails!: FailingSocket;
+ const auth = await injectClaude({
+ home,
+ env,
+ sessionId: SESSION,
+ messageId: 'm',
+ text: 'x',
+ connect: () => (authFails = new FailingSocket(1)) as unknown as net.Socket,
+ });
+ expect(auth.outcome).toBe('retry');
+ expect(auth.detail).toContain('EPIPE');
+ expect(authFails.writes).toHaveLength(1);
+ expect(authFails.destroyed).toBe(true);
+
+ const mid = await injectClaude({
+ home,
+ env,
+ sessionId: SESSION,
+ messageId: 'm',
+ text: 'x',
+ connect: () => new FailingSocket(2) as unknown as net.Socket,
+ });
+ expect(mid.outcome).toBe('in_doubt');
+ expect(mid.detail).toMatch(/^the session's inbox connection broke while sending: /);
+ });
+
+ it('names the session of the nearest ancestor with a matching record', async () => {
+ const { home, env, sessions } = await tempHome();
+ const parentStart = (await processStart(process.ppid))!;
+ await writeRecord(sessions, {
+ pid: process.ppid,
+ sessionId: OTHER_SESSION,
+ procStart: parentStart,
+ socket: '/x',
+ });
+ expect(await claudeCallerSession({ home, env, pid: process.pid })).toBe(OTHER_SESSION);
+
+ // A record of the calling process itself is nearer and wins.
+ await writeRecord(sessions, {
+ pid: process.pid,
+ procStart: await processStart(process.pid),
+ socket: '/y',
+ });
+ expect(await claudeCallerSession({ home, env, pid: process.pid })).toBe(SESSION);
+
+ // A record without a start time can never be replied from.
+ await writeRecord(sessions, { pid: process.pid, procStart: null, socket: '/y' });
+ expect(await claudeCallerSession({ home, env, pid: process.pid })).toBe(OTHER_SESSION);
+
+ // A running session whose record does not parse ends the walk.
+ await writeFile(join(sessions, `${process.pid}.json`), '{ broken');
+ await writeKey(sessions, process.pid, 'cccc', 'token', await processStart(process.pid));
+ expect(await claudeCallerSession({ home, env, pid: process.pid })).toBeNull();
+ });
+});
+
+describe('ensureClaudeDirectDelivery', () => {
+ it('sets accept keeping other keys, keeps the mode and backs the original up once', async () => {
+ const { home, env } = await tempHome();
+ const settings = join(home, '.claude/settings.json');
+ const original = '{"model": "opus", "env": {"TOKEN": "x"}, "permissions": {"allow": ["Bash(ls)"]}}';
+ await writeFile(settings, original);
+ await chmod(settings, 0o600);
+ const managedSettingsPath = join(home, 'managed-settings.json');
+
+ expect(await ensureClaudeDirectDelivery({ home, env, managedSettingsPath })).toMatchObject({
+ changed: true,
+ });
+ const updated = JSON.parse(await readFile(settings, 'utf8'));
+ expect(updated).toEqual({
+ model: 'opus',
+ env: { TOKEN: 'x' },
+ permissions: { allow: ['Bash(ls)'] },
+ crossSessionInbound: 'accept',
+ });
+ const backup = `${settings}.relay-desktop-backup`;
+ expect(await readFile(backup, 'utf8')).toBe(original);
+ expect((await stat(settings)).mode & 0o777).toBe(0o600);
+ expect((await stat(backup)).mode & 0o777).toBe(0o600);
+
+ expect(await ensureClaudeDirectDelivery({ home, env, managedSettingsPath })).toMatchObject({
+ changed: false,
+ });
+
+ await writeFile(settings, '{"crossSessionInbound": "hold", "model": "opus"}');
+ expect(await ensureClaudeDirectDelivery({ home, env, managedSettingsPath })).toMatchObject({
+ changed: true,
+ });
+ expect(await readFile(backup, 'utf8')).toBe(original);
+ expect(JSON.parse(await readFile(settings, 'utf8'))).toEqual({
+ crossSessionInbound: 'accept',
+ model: 'opus',
+ });
+ });
+
+ it('creates a private settings file where there is none, and refuses one that is not JSON', async () => {
+ const { home, env } = await tempHome();
+ const managedSettingsPath = join(home, 'managed-settings.json');
+ expect(await ensureClaudeDirectDelivery({ home, env, managedSettingsPath })).toMatchObject({
+ changed: true,
+ });
+ const settings = join(home, '.claude/settings.json');
+ expect(JSON.parse(await readFile(settings, 'utf8'))).toEqual({ crossSessionInbound: 'accept' });
+ expect((await stat(settings)).mode & 0o777).toBe(0o600);
+
+ await writeFile(settings, '{ broken');
+ await expect(ensureClaudeDirectDelivery({ home, env, managedSettingsPath })).rejects.toMatchObject({
+ code: 'claude_settings_unreadable',
+ });
+ expect(await readFile(settings, 'utf8')).toBe('{ broken');
+ });
+
+ it('refuses when managed settings pin another value, naming the managed file', async () => {
+ const { home, env } = await tempHome();
+ const settings = join(home, '.claude/settings.json');
+ await writeFile(settings, '{"model": "opus"}');
+ const managedSettingsPath = join(home, 'managed-settings.json');
+ await writeFile(managedSettingsPath, '{"crossSessionInbound": "hold"}');
+
+ const error = await ensureClaudeDirectDelivery({ home, env, managedSettingsPath }).catch(
+ (caught) => caught
+ );
+ expect(error).toBeInstanceOf(Error);
+ expect(error.code).toBe('claude_inbound_managed');
+ expect(error.message).toContain(managedSettingsPath);
+ expect(await readFile(settings, 'utf8')).toBe('{"model": "opus"}');
+
+ await writeFile(managedSettingsPath, '{"crossSessionInbound": "accept"}');
+ expect(await ensureClaudeDirectDelivery({ home, env, managedSettingsPath })).toMatchObject({
+ changed: false,
+ });
+ expect(await readFile(settings, 'utf8')).toBe('{"model": "opus"}');
+ });
+});
diff --git a/packages/connect/tests/relay-codex.test.ts b/packages/connect/tests/relay-codex.test.ts
new file mode 100644
index 000000000..a48c3759f
--- /dev/null
+++ b/packages/connect/tests/relay-codex.test.ts
@@ -0,0 +1,460 @@
+import { existsSync } from 'node:fs';
+import { chmod, mkdir, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises';
+import net from 'node:net';
+import os from 'node:os';
+import { join } from 'node:path';
+import { afterEach, describe, expect, it } from 'vitest';
+import {
+ BEHIND_TURN,
+ BUSY,
+ FrameBuffer,
+ HANDED_NOT_RUNNING,
+ NOT_CONSUMED,
+ NOT_RUNNING,
+ RECORDING,
+ codexCallerThread,
+ codexPaths,
+ codexSqliteBackend,
+ createCodexRouter,
+ encodeFrame,
+ messageRef,
+ messageRefInText,
+ queueOutcome,
+} from '../src/relay/codex.js';
+import { resetSqliteBackend } from '../src/relay/sqlite.js';
+
+const THREAD = '01a0bcd4-0b35-7d42-8d7d-6375f7732587';
+const cleanups: Array<() => Promise | void> = [];
+
+afterEach(async () => {
+ while (cleanups.length) await cleanups.pop()!();
+});
+
+/** A Connect message as the daemon injects it: the attribution line first. */
+function connectText(messageId: string, body = 'Please review this.') {
+ return `[Relay Connect — from guest · ref ${messageRef(messageId)}]\n[reply: npx -y @agent-relay/connect send guest "..."]\n\n${body}`;
+}
+
+/** A fake `codex` that supports `queue` (or not) and writes queue rows like Codex. */
+function fakeCodex({ queue = true, version = '0.130.0' } = {}) {
+ return `#!${process.execPath} --no-warnings
+const { appendFileSync } = require('node:fs');
+const { randomUUID } = require('node:crypto');
+const args = process.argv.slice(2);
+appendFileSync(process.env.CODEX_HOME + '/fake-codex.log', JSON.stringify(args) + '\\n');
+(async () => {
+ if (args[0] === '--version') { console.log('codex-cli ${version}'); return; }
+ if (args[0] === 'queue' && args[1] === '--help') {
+ if (!${queue}) { console.error("error: unrecognized subcommand 'queue'"); process.exit(2); }
+ console.log('Usage: codex queue --thread --message ');
+ return;
+ }
+ if (args[0] === 'queue' && args[1] === '--thread' && args[3].startsWith('--message=')) {
+ const { DatabaseSync } = await import('node:sqlite');
+ const db = new DatabaseSync(process.env.CODEX_SQLITE_HOME + '/queue_1.sqlite');
+ db.exec('create table if not exists queued_items (id text, thread_id text, payload_json text, created_at_ms integer)');
+ const id = randomUUID();
+ const payload = JSON.stringify({ items: [{ type: 'text', text: args[3].slice('--message='.length) }] });
+ db.prepare('insert into queued_items values (?, ?, ?, ?)').run(id, args[2], payload, Date.now());
+ db.close();
+ console.log('Queued message ' + id + ' for thread ' + args[2] + '.');
+ return;
+ }
+ console.error('unexpected arguments');
+ process.exit(1);
+})();
+`;
+}
+
+async function setup({ queue = true } = {}) {
+ const home = await mkdtemp(join(process.env.TMPDIR || os.tmpdir(), 'cx-'));
+ cleanups.push(() => rm(home, { recursive: true, force: true }));
+ const bin = join(home, 'bin');
+ await mkdir(bin, { recursive: true });
+ await writeFile(join(bin, 'codex'), fakeCodex({ queue }));
+ await chmod(join(bin, 'codex'), 0o755);
+ const env = { CODEX_HOME: join(home, '.codex'), PATH: `${bin}:/usr/bin:/bin` };
+ const paths = codexPaths({ home, env });
+ await mkdir(paths.codexHome, { recursive: true });
+ const clock = { now: 1_000_000 };
+ const log = async () =>
+ (await readFile(join(paths.codexHome, 'fake-codex.log'), 'utf8').catch(() => ''))
+ .split('\n')
+ .filter(Boolean)
+ .map((line) => JSON.parse(line));
+ return { home, env, paths, clock, log, now: () => clock.now };
+}
+
+async function writeRollout(codexHome: string, thread: string, events: object[]) {
+ const directory = join(codexHome, 'sessions/2026/10/12');
+ await mkdir(directory, { recursive: true });
+ const path = join(directory, `rollout-2026-10-12T10-00-00-${thread}.jsonl`);
+ const lines = [
+ { type: 'session_meta', payload: { id: thread } },
+ { type: 'response_item', payload: { type: 'message', content: 'model output' } },
+ ...events,
+ ].map((event) => JSON.stringify(event));
+ await writeFile(path, lines.join('\n') + '\n');
+ return path;
+}
+
+function userMessage(turn: string, text: string) {
+ return {
+ type: 'event_msg',
+ payload: {
+ type: 'item_completed',
+ turn_id: turn,
+ item: { type: 'UserMessage', content: [{ type: 'text', text }] },
+ },
+ };
+}
+
+async function clearQueue(sqliteHome: string) {
+ const { DatabaseSync } = await import('node:sqlite');
+ const db = new DatabaseSync(join(sqliteHome, 'queue_1.sqlite'));
+ db.exec('delete from queued_items');
+ db.close();
+}
+
+describe('message references', () => {
+ it('reads the reference from a relay attribution line only', () => {
+ const ref = messageRef('message-connect-1');
+ expect(ref).toMatch(/^[0-9a-f]{12}$/);
+ expect(messageRefInText(`[Relay Connect — from guest · ref ${ref}]\n\nPlease review this.`)).toBe(ref);
+ expect(
+ messageRefInText(`[via Agent Relay — from @alice to @codex · message m1 · ref ${ref}]\r\nhi`)
+ ).toBe(ref);
+ expect(messageRefInText(`hello [Relay Connect — from guest · ref ${ref}]`)).toBeNull();
+ expect(messageRefInText(`[Relay Connect — from guest · ref ABCDEF123456]`)).toBeNull();
+ });
+
+ it('reads codex queue confirmations like the probe', () => {
+ expect(queueOutcome(0, `Queued message 01a0bd9b-5bf2 for thread ${THREAD}.\n`)).toEqual({
+ queued: true,
+ item: '01a0bd9b-5bf2',
+ });
+ expect(queueOutcome(0, '', '')).toEqual({ queued: false, reason: 'codex exited with code 0' });
+ expect(queueOutcome(1, 'Queued message x', '\nerror: no session found\nmore')).toEqual({
+ queued: false,
+ reason: 'error: no session found',
+ });
+ expect(queueOutcome(2, '', 'x'.repeat(400)).reason).toHaveLength(300);
+ });
+});
+
+describe('Codex router', () => {
+ it('is delivered once the rollout records the message, without running codex', async () => {
+ const { home, env, paths, log, now } = await setup();
+ await writeRollout(paths.codexHome, THREAD, [
+ { type: 'event_msg', payload: { type: 'task_started', turn_id: 't1' } },
+ userMessage('t1', connectText('m1')),
+ ]);
+ const router = createCodexRouter({ home, env, now, systemCodex: [], terminalLive: () => true });
+ expect(await router.inject({ threadId: THREAD, messageId: 'm1', text: connectText('m1') })).toMatchObject(
+ {
+ outcome: 'delivered',
+ }
+ );
+ expect(
+ await router.observe({ threadId: THREAD, messageId: 'm1', text: connectText('m1') })
+ ).toMatchObject({
+ outcome: 'delivered',
+ });
+ expect(await log()).toEqual([]);
+ });
+
+ it('hands a live terminal the message through codex queue, then observes it until the rollout or 45 s', async () => {
+ const { home, env, paths, clock, log, now } = await setup();
+ const router = createCodexRouter({ home, env, now, systemCodex: [], terminalLive: () => true });
+ const text = connectText('m2', '--dangerously-bypass-approvals-and-sandbox');
+
+ expect(await router.inject({ threadId: THREAD, messageId: 'm2', text })).toEqual({
+ outcome: 'accepted',
+ detail: NOT_CONSUMED,
+ });
+ expect(await log()).toEqual([
+ ['queue', '--help'],
+ ['queue', '--thread', THREAD, `--message=${text}`],
+ ]);
+
+ // Still queued: observed, and a second inject never writes again.
+ clock.now += 30_000;
+ expect(await router.observe({ threadId: THREAD, messageId: 'm2', text })).toEqual({
+ outcome: 'accepted',
+ detail: NOT_CONSUMED,
+ });
+ expect((await router.inject({ threadId: THREAD, messageId: 'm2', text })).outcome).toBe('accepted');
+ expect((await log()).filter((args) => args[1] === '--thread')).toHaveLength(1);
+
+ // Codex took it from the queue but has not recorded it yet.
+ await clearQueue(paths.sqliteHome);
+ clock.now += 44_000;
+ expect(await router.observe({ threadId: THREAD, messageId: 'm2', text })).toEqual({
+ outcome: 'accepted',
+ detail: RECORDING,
+ });
+ clock.now += 1_000;
+ expect(await router.observe({ threadId: THREAD, messageId: 'm2', text })).toMatchObject({
+ outcome: 'in_doubt',
+ });
+ });
+
+ it('confirms a queued message from the rollout, and waits behind a running turn', async () => {
+ const { home, env, paths, clock, now } = await setup();
+ await writeRollout(paths.codexHome, THREAD, [
+ { type: 'event_msg', payload: { type: 'task_started', turn_id: 't1' } },
+ ]);
+ const router = createCodexRouter({ home, env, now, systemCodex: [], terminalLive: () => true });
+ const text = connectText('m3');
+ expect(await router.inject({ threadId: THREAD, messageId: 'm3', text })).toEqual({
+ outcome: 'waiting_for_turn',
+ detail: BEHIND_TURN,
+ });
+
+ await clearQueue(paths.sqliteHome);
+ await writeRollout(paths.codexHome, THREAD, [
+ { type: 'event_msg', payload: { type: 'task_started', turn_id: 't1' } },
+ { type: 'event_msg', payload: { type: 'task_complete', turn_id: 't1', last_agent_message: 'done' } },
+ userMessage('t2', text),
+ ]);
+ clock.now += 120_000;
+ expect(await router.observe({ threadId: THREAD, messageId: 'm3', text })).toMatchObject({
+ outcome: 'delivered',
+ });
+ });
+
+ it('queues behind relay messages already waiting even when no terminal runs the thread', async () => {
+ const { home, env, now } = await setup();
+ const router = createCodexRouter({ home, env, now, systemCodex: [], terminalLive: () => true });
+ await router.inject({ threadId: THREAD, messageId: 'first', text: connectText('first') });
+ const offline = createCodexRouter({ home, env, now, systemCodex: [], terminalLive: () => false });
+ expect(
+ await offline.inject({ threadId: THREAD, messageId: 'second', text: connectText('second') })
+ ).toEqual({
+ outcome: 'accepted',
+ detail: HANDED_NOT_RUNNING,
+ });
+ });
+
+ it('leaves a message waiting when no terminal and no Codex app has the thread', async () => {
+ const { home, env, paths, log, now } = await setup();
+ // The real process scan: no Codex holds this test thread's writer lock.
+ const router = createCodexRouter({ home, env, now, systemCodex: [] });
+ expect(await router.inject({ threadId: THREAD, messageId: 'm4', text: connectText('m4') })).toEqual({
+ outcome: 'waiting',
+ detail: NOT_RUNNING,
+ });
+ await writeRollout(paths.codexHome, THREAD, [
+ { type: 'event_msg', payload: { type: 'task_started', turn_id: 't1' } },
+ ]);
+ expect(await router.inject({ threadId: THREAD, messageId: 'm4', text: connectText('m4') })).toEqual({
+ outcome: 'waiting',
+ detail: BUSY,
+ });
+ expect((await log()).filter((args) => args[1] === '--thread')).toEqual([]);
+ expect((await router.inject({ threadId: 'not-a-thread', messageId: 'm', text: 'x' })).outcome).toBe(
+ 'refused'
+ );
+ });
+
+ it('starts a turn through the Codex app window that owns the thread', async () => {
+ const { home, env, paths, now } = await setup();
+ await mkdir(join(paths.codexHome, 'ipc'), { recursive: true });
+ const seen: any[] = [];
+ let refuse: string | null = null;
+ const server = net.createServer((socket) => {
+ const buffer = new FrameBuffer();
+ const send = (value: object) => socket.write(encodeFrame(Buffer.from(JSON.stringify(value))));
+ socket.on('data', (chunk) => {
+ for (const payload of buffer.append(chunk)) {
+ const request = JSON.parse(payload.toString());
+ seen.push(request);
+ const id = request.requestId;
+ if (request.method === 'initialize') {
+ // A discovery request and a broadcast arrive first.
+ send({ type: 'client-discovery-request', requestId: 'd1' });
+ send({ type: 'broadcast', method: 'thread-stream-state-changed' });
+ send({ type: 'response', requestId: id, resultType: 'success', result: { clientId: 'relay-1' } });
+ } else if (request.method === 'thread-owner-discovery') {
+ send(
+ request.params.conversationId === THREAD
+ ? { type: 'response', requestId: id, resultType: 'success', handledByClientId: 'owner-1' }
+ : { type: 'response', requestId: id, resultType: 'error', error: 'no-client-found' }
+ );
+ } else if (request.method === 'thread-follower-start-turn') {
+ send(
+ refuse
+ ? { type: 'response', requestId: id, resultType: 'error', error: refuse }
+ : {
+ type: 'response',
+ requestId: id,
+ resultType: 'success',
+ result: { result: { turn: { id: 'turn-1', status: 'inProgress' } } },
+ }
+ );
+ }
+ }
+ });
+ });
+ await new Promise((resolve) => server.listen(paths.ipcSocket, resolve));
+ cleanups.push(() => new Promise((resolve) => server.close(() => resolve())));
+
+ const router = createCodexRouter({ home, env, now, systemCodex: [], terminalLive: () => false });
+ const text = connectText('m5');
+ expect(await router.inject({ threadId: THREAD, messageId: 'm5', text })).toMatchObject({
+ outcome: 'delivered',
+ });
+
+ const [initialize, decline, discovery, start] = seen;
+ expect(initialize).toMatchObject({
+ type: 'request',
+ method: 'initialize',
+ version: 0,
+ sourceClientId: 'initializing-client',
+ params: { clientType: 'agent-relay-desktop' },
+ });
+ expect(decline).toEqual({
+ type: 'client-discovery-response',
+ requestId: 'd1',
+ response: { canHandle: false },
+ });
+ expect(discovery).toMatchObject({
+ method: 'thread-owner-discovery',
+ version: 1,
+ sourceClientId: 'relay-1',
+ params: { hostId: 'local', conversationId: THREAD },
+ });
+ expect(discovery.targetClientId).toBeUndefined();
+ expect(start).toMatchObject({
+ method: 'thread-follower-start-turn',
+ version: 2,
+ sourceClientId: 'relay-1',
+ targetClientId: 'owner-1',
+ timeoutMs: 30_000,
+ params: {
+ conversationId: THREAD,
+ turnStart: {
+ request: {
+ threadId: THREAD,
+ clientUserMessageId: 'm5',
+ input: [{ type: 'text', text, text_elements: [] }],
+ },
+ context: {},
+ },
+ },
+ });
+
+ refuse = 'thread is archived';
+ expect(await router.inject({ threadId: THREAD, messageId: 'm6', text: connectText('m6') })).toEqual({
+ outcome: 'retry',
+ detail: 'thread is archived',
+ });
+ const other = '7c9e6679-7425-40de-944b-e07fc1f90ae7';
+ expect((await router.inject({ threadId: other, messageId: 'm7', text: connectText('m7') })).outcome).toBe(
+ 'waiting'
+ );
+ expect(await router.preflight({ threadId: THREAD })).toMatchObject({ ok: true });
+ });
+
+ it('preflights the queue command and names an old codex', async () => {
+ const { home, env, now } = await setup();
+ const router = createCodexRouter({ home, env, now, systemCodex: [] });
+ expect(await router.preflight({ threadId: THREAD })).toMatchObject({ ok: true });
+
+ const old = join(home, 'old-codex');
+ await mkdir(old);
+ await writeFile(join(old, 'codex'), fakeCodex({ queue: false, version: '0.98.0' }));
+ await chmod(join(old, 'codex'), 0o755);
+ const outdated = createCodexRouter({
+ home,
+ env: { ...env, RELAY_DESKTOP_CODEX: join(old, 'codex') },
+ now,
+ systemCodex: [],
+ });
+ const check = await outdated.preflight({ threadId: THREAD });
+ expect(check.ok).toBe(false);
+ expect(check.reason).toBe(
+ `Codex 0.98.0 (${join(old, 'codex')}) cannot receive injected messages; update Codex.`
+ );
+ expect(await outdated.inject({ threadId: THREAD, messageId: 'm', text: connectText('m') })).toMatchObject(
+ {
+ outcome: 'waiting',
+ }
+ );
+ const live = createCodexRouter({
+ home,
+ env: { ...env, RELAY_DESKTOP_CODEX: join(old, 'codex') },
+ now,
+ systemCodex: [],
+ terminalLive: () => true,
+ });
+ expect(await live.inject({ threadId: THREAD, messageId: 'm', text: connectText('m') })).toEqual({
+ outcome: 'retry',
+ detail: 'the codex command line is not installed',
+ });
+ });
+});
+
+describe('Codex caller thread', () => {
+ it('needs a UUID thread id that Codex knows', async () => {
+ const { home, env, paths } = await setup();
+ expect(await codexCallerThread({ env, home })).toEqual({
+ threadId: null,
+ reason: 'no Codex thread id in env',
+ });
+ expect(
+ (await codexCallerThread({ env: { ...env, CODEX_THREAD_ID: 'my-session' }, home })).threadId
+ ).toBeNull();
+ const missing = await codexCallerThread({ env: { ...env, CODEX_THREAD_ID: THREAD }, home });
+ expect(missing.threadId).toBeNull();
+ expect(missing.reason).toContain(THREAD);
+
+ await writeRollout(paths.codexHome, THREAD, []);
+ expect(await codexCallerThread({ env: { ...env, CODEX_THREAD_ID: THREAD.toUpperCase() }, home })).toEqual(
+ {
+ threadId: THREAD,
+ }
+ );
+
+ const other = '7c9e6679-7425-40de-944b-e07fc1f90ae7';
+ const { DatabaseSync } = await import('node:sqlite');
+ const db = new DatabaseSync(join(paths.sqliteHome, 'state_5.sqlite'));
+ db.exec('create table threads (id text, title text, archived integer, source text, rollout_path text)');
+ db.prepare('insert into threads values (?, ?, 0, ?, ?)').run(other, 'Fix CI', 'cli', '');
+ db.close();
+ expect(await codexCallerThread({ env: { ...env, CODEX_THREAD_ID: other }, home })).toEqual({
+ threadId: other,
+ });
+ });
+});
+
+describe('SQLite fallbacks', () => {
+ const backends = ['sqlite3', 'none'].filter((kind) => kind !== 'sqlite3' || existsSync('/usr/bin/sqlite3'));
+
+ for (const kind of backends) {
+ it(`works with ${kind === 'none' ? 'no SQLite reader' : 'the sqlite3 command line'}`, async () => {
+ const previous = process.env.AGENT_RELAY_CONNECT_SQLITE;
+ process.env.AGENT_RELAY_CONNECT_SQLITE = kind;
+ resetSqliteBackend();
+ cleanups.push(() => {
+ if (previous === undefined) delete process.env.AGENT_RELAY_CONNECT_SQLITE;
+ else process.env.AGENT_RELAY_CONNECT_SQLITE = previous;
+ resetSqliteBackend();
+ });
+ expect(await codexSqliteBackend()).toBe(kind === 'none' ? null : 'sqlite3');
+
+ const { home, env, paths, clock, now } = await setup();
+ const router = createCodexRouter({ home, env, now, systemCodex: [], terminalLive: () => true });
+ const text = connectText('m8');
+ expect((await router.inject({ threadId: THREAD, messageId: 'm8', text })).outcome).toBe('accepted');
+ const queued = await router.observe({ threadId: THREAD, messageId: 'm8', text });
+ // Without a reader the queue reads as empty: the rollout and the 45 s rule decide.
+ expect(queued).toEqual({ outcome: 'accepted', detail: kind === 'none' ? RECORDING : NOT_CONSUMED });
+ await writeRollout(paths.codexHome, THREAD, [userMessage('t1', text)]);
+ clock.now += 60_000;
+ expect(await router.observe({ threadId: THREAD, messageId: 'm8', text })).toMatchObject({
+ outcome: 'delivered',
+ });
+ });
+ }
+});
diff --git a/packages/connect/tests/relay-core.test.ts b/packages/connect/tests/relay-core.test.ts
new file mode 100644
index 000000000..96da2fbcc
--- /dev/null
+++ b/packages/connect/tests/relay-core.test.ts
@@ -0,0 +1,305 @@
+import { mkdtemp, readFile, rm } from 'node:fs/promises';
+import os from 'node:os';
+import { join } from 'node:path';
+import { afterEach, describe, expect, it } from 'vitest';
+import { ConnectService, injectedText, messageRef, parseDelivery } from '../src/relay/connect.js';
+import { RelayState } from '../src/relay/state.js';
+import { delivery, FakeConnectCloud } from './support/fake-connect-cloud.js';
+
+const cleanups: Array<() => Promise> = [];
+afterEach(async () => {
+ while (cleanups.length) await cleanups.pop()?.();
+});
+
+const TARGET = { kind: 'claude', id: '11111111-2222-3333-4444-555555555555' };
+
+type Outcome = { outcome: string; detail: string };
+
+class FakeInjector {
+ injected: Array<{ messageId: string; text: string }> = [];
+ observed: string[] = [];
+ next: Outcome[] = [];
+ observeNext: Outcome[] = [];
+ onInject?: (messageId: string) => Promise;
+ prepared = 0;
+ async prepare() {
+ this.prepared += 1;
+ }
+ async inject(_target: unknown, message: { messageId: string; text: string }) {
+ await this.onInject?.(message.messageId);
+ this.injected.push(message);
+ return this.next.shift() || { outcome: 'delivered', detail: 'ok' };
+ }
+ async observe(_target: unknown, message: { messageId: string }) {
+ this.observed.push(message.messageId);
+ return this.observeNext.shift() || { outcome: 'accepted', detail: 'still queued' };
+ }
+ async preflight() {
+ return { ok: true, reason: '' };
+ }
+}
+
+async function setup() {
+ const cloud = await new FakeConnectCloud().start();
+ const root = await mkdtemp(join(os.tmpdir(), 'connect-core-test-'));
+ cleanups.push(async () => {
+ await cloud.stop();
+ await rm(root, { recursive: true, force: true });
+ });
+ let clock = Date.parse('2026-10-12T12:00:00Z');
+ const now = () => clock;
+ const state = await RelayState.load(join(root, 'state.json'), { now });
+ const injector = new FakeInjector();
+ const logs: string[] = [];
+ const service = new ConnectService({
+ state,
+ injector,
+ env: { AGENT_RELAY_CONNECT_CLOUD_URL: cloud.base },
+ now,
+ log: (line: string) => logs.push(line),
+ replyHint: (sender: string) => `[reply: connect send --to ${sender}]`,
+ });
+ return {
+ cloud,
+ state,
+ injector,
+ service,
+ logs,
+ statePath: join(root, 'state.json'),
+ advance: (ms: number) => {
+ clock += ms;
+ },
+ };
+}
+
+describe('Node relay Connect service', () => {
+ it('formats injected text exactly like the probe', () => {
+ const parsed = parseDelivery(
+ delivery('d1', 'Hello [there]', { message: { agent_name: 'host [x]', text: 'Hello [there]' } })
+ )!;
+ expect(injectedText(parsed, '[reply: hint]')).toBe(
+ `[Relay Connect — from host (x) · ref ${messageRef('msg-d1')}]\n[reply: hint]\n\nHello [there]`
+ );
+ expect(messageRef('msg-d1')).toMatch(/^[0-9a-f]{12}$/);
+ });
+
+ it('joins once, persists the credential, and answers a repeat join locally', async () => {
+ const { cloud, service, injector, statePath } = await setup();
+ const joined = await service.join(TARGET, {
+ link: 'https://agentrelay.com/connect/conn-1/join',
+ name: 'guest-one',
+ });
+ expect(joined).toMatchObject({
+ connect_id: 'conn-1',
+ agent_name: 'guest-one',
+ role: 'guest',
+ warnings: [],
+ });
+ expect(injector.prepared).toBe(1);
+ const again = await service.join(TARGET, { link: 'conn-1' });
+ expect(again.connect_id).toBe('conn-1');
+ expect(cloud.callsTo('POST', '/cloud/connect/conn-1/join')).toHaveLength(1);
+ const saved = JSON.parse(await readFile(statePath, 'utf8'));
+ expect(saved.sessions[`claude:${TARGET.id}`].token).toBe('participant-token');
+ await expect(service.join(TARGET, { link: 'conn-2' })).rejects.toMatchObject({
+ code: 'connect_already_joined',
+ });
+ });
+
+ it('reuses the join idempotency key after a 5xx and clears it after a 4xx', async () => {
+ const { cloud, service } = await setup();
+ cloud.joinStatus = 503;
+ cloud.joinError = { code: 'connect_unavailable', message: 'down' };
+ await expect(service.join(TARGET, { link: 'conn-1' })).rejects.toMatchObject({
+ code: 'connect_unavailable',
+ });
+ await expect(service.join(TARGET, { link: 'conn-1' })).rejects.toMatchObject({
+ code: 'connect_unavailable',
+ });
+ const keys = cloud
+ .callsTo('POST', '/cloud/connect/conn-1/join')
+ .map((call) => call.headers['idempotency-key']);
+ expect(keys[0]).toBe(keys[1]);
+ await expect(service.join(TARGET, { link: 'conn-1', name: 'other-name' })).rejects.toMatchObject({
+ code: 'connect_join_pending',
+ });
+ cloud.joinStatus = 403;
+ cloud.joinError = { code: 'connect_claim_invalid', message: 'bad claim' };
+ await expect(service.join(TARGET, { link: 'conn-1' })).rejects.toMatchObject({
+ code: 'connect_claim_invalid',
+ });
+ cloud.joinError = null;
+ await service.join(TARGET, { link: 'conn-1' });
+ const last = cloud.callsTo('POST', '/cloud/connect/conn-1/join').at(-1)!;
+ expect(last.headers['idempotency-key']).not.toBe(keys[0]);
+ });
+
+ it('injects, persists before injecting, acks, and never injects a settled delivery again', async () => {
+ const { cloud, service, injector, statePath } = await setup();
+ await service.join(TARGET, { link: 'conn-1' });
+ cloud.deliveries.push(delivery('d1', 'first'));
+ injector.onInject = async () => {
+ const saved = JSON.parse(await readFile(statePath, 'utf8'));
+ expect(saved.pending['agent-guest:d1']).toBe('msg-d1');
+ };
+ await service.pollAll();
+ expect(injector.injected.map((m) => m.messageId)).toEqual(['msg-d1']);
+ expect(injector.injected[0].text).toContain('[reply: connect send --to host-agent]');
+ expect(cloud.callsTo('POST', '/v1/deliveries/d1/ack')).toHaveLength(1);
+
+ // Relaycast redrives an item whose ack was lost: re-acked from the ledger.
+ cloud.deliveries[0].status = 'delivered';
+ await service.pollAll();
+ expect(injector.injected).toHaveLength(1);
+ expect(cloud.callsTo('POST', '/v1/deliveries/d1/ack')).toHaveLength(2);
+ });
+
+ it('fails an in-doubt handoff without retry and never resends it', async () => {
+ const { cloud, service, injector } = await setup();
+ await service.join(TARGET, { link: 'conn-1' });
+ cloud.deliveries.push(delivery('d1', 'first'));
+ injector.next.push({ outcome: 'in_doubt', detail: 'socket closed mid-write' });
+ await service.pollAll();
+ const fail = cloud.callsTo('POST', '/v1/deliveries/d1/fail');
+ expect(fail).toHaveLength(1);
+ expect(fail[0].body).toEqual({
+ error: 'In doubt; not resent — socket closed mid-write',
+ retryable: false,
+ });
+ cloud.deliveries[0].status = 'queued';
+ await service.pollAll();
+ expect(injector.injected).toHaveLength(1);
+ });
+
+ it('backs off a waiting head and keeps later items behind it, then gives up after the handoff TTL', async () => {
+ const { cloud, service, injector, advance } = await setup();
+ await service.join(TARGET, { link: 'conn-1' });
+ cloud.deliveries.push(delivery('d1', 'first'), delivery('d2', 'second'));
+ injector.next.push({ outcome: 'waiting', detail: 'session not running' });
+ await service.pollAll();
+ expect(injector.injected.map((m) => m.messageId)).toEqual(['msg-d1']);
+ await service.pollAll();
+ expect(injector.injected).toHaveLength(1);
+ advance(5_000);
+ injector.next.push({ outcome: 'waiting', detail: 'session not running' });
+ await service.pollAll();
+ expect(injector.injected.map((m) => m.messageId)).toEqual(['msg-d1', 'msg-d1']);
+ advance(60 * 60_000);
+ injector.next.push({ outcome: 'waiting', detail: 'session not running' });
+ await service.pollAll();
+ expect(cloud.callsTo('POST', '/v1/deliveries/d1/fail')[0].body.error).toContain(
+ 'The local session remained unavailable without accepting the handoff'
+ );
+ expect(injector.injected.at(-1)!.messageId).toBe('msg-d2');
+ });
+
+ it('only observes a delivery the provider queue accepted, and lets later items pass', async () => {
+ const { cloud, service, injector, advance } = await setup();
+ await service.join(TARGET, { link: 'conn-1' });
+ cloud.deliveries.push(delivery('d1', 'first'), delivery('d2', 'second'));
+ injector.next.push({ outcome: 'accepted', detail: 'queued' });
+ await service.pollAll();
+ expect(injector.injected.map((m) => m.messageId)).toEqual(['msg-d1', 'msg-d2']);
+ advance(5_000);
+ injector.observeNext.push({ outcome: 'delivered', detail: 'recorded in the rollout' });
+ await service.pollAll();
+ expect(injector.observed).toEqual(['msg-d1']);
+ expect(injector.injected).toHaveLength(2);
+ expect(cloud.callsTo('POST', '/v1/deliveries/d1/ack')).toHaveLength(1);
+ });
+
+ it('acks its own messages and messages not addressed to it without injecting', async () => {
+ const { cloud, service, injector } = await setup();
+ await service.join(TARGET, { link: 'conn-1' });
+ cloud.deliveries.push(
+ delivery('own', 'mine', { message: { agent_name: 'guest-one', text: 'mine' } }),
+ delivery('chan', 'channel chatter', { reason: 'channel' })
+ );
+ await service.pollAll();
+ expect(injector.injected).toEqual([]);
+ expect(cloud.callsTo('POST', '/v1/deliveries/own/ack')).toHaveLength(1);
+ expect(cloud.callsTo('POST', '/v1/deliveries/chan/ack')).toHaveLength(1);
+ });
+
+ it('sends with a per-recipient idempotency key and names each failure cause', async () => {
+ const { cloud, service, logs } = await setup();
+ await service.join(TARGET, { link: 'conn-1' });
+ await expect(
+ service.send(TARGET, 'hi', { to: 'host-agent', requestKey: 'req-1' })
+ ).resolves.toMatchObject({
+ sent: [{ to: 'host-agent' }],
+ });
+ expect(cloud.callsTo('POST', '/v1/dm')[0].headers['idempotency-key']).toBe(
+ 'connect-send:req-1:host-agent'
+ );
+
+ cloud.apiErrors.set('POST /v1/dm', { status: 401, code: 'unauthorized' });
+ await expect(service.send(TARGET, 'hi', { to: 'host-agent' })).rejects.toMatchObject({
+ status: 503,
+ code: 'connect_auth_rejected',
+ });
+ cloud.apiErrors.set('POST /v1/dm', { status: 500, code: 'internal_error' });
+ await expect(service.send(TARGET, 'hi', { to: 'host-agent' })).rejects.toMatchObject({
+ status: 502,
+ code: 'connect_upstream_error',
+ });
+ expect(
+ logs.some(
+ (line) => line.includes('code=connect_auth_rejected') && line.includes('cause=http_401:unauthorized')
+ )
+ ).toBe(true);
+ expect(logs.join('\n')).not.toContain('participant-token');
+
+ cloud.apiErrors.set('POST /v1/dm', { status: 401, code: 'unauthorized' });
+ cloud.availability = { status: 410, code: 'connect_ended' };
+ await expect(service.send(TARGET, 'hi', { to: 'host-agent' })).rejects.toMatchObject({
+ code: 'connect_ended',
+ });
+ await expect(service.status(TARGET)).rejects.toMatchObject({ code: 'connect_not_joined' });
+ });
+
+ it('reports presence from the roster', async () => {
+ const { service } = await setup();
+ await service.join(TARGET, { link: 'conn-1' });
+ const status = await service.status(TARGET);
+ expect(status.participants).toEqual([
+ { agent_name: 'host-agent', role: 'host', online: true },
+ { agent_name: 'guest-one', role: 'guest', online: true },
+ ]);
+ });
+
+ it('leaves through Cloud and Relaycast, then forgets the credential', async () => {
+ const { cloud, service, statePath } = await setup();
+ await service.join(TARGET, { link: 'conn-1' });
+ await expect(service.leave(TARGET)).resolves.toEqual({ left: true, connect_id: 'conn-1' });
+ expect(cloud.callsTo('POST', '/cloud/connect/conn-1/leave')[0].headers.authorization).toBe(
+ 'Bearer participant-token'
+ );
+ // Cloud deleted the agent, so no Relaycast disconnect follows.
+ expect(cloud.callsTo('POST', '/v1/agents/disconnect')).toHaveLength(0);
+ const saved = JSON.parse(await readFile(statePath, 'utf8'));
+ expect(saved.sessions).toEqual({});
+ });
+
+ it('still leaves locally when Cloud has no leave route yet', async () => {
+ const { cloud, service, logs } = await setup();
+ await service.join(TARGET, { link: 'conn-1' });
+ cloud.apiErrors.set('POST /cloud/connect/conn-1/leave', { status: 404, code: 'connect_route_not_found' });
+ await expect(service.leave(TARGET)).resolves.toMatchObject({ left: true });
+ expect(logs.some((line) => line.includes('relay connect leave failed'))).toBe(true);
+ expect(cloud.callsTo('POST', '/v1/agents/disconnect')).toHaveLength(1);
+ });
+
+ it('closes an expired Connect and injects one notice', async () => {
+ const { cloud, service, injector, advance } = await setup();
+ cloud.expiresAt = '2026-10-12T12:30:00Z';
+ await service.join(TARGET, { link: 'conn-1' });
+ advance(31 * 60_000);
+ await service.pollAll();
+ await service.pollAll();
+ expect(injector.injected.map((m) => m.text)).toEqual([
+ '[Relay Connect] This Connect expired. Its local credential was removed.',
+ ]);
+ await expect(service.status(TARGET)).rejects.toMatchObject({ code: 'connect_not_joined' });
+ });
+});
diff --git a/packages/connect/tests/relay-daemon.test.ts b/packages/connect/tests/relay-daemon.test.ts
new file mode 100644
index 000000000..fc63ca148
--- /dev/null
+++ b/packages/connect/tests/relay-daemon.test.ts
@@ -0,0 +1,201 @@
+import { EventEmitter } from 'node:events';
+import { mkdtemp, readFile, rm, stat, writeFile, mkdir } from 'node:fs/promises';
+import os from 'node:os';
+import { join } from 'node:path';
+import { afterEach, describe, expect, it } from 'vitest';
+import { requestJson } from '../src/http.js';
+import { startDaemon, replyHintFor } from '../src/relay/daemon.js';
+import { ensureRelay, olderVersion, RelayStartError } from '../src/relay/launch.js';
+import { relayPaths } from '../src/relay/paths.js';
+import { FakeConnectCloud } from './support/fake-connect-cloud.js';
+
+const cleanups: Array<() => Promise> = [];
+afterEach(async () => {
+ while (cleanups.length) await cleanups.pop()?.();
+});
+
+// Unix socket paths are limited to ~104 bytes; macOS's per-user TMPDIR is long.
+const shortTmp = process.platform === 'darwin' ? '/tmp' : os.tmpdir();
+
+async function tempHome() {
+ const root = await mkdtemp(join(shortTmp, 'crd-'));
+ cleanups.push(async () => rm(root, { recursive: true, force: true }));
+ return root;
+}
+
+const fakeInjector = {
+ async prepare() {},
+ async inject() {
+ return { outcome: 'delivered', detail: 'ok' };
+ },
+ async observe() {
+ return { outcome: 'delivered', detail: 'ok' };
+ },
+ async preflight() {
+ return { ok: true, reason: '' };
+ },
+};
+
+const TARGET = 'codex:019a0000-0000-7000-8000-000000000001';
+
+describe('Node relay daemon', () => {
+ it('serves the Connect routes on a private socket and requires a session target', async () => {
+ const home = await tempHome();
+ const cloud = await new FakeConnectCloud().start();
+ cleanups.push(() => cloud.stop());
+ const daemon = await startDaemon({
+ home,
+ env: { AGENT_RELAY_CONNECT_CLOUD_URL: cloud.base },
+ injector: fakeInjector,
+ });
+ cleanups.push(() => daemon.stop());
+
+ expect(((await stat(daemon.socket)).mode & 0o777).toString(8)).toBe('600');
+ expect(((await stat(relayPaths(home).root)).mode & 0o777).toString(8)).toBe('700');
+ const health = await requestJson(daemon.socket, { path: '/health' });
+ expect(health).toMatchObject({ ok: true, data: { sessions: 0 } });
+
+ const anonymous = await requestJson(daemon.socket, {
+ method: 'POST',
+ path: '/connect/join',
+ body: JSON.stringify({ link: 'conn-1' }),
+ });
+ expect(anonymous).toMatchObject({ ok: false, error: { code: 'not_a_relay_session' } });
+
+ const joined = await requestJson(daemon.socket, {
+ method: 'POST',
+ path: '/connect/join',
+ body: JSON.stringify({ link: 'conn-1' }),
+ headers: { 'x-agent-relay-target': TARGET },
+ });
+ expect(joined).toMatchObject({ ok: true, data: { connect_id: 'conn-1', agent_name: 'guest-one' } });
+
+ const status = await requestJson(daemon.socket, {
+ path: '/connect/status',
+ headers: { 'x-agent-relay-target': TARGET },
+ });
+ expect(status).toMatchObject({ ok: true, data: { connect_id: 'conn-1' } });
+
+ const busy = await requestJson(daemon.socket, { method: 'POST', path: '/shutdown' });
+ expect(busy).toMatchObject({ ok: false, error: { code: 'relay_busy' } });
+
+ const sent = await requestJson(daemon.socket, {
+ method: 'POST',
+ path: '/connect/send?to=host-agent',
+ body: 'hello host',
+ headers: { 'x-agent-relay-target': TARGET },
+ });
+ expect(sent).toMatchObject({ ok: true, data: { sent: [{ to: 'host-agent' }] } });
+ expect(cloud.callsTo('POST', '/v1/dm')[0].body).toEqual({
+ to: 'host-agent',
+ text: 'hello host',
+ mode: 'wait',
+ });
+
+ const state = JSON.parse(await readFile(relayPaths(home).state, 'utf8'));
+ expect(Object.keys(state.sessions)).toEqual([TARGET]);
+ });
+
+ it('refuses a second instance and cleans up its socket and lock on stop', async () => {
+ const home = await tempHome();
+ const daemon = await startDaemon({ home, env: {}, injector: fakeInjector });
+ await expect(startDaemon({ home, env: {}, injector: fakeInjector })).rejects.toMatchObject({
+ code: 'already_running',
+ });
+ await daemon.stop();
+ await expect(stat(daemon.socket)).rejects.toMatchObject({ code: 'ENOENT' });
+ await expect(stat(relayPaths(home).lock)).rejects.toMatchObject({ code: 'ENOENT' });
+ });
+
+ it('replaces a lock left by a relay that died', async () => {
+ const home = await tempHome();
+ const paths = relayPaths(home);
+ await mkdir(paths.run, { recursive: true, mode: 0o700 });
+ await writeFile(paths.lock, '999999\n');
+ const daemon = await startDaemon({ home, env: {}, injector: fakeInjector });
+ cleanups.push(() => daemon.stop());
+ expect((await readFile(paths.lock, 'utf8')).trim()).toBe(String(process.pid));
+ });
+
+ it('shows a reply command that runs this runtime under this Node', () => {
+ expect(replyHintFor('host-agent', '/opt/node/bin/node', '/home/a b/cli.js')).toBe(
+ "[reply: printf '%s' 'your reply' | /opt/node/bin/node '/home/a b/cli.js' send --to host-agent]"
+ );
+ });
+});
+
+describe('Node relay launcher', () => {
+ function fakeChild(behaviour: (child: EventEmitter & Record) => void) {
+ return () => {
+ const child = Object.assign(new EventEmitter(), {
+ pid: undefined,
+ connected: false,
+ disconnect() {},
+ unref() {},
+ });
+ setImmediate(() => behaviour(child));
+ return child;
+ };
+ }
+
+ it("reports the daemon's own start error and the end of its log, without a blind wait", async () => {
+ const home = await tempHome();
+ const paths = relayPaths(home);
+ const started = Date.now();
+ await expect(
+ ensureRelay({
+ home,
+ env: {},
+ spawnProcess: fakeChild((child) => {
+ writeFile(paths.log, 'boot line\nrelay connect failed to start: EACCES on run dir\n').then(() =>
+ child.emit('message', { type: 'error', code: 'start_failed', message: 'EACCES on run dir' })
+ );
+ }) as never,
+ })
+ ).rejects.toThrow(
+ /could not start: EACCES on run dir\.\nLast lines of .*daemon\.log:\nboot line\nrelay connect failed to start/
+ );
+ expect(Date.now() - started).toBeLessThan(3_000);
+ });
+
+ it('reports a daemon that exits before it is ready', async () => {
+ const home = await tempHome();
+ await expect(
+ ensureRelay({
+ home,
+ env: {},
+ spawnProcess: fakeChild((child) => child.emit('exit', null, 'SIGKILL')) as never,
+ })
+ ).rejects.toBeInstanceOf(RelayStartError);
+ await expect(
+ ensureRelay({
+ home,
+ env: {},
+ spawnProcess: fakeChild((child) => child.emit('exit', null, 'SIGKILL')) as never,
+ })
+ ).rejects.toThrow('it exited on SIGKILL before it was ready');
+ });
+
+ it('reuses a running relay instead of starting another', async () => {
+ const home = await tempHome();
+ const daemon = await startDaemon({ home, env: {}, injector: fakeInjector });
+ cleanups.push(() => daemon.stop());
+ let spawned = 0;
+ const socket = await ensureRelay({
+ home,
+ env: {},
+ spawnProcess: (() => {
+ spawned += 1;
+ throw new Error('should not spawn');
+ }) as never,
+ });
+ expect(socket).toBe(daemon.socket);
+ expect(spawned).toBe(0);
+ });
+
+ it('compares versions numerically', () => {
+ expect(olderVersion('13.4.0', '13.10.0')).toBe(true);
+ expect(olderVersion('13.10.0', '13.4.0')).toBe(false);
+ expect(olderVersion('13.4.0', '13.4.0')).toBe(false);
+ });
+});
diff --git a/packages/connect/tests/relay-processes.test.ts b/packages/connect/tests/relay-processes.test.ts
new file mode 100644
index 000000000..9c92f1c27
--- /dev/null
+++ b/packages/connect/tests/relay-processes.test.ts
@@ -0,0 +1,82 @@
+import { spawn } from 'node:child_process';
+import { mkdtemp, rm, writeFile } from 'node:fs/promises';
+import os from 'node:os';
+import { join } from 'node:path';
+import { afterEach, describe, expect, it } from 'vitest';
+import {
+ ancestors,
+ argvOf,
+ claudeStartMatches,
+ claudeStartSeconds,
+ holdsFile,
+ isAlive,
+ parentOf,
+ processStart,
+} from '../src/relay/processes.js';
+
+const cleanups: Array<() => Promise | void> = [];
+
+afterEach(async () => {
+ while (cleanups.length) await cleanups.pop()!();
+});
+
+async function tempDir() {
+ const dir = await mkdtemp(join(process.env.TMPDIR || os.tmpdir(), 'relay-proc-'));
+ cleanups.push(() => rm(dir, { recursive: true, force: true }));
+ return dir;
+}
+
+describe('processes', () => {
+ it('walks the current process up through its parent', async () => {
+ const chain = await ancestors(process.pid);
+ expect(chain[0].pid).toBe(process.pid);
+ expect(chain.map((entry) => entry.pid)).toContain(process.ppid);
+ expect(await parentOf(process.pid)).toBe(process.ppid);
+ expect(chain.length).toBeLessThanOrEqual(64);
+ expect(await ancestors(process.pid, 1)).toHaveLength(1);
+ });
+
+ it('reads a stable start time that matches itself as Claude would record it', async () => {
+ const first = await processStart(process.pid);
+ const second = await processStart(process.pid);
+ expect(first).toBeTruthy();
+ expect(second).toBe(first);
+ expect(claudeStartMatches(first, second)).toBe(true);
+ if (process.platform === 'darwin') {
+ expect(first).toMatch(/^[A-Z][a-z]{2} [A-Z][a-z]{2} +\d{1,2} \d{2}:\d{2}:\d{2} \d{4}$/);
+ } else {
+ expect(first).toMatch(/^\d+$/);
+ }
+ });
+
+ it('compares macOS start strings to the second, and Linux ticks exactly', () => {
+ expect(claudeStartSeconds('Wed Sep 30 10:00:00 2026')).toBe(Date.UTC(2026, 8, 30, 10, 0, 0) / 1000);
+ expect(claudeStartSeconds('Mon Oct 5 01:02:03 2026')).toBe(Date.UTC(2026, 9, 5, 1, 2, 3) / 1000);
+ expect(claudeStartSeconds('Mon Feb 31 01:02:03 2026')).toBeNull();
+ expect(claudeStartSeconds('garbage')).toBeNull();
+ expect(claudeStartMatches('Mon Oct 5 01:02:03 2026', 'Mon Oct 5 01:02:03 2026', 'darwin')).toBe(true);
+ expect(claudeStartMatches('Mon Oct 5 01:02:04 2026', 'Mon Oct 5 01:02:03 2026', 'darwin')).toBe(false);
+ expect(claudeStartMatches(' 12345 ', '12345', 'linux')).toBe(true);
+ expect(claudeStartMatches('12346', '12345', 'linux')).toBe(false);
+ });
+
+ it('sees a file a child holds open, its arguments, and its death', async () => {
+ const dir = await tempDir();
+ const file = join(dir, 'held.lock');
+ await writeFile(file, '');
+ const child = spawn('/bin/sh', ['-c', 'exec sleep 30 3<"$0"', file], { stdio: 'ignore' });
+ cleanups.push(() => {
+ child.kill('SIGKILL');
+ });
+ await new Promise((resolve) => setTimeout(resolve, 300));
+ expect(isAlive(child.pid!)).toBe(true);
+ expect(await holdsFile(child.pid!, file)).toBe(true);
+ expect(await holdsFile(child.pid!, join(dir, 'other.lock'))).toBe(false);
+ expect(await argvOf(child.pid!)).toEqual(['sleep', '30']);
+ child.kill('SIGKILL');
+ await new Promise((resolve) => child.once('exit', resolve));
+ await new Promise((resolve) => setTimeout(resolve, 100));
+ expect(isAlive(child.pid!)).toBe(false);
+ expect(await processStart(child.pid!)).toBeNull();
+ });
+});
diff --git a/packages/connect/tests/support/fake-connect-cloud.ts b/packages/connect/tests/support/fake-connect-cloud.ts
new file mode 100644
index 000000000..6850b1eb6
--- /dev/null
+++ b/packages/connect/tests/support/fake-connect-cloud.ts
@@ -0,0 +1,148 @@
+import http from 'node:http';
+
+export type Delivery = {
+ id: string;
+ message_id: string;
+ status: string;
+ reason: string;
+ message: { agent_name: string; text: string };
+};
+
+type Call = { method: string; path: string; headers: http.IncomingHttpHeaders; body: any };
+
+/** Cloud's public Connect routes plus the Relaycast routes a participant uses, on loopback http. */
+export class FakeConnectCloud {
+ server!: http.Server;
+ base = '';
+ calls: Call[] = [];
+ deliveries: Delivery[] = [];
+ roster: Array<{ name: string; status: string }> = [
+ { name: 'host-agent', status: 'online' },
+ { name: 'guest-one', status: 'online' },
+ ];
+ joinStatus = 200;
+ joinError: { code: string; message: string } | null = null;
+ availability: { status: number; code?: string } = { status: 200 };
+ apiErrors = new Map();
+ expiresAt = '2099-01-01T00:00:00Z';
+
+ async start() {
+ this.server = http.createServer((request, response) => {
+ const chunks: Buffer[] = [];
+ request.on('data', (chunk) => chunks.push(chunk));
+ request.on('end', () => this.handle(request, response, Buffer.concat(chunks).toString('utf8')));
+ });
+ await new Promise((resolve) => this.server.listen(0, '127.0.0.1', resolve));
+ const address = this.server.address() as { port: number };
+ this.base = `http://127.0.0.1:${address.port}`;
+ return this;
+ }
+
+ async stop() {
+ await new Promise((resolve) => this.server.close(() => resolve()));
+ }
+
+ callsTo(method: string, path: string) {
+ return this.calls.filter((call) => call.method === method && call.path === path);
+ }
+
+ private send(response: http.ServerResponse, status: number, value: unknown) {
+ response.writeHead(status, { 'content-type': 'application/json' });
+ response.end(JSON.stringify(value));
+ }
+
+ private handle(request: http.IncomingMessage, response: http.ServerResponse, text: string) {
+ const path = request.url || '/';
+ let body: any = null;
+ try {
+ body = text ? JSON.parse(text) : null;
+ } catch {
+ body = text;
+ }
+ this.calls.push({ method: request.method || 'GET', path, headers: request.headers, body });
+ const forced = this.apiErrors.get(`${request.method} ${path.split('?')[0]}`);
+ if (forced) {
+ this.send(response, forced.status, { ok: false, error: { code: forced.code, message: forced.code } });
+ return;
+ }
+ const join = /^\/cloud\/connect\/([^/]+)\/join$/.exec(path);
+ if (join && request.method === 'POST') {
+ if (this.joinError) {
+ this.send(response, this.joinStatus, { ok: false, error: this.joinError });
+ return;
+ }
+ const name = body?.agent_name || 'guest-one';
+ this.send(response, 200, {
+ ok: true,
+ data: {
+ connect_id: join[1],
+ task: 'Review the patch',
+ expires_at: this.expiresAt,
+ host: { person: 'Host Person', agent_name: 'host-agent' },
+ participant: {
+ agent_name: name,
+ agent_id: 'agent-guest',
+ role: 'guest',
+ token: 'participant-token',
+ relay_base_url: this.base,
+ },
+ participants: [
+ { agent_name: 'host-agent', role: 'host' },
+ { agent_name: name, role: 'guest' },
+ ],
+ },
+ });
+ return;
+ }
+ if (/^\/cloud\/connect\/[^/]+\/leave$/.test(path)) {
+ this.send(response, 200, { ok: true, data: { left: true } });
+ return;
+ }
+ if (/^\/connect\/[^/]+$/.test(path)) {
+ const { status, code } = this.availability;
+ if (status === 200) this.send(response, 200, { ok: true, data: { active: true } });
+ else
+ this.send(response, status, { ok: false, error: { code: code || 'connect_ended', message: 'gone' } });
+ return;
+ }
+ if (path === '/v1/deliveries?limit=50') {
+ this.send(response, 200, { ok: true, data: this.deliveries });
+ return;
+ }
+ const settle = /^\/v1\/deliveries\/([^/]+)\/(ack|fail)$/.exec(path);
+ if (settle) {
+ const id = decodeURIComponent(settle[1]);
+ const delivery = this.deliveries.find((item) => item.id === id);
+ if (delivery) delivery.status = settle[2] === 'ack' ? 'acked' : 'failed';
+ this.send(response, 200, { ok: true, data: { id, status: delivery?.status } });
+ return;
+ }
+ if (path === '/v1/dm') {
+ this.send(response, 200, {
+ ok: true,
+ data: { conversation_id: 'c1', message: { id: `m-${this.calls.length}` } },
+ });
+ return;
+ }
+ if (path === '/v1/agents?status=all') {
+ this.send(response, 200, { ok: true, data: this.roster });
+ return;
+ }
+ if (path === '/v1/agents/heartbeat' || path === '/v1/agents/disconnect') {
+ this.send(response, 200, { ok: true, data: {} });
+ return;
+ }
+ this.send(response, 404, { ok: false, error: { code: 'not_found', message: path } });
+ }
+}
+
+export function delivery(id: string, text: string, overrides: Partial = {}): Delivery {
+ return {
+ id,
+ message_id: `msg-${id}`,
+ status: 'queued',
+ reason: 'dm',
+ message: { agent_name: 'host-agent', text },
+ ...overrides,
+ };
+}
]