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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 14 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,8 +10,21 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
### Added

- `relay node agent list --pretty` now provides a compact agent view with each agent's name, CLI/model, state, and relative last activity time.
- `agent-relay fleet spawn|release` can create, target, and release agents across live Fleet nodes directly from the terminal.
- `agent-relay fleet nodes --all` includes offline and direct fleet-history records when they are needed for diagnostics.
- `agent-relay message dm send --mode steer` can wake an idle remote agent immediately from the terminal.

## [Unreleased]
### Changed

- `agent-relay fleet nodes` now shows only live fleet providers by default instead of mixing unavailable nodes with direct-delivery history.
- CLI and MCP workspace selection is now pinned to the current project, so later agents and processes resume one collaboration session until a new workspace is explicitly created or selected.
- Enrolled Fleet nodes now retain their node identity when a pinned project session is restarted.

### Fixed

- MCP workspace creation and selection now preserve completed remote or in-memory changes with a warning when local persistence fails, preventing duplicate workspaces and false failed switches.
- Workspace creation now rejects invalid names before provisioning a remote workspace.
- Fleet node restarts now reject stored enrollment fallbacks that do not match the project-pinned node identity.

## [11.0.2] - 2026-07-22

Expand Down
31 changes: 31 additions & 0 deletions packages/cli/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,37 @@ agent-relay node agent release <name>

For AI SDK native harnesses, attach renders structured activity, text, tools, approvals, files, usage, and lifecycle events. Add `--json` for NDJSON, `--reasoning` for reasoning events, or `--diagnostics` for sidecar diagnostics. Native harness `drive` is line-oriented and acknowledged; native harness `passthrough` is unsupported because no terminal stream exists. PTY attach behavior is unchanged.

## Remote fleet agents

The `fleet` command group lists and controls agents across all live nodes in
the active project workspace:

```bash
agent-relay fleet nodes
agent-relay fleet nodes --name sf-mini --capability spawn:codex

# Exact-node placement uses the same agent-scoped Fleet action as the MCP tool.
agent-relay fleet spawn codex \
--name api-worker \
--task "Use https://agentrelay.com/skill, ACK over Relay, then wait for details." \
--node sf-mini

# Omit --node for automatic eligible-node placement.
agent-relay fleet spawn codex --name api-worker --task "Review the current diff."

agent-relay message dm send api-worker "Detailed task instructions"
# Wake an idle worker immediately instead of queueing for its next tool boundary.
agent-relay message dm send api-worker "Please check Relay now." --mode steer
agent-relay message inbox check --limit 20
agent-relay fleet release api-worker --reason "Work accepted"
```

Commands use the workspace session pinned to the current project. Targeted
spawn and messaging operations also need an agent identity: pass `--token` or
set `RELAY_AGENT_TOKEN` to the token returned by
`agent-relay agent register <lead-name>`. Automatic placement and release need
only the workspace key.

To run as a Cloud-managed node, first redeem a one-time enrollment token, then start the node:

```bash
Expand Down
150 changes: 149 additions & 1 deletion packages/cli/src/cli/agent-relay-mcp.startup.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
type LoadOptions = {
connectThrows?: boolean;
forceEntrypoint?: boolean;
persistedWorkspaceKey?: string;
};

type RelayBehavior = {
Expand All @@ -28,6 +29,13 @@ async function loadAgentRelayMcpModule(options: LoadOptions = {}) {
const telemetryTrack = vi.fn();
const telemetryInit = vi.fn();
const telemetryShutdown = vi.fn(async () => undefined);
const persistWorkspaceSession = vi.fn();
const resolveWorkspaceSessionKey = vi.fn(() => options.persistedWorkspaceKey);
const validateWorkspaceSessionName = vi.fn((name: string) => {
const trimmed = name.trim();
if (!trimmed) throw new Error('Workspace name is required.');
return trimmed;
});
const relayInstances: Array<{
config: Record<string, unknown>;
registerOrRotate: ReturnType<typeof vi.fn>;
Expand Down Expand Up @@ -248,6 +256,11 @@ async function loadAgentRelayMcpModule(options: LoadOptions = {}) {
shutdown: telemetryShutdown,
track: telemetryTrack,
}));
vi.doMock('./lib/workspace-session.js', () => ({
persistWorkspaceSession,
resolveWorkspaceSessionKey,
validateWorkspaceSessionName,
}));

const mod = await import('./agent-relay-mcp.js');
if (options.forceEntrypoint) {
Expand All @@ -263,6 +276,9 @@ async function loadAgentRelayMcpModule(options: LoadOptions = {}) {
telemetryTrack,
telemetryInit,
telemetryShutdown,
persistWorkspaceSession,
resolveWorkspaceSessionKey,
validateWorkspaceSessionName,
RelayCast,
FakeTransport,
},
Expand Down Expand Up @@ -314,6 +330,73 @@ describe('agent-relay-mcp startup helpers', () => {
skipBootstrap: true,
});
});

it('resumes the persisted project workspace when no workspace env is set', async () => {
const { mod, mocks } = await loadAgentRelayMcpModule({
persistedWorkspaceKey: 'rk_live_persisted',
});
vi.stubEnv('RELAY_WORKSPACE_KEY', '');
vi.stubEnv('AGENT_RELAY_WORKSPACE_KEY', '');
vi.stubEnv('RELAY_API_KEY', '');
vi.stubEnv('RELAY_AGENT_TOKEN', '');
vi.stubEnv('RELAY_AGENT_NAME', '');
vi.stubEnv('RELAY_CLAW_NAME', '');

expect(mod.optionsFromEnv()).toMatchObject({
workspaceKey: 'rk_live_persisted',
agentName: 'orchestrator',
});
expect(mocks.resolveWorkspaceSessionKey).toHaveBeenCalledTimes(1);
});

it('does not pair a persisted workspace with an unbound ambient agent token', async () => {
const { mod } = await loadAgentRelayMcpModule({
persistedWorkspaceKey: 'rk_live_persisted',
});
vi.stubEnv('RELAY_WORKSPACE_KEY', '');
vi.stubEnv('AGENT_RELAY_WORKSPACE_KEY', '');
vi.stubEnv('RELAY_API_KEY', '');
vi.stubEnv('RELAY_AGENT_TOKEN', 'at_live_stale_workspace');
vi.stubEnv('RELAY_AGENT_NAME', '');
vi.stubEnv('RELAY_CLAW_NAME', '');

expect(mod.optionsFromEnv()).toMatchObject({
workspaceKey: 'rk_live_persisted',
agentToken: undefined,
agentName: 'orchestrator',
});
});

it('keeps an agent token paired with an explicitly configured agent-relay workspace key', async () => {
const { mod } = await loadAgentRelayMcpModule({
persistedWorkspaceKey: 'rk_live_unrelated_persisted',
});
vi.stubEnv('RELAY_WORKSPACE_KEY', '');
vi.stubEnv('AGENT_RELAY_WORKSPACE_KEY', 'rk_live_agent_env');
vi.stubEnv('RELAY_API_KEY', '');
vi.stubEnv('RELAY_AGENT_TOKEN', 'at_live_agent_env');

expect(mod.optionsFromEnv()).toMatchObject({
workspaceKey: 'rk_live_agent_env',
agentToken: 'at_live_agent_env',
});
});

it('trims workspace env values and falls through whitespace-only primary candidates', async () => {
const { mod, mocks } = await loadAgentRelayMcpModule({
persistedWorkspaceKey: 'rk_live_unrelated_persisted',
});
vi.stubEnv('RELAY_WORKSPACE_KEY', ' ');
vi.stubEnv('AGENT_RELAY_WORKSPACE_KEY', ' rk_live_agent_env ');
vi.stubEnv('RELAY_API_KEY', 'rk_live_legacy');
vi.stubEnv('RELAY_AGENT_TOKEN', ' at_live_agent_env ');

expect(mod.optionsFromEnv()).toMatchObject({
workspaceKey: 'rk_live_agent_env',
agentToken: 'at_live_agent_env',
});
expect(mocks.resolveWorkspaceSessionKey).not.toHaveBeenCalled();
});
});

describe('createAgentRelayMcpServer', () => {
Expand Down Expand Up @@ -357,6 +440,10 @@ describe('createAgentRelayMcpServer', () => {
workspaceKey: 'rk_live_created',
workspaceName: 'Test Workspace',
});
expect(mocks.persistWorkspaceSession).toHaveBeenCalledWith({
name: 'Test Workspace',
workspaceKey: 'rk_live_created',
});

const registerResult = await server.tools.get('register_agent')?.handler({
name: 'WorkerA',
Expand Down Expand Up @@ -422,6 +509,64 @@ describe('createAgentRelayMcpServer', () => {
expect(promptResult.messages[0].content.text).not.toContain('workspace.create');
});

it('returns a created workspace key when local session persistence fails', async () => {
const { mod, mocks } = await loadAgentRelayMcpModule();
mocks.persistWorkspaceSession.mockImplementationOnce(() => {
throw new Error('project directory is read-only');
});

mod.createAgentRelayMcpServer({ baseUrl: 'https://relay.example.com/' });
const server = mocks.serverInstances[0];
const result = await server.tools.get('create_workspace')?.handler({ name: 'Durable Workspace' });

expect(result.structuredContent).toEqual({
workspaceKey: 'rk_live_created',
workspaceName: 'Test Workspace',
warning:
'Workspace created, but its session could not be persisted locally: project directory is read-only. ' +
'Keep the returned workspace key and retry persistence before starting another session.',
});
expect(mocks.RelayCast.createWorkspace).toHaveBeenCalledTimes(1);

await server.tools.get('register_agent')?.handler({ name: 'WorkerAfterWarning' });
expect(mocks.relayInstances.some((instance) => instance.config.apiKey === 'rk_live_created')).toBe(true);
});

it('rejects a blank workspace name before provisioning a remote workspace', async () => {
const { mod, mocks } = await loadAgentRelayMcpModule();
mod.createAgentRelayMcpServer({ baseUrl: 'https://relay.example.com/' });
const server = mocks.serverInstances[0];

await expect(server.tools.get('create_workspace')?.handler({ name: ' ' })).rejects.toThrow(
'Workspace name is required.'
);
expect(mocks.RelayCast.createWorkspace).not.toHaveBeenCalled();
expect(mocks.persistWorkspaceSession).not.toHaveBeenCalled();
});

it('keeps a selected workspace usable when local session persistence fails', async () => {
const { mod, mocks } = await loadAgentRelayMcpModule();
mocks.persistWorkspaceSession.mockImplementationOnce(() => {
throw new Error('project directory is read-only');
});

mod.createAgentRelayMcpServer({ baseUrl: 'https://relay.example.com/' });
const server = mocks.serverInstances[0];
const result = await server.tools
.get('set_workspace_key')
?.handler({ workspace_key: 'rk_live_selected' });

expect(result.structuredContent).toEqual({
message:
'Workspace key set. Call "register_agent" to join this workspace. ' +
'The workspace is active for this process, but its session could not be persisted locally: ' +
'project directory is read-only. Retry persistence before restarting this MCP server.',
});

await server.tools.get('register_agent')?.handler({ name: 'WorkerAfterSetWarning' });
expect(mocks.relayInstances.some((instance) => instance.config.apiKey === 'rk_live_selected')).toBe(true);
});

it('registers submit_result when a spawned-agent result callback is configured', async () => {
vi.stubEnv('AGENT_RELAY_RESULT_URL', 'http://127.0.0.1:3889/api/agent-result');
vi.stubEnv('AGENT_RELAY_RESULT_TOKEN', 'arr_test');
Expand Down Expand Up @@ -714,7 +859,10 @@ describe('createAgentRelayMcpServer', () => {
const result = await setWorkspaceKeyTool?.handler({ workspace_key: 'rk_live_existing' });

expect(result.structuredContent).toEqual({
message: 'Workspace key set.',
message: 'Workspace key set and persisted for this project.',
});
expect(mocks.persistWorkspaceSession).toHaveBeenCalledWith({
workspaceKey: 'rk_live_existing',
});

await server.tools.get('check_inbox')?.handler({});
Expand Down
7 changes: 7 additions & 0 deletions packages/cli/src/cli/agent-relay-mcp.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -209,12 +209,14 @@ describe('optionsFromEnv', () => {
it('ignores unresolved template environment placeholders', () => {
const previous = {
workspaceKey: process.env.RELAY_WORKSPACE_KEY,
agentRelayWorkspaceKey: process.env.AGENT_RELAY_WORKSPACE_KEY,
apiKey: process.env.RELAY_API_KEY,
agentName: process.env.RELAY_AGENT_NAME,
clawName: process.env.RELAY_CLAW_NAME,
agentToken: process.env.RELAY_AGENT_TOKEN,
};
process.env.RELAY_WORKSPACE_KEY = '${RELAY_WORKSPACE_KEY}';
delete process.env.AGENT_RELAY_WORKSPACE_KEY;
delete process.env.RELAY_API_KEY;
process.env.RELAY_AGENT_NAME = '${RELAY_AGENT_NAME}';
process.env.RELAY_CLAW_NAME = 'ClawFallback';
Expand All @@ -229,6 +231,11 @@ describe('optionsFromEnv', () => {
} finally {
if (previous.workspaceKey === undefined) delete process.env.RELAY_WORKSPACE_KEY;
else process.env.RELAY_WORKSPACE_KEY = previous.workspaceKey;
if (previous.agentRelayWorkspaceKey === undefined) {
delete process.env.AGENT_RELAY_WORKSPACE_KEY;
} else {
process.env.AGENT_RELAY_WORKSPACE_KEY = previous.agentRelayWorkspaceKey;
}
if (previous.apiKey === undefined) delete process.env.RELAY_API_KEY;
else process.env.RELAY_API_KEY = previous.apiKey;
if (previous.agentName === undefined) delete process.env.RELAY_AGENT_NAME;
Expand Down
Loading
Loading