From b5ee02f05f1c8fbf4ae916da3e7239d2463c0f15 Mon Sep 17 00:00:00 2001 From: Will Washburn Date: Fri, 5 Jun 2026 07:56:49 -0400 Subject: [PATCH 1/5] feat(harnesses): add Grok CLI harness with broker MCP injection Expose `grok` in @agent-relay/harnesses for PTY spawn workflows, register Grok in the CLI registry, and configure Relaycast MCP via `grok mcp add` so spawned agents can message over Relay. Auto-inject `--always-approve` for unattended broker spawns. --- CHANGELOG.md | 1 + crates/broker/src/snippets.rs | 172 +++++++++++++++++- crates/broker/src/worker.rs | 10 + .../telemetry/orchestrator-harness.test.ts | 1 + .../src/cli/telemetry/orchestrator-harness.ts | 1 + packages/cloud/src/permissions.ts | 1 + packages/config/src/cli-registry.generated.ts | 43 +++++ packages/harnesses/README.md | 2 +- packages/harnesses/src/define.test.ts | 5 +- packages/harnesses/src/index.ts | 2 + packages/sdk-py/src/agent_relay/models.py | 22 +++ packages/utils/cli-registry.yaml | 14 ++ 12 files changed, 271 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index dfcb9844f..ae10c6531 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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, with broker MCP injection via `grok mcp add` so spawned agents can use Relaycast tools. - `@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. diff --git a/crates/broker/src/snippets.rs b/crates/broker/src/snippets.rs index 3bc229a31..836bad9d2 100644 --- a/crates/broker/src/snippets.rs +++ b/crates/broker/src/snippets.rs @@ -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) @@ -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()); @@ -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, @@ -1110,6 +1122,138 @@ 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 { + 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); + args.push("--args".to_string()); + args.extend(mcp_command.args); + args +} + +fn grok_manual_mcp_add_cmd(cli: &str) -> String { + let mcp_command = agent_relay_mcp_command(); + let mut rendered_parts = vec![mcp_command.command]; + rendered_parts.extend(mcp_command.args); + let rendered_mcp_command = rendered_parts.join(" "); + format!( + "{cli} mcp add --env RELAY_API_KEY= --env RELAY_BASE_URL= {AGENT_RELAY_MCP_SERVER} --command {rendered_mcp_command}" + ) +} + +#[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); + + for server_name in [AGENT_RELAY_MCP_SERVER, LEGACY_RELAYCAST_SERVER] { + let _ = std::process::Command::new(&exe) + .args(["mcp", "remove", server_name]) + .stdin(Stdio::null()) + .stdout(Stdio::null()) + .stderr(Stdio::null()) + .spawn() + .and_then(|mut c| c.wait()); + } + + 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::piped()); + + 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; + 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}" + ); + } + _ => {} + }, + 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, @@ -1584,6 +1728,32 @@ 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"); + } + #[test] fn droid_mcp_add_args_include_option_separator() { let args = super::gemini_droid_mcp_add_args( diff --git a/crates/broker/src/worker.rs b/crates/broker/src/worker.rs index b14277b90..264029ba3 100644 --- a/crates/broker/src/worker.rs +++ b/crates/broker/src/worker.rs @@ -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, @@ -356,6 +357,10 @@ 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 }; @@ -481,6 +486,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, @@ -559,6 +565,10 @@ 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 }; diff --git a/packages/cli/src/cli/telemetry/orchestrator-harness.test.ts b/packages/cli/src/cli/telemetry/orchestrator-harness.test.ts index cb3efa28b..fab5eea22 100644 --- a/packages/cli/src/cli/telemetry/orchestrator-harness.test.ts +++ b/packages/cli/src/cli/telemetry/orchestrator-harness.test.ts @@ -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( diff --git a/packages/cli/src/cli/telemetry/orchestrator-harness.ts b/packages/cli/src/cli/telemetry/orchestrator-harness.ts index 2e0d28284..2e5d9b5d7 100644 --- a/packages/cli/src/cli/telemetry/orchestrator-harness.ts +++ b/packages/cli/src/cli/telemetry/orchestrator-harness.ts @@ -47,6 +47,7 @@ export function inferHarnessFromCommand(command: string | undefined): string | u 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 === 'grok' || lower.includes('/grok')) return 'grok'; if (base === 'amp' || normalized.includes('/amp')) return 'amp'; if (lower.includes('copilot')) return 'github-copilot'; if (base === 'zed' || lower.includes('zed')) return 'zed'; diff --git a/packages/cloud/src/permissions.ts b/packages/cloud/src/permissions.ts index dd412bbd6..602f9f1ef 100644 --- a/packages/cloud/src/permissions.ts +++ b/packages/cloud/src/permissions.ts @@ -14,6 +14,7 @@ export type AgentCli = | 'gemini' | 'aider' | 'goose' + | 'grok' | 'opencode' | 'droid' | 'cursor' diff --git a/packages/config/src/cli-registry.generated.ts b/packages/config/src/cli-registry.generated.ts index 274e33582..7b7873ed2 100644 --- a/packages/config/src/cli-registry.generated.ts +++ b/packages/config/src/cli-registry.generated.ts @@ -24,6 +24,8 @@ export const CLIVersions = { DROID: '0.1.0', /** OpenCode v1.2.24 */ OPENCODE: '1.2.24', + /** Grok v0.1.0 */ + GROK: '0.1.0', /** Aider v0.72.1 */ AIDER: '0.72.1', /** Goose v1.0.16 */ @@ -40,6 +42,7 @@ export const CLIs = { CURSOR: 'cursor', DROID: 'droid', OPENCODE: 'opencode', + GROK: 'grok', AIDER: 'aider', GOOSE: 'goose', } as const; @@ -420,6 +423,18 @@ export const OpencodeModels = { export type OpencodeModel = (typeof OpencodeModels)[keyof typeof OpencodeModels]; +/** + * Grok model identifiers. + */ +export const GrokModels = { + /** Grok Build (default) */ + GROK_BUILD: 'grok-build', + /** Grok Composer 2.5 Fast */ + GROK_COMPOSER_2_5_FAST: 'grok-composer-2.5-fast', +} as const; + +export type GrokModel = (typeof GrokModels)[keyof typeof GrokModels]; + /** Reasoning effort levels supported by model providers. */ export const ReasoningEfforts = { LOW: 'low', @@ -637,6 +652,14 @@ export const OPENCODE_MODEL_OPTIONS: ModelOption[] = [ { value: 'openai/o4-mini-deep-research', label: 'O4 Mini Deep Research' }, ]; +/** + * Grok model options for UI dropdowns. + */ +export const GROK_MODEL_OPTIONS: ModelOption[] = [ + { value: 'grok-build', label: 'Grok Build' }, + { value: 'grok-composer-2.5-fast', label: 'Grok Composer 2.5 Fast' }, +]; + /** * Claude Code model metadata keyed by model id. */ @@ -836,6 +859,14 @@ export const OPENCODE_MODEL_METADATA: Record = { 'openai/o4-mini-deep-research': { value: 'openai/o4-mini-deep-research', label: 'O4 Mini Deep Research' }, }; +/** + * Grok model metadata keyed by model id. + */ +export const GROK_MODEL_METADATA: Record = { + 'grok-build': { value: 'grok-build', label: 'Grok Build' }, + 'grok-composer-2.5-fast': { value: 'grok-composer-2.5-fast', label: 'Grok Composer 2.5 Fast' }, +}; + /** * All models grouped by CLI tool. * @@ -854,6 +885,7 @@ export const Models = { Cursor: CursorModels, Droid: DroidModels, Opencode: OpencodeModels, + Grok: GrokModels, } as const; /** @@ -875,6 +907,7 @@ export const ModelOptions = { Cursor: CURSOR_MODEL_OPTIONS, Droid: DROID_MODEL_OPTIONS, Opencode: OPENCODE_MODEL_OPTIONS, + Grok: GROK_MODEL_OPTIONS, } as const; /** @@ -887,6 +920,7 @@ export const ModelMetadata = { Cursor: CURSOR_MODEL_METADATA, Droid: DROID_MODEL_METADATA, Opencode: OPENCODE_MODEL_METADATA, + Grok: GROK_MODEL_METADATA, } as const; const MODEL_METADATA_BY_CLI: Record> = { @@ -896,6 +930,7 @@ const MODEL_METADATA_BY_CLI: Record> = { cursor: CURSOR_MODEL_METADATA, droid: DROID_MODEL_METADATA, opencode: OPENCODE_MODEL_METADATA, + grok: GROK_MODEL_METADATA, aider: {}, goose: {}, }; @@ -1001,6 +1036,13 @@ export const CLIRegistry = { install: 'npm install -g opencode-ai', npmLink: 'https://www.npmjs.com/package/opencode-ai', }, + grok: { + name: 'Grok', + package: 'grok', + version: '0.1.0', + install: 'Install from x.ai (Grok CLI)', + npmLink: undefined, + }, aider: { name: 'Aider', package: 'aider-chat', @@ -1027,4 +1069,5 @@ export const DefaultModels = { cursor: 'composer-2-fast', droid: 'opus-4.6-fast', opencode: 'openai/gpt-5.2', + grok: 'grok-build', } as const; diff --git a/packages/harnesses/README.md b/packages/harnesses/README.md index f86856f32..f7dfa4f12 100644 --- a/packages/harnesses/README.md +++ b/packages/harnesses/README.md @@ -4,4 +4,4 @@ Pre-built harness definitions for common local agent CLIs. Use this package with `@agent-relay/harness-driver` when Agent Relay should create or supervise managed sessions for tools such as Claude Code, Codex, Gemini, -OpenCode, Aider, Goose, Cursor, or Droid. +OpenCode, Aider, Goose, Grok, Cursor, or Droid. diff --git a/packages/harnesses/src/define.test.ts b/packages/harnesses/src/define.test.ts index 376f207b8..e3063dd65 100644 --- a/packages/harnesses/src/define.test.ts +++ b/packages/harnesses/src/define.test.ts @@ -1,12 +1,15 @@ import { describe, expect, it } from 'vitest'; -import { claude, codex, definePtyHarness } from './index.js'; +import { claude, codex, definePtyHarness, grok } from './index.js'; describe('harness factories (Phase C)', () => { it('exposes the static definition shape for the runtime', () => { expect(claude.runtime).toBe('pty'); expect(claude.command).toBe('claude'); expect(claude.name).toBe('claude'); + expect(grok.runtime).toBe('pty'); + expect(grok.command).toBe('grok'); + expect(grok.name).toBe('grok'); }); it('create() returns a registerable agent handle with identity + model', async () => { diff --git a/packages/harnesses/src/index.ts b/packages/harnesses/src/index.ts index a0306efbf..ac0eb790d 100644 --- a/packages/harnesses/src/index.ts +++ b/packages/harnesses/src/index.ts @@ -28,3 +28,5 @@ export const opencode: PtyHarness = definePtyHarness({ runtime: 'pty', command: export const aider: PtyHarness = definePtyHarness({ runtime: 'pty', command: 'aider' }); export const goose: PtyHarness = definePtyHarness({ runtime: 'pty', command: 'goose' }); + +export const grok: PtyHarness = definePtyHarness({ runtime: 'pty', command: 'grok' }); diff --git a/packages/sdk-py/src/agent_relay/models.py b/packages/sdk-py/src/agent_relay/models.py index f1046c3f4..57f70fac6 100644 --- a/packages/sdk-py/src/agent_relay/models.py +++ b/packages/sdk-py/src/agent_relay/models.py @@ -15,6 +15,7 @@ class CLIVersions: CURSOR: Final[str] = "2026.02.27-e7d2ef6" # Cursor DROID: Final[str] = "0.1.0" # Droid OPENCODE: Final[str] = "1.2.24" # OpenCode + GROK: Final[str] = "0.1.0" # Grok AIDER: Final[str] = "0.72.1" # Aider GOOSE: Final[str] = "1.0.16" # Goose @@ -27,6 +28,7 @@ class CLIs: CURSOR: Final[str] = "cursor" DROID: Final[str] = "droid" OPENCODE: Final[str] = "opencode" + GROK: Final[str] = "grok" AIDER: Final[str] = "aider" GOOSE: Final[str] = "goose" @@ -218,6 +220,12 @@ class OpencodeModels: OPENAI_O4_MINI_DEEP_RESEARCH: Final[str] = "openai/o4-mini-deep-research" # O4 Mini Deep Research +class GrokModels: + """Grok model identifiers.""" + GROK_BUILD: Final[str] = "grok-build" # Grok Build (default) + GROK_COMPOSER_2_5_FAST: Final[str] = "grok-composer-2.5-fast" # Grok Composer 2.5 Fast + + class ModelOption(TypedDict): """Model option for UI dropdowns.""" value: str @@ -405,6 +413,11 @@ class ModelOption(TypedDict): {"value": "openai/o4-mini-deep-research", "label": "O4 Mini Deep Research"}, ] +GROK_MODEL_OPTIONS: Final[List[ModelOption]] = [ + {"value": "grok-build", "label": "Grok Build"}, + {"value": "grok-composer-2.5-fast", "label": "Grok Composer 2.5 Fast"}, +] + class Models: """All models grouped by CLI tool.""" Claude = ClaudeModels @@ -413,6 +426,7 @@ class Models: Cursor = CursorModels Droid = DroidModels Opencode = OpencodeModels + Grok = GrokModels class ModelOptions: @@ -423,6 +437,7 @@ class ModelOptions: Cursor = CURSOR_MODEL_OPTIONS Droid = DROID_MODEL_OPTIONS Opencode = OPENCODE_MODEL_OPTIONS + Grok = GROK_MODEL_OPTIONS class SwarmPatterns: @@ -446,6 +461,7 @@ class SwarmPatterns: "cursor": "composer-2-fast", "droid": "opus-4.6-fast", "opencode": "openai/gpt-5.2", + "grok": "grok-build", } CLI_REGISTRY: Final[dict] = { @@ -485,6 +501,12 @@ class SwarmPatterns: "version": "1.2.24", "install": "npm install -g opencode-ai", }, + "grok": { + "name": "Grok", + "package": "grok", + "version": "0.1.0", + "install": "Install from x.ai (Grok CLI)", + }, "aider": { "name": "Aider", "package": "aider-chat", diff --git a/packages/utils/cli-registry.yaml b/packages/utils/cli-registry.yaml index 4f995f387..9385e8ffd 100644 --- a/packages/utils/cli-registry.yaml +++ b/packages/utils/cli-registry.yaml @@ -565,6 +565,20 @@ clis: id: 'openai/o4-mini-deep-research' label: 'O4 Mini Deep Research' + grok: + name: 'Grok' + package: 'grok' + version: '0.1.0' + install: 'Install from x.ai (Grok CLI)' + models: + grok_build: + id: 'grok-build' + label: 'Grok Build' + default: true + grok_composer_2_5_fast: + id: 'grok-composer-2.5-fast' + label: 'Grok Composer 2.5 Fast' + aider: name: 'Aider' package: 'aider-chat' From 812df3aadb5119ccfd46ecf10e73c527ad19f27f Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Fri, 5 Jun 2026 11:57:43 +0000 Subject: [PATCH 2/5] style: auto-format Rust code with cargo fmt --- crates/broker/src/worker.rs | 8 ++------ 1 file changed, 2 insertions(+), 6 deletions(-) diff --git a/crates/broker/src/worker.rs b/crates/broker/src/worker.rs index 264029ba3..f81cf539c 100644 --- a/crates/broker/src/worker.rs +++ b/crates/broker/src/worker.rs @@ -357,9 +357,7 @@ 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") - { + } else if is_grok && !effective_args.iter().any(|a| a == "--always-approve") { Some("--always-approve") } else { None @@ -565,9 +563,7 @@ 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") - { + } else if is_grok && !effective_args.iter().any(|a| a == "--always-approve") { Some("--always-approve") } else { None From 5897c359c90ab4e0ade470d308b5b1d5dd3f1d6a Mon Sep 17 00:00:00 2001 From: Will Washburn Date: Fri, 5 Jun 2026 08:06:01 -0400 Subject: [PATCH 3/5] fix(harnesses): address PR review feedback for Grok harness Repeat `--args` per MCP command token so Grok CLI parses `-y` correctly, fix manual fallback formatting, reap timed-out MCP setup children, tighten telemetry detection to the grok binary name, add grok to Python AgentCli, and simplify the changelog bullet. --- CHANGELOG.md | 2 +- crates/broker/src/snippets.rs | 23 ++++++++++++++----- .../src/cli/telemetry/orchestrator-harness.ts | 2 +- packages/sdk-py/src/agent_relay/types.py | 14 ++++++++++- 4 files changed, 32 insertions(+), 9 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index ae10c6531..02062917f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,7 +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, with broker MCP injection via `grok mcp add` so spawned agents can use Relaycast tools. +- `@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. diff --git a/crates/broker/src/snippets.rs b/crates/broker/src/snippets.rs index 836bad9d2..1895764ed 100644 --- a/crates/broker/src/snippets.rs +++ b/crates/broker/src/snippets.rs @@ -1165,18 +1165,24 @@ fn grok_mcp_add_args( let mcp_command = agent_relay_mcp_command(); args.push("--command".to_string()); args.push(mcp_command.command); - args.push("--args".to_string()); - args.extend(mcp_command.args); + 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 mut rendered_parts = vec![mcp_command.command]; - rendered_parts.extend(mcp_command.args); - let rendered_mcp_command = rendered_parts.join(" "); + let rendered_args = mcp_command + .args + .iter() + .map(|arg| format!("--args {arg}")) + .collect::>() + .join(" "); format!( - "{cli} mcp add --env RELAY_API_KEY= --env RELAY_BASE_URL= {AGENT_RELAY_MCP_SERVER} --command {rendered_mcp_command}" + "{cli} mcp add --env RELAY_API_KEY= --env RELAY_BASE_URL= {AGENT_RELAY_MCP_SERVER} --command {} {rendered_args}", + mcp_command.command ) } @@ -1236,6 +1242,7 @@ async fn configure_grok_mcp( } 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}" @@ -1752,6 +1759,10 @@ mod tests { assert_eq!(args[server_idx + 2], "npx"); assert_eq!(args[server_idx + 3], "--args"); assert_eq!(args[server_idx + 4], "-y"); + 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] diff --git a/packages/cli/src/cli/telemetry/orchestrator-harness.ts b/packages/cli/src/cli/telemetry/orchestrator-harness.ts index 2e5d9b5d7..c0c3d2517 100644 --- a/packages/cli/src/cli/telemetry/orchestrator-harness.ts +++ b/packages/cli/src/cli/telemetry/orchestrator-harness.ts @@ -47,7 +47,7 @@ export function inferHarnessFromCommand(command: string | undefined): string | u 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 === 'grok' || lower.includes('/grok')) return 'grok'; + if (base === 'grok') return 'grok'; if (base === 'amp' || normalized.includes('/amp')) return 'amp'; if (lower.includes('copilot')) return 'github-copilot'; if (base === 'zed' || lower.includes('zed')) return 'zed'; diff --git a/packages/sdk-py/src/agent_relay/types.py b/packages/sdk-py/src/agent_relay/types.py index fd47ce684..695241a43 100644 --- a/packages/sdk-py/src/agent_relay/types.py +++ b/packages/sdk-py/src/agent_relay/types.py @@ -32,7 +32,19 @@ "review-loop", ] -AgentCli = Literal["claude", "codex", "gemini", "aider", "goose", "opencode", "droid", "cursor", "cursor-agent", "agent"] +AgentCli = Literal[ + "claude", + "codex", + "gemini", + "aider", + "goose", + "grok", + "opencode", + "droid", + "cursor", + "cursor-agent", + "agent", +] AgentStatus = Literal["healthy", "restarting", "dead", "released"] CrashCategory = Literal["oom", "segfault", "error", "signal", "unknown"] WorkflowOnError = Literal["fail", "skip", "retry"] From 8d4009827ae9bed609d4627b510d799576bc41f3 Mon Sep 17 00:00:00 2001 From: Will Washburn Date: Fri, 5 Jun 2026 12:13:30 -0400 Subject: [PATCH 4/5] fix(broker): make Grok MCP setup non-blocking and avoid stderr deadlock Run `grok mcp remove` via tokio::process with a timeout instead of blocking wait() on the runtime thread, and discard child stderr so a full pipe cannot stall MCP configuration. --- crates/broker/src/snippets.rs | 25 +++++++++++++++---------- 1 file changed, 15 insertions(+), 10 deletions(-) diff --git a/crates/broker/src/snippets.rs b/crates/broker/src/snippets.rs index 1895764ed..d053b1d02 100644 --- a/crates/broker/src/snippets.rs +++ b/crates/broker/src/snippets.rs @@ -1186,6 +1186,19 @@ fn grok_manual_mcp_add_cmd(cli: &str) -> String { ) } +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; + } + } +} + #[allow(clippy::too_many_arguments)] async fn configure_grok_mcp( cli: &str, @@ -1201,15 +1214,7 @@ async fn configure_grok_mcp( .unwrap_or_else(|| cli.trim().to_string()); let manual_cmd = grok_manual_mcp_add_cmd(&exe); - for server_name in [AGENT_RELAY_MCP_SERVER, LEGACY_RELAYCAST_SERVER] { - let _ = std::process::Command::new(&exe) - .args(["mcp", "remove", server_name]) - .stdin(Stdio::null()) - .stdout(Stdio::null()) - .stderr(Stdio::null()) - .spawn() - .and_then(|mut c| c.wait()); - } + remove_grok_mcp_servers(&exe).await; let mut mcp_cmd = Command::new(&exe); mcp_cmd.args(grok_mcp_add_args( @@ -1223,7 +1228,7 @@ async fn configure_grok_mcp( mcp_cmd .stdin(Stdio::null()) .stdout(Stdio::null()) - .stderr(Stdio::piped()); + .stderr(Stdio::null()); match mcp_cmd.spawn() { Ok(mut child) => match tokio::time::timeout(Duration::from_secs(15), child.wait()).await { From da27082c1a682a0f536c184f989d4983bd196599 Mon Sep 17 00:00:00 2001 From: Will Washburn Date: Fri, 5 Jun 2026 13:19:45 -0400 Subject: [PATCH 5/5] fix(ci): format pear page and reduce harness detector complexity Merge main pear changes with Prettier formatting so format:check passes, and refactor inferHarnessFromCommand to a matcher table (includes grok) to stay under ESLint complexity limits. --- .../src/cli/telemetry/orchestrator-harness.ts | 55 +++++++++++++++---- web/app/pear/page.tsx | 15 +++-- 2 files changed, 53 insertions(+), 17 deletions(-) diff --git a/packages/cli/src/cli/telemetry/orchestrator-harness.ts b/packages/cli/src/cli/telemetry/orchestrator-harness.ts index c0c3d2517..c41ee93fc 100644 --- a/packages/cli/src/cli/telemetry/orchestrator-harness.ts +++ b/packages/cli/src/cli/telemetry/orchestrator-harness.ts @@ -33,24 +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 === 'grok') return 'grok'; - 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; } diff --git a/web/app/pear/page.tsx b/web/app/pear/page.tsx index aa707311e..eec5a0ac1 100644 --- a/web/app/pear/page.tsx +++ b/web/app/pear/page.tsx @@ -39,7 +39,12 @@ function PearMark({ className }: { className?: string }) { // Plain img: the SST/OpenNext image optimizer has no working sharp // runtime, so next/image's /_next/image endpoint 500s. // eslint-disable-next-line @next/next/no-img-element - + ); } @@ -199,8 +204,8 @@ export default function PearPage() {

They coordinate with each other

Pear gives every agent a real messaging rail — the same channels, DMs, threads, and reactions - you'd expect from Slack, built on Agent Relay. Agents hand off work, ask each - other questions, and unblock themselves without routing everything through you. + you'd expect from Slack, built on Agent Relay. Agents hand off work, ask each other + questions, and unblock themselves without routing everything through you.

  • @@ -272,8 +277,8 @@ export default function PearPage() {

    Run agents on your machine or in the cloud

    Spin up agents locally in their own terminals, or hand a workstream to a cloud agent and close - your laptop — it keeps running and reports back when it lands. Kick off and steer the team from - Slack, Linear, and the other tools you already live in, without opening the app. + your laptop — it keeps running and reports back when it lands. Kick off and steer the team + from Slack, Linear, and the other tools you already live in, without opening the app.