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
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

### Added

- `@agent-relay/harnesses` adds a `grok` PTY harness for the Grok CLI, including Relaycast MCP support for spawned agents.
- `@agent-relay/harnesses` is now published to npm, so SDK consumers can install the prebuilt PTY harnesses and harness-authoring helpers.
- `agent-relay drive` and `agent-relay passthrough` add adaptive predictive echo so typing stays responsive when driving a high-latency or remote agent, and stays invisible on fast local links.
- `@agent-relay/harness-driver` exports a reusable `PredictiveEchoEngine` so other attach UIs (CLI, Electron, browser) can share one predictive-echo implementation.
Expand Down
188 changes: 187 additions & 1 deletion crates/broker/src/snippets.rs
Original file line number Diff line number Diff line change
Expand Up @@ -687,7 +687,7 @@ pub fn ensure_cursor_mcp_config(
Ok(changed)
}

/// - `cli`: CLI tool name (e.g. "claude", "codex", "gemini", "droid", "opencode", "cursor")
/// - `cli`: CLI tool name (e.g. "claude", "codex", "gemini", "droid", "grok", "opencode", "cursor")
/// - `agent_name`: the name of the agent being spawned
/// - `api_key`: optional relay API key (empty or `None` means omit)
/// - `base_url`: optional relay base URL (empty or `None` means omit)
Expand Down Expand Up @@ -765,6 +765,7 @@ pub async fn configure_agent_relay_mcp_with_result(
let is_gemini = cli_lower == "gemini";
let is_droid = cli_lower == "droid";
let is_opencode = cli_lower == "opencode";
let is_grok = cli_lower == "grok";
let is_cursor = cli_lower == "cursor" || cli_lower == "cursor-agent" || cli_lower == "agent"; // "agent" is cursor-agent's binary name

let api_key = api_key.map(str::trim).filter(|s| !s.is_empty());
Expand Down Expand Up @@ -920,6 +921,17 @@ pub async fn configure_agent_relay_mcp_with_result(
None,
)
.await?;
} else if is_grok {
configure_grok_mcp(
cli,
api_key,
base_url,
Some(agent_name),
agent_token,
workspaces_json,
default_workspace,
)
.await?;
} else if is_opencode && !existing_args.iter().any(|a| a == "--agent") {
ensure_opencode_config_with_result(
cwd,
Expand Down Expand Up @@ -1110,6 +1122,150 @@ fn gemini_droid_mcp_add_args_with_result(
args
}

fn grok_mcp_add_args(
api_key: Option<&str>,
base_url: Option<&str>,
agent_name: Option<&str>,
agent_token: Option<&str>,
workspaces_json: Option<&str>,
default_workspace: Option<&str>,
) -> Vec<String> {
let mut args = vec!["mcp".to_string(), "add".to_string()];
if let Some(key) = api_key {
args.push("--env".to_string());
args.push(format!("RELAY_API_KEY={key}"));
}
if let Some(url) = base_url {
args.push("--env".to_string());
args.push(format!("RELAY_BASE_URL={url}"));
}
if let Some(name) = agent_name.map(str::trim).filter(|s| !s.is_empty()) {
args.push("--env".to_string());
args.push(format!("RELAY_AGENT_NAME={name}"));
args.push("--env".to_string());
args.push("RELAY_AGENT_TYPE=agent".to_string());
args.push("--env".to_string());
args.push("RELAY_STRICT_AGENT_NAME=1".to_string());
}
if let Some(token) = agent_token.map(str::trim).filter(|s| !s.is_empty()) {
args.push("--env".to_string());
args.push(format!("RELAY_AGENT_TOKEN={token}"));
args.push("--env".to_string());
args.push("RELAY_SKIP_BOOTSTRAP=1".to_string());
}
if let Some(wj) = workspaces_json.map(str::trim).filter(|s| !s.is_empty()) {
args.push("--env".to_string());
args.push(format!("RELAY_WORKSPACES_JSON={wj}"));
}
if let Some(dw) = default_workspace.map(str::trim).filter(|s| !s.is_empty()) {
args.push("--env".to_string());
args.push(format!("RELAY_DEFAULT_WORKSPACE={dw}"));
}
args.push(AGENT_RELAY_MCP_SERVER.to_string());
let mcp_command = agent_relay_mcp_command();
args.push("--command".to_string());
args.push(mcp_command.command);
for arg in mcp_command.args {
args.push("--args".to_string());
args.push(arg);
}
args
}

fn grok_manual_mcp_add_cmd(cli: &str) -> String {
let mcp_command = agent_relay_mcp_command();
let rendered_args = mcp_command
.args
.iter()
.map(|arg| format!("--args {arg}"))
.collect::<Vec<_>>()
.join(" ");
format!(
"{cli} mcp add --env RELAY_API_KEY=<key> --env RELAY_BASE_URL=<url> {AGENT_RELAY_MCP_SERVER} --command {} {rendered_args}",
mcp_command.command
)
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.

async fn remove_grok_mcp_servers(exe: &str) {
for server_name in [AGENT_RELAY_MCP_SERVER, LEGACY_RELAYCAST_SERVER] {
let mut cmd = Command::new(exe);
cmd.args(["mcp", "remove", server_name])
.stdin(Stdio::null())
.stdout(Stdio::null())
.stderr(Stdio::null());
if let Ok(mut child) = cmd.spawn() {
let _ = tokio::time::timeout(Duration::from_secs(5), child.wait()).await;

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2: Timed-out grok mcp remove subprocesses are not terminated, which can leave background processes and race with subsequent MCP setup.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At crates/broker/src/snippets.rs, line 1197:

<comment>Timed-out `grok mcp remove` subprocesses are not terminated, which can leave background processes and race with subsequent MCP setup.</comment>

<file context>
@@ -1186,6 +1186,19 @@ fn grok_manual_mcp_add_cmd(cli: &str) -> String {
+            .stdout(Stdio::null())
+            .stderr(Stdio::null());
+        if let Ok(mut child) = cmd.spawn() {
+            let _ = tokio::time::timeout(Duration::from_secs(5), child.wait()).await;
+        }
+    }
</file context>
Suggested change
let _ = tokio::time::timeout(Duration::from_secs(5), child.wait()).await;
if tokio::time::timeout(Duration::from_secs(5), child.wait())
.await
.is_err()
{
let _ = child.kill().await;
let _ = child.wait().await;
}

}
}
}
Comment on lines +1189 to +1200

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

Timed-out child processes are not killed.

When the 5-second timeout expires at line 1197, the result is discarded but the child process is not killed. This differs from configure_grok_mcp (lines 1249-1250), which explicitly kills timed-out children. If grok mcp remove hangs, orphaned processes will accumulate.

🔧 Suggested fix to kill timed-out children
 async fn remove_grok_mcp_servers(exe: &str) {
     for server_name in [AGENT_RELAY_MCP_SERVER, LEGACY_RELAYCAST_SERVER] {
         let mut cmd = Command::new(exe);
         cmd.args(["mcp", "remove", server_name])
             .stdin(Stdio::null())
             .stdout(Stdio::null())
             .stderr(Stdio::null());
         if let Ok(mut child) = cmd.spawn() {
-            let _ = tokio::time::timeout(Duration::from_secs(5), child.wait()).await;
+            match tokio::time::timeout(Duration::from_secs(5), child.wait()).await {
+                Err(_) => {
+                    let _ = child.kill().await;
+                    let _ = child.wait().await;
+                }
+                _ => {}
+            }
         }
     }
 }
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
async fn remove_grok_mcp_servers(exe: &str) {
for server_name in [AGENT_RELAY_MCP_SERVER, LEGACY_RELAYCAST_SERVER] {
let mut cmd = Command::new(exe);
cmd.args(["mcp", "remove", server_name])
.stdin(Stdio::null())
.stdout(Stdio::null())
.stderr(Stdio::null());
if let Ok(mut child) = cmd.spawn() {
let _ = tokio::time::timeout(Duration::from_secs(5), child.wait()).await;
}
}
}
async fn remove_grok_mcp_servers(exe: &str) {
for server_name in [AGENT_RELAY_MCP_SERVER, LEGACY_RELAYCAST_SERVER] {
let mut cmd = Command::new(exe);
cmd.args(["mcp", "remove", server_name])
.stdin(Stdio::null())
.stdout(Stdio::null())
.stderr(Stdio::null());
if let Ok(mut child) = cmd.spawn() {
match tokio::time::timeout(Duration::from_secs(5), child.wait()).await {
Err(_) => {
let _ = child.kill().await;
let _ = child.wait().await;
}
_ => {}
}
}
}
}
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@crates/broker/src/snippets.rs` around lines 1189 - 1200, The
remove_grok_mcp_servers function currently discards the timeout result and can
leave hung children running; modify it so that after spawning (in
remove_grok_mcp_servers) you capture the tokio::time::timeout result, and if it
indicates a timeout, call child.kill().await (and then await child.wait().await)
to ensure the child is terminated—mirror the logic used in configure_grok_mcp to
explicitly kill timed-out children and then wait for their exit.


#[allow(clippy::too_many_arguments)]
async fn configure_grok_mcp(
cli: &str,
api_key: Option<&str>,
base_url: Option<&str>,
agent_name: Option<&str>,
agent_token: Option<&str>,
workspaces_json: Option<&str>,
default_workspace: Option<&str>,
) -> Result<()> {
let exe = shlex::split(cli)
.and_then(|parts| parts.first().cloned())
.unwrap_or_else(|| cli.trim().to_string());
let manual_cmd = grok_manual_mcp_add_cmd(&exe);

remove_grok_mcp_servers(&exe).await;

let mut mcp_cmd = Command::new(&exe);
mcp_cmd.args(grok_mcp_add_args(
api_key,
base_url,
agent_name,
agent_token,
workspaces_json,
default_workspace,
));
mcp_cmd
.stdin(Stdio::null())
.stdout(Stdio::null())
.stderr(Stdio::null());

match mcp_cmd.spawn() {
Ok(mut child) => match tokio::time::timeout(Duration::from_secs(15), child.wait()).await {
Ok(Ok(status)) if !status.success() => {
anyhow::bail!(
"failed to configure Agent Relay MCP for {cli}: `{cli} mcp add` exited with code {:?}. \
Please configure the Agent Relay MCP server manually:\n {manual_cmd}",
status.code()
);
}
Ok(Err(error)) => {
anyhow::bail!(
"failed to configure Agent Relay MCP for {cli}: {error}. \
Please configure the Agent Relay MCP server manually:\n {manual_cmd}"
);
}
Err(_) => {
let _ = child.kill().await;
let _ = child.wait().await;
anyhow::bail!(
"failed to configure Agent Relay MCP for {cli}: `{cli} mcp add` timed out after 15s. \
Please configure the Agent Relay MCP server manually:\n {manual_cmd}"
);
Comment on lines +1248 to +1254

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggestion: On timeout, the child is killed but never reaped with a follow-up wait, which can leave a zombie process entry behind. After kill(), explicitly wait() (or try_wait loop) before returning the error to ensure process cleanup is complete. [missing cleanup]

Severity Level: Major ⚠️
- ⚠️ Timeout during Grok MCP setup can leave zombie processes.
- ⚠️ Repeated Grok timeouts may slowly leak OS process entries.
Steps of Reproduction ✅
1. As in suggestion 1, starting from `crates/broker/src/worker.rs:16-26`, calling
`build_mcp_args` with a Grok CLI (`cli_name = \"grok\"`) leads to
`configure_agent_relay_mcp_with_result` in `crates/broker/src/snippets.rs:750-761`, which
dispatches to `configure_grok_mcp` for Grok.

2. In `configure_grok_mcp` (`crates/broker/src/snippets.rs:143-176`), the code creates a
`tokio::process::Command` (`mcp_cmd`) and runs `mcp_cmd.spawn()`, then wraps
`child.wait()` in `tokio::time::timeout(Duration::from_secs(15), child.wait()).await`
(lines ~143-145).

3. When the spawned `grok mcp add` process does not complete `wait()` within 15 seconds
(for example, by pointing `cli` to a script that sleeps indefinitely), the timeout branch
`Err(_) => { ... }` at lines 158-163 is taken:

   `Err(_) => { let _ = child.kill().await; anyhow::bail!(\"failed to configure ... timed
   out after 15s ...\"); }`.

4. In this timeout path, the code kills the child process but never calls
`wait()`/`try_wait()` afterward to reap it. On platforms where `kill()` only sends a
signal and does not reap the process, this can leave a zombie Grok CLI process owned by
the broker until the parent exits, leaking process table entries whenever
`configure_grok_mcp` times out.

Fix in Cursor | Fix in VSCode Claude

(Use Cmd/Ctrl + Click for best experience)

Prompt for AI Agent 🤖
This is a comment left during a code review.

**Path:** crates/broker/src/snippets.rs
**Line:** 1237:1242
**Comment:**
	*Missing Cleanup: On timeout, the child is killed but never reaped with a follow-up wait, which can leave a zombie process entry behind. After `kill()`, explicitly `wait()` (or `try_wait` loop) before returning the error to ensure process cleanup is complete.

Validate the correctness of the flagged issue. If correct, How can I resolve this? If you propose a fix, implement it and please make it concise.
Once fix is implemented, also check other comments on the same PR, and ask user if the user wants to fix the rest of the comments as well. if said yes, then fetch all the comments validate the correctness and implement a minimal fix
👍 | 👎

}
_ => {}
},
Err(error) => {
anyhow::bail!(
"failed to configure Agent Relay MCP for {cli}: {error}. \
Please configure the Agent Relay MCP server manually:\n {manual_cmd}"
);
}
}

Ok(())
}

#[allow(clippy::too_many_arguments)]
async fn configure_gemini_droid_mcp(
cli: &str,
Expand Down Expand Up @@ -1584,6 +1740,36 @@ mod tests {
assert_eq!(args[0], "--mcp-config");
}

#[test]
fn grok_mcp_add_args_use_command_and_args_flags() {
let args = super::grok_mcp_add_args(
Some("rk_live_xyz"),
Some("https://api.relaycast.dev"),
Some("GrokWorker"),
Some("tok_grok_123"),
None,
None,
);

assert!(args.starts_with(&["mcp".to_string(), "add".to_string()]));
assert!(args.contains(&"--env".to_string()));
assert!(args.contains(&"RELAY_API_KEY=rk_live_xyz".to_string()));
assert!(args.contains(&"RELAY_AGENT_NAME=GrokWorker".to_string()));
assert!(args.contains(&"RELAY_AGENT_TOKEN=tok_grok_123".to_string()));
let server_idx = args
.iter()
.position(|arg| arg == "agent-relay")
.expect("agent-relay arg");
assert_eq!(args[server_idx + 1], "--command");
assert_eq!(args[server_idx + 2], "npx");
assert_eq!(args[server_idx + 3], "--args");
assert_eq!(args[server_idx + 4], "-y");
Comment on lines +1765 to +1766

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

Update the test assertion to match the joined --args string suggestion.

        assert_eq!(args[server_idx + 3], "--args");\n        assert_eq!(args[server_idx + 4], "-y agent-relay mcp");

assert_eq!(args[server_idx + 5], "--args");
assert_eq!(args[server_idx + 6], "agent-relay");
assert_eq!(args[server_idx + 7], "--args");
assert_eq!(args[server_idx + 8], "mcp");
}

#[test]
fn droid_mcp_add_args_include_option_separator() {
let args = super::gemini_droid_mcp_add_args(
Expand Down
6 changes: 6 additions & 0 deletions crates/broker/src/worker.rs
Original file line number Diff line number Diff line change
Expand Up @@ -280,6 +280,7 @@ impl WorkerRegistry {
let is_claude = cli_lower == "claude" || cli_lower.starts_with("claude:");
let is_codex = cli_lower == "codex";
let is_gemini = cli_lower == "gemini";
let is_grok = cli_lower == "grok";
if let Some(model) = apply_codex_model_arg_fallback(
&resolved_cli,
&cli_lower,
Expand Down Expand Up @@ -356,6 +357,8 @@ impl WorkerRegistry {
Some("--dangerously-bypass-approvals-and-sandbox")
} else if is_gemini && !effective_args.iter().any(|a| a == "--yolo" || a == "-y") {
Some("--yolo")
} else if is_grok && !effective_args.iter().any(|a| a == "--always-approve") {
Some("--always-approve")
} else {
None
};
Expand Down Expand Up @@ -481,6 +484,7 @@ impl WorkerRegistry {
let is_claude = cli_lower == "claude" || cli_lower.starts_with("claude:");
let is_codex = cli_lower == "codex";
let is_gemini = cli_lower == "gemini";
let is_grok = cli_lower == "grok";
if let Some(model) = apply_codex_model_arg_fallback(
&resolved_cli,
&cli_lower,
Expand Down Expand Up @@ -559,6 +563,8 @@ impl WorkerRegistry {
&& !effective_args.iter().any(|a| a == "--yolo" || a == "-y")
{
Some("--yolo")
} else if is_grok && !effective_args.iter().any(|a| a == "--always-approve") {
Some("--always-approve")
} else {
None
};
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ describe('orchestrator harness detection', () => {
it('maps common process command names to harness identifiers', () => {
expect(inferHarnessFromCommand('/usr/local/bin/claude')).toBe('claude-code');
expect(inferHarnessFromCommand('/opt/homebrew/bin/codex')).toBe('codex');
expect(inferHarnessFromCommand('/Users/will/.grok/bin/grok')).toBe('grok');
expect(inferHarnessFromCommand('/Applications/Cursor.app/Contents/MacOS/Cursor')).toBe('cursor');
expect(inferHarnessFromCommand('/usr/local/bin/gemini-cli')).toBe('gemini-cli');
expect(inferHarnessFromCommand(String.raw`C:\Users\will\AppData\Roaming\npm\gemini.cmd`)).toBe(
Expand Down
54 changes: 43 additions & 11 deletions packages/cli/src/cli/telemetry/orchestrator-harness.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,23 +33,55 @@ export function sanitizeOrchestratorHarness(raw: string | undefined): string | u
return trimmed.slice(0, HARNESS_MAX_LENGTH).toLowerCase();
}

interface HarnessCommandContext {
base: string;
lower: string;
normalized: string;
}

const HARNESS_COMMAND_MATCHERS: ReadonlyArray<{
harness: string;
matches: (ctx: HarnessCommandContext) => boolean;
}> = [
{
harness: 'claude-code',
matches: ({ base, lower }) => base === 'claude' || lower.includes('claude-code'),
},
{
harness: 'codex',
matches: ({ base, normalized }) => base === 'codex' || normalized.includes('/codex'),
},
{
harness: 'cursor',
matches: ({ base, lower }) => base === 'cursor' || base === 'cursor-agent' || lower.includes('cursor'),
},
{
harness: 'gemini-cli',
matches: ({ base, lower }) => base === 'gemini' || base === 'gemini-cli' || lower.includes('gemini-cli'),
},
{ harness: 'aider', matches: ({ base, lower }) => base === 'aider' || lower.includes('aider') },
{
harness: 'opencode',
matches: ({ base, lower }) => base === 'opencode' || lower.includes('opencode'),
},
{ harness: 'goose', matches: ({ base, lower }) => base === 'goose' || lower.includes('goose') },
{ harness: 'droid', matches: ({ base, lower }) => base === 'droid' || lower.includes('droid') },
{ harness: 'grok', matches: ({ base }) => base === 'grok' },
{ harness: 'amp', matches: ({ base, normalized }) => base === 'amp' || normalized.includes('/amp') },
{ harness: 'github-copilot', matches: ({ lower }) => lower.includes('copilot') },
{ harness: 'zed', matches: ({ base, lower }) => base === 'zed' || lower.includes('zed') },
];

export function inferHarnessFromCommand(command: string | undefined): string | undefined {
if (!command) return undefined;
const lower = command.toLowerCase();
const normalized = lower.replace(/\\/g, '/');
const base = path.basename(normalized).replace(/\.(exe|cmd|bat)$/i, '');
const ctx: HarnessCommandContext = { base, lower, normalized };

if (base === 'claude' || lower.includes('claude-code')) return 'claude-code';
if (base === 'codex' || normalized.includes('/codex')) return 'codex';
if (base === 'cursor' || base === 'cursor-agent' || lower.includes('cursor')) return 'cursor';
if (base === 'gemini' || base === 'gemini-cli' || lower.includes('gemini-cli')) return 'gemini-cli';
if (base === 'aider' || lower.includes('aider')) return 'aider';
if (base === 'opencode' || lower.includes('opencode')) return 'opencode';
if (base === 'goose' || lower.includes('goose')) return 'goose';
if (base === 'droid' || lower.includes('droid')) return 'droid';
if (base === 'amp' || normalized.includes('/amp')) return 'amp';
if (lower.includes('copilot')) return 'github-copilot';
if (base === 'zed' || lower.includes('zed')) return 'zed';
for (const matcher of HARNESS_COMMAND_MATCHERS) {
if (matcher.matches(ctx)) return matcher.harness;
}

return undefined;
}
Expand Down
1 change: 1 addition & 0 deletions packages/cloud/src/permissions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ export type AgentCli =
| 'gemini'
| 'aider'
| 'goose'
| 'grok'
Comment thread
coderabbitai[bot] marked this conversation as resolved.
| 'opencode'
| 'droid'
| 'cursor'
Expand Down
Loading
Loading