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
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
# Fix Relay v11.2 CLI demo visuals and Relay-driven coordination

**Status:** Completed
**Confidence:** 90%
**Date:** 2026-07-25

## Summary

Fixed `attach --mode drive|passthrough` terminal corruption by reserving a
non-wrapping local status row, clipping its label, preserving the child
application's ANSI boundary, scroll-region, origin-mode, and alternate-screen
state, and reconciling terminal resizes before and during setup. Predictive echo
now updates boundary state only after its actual terminal writes.

Published Relay-first coordination rules through MCP initialize instructions so
interactive Codex sessions contact existing named Relay participants instead of
substituting provider-native subagents. Added a credential-gated real Codex E2E
that requires a relevant message to the named participant and rejects the
native-subagent wait flow.

## Decisions

- Reserve a dedicated terminal row and spare autowrap column for Relay's attach
status, with dynamic handling for degenerate and resized terminals.
- Track terminal controls with a streaming ANSI state machine so Relay repaints
restore the child's exact DECSTBM/DECOM/buffer state without interpreting
control-looking bytes inside OSC/DCS payloads.
- Deliver named-participant routing guidance as MCP server instructions because
interactive CLI sessions may start without a task-prefix prompt.

## Validation

- Full CLI suite: 816 passed, 11 skipped.
- Focused attach/MCP suite: 211 passed.
- CLI build, broker integration TypeScript build, lint (zero errors), and
`git diff --check` passed.
- Independent Codex fresh-context review approved the final state.
- Claude review and the credentialed live Codex E2E were unavailable because
Claude was not authenticated and external credential egress was not approved.
3 changes: 3 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- Identity forwarding to the Relaycast gateway is no longer gated on the local process carrying a PostHog key. An npm-installed CLI bakes no key, so it previously forwarded no identity at all and every hosted event fell back to being keyed on the workspace. Forwarding now follows the telemetry preference alone.
- `node agent list` no longer reports an exited agent as `working` indefinitely; the broker now reaps a worker whose harness has gone away or never started.
- `node agent attach --mode drive|passthrough` truncates its status line to the terminal width, so a narrow pane no longer stacks status lines over the agent's output.
- PTY `node agent attach --mode drive|passthrough` sessions now reserve and safely clip their status row, preventing full-screen agent CLIs from tearing, scrolling, or duplicating Relay's controls.
- Detaching from `node agent attach --mode drive|passthrough` restores the row and column the status line reserved, so a later `--mode view` session no longer inherits a PTY one row and column short. `POST /api/resize/{name}` applies dimensions sent alongside `release: true`.
- Agent Relay MCP instructions now route work with existing named participants through Relay instead of provider-native subagents.
- `node agent attach --mode view` now exits on the first Ctrl-C instead of waiting for a WebSocket close handshake.
- The broker now sends its anonymous telemetry id (`X-Agent-Relay-Distinct-Id`) and origin actor with its Relaycast requests, so hosted usage can be attributed to an install instead of only to a workspace. The id header is omitted when telemetry is opted out; requests and origin actor are unaffected.
- The broker now reads its telemetry preference and machine-id files from `AGENT_RELAY_DATA_DIR` when set, matching the CLI. It previously only read `~/.agentworkforce/relay/telemetry.json`, so an opt-out written by `agent-relay telemetry disable` under a configured data directory was ignored.
Expand Down
56 changes: 53 additions & 3 deletions crates/broker/src/listen_api.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2095,9 +2095,11 @@ async fn send_pty_input_ws_error(

#[derive(Deserialize)]
struct ResizePtyBody {
/// Target dimensions. Defaulted so a pure ownership release (`release:
/// true`) doesn't have to carry dummy dimensions — the handler skips the
/// resize entirely on release.
/// Target dimensions. Defaulted to zero so a pure ownership release
/// (`release: true`) doesn't have to carry dummy dimensions — the handler
/// skips the resize when a release carries no real size. A release that
/// *does* carry dimensions applies them before dropping ownership, so an
/// attach client can hand back a reserved status row in one request.
#[serde(default)]
rows: u16,
#[serde(default)]
Expand Down Expand Up @@ -4802,6 +4804,54 @@ mod auth_tests {
replier.await.expect("replier should complete");
}

#[tokio::test]
async fn resize_pty_route_release_forwards_restore_dimensions() {
// An attach that reserved a status row hands it back on the release
// itself, so the route must forward rows/cols alongside `release: true`
// rather than dropping them as it would for a pure release.
let (router, mut rx) = test_router(Some("secret"));
let replier = tokio::spawn(async move {
match rx.recv().await {
Some(ListenApiRequest::ResizePty {
rows,
cols,
session_id,
release,
reply,
..
}) => {
assert_eq!(rows, 30);
assert_eq!(cols, 100);
assert_eq!(session_id.as_deref(), Some("sess-1"));
assert!(release);
let _ = reply.send(Ok(json!({ "released": true, "resized": true })));
}
other => panic!("unexpected request: {:?}", other.map(|_| "other")),
}
});

let response = router
.oneshot(
Request::builder()
.uri("/api/resize/worker-a")
.method("POST")
.header("x-api-key", "secret")
.header("content-type", "application/json")
.body(Body::from(
json!({ "rows": 30, "cols": 100, "session_id": "sess-1", "release": true })
.to_string(),
))
.expect("request should build"),
)
.await
.expect("request should succeed");

assert_eq!(response.status(), StatusCode::OK);
let body = response_json(response).await;
assert_eq!(body["resized"], json!(true));
replier.await.expect("replier should complete");
}

#[tokio::test]
async fn resize_pty_route_normalises_blank_session_id() {
// A whitespace-only session id must arrive as `None`, never as a shared
Expand Down
62 changes: 46 additions & 16 deletions crates/broker/src/runtime/api.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1392,26 +1392,56 @@ impl BrokerRuntime {

// Explicit ownership release on detach (see `resize_owners`
// doc on `BrokerRuntime`). A release carries the owning
// `session_id`; we drop ownership only if it matches, then
// return without touching the PTY size. A release without a
// session id, or from a non-owner, is a no-op. The response
// reports the *actual* outcome so the client can tell a real
// release from a no-op.
// `session_id`; we drop ownership only if it matches. A release
// without a session id, or from a non-owner, is a no-op. The
// response reports the *actual* outcome so the client can tell
// a real release from a no-op.
//
// A release MAY carry dimensions, which are applied to the
// worker before ownership is dropped. Attach clients that
// reserve a status row need to hand that row back on detach,
// and doing it here keeps the restore atomic with the release:
// a separate resize call would need to land strictly before the
// release (a later one re-claims the lease — the detach race in
// #1247) and would add a second round-trip to teardown. Zero
// dimensions mean "no restore", so a pure release still needs
// no placeholder size.
if release {
let released = match session_id.as_deref() {
Some(sid)
if resize_owners
.get(&name)
.is_some_and(|owner| owner.session_id == sid) =>
{
resize_owners.remove(&name);
true
}
_ => false,
let owns = match session_id.as_deref() {
Some(sid) => resize_owners
.get(&name)
.is_some_and(|owner| owner.session_id == sid),
None => false,
};
// Only the owner may resize, and only a real size restores.
// A worker that has already exited just releases: teardown
// is best-effort and must not fail on a gone worker.
let resized = owns
&& rows > 0
&& cols > 0
&& matches!(
workers
.workers
.get(&name)
.map(|handle| handle.spec.runtime.clone()),
Some(AgentRuntime::Pty)
)
&& workers
.send_to_worker(
&name,
"resize_pty",
Some(RequestId::new(format!("api_{}", Uuid::new_v4().simple()))),
json!({ "rows": rows, "cols": cols }),
)
.await
.is_ok();
if owns {
resize_owners.remove(&name);
}
let _ = reply.send(Ok(json!({
"name": name,
"released": released,
"released": owns,
"resized": resized,
})));
} else if rows == 0 || cols == 0 {
let _ =
Expand Down
26 changes: 26 additions & 0 deletions packages/cli/src/cli/agent-relay-mcp.protocol.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
import { Client } from '@modelcontextprotocol/sdk/client/index.js';
import { InMemoryTransport } from '@modelcontextprotocol/sdk/inMemory.js';
import { describe, expect, it } from 'vitest';

import { createAgentRelayMcpServer } from './agent-relay-mcp.js';

describe('Agent Relay MCP initialization', () => {
it('delivers Relay-first coordination instructions through the MCP protocol', async () => {
const server = createAgentRelayMcpServer({});
const client = new Client({ name: 'relay-protocol-test', version: '1.0.0' });
const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair();

try {
await server.connect(serverTransport);
await client.connect(clientTransport);

expect(client.getInstructions()).toContain(
'Existing Relay participants are not local or built-in subagents'
);
expect(client.getInstructions()).toContain('"send_dm"');
} finally {
await client.close();
await server.close();
}
});
});
9 changes: 8 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 @@ -62,6 +62,7 @@ async function loadAgentRelayMcpModule(options: LoadOptions = {}) {
class FakeTransport {}

class FakeMcpServer {
readonly options: unknown;
readonly tools = new Map<string, { config: unknown; handler: (input: any) => Promise<any> }>();
readonly prompts = new Map<string, { config: unknown; handler: () => Promise<any> }>();
readonly resources = new Map<
Expand All @@ -80,7 +81,8 @@ async function loadAgentRelayMcpModule(options: LoadOptions = {}) {
};
listToolsHandler?: (req: unknown, extra: unknown) => Promise<{ tools?: Array<Record<string, unknown>> }>;

constructor(_info: unknown, _capabilities: unknown) {
constructor(_info: unknown, capabilities: unknown) {
this.options = capabilities;
this.server = {
_requestHandlers: new Map([
[
Expand Down Expand Up @@ -425,6 +427,11 @@ describe('createAgentRelayMcpServer', () => {
expect(server.server._requestHandlers.has('resources/subscribe')).toBe(true);
expect(server.server._requestHandlers.has('resources/unsubscribe')).toBe(true);
expect(server.prompts.get('system')).toBeDefined();
expect(server.options).toMatchObject({
instructions: expect.stringContaining(
'Existing Relay participants are not local or built-in subagents'
),
});

await expect(server.tools.get('register_agent')?.handler({ name: 'WorkerA' })).rejects.toThrow(
'Workspace key not configured. Call "create_workspace" first, or "set_workspace_key" if someone shared a workspace key.'
Expand Down
10 changes: 9 additions & 1 deletion packages/cli/src/cli/agent-relay-mcp.ts
Original file line number Diff line number Diff line change
Expand Up @@ -61,7 +61,12 @@
return `${task}\n\n${EXIT_AFTER_TASK_INSTRUCTION}`;
}

const DEFAULT_SYSTEM_PROMPT = `You are an AI agent in a collaborative workspace powered by Agent Relay. You can communicate with other agents using these MCP tools:
export const AGENT_RELAY_MCP_INSTRUCTIONS = `You are an AI agent in a collaborative workspace powered by Agent Relay. You can communicate with other agents using these MCP tools:

## Coordination rule
- When the user asks you to work with, contact, coordinate with, or wait for named participants that already exist in this Agent Relay workspace, use Agent Relay tools such as "list_agents", "send_dm", "post_message", and "check_inbox".
- Existing Relay participants are not local or built-in subagents. Do not replace them with your CLI's native subagent, team, task, or collaboration feature.
- Do not claim to have contacted or waited for a Relay participant unless the corresponding Relay tool call succeeded.

## Getting Started
1. The current project workspace is resumed automatically when one was selected before
Expand Down Expand Up @@ -89,6 +94,8 @@
- React with emoji to acknowledge messages
- Keep messages concise and actionable`;

const DEFAULT_SYSTEM_PROMPT = AGENT_RELAY_MCP_INSTRUCTIONS;

type AgentResultCallbackConfig = {
url: string;
token: string;
Expand Down Expand Up @@ -267,7 +274,7 @@
});
} catch (err) {
if ((err as { name?: string }).name === 'AbortError') {
throw new Error(`Agent Relay result submission timed out after ${timeoutMs}ms`);

Check warning on line 277 in packages/cli/src/cli/agent-relay-mcp.ts

View workflow job for this annotation

GitHub Actions / lint

There is no `cause` attached to the symptom error being thrown
}
throw err;
} finally {
Expand Down Expand Up @@ -320,7 +327,7 @@
return { agentName, agentToken };
}

export async function registerAgentWithRebind({

Check warning on line 330 in packages/cli/src/cli/agent-relay-mcp.ts

View workflow job for this annotation

GitHub Actions / lint

Async function 'registerAgentWithRebind' has a complexity of 23. Maximum allowed is 15
session,
setSession,
getRelay,
Expand Down Expand Up @@ -738,6 +745,7 @@
tools: {},
prompts: {},
},
instructions: AGENT_RELAY_MCP_INSTRUCTIONS,
}
);

Expand Down
Loading
Loading