From 06d79c17508a84070e1e71d689c9d4dd3ca4359b Mon Sep 17 00:00:00 2001 From: Sanjay Ramadugu Date: Wed, 15 Jul 2026 00:30:36 -0400 Subject: [PATCH] feat(agent): add Grok Build CLI as an ACP runtime (#5285) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add xAI Grok Build (`grok`) as a first-class Multica runtime over ACP (`grok --no-auto-update agent --always-approve stdio`), reusing hermesClient like traecli and kimi. Includes daemon discovery, protocol_family migration 174, model discovery, MCP passthrough, thinking effort, frontend branding, and product docs. Follows xAI's documented headless ACP flow: after `initialize`, read the advertised `authMethods` and send `authenticate` (preferring xai.api_key when XAI_API_KEY is set, else the cached login token) before any session operation — a real, logged-in CLI rejects session/new and session/load without it. Model discovery performs the same handshake so it returns the live catalog instead of the static fallback. `--no-auto-update` is passed as a global flag (and kept daemon-owned in the blocked custom-arg set) so a background update check can't stall an unattended ACP task. Thinking effort uses the current `--effort` flag, and the minimum grok version is 0.2.89 (ACP + authenticate + session/load + session/set_model + MCP + --effort). Closes #2895 --- CLI_AND_DAEMON.md | 3 + CLI_INSTALL.md | 8 +- SELF_HOSTING.md | 3 +- SELF_HOSTING_ADVANCED.md | 4 +- apps/docs/content/docs/how-multica-works.mdx | 2 +- apps/docs/content/docs/index.mdx | 2 +- .../content/docs/install-agent-runtime.mdx | 19 +- apps/docs/content/docs/providers.mdx | 17 +- docs/product-overview.md | 2 +- packages/core/agents/mcp-support.test.ts | 1 + packages/core/agents/mcp-support.ts | 1 + packages/core/types/agent.ts | 1 + .../runtimes/components/provider-logo.tsx | 17 + server/internal/daemon/config.go | 12 +- server/internal/daemon/config_test.go | 1 + server/internal/daemon/daemon.go | 1 + server/internal/daemon/execenv/context.go | 5 + .../internal/daemon/execenv/runtime_config.go | 3 +- server/internal/daemon/local_skills.go | 4 + server/internal/daemon/local_skills_test.go | 6 + server/internal/metrics/labels.go | 1 + .../179_runtime_profile_add_grok.down.sql | 23 + .../179_runtime_profile_add_grok.up.sql | 26 + server/pkg/agent/agent.go | 28 +- .../pkg/agent/agent_supported_types_test.go | 2 +- server/pkg/agent/agent_test.go | 2 +- server/pkg/agent/grok.go | 525 ++++++++++++++++++ server/pkg/agent/grok_test.go | 503 +++++++++++++++++ server/pkg/agent/hermes.go | 26 + server/pkg/agent/models.go | 102 +++- server/pkg/agent/thinking.go | 12 + server/pkg/agent/thinking_test.go | 6 + server/pkg/agent/version.go | 1 + server/pkg/agent/version_test.go | 4 + 34 files changed, 1334 insertions(+), 39 deletions(-) create mode 100644 server/migrations/179_runtime_profile_add_grok.down.sql create mode 100644 server/migrations/179_runtime_profile_add_grok.up.sql create mode 100644 server/pkg/agent/grok.go create mode 100644 server/pkg/agent/grok_test.go diff --git a/CLI_AND_DAEMON.md b/CLI_AND_DAEMON.md index aaa56d25f..3749abd57 100644 --- a/CLI_AND_DAEMON.md +++ b/CLI_AND_DAEMON.md @@ -151,6 +151,7 @@ The daemon auto-detects these AI CLIs on your PATH: | Kiro CLI | `kiro-cli` | Kiro ACP coding agent | | [Qoder CLI](https://docs.qoder.com/) | `qodercli` | Qoder ACP coding agent | | [Trae](https://docs.trae.cn/cli) | `traecli` | ByteDance TRAE CLI (ACP via `traecli acp serve`) | +| [Grok Build CLI](https://docs.x.ai/) | `grok` | xAI Grok Build CLI (ACP via `grok agent stdio`) | You need at least one installed. The daemon registers each detected CLI as an available runtime. @@ -226,6 +227,8 @@ Agent-specific overrides: | `MULTICA_QODER_MODEL` | Override the Qoder model used | | `MULTICA_TRAECLI_PATH` | Custom path to the `traecli` binary | | `MULTICA_TRAECLI_MODEL` | Override the Trae model used (a model id from your logged-in traecli catalog, e.g. `Doubao-Seed-2.1-Pro`) | +| `MULTICA_GROK_PATH` | Custom path to the `grok` binary (defaults to `grok` on PATH; often `~/.grok/bin/grok`) | +| `MULTICA_GROK_MODEL` | Override the Grok model used (e.g. `grok-4.5`) | If a previously generated `~/.multica/hooks` wrapper is first on `PATH` and calls the same command name again, the daemon skips that hooks directory during built-in agent discovery and records the real binary path behind it. If your interactive shell still recurses when you run `claude`, `codex`, or `hermes` manually, remove the hooks entry from your shell startup file or replace the wrapper body with an absolute `exec /path/to/real-binary "$@"`. diff --git a/CLI_INSTALL.md b/CLI_INSTALL.md index 6978c576a..283f63a75 100644 --- a/CLI_INSTALL.md +++ b/CLI_INSTALL.md @@ -166,12 +166,12 @@ Wait 3 seconds, then verify: multica daemon status ``` -Expected output should show `running` status with detected agents (e.g. `claude`, `codex`, `copilot`, `opencode`, `openclaw`, `hermes`, `gemini`, `pi`, `cursor-agent`). +Expected output should show `running` status with detected agents (e.g. `claude`, `codex`, `copilot`, `opencode`, `openclaw`, `hermes`, `pi`, `cursor-agent`, `grok`). **If daemon fails to start:** - Check logs: `multica daemon logs` - If a port conflict occurs, the daemon may already be running under a different profile. -- If no agents are detected, ensure at least one AI CLI (`claude`, `codex`, `copilot`, `opencode`, `openclaw`, `hermes`, `gemini`, `pi`, or `cursor-agent`) is installed and on the `$PATH`. +- If no agents are detected, ensure at least one AI CLI (`claude`, `codex`, `copilot`, `opencode`, `openclaw`, `hermes`, `pi`, `cursor-agent`, or `grok`) is installed and on the `$PATH`. --- @@ -185,12 +185,12 @@ multica daemon status Confirm: 1. Status is `running` -2. At least one agent is listed (e.g. `claude`, `codex`, `copilot`, `opencode`, `openclaw`, `hermes`, `gemini`, `pi`, or `cursor-agent`) +2. At least one agent is listed (e.g. `claude`, `codex`, `copilot`, `opencode`, `openclaw`, `hermes`, `pi`, `cursor-agent`, or `grok`) 3. At least one workspace is being watched If the agents list is empty, tell the user: -> "The Multica daemon is running but no AI agent CLIs were detected. Please install at least one supported CLI (`claude`, `codex`, `copilot`, `opencode`, `openclaw`, `hermes`, `gemini`, `pi`, or `cursor-agent`), then restart the daemon with `multica daemon stop && multica daemon start`." +> "The Multica daemon is running but no AI agent CLIs were detected. Please install at least one supported CLI (`claude`, `codex`, `copilot`, `opencode`, `openclaw`, `hermes`, `pi`, `cursor-agent`, or `grok`), then restart the daemon with `multica daemon stop && multica daemon start`." --- diff --git a/SELF_HOSTING.md b/SELF_HOSTING.md index e3eb55e2b..635b225ce 100644 --- a/SELF_HOSTING.md +++ b/SELF_HOSTING.md @@ -96,12 +96,13 @@ You also need at least one AI agent CLI installed: - [OpenClaw](https://github.com/openclaw/openclaw) (`openclaw` on PATH) - [OpenCode](https://github.com/anomalyco/opencode) (`opencode` on PATH) - [Hermes](https://github.com/NousResearch/hermes) (`hermes` on PATH) -- Gemini (`gemini` on PATH) - [Pi](https://pi.dev/) (`pi` on PATH) - [Cursor Agent](https://cursor.com/) (`cursor-agent` on PATH) - Kimi (`kimi` on PATH) - Kiro CLI (`kiro-cli` on PATH) - Qoder CLI (`qodercli` on PATH) +- Trae CLI (`traecli` on PATH) +- [Grok Build CLI](https://docs.x.ai/) (`grok` on PATH) ### b) One-command setup diff --git a/SELF_HOSTING_ADVANCED.md b/SELF_HOSTING_ADVANCED.md index 667385d73..9fcaa4619 100644 --- a/SELF_HOSTING_ADVANCED.md +++ b/SELF_HOSTING_ADVANCED.md @@ -146,12 +146,12 @@ Agent-specific overrides: | `MULTICA_OPENCLAW_MODEL` | Override the OpenClaw model used | | `MULTICA_HERMES_PATH` | Custom path to the `hermes` binary | | `MULTICA_HERMES_MODEL` | Override the Hermes model used | -| `MULTICA_GEMINI_PATH` | Custom path to the `gemini` binary | -| `MULTICA_GEMINI_MODEL` | Override the Gemini model used | | `MULTICA_PI_PATH` | Custom path to the `pi` binary | | `MULTICA_PI_MODEL` | Override the Pi model used | | `MULTICA_CURSOR_PATH` | Custom path to the `cursor-agent` binary | | `MULTICA_CURSOR_MODEL` | Override the Cursor Agent model used | +| `MULTICA_GROK_PATH` | Custom path to the `grok` binary | +| `MULTICA_GROK_MODEL` | Override the Grok model used (e.g. `grok-4.5`) | ## Database Setup diff --git a/apps/docs/content/docs/how-multica-works.mdx b/apps/docs/content/docs/how-multica-works.mdx index 72f81e0e1..c064e0283 100644 --- a/apps/docs/content/docs/how-multica-works.mdx +++ b/apps/docs/content/docs/how-multica-works.mdx @@ -13,7 +13,7 @@ Multica is a **distributed** platform. The web interface you see is just the fro - **Multica server** — the workspaces, issue lists, and comment threads you see all live in its database. It's also a WebSocket hub that pushes real-time updates between you and your teammates. It does **not** execute any agent tasks. - **Daemon** — part of the Multica CLI, running on your own machine. On start it detects which AI coding tools are installed locally, registers with the server, and begins polling for tasks every 3 seconds and sending heartbeats every 15 seconds. -- **AI coding tools** — one of the fifteen (or several in parallel): [Antigravity](/providers#antigravity), [Claude Code](/providers#claude-code), [CodeBuddy](/providers#codebuddy), [Codex](/providers#codex), [Cursor](/providers#cursor), [Copilot](/providers#copilot), [Hermes](/providers#hermes), [Kimi](/providers#kimi), [Kiro CLI](/providers#kiro-cli), [OpenCode](/providers#opencode), [OpenClaw](/providers#openclaw), [Pi](/providers#pi), [Qoder](/providers#qoder), [Trae CLI](/providers#trae), [DevEco Code](/providers#deveco-code). Once the daemon has picked up a task, it uses these tools to actually do the work. +- **AI coding tools** — one of the sixteen (or several in parallel): [Antigravity](/providers#antigravity), [Claude Code](/providers#claude-code), [CodeBuddy](/providers#codebuddy), [Codex](/providers#codex), [Cursor](/providers#cursor), [Copilot](/providers#copilot), [Hermes](/providers#hermes), [Kimi](/providers#kimi), [Kiro CLI](/providers#kiro-cli), [OpenCode](/providers#opencode), [OpenClaw](/providers#openclaw), [Pi](/providers#pi), [Qoder](/providers#qoder), [Trae CLI](/providers#trae), [DevEco Code](/providers#deveco-code), [Grok](/providers#grok). Once the daemon has picked up a task, it uses these tools to actually do the work. Because the toolchain stays local, **your API keys, code directories, and authorized tools** are only ever used on your machine — the Multica server never sees any of them. This holds whether you self-host or use Cloud. diff --git a/apps/docs/content/docs/index.mdx b/apps/docs/content/docs/index.mdx index 27fa25e06..912ca3fff 100644 --- a/apps/docs/content/docs/index.mdx +++ b/apps/docs/content/docs/index.mdx @@ -13,7 +13,7 @@ This page explains where agents run and the ways you can start using Multica. Agents do **not** execute tasks on Multica's servers. Multica currently supports one runtime model: -- **Local [daemon](/daemon-runtimes)** — you run `multica daemon` on your own machine, and it drives the [AI coding tools](/providers) installed locally. Fifteen are built in today: [Antigravity](/providers#antigravity), [Claude Code](/providers#claude-code), [CodeBuddy](/providers#codebuddy), [Codex](/providers#codex), [Cursor](/providers#cursor), [Copilot](/providers#copilot), [Hermes](/providers#hermes), [Kimi](/providers#kimi), [Kiro CLI](/providers#kiro-cli), [OpenCode](/providers#opencode), [OpenClaw](/providers#openclaw), [Pi](/providers#pi), [Qoder](/providers#qoder), [Trae CLI](/providers#trae), [DevEco Code](/providers#deveco-code). Your API keys, toolchain, and code directories stay on your machine. +- **Local [daemon](/daemon-runtimes)** — you run `multica daemon` on your own machine, and it drives the [AI coding tools](/providers) installed locally. Sixteen are built in today: [Antigravity](/providers#antigravity), [Claude Code](/providers#claude-code), [CodeBuddy](/providers#codebuddy), [Codex](/providers#codex), [Cursor](/providers#cursor), [Copilot](/providers#copilot), [Hermes](/providers#hermes), [Kimi](/providers#kimi), [Kiro CLI](/providers#kiro-cli), [OpenCode](/providers#opencode), [OpenClaw](/providers#openclaw), [Pi](/providers#pi), [Qoder](/providers#qoder), [Trae CLI](/providers#trae), [DevEco Code](/providers#deveco-code), [Grok](/providers#grok). Your API keys, toolchain, and code directories stay on your machine. **Cloud runtimes are coming**, currently waitlist-only. Once live, you won't need a local daemon — agent tasks will execute on Multica Cloud directly. Sign up on the [Downloads](https://multica.ai/download) page to get notified. diff --git a/apps/docs/content/docs/install-agent-runtime.mdx b/apps/docs/content/docs/install-agent-runtime.mdx index 27696e3a2..bd1b618c6 100644 --- a/apps/docs/content/docs/install-agent-runtime.mdx +++ b/apps/docs/content/docs/install-agent-runtime.mdx @@ -1,11 +1,11 @@ --- title: Install an agent runtime -description: Multica drives whichever AI coding tools you have on your machine. This page shows you how to install each of the 15 supported tools so the daemon can detect them. +description: Multica drives whichever AI coding tools you have on your machine. This page shows you how to install each of the 16 supported tools so the daemon can detect them. --- import { Callout } from "fumadocs-ui/components/callout"; -A **runtime** in Multica is the daemon on your machine paired with one AI coding tool the daemon found on your `PATH`. If the onboarding "Connect a runtime" step shows **No supported tools detected**, it means the daemon scanned `PATH` and didn't find any of the 15 tools it knows how to drive. Install one (or several) of the tools below, then come back to the step and re-scan — the runtime will show up within a few seconds. +A **runtime** in Multica is the daemon on your machine paired with one AI coding tool the daemon found on your `PATH`. If the onboarding "Connect a runtime" step shows **No supported tools detected**, it means the daemon scanned `PATH` and didn't find any of the 16 tools it knows how to drive. Install one (or several) of the tools below, then come back to the step and re-scan — the runtime will show up within a few seconds. This page is the install-side companion to: @@ -31,9 +31,9 @@ multica daemon restart Or, in the desktop app, just relaunch the app. The daemon re-scans `PATH` on every start. -## The 15 supported tools +## The 16 supported tools -Listed roughly from most to least common. Pick whichever ones you already have credentials for — you don't need all 15. +Listed roughly from most to least common. Pick whichever ones you already have credentials for — you don't need all 16. ### Claude Code (Anthropic) @@ -192,6 +192,17 @@ Google's Antigravity CLI (`agy`). Pairs with Google's Antigravity service and ru | Authentication | Run `agy` once interactively and complete the Google account login, or sign in via the Antigravity desktop app — the CLI reuses the keyring entry the GUI writes. | | Notes | The CLI emits plain assistant text on stdout, not a structured event stream; intermediate "I will run X" lines and the final reply are both relayed to Multica as text, and per-tool telemetry is not available today. | +### Grok (xAI) + +xAI's Grok Build CLI (`grok`). Multica drives it over ACP via `grok --no-auto-update agent --always-approve stdio`, sharing the transport with Hermes, Kimi, Kiro CLI, Qoder, and Trae. It completes xAI's headless `authenticate` handshake after `initialize`, before any session operation. Session resumption works through ACP `session/load`. Models are discovered from `session/new`. MCP config is passed through ACP `mcpServers`. Skills are copied into `.grok/skills/`. + +| | | +|---|---| +| Daemon looks for | `grok` | +| Install | Install the Grok Build CLI and ensure `grok` is on `PATH` (often `~/.grok/bin`). | +| Authentication | Run `grok login` (or complete the interactive login flow) so ACP sessions can authenticate. | +| Notes | Override the binary with `MULTICA_GROK_PATH` and the default model with `MULTICA_GROK_MODEL` (e.g. `grok-4.5`). Minimum CLI version: `0.2.89`. | + ## After installing 1. **Confirm the binary is on `PATH`.** Open a fresh terminal and run `which ` (for example `which claude`, `which cursor-agent`, `which kiro-cli`, `which agy`). If it prints a path, the daemon will find it. If it prints nothing, fix your shell `PATH` first (the typical cause is a per-shell rc file that wasn't reloaded). diff --git a/apps/docs/content/docs/providers.mdx b/apps/docs/content/docs/providers.mdx index 89fd68000..0264e4563 100644 --- a/apps/docs/content/docs/providers.mdx +++ b/apps/docs/content/docs/providers.mdx @@ -1,11 +1,11 @@ --- title: AI coding tools matrix -description: Multica supports 15 AI coding tools; they implement the same interface, but the capability details diverge significantly. +description: Multica supports 16 AI coding tools; they implement the same interface, but the capability details diverge significantly. --- import { Callout } from "fumadocs-ui/components/callout"; -Multica ships with built-in support for **15 AI coding tools**. They all implement the same interface — queue, dispatch, execute, return results — so you can drive any of them from the same Multica board. **But the capability details diverge significantly**: whether session resumption actually works, whether MCP is supported, where skill files live, how models are selected. This page is the full matrix. +Multica ships with built-in support for **16 AI coding tools**. They all implement the same interface — queue, dispatch, execute, return results — so you can drive any of them from the same Multica board. **But the capability details diverge significantly**: whether session resumption actually works, whether MCP is supported, where skill files live, how models are selected. This page is the full matrix. For guidance on picking a tool when creating an agent, see [Creating and configuring agents](/agents-create). @@ -28,6 +28,7 @@ For guidance on picking a tool when creating an agent, see [Creating and configu | **Pi** | Inflection AI | ✅ (session is a file path) | ❌ | `.pi/skills/` | Dynamic discovery | | **Qoder** | Alibaba | ✅ | ✅ | `.qoder/skills/` | Dynamic discovery | | **Trae** | ByteDance | ✅ (ACP `session/load`) | ✅ | `.traecli/skills/` | Dynamic discovery | +| **Grok** | xAI | ✅ (ACP `session/load`) | ✅ | `.grok/skills/` | Dynamic discovery (ACP `session/new`) | ## What each tool is for @@ -108,15 +109,19 @@ From ByteDance — the official **TRAE CLI** (`traecli`, documented at [docs.tra > The capabilities above were captured from the real `traecli` v0.120.42 binary: `initialize` → `loadSession:true` + `mcpCapabilities{http,sse}`; `session/new` → `result.sessionId` + `models.availableModels`; `session/prompt` streams `session/update` notifications with `sessionUpdate: agent_message_chunk`. +### Grok + +From xAI. CLI binary name is `grok` (Grok Build CLI, often installed to `~/.grok/bin/grok`). Multica drives it over **ACP** via `grok --no-auto-update agent --always-approve stdio`, reusing the same transport as Trae / Kimi / Kiro / Qoder. After `initialize` Multica completes xAI's headless **auth handshake** (`authenticate`, preferring `xai.api_key` when `XAI_API_KEY` is set, else the cached login token) before any session operation. **Session resumption works** through ACP `session/load`. **Model selection works** via the catalog returned from `session/new` and `session/set_model`. **Reasoning effort** is exposed through Multica’s thinking picker and passed as `--effort` on the `agent` command. **MCP is supported** — config is passed through ACP `mcpServers` (filtered to transports advertised by `initialize`). Skills land in `.grok/skills/` (project) and `~/.grok/skills` (user); the CLI also scans `.agents/skills/`. Tool calls stream as ACP `tool_call` / `tool_call_update` events. + ## Session resumption: who really supports it The session resumption mechanism is covered in [Tasks](/tasks#can-a-task-continue-from-the-previous-context). **Every supported tool resumes sessions** — pass the resume id and the task continues from the previous context. The one quirk is Pi, whose resume id is a session file path on disk rather than a string id (see [Pi](#pi) above). ## MCP configuration: provider-specific support -**Of the 15 tools, eleven consume `mcp_config`: Claude Code, CodeBuddy, Codex, Cursor, Hermes, Kimi, Kiro CLI, OpenCode, OpenClaw, Qoder, and Trae**. The other four (Antigravity, Copilot, DevEco Code, Pi) accept the field but **ignore it** — no error, no warning, the config just has no effect. +**Of the 16 tools, twelve consume `mcp_config`: Claude Code, CodeBuddy, Codex, Cursor, Hermes, Kimi, Kiro CLI, OpenCode, OpenClaw, Qoder, Trae, and Grok**. The other four (Antigravity, Copilot, DevEco Code, Pi) accept the field but **ignore it** — no error, no warning, the config just has no effect. -The runtime paths are provider-specific: Claude Code and CodeBuddy receive it through `--mcp-config` paired with `--strict-mcp-config`; Codex writes a daemon-managed `mcp_servers` block into the per-task `$CODEX_HOME/config.toml`; Cursor writes `.cursor/mcp.json` plus per-task project approvals under `CURSOR_DATA_DIR`; Hermes, Kimi, Kiro CLI, Qoder, and Trae receive ACP `mcpServers`; OpenCode receives inline config through `OPENCODE_CONFIG_CONTENT`; OpenClaw receives `mcp.servers` through Multica's per-task config wrapper. OpenCode's path does **not** rewrite the project's `opencode.json`. +The runtime paths are provider-specific: Claude Code and CodeBuddy receive it through `--mcp-config` paired with `--strict-mcp-config`; Codex writes a daemon-managed `mcp_servers` block into the per-task `$CODEX_HOME/config.toml`; Cursor writes `.cursor/mcp.json` plus per-task project approvals under `CURSOR_DATA_DIR`; Hermes, Kimi, Kiro CLI, Qoder, Trae, and Grok receive ACP `mcpServers`; OpenCode receives inline config through `OPENCODE_CONFIG_CONTENT`; OpenClaw receives `mcp.servers` through Multica's per-task config wrapper. OpenCode's path does **not** rewrite the project's `opencode.json`. If you set `mcp_config` in an agent configuration but pick a tool not marked ✅ in the MCP column, your MCP servers have **no effect** on that agent. MCP integration is provider-specific. @@ -139,6 +144,8 @@ Each tool uses **its own** skill discovery path. Before a task runs, the Multica | DevEco Code | `.deveco/skills/` | ✅ Native | | Pi | `.pi/skills/` | ✅ Native | | Qoder | `.qoder/skills/` | ✅ Native | +| Trae | `.traecli/skills/` | ✅ Native | +| Grok | `.grok/skills/` | ✅ Native | | Antigravity | `.agents/skills/` | ✅ Native (inherits Gemini CLI's workspace layout — see [Antigravity docs](https://antigravity.google/docs/gcli-migration)) | | Hermes | `HERMES_HOME/skills/` (per-task home) | ✅ Native (seeded per task from `~/.hermes/`) | | OpenClaw | `.agent_context/skills/` | ⚠️ Generic fallback | @@ -154,4 +161,4 @@ For creating and using skills, see [Skills](/skills). - [Creating and configuring agents](/agents-create) — pick a tool for your agent - [Tasks](/tasks) — task lifecycle and session-resumption mechanics - [Daemon and runtimes](/daemon-runtimes) — where the tools run and how they connect to Multica -- [Install an agent runtime](/install-agent-runtime) — installation and authentication for each of the 15 supported tools +- [Install an agent runtime](/install-agent-runtime) — installation and authentication for each of the 16 supported tools diff --git a/docs/product-overview.md b/docs/product-overview.md index 895c51974..56c51af9f 100644 --- a/docs/product-overview.md +++ b/docs/product-overview.md @@ -291,7 +291,7 @@ Agent 是 Multica 的灵魂。几乎所有功能都围绕"如何让一个 agent `multica` CLI 在用户的机器上启动一个后台进程(macOS launchd / Linux systemd / Windows 服务风格),它: -1. **自动探测** `$PATH` 上安装的 coding CLI(`claude`, `codex`, `opencode`, `openclaw`, `hermes`, `gemini`, `pi`, `cursor-agent`, `kimi`, `kiro-cli`, `qodercli`) +1. **自动探测** `$PATH` 上安装的 coding CLI(`claude`, `codex`, `opencode`, `openclaw`, `hermes`, `pi`, `cursor-agent`, `kimi`, `kiro-cli`, `qodercli`, `traecli`, `grok`) 2. 向 server **注册** 为一组 runtime(一个 CLI = 一个 runtime) 3. 每 3 秒 **轮询** 一次 server,有任务就认领 4. 每 15 秒 **心跳**(keepalive),报告自己还活着 diff --git a/packages/core/agents/mcp-support.test.ts b/packages/core/agents/mcp-support.test.ts index de5d8a647..f0cfea69c 100644 --- a/packages/core/agents/mcp-support.test.ts +++ b/packages/core/agents/mcp-support.test.ts @@ -15,6 +15,7 @@ describe("providerSupportsMcpConfig", () => { expect(providerSupportsMcpConfig("openclaw")).toBe(true); expect(providerSupportsMcpConfig("qoder")).toBe(true); expect(providerSupportsMcpConfig("traecli")).toBe(true); + expect(providerSupportsMcpConfig("grok")).toBe(true); }); it("rejects providers whose runtime ignores mcp_config", () => { diff --git a/packages/core/agents/mcp-support.ts b/packages/core/agents/mcp-support.ts index 57d6bf310..8df6a4496 100644 --- a/packages/core/agents/mcp-support.ts +++ b/packages/core/agents/mcp-support.ts @@ -10,6 +10,7 @@ const MCP_SUPPORTED_PROVIDERS = new Set([ "codebuddy", "codex", "cursor", + "grok", "hermes", "kimi", "kiro", diff --git a/packages/core/types/agent.ts b/packages/core/types/agent.ts index efe90379b..fd28abb35 100644 --- a/packages/core/types/agent.ts +++ b/packages/core/types/agent.ts @@ -119,6 +119,7 @@ export const RUNTIME_PROFILE_PROTOCOL_FAMILIES = [ "antigravity", "qoder", "traecli", + "grok", ] as const; export type RuntimeProtocolFamily = diff --git a/packages/views/runtimes/components/provider-logo.tsx b/packages/views/runtimes/components/provider-logo.tsx index b2bd9652e..582b0bd33 100644 --- a/packages/views/runtimes/components/provider-logo.tsx +++ b/packages/views/runtimes/components/provider-logo.tsx @@ -268,6 +268,21 @@ function TraeLogo({ className }: { className: string }) { return Trae; } +// Grok (xAI) — simple monochrome wordmark tile (G on dark square). Matches the +// compact logo style used for Pi / OpenCode so the runtime list stays dense. +function GrokLogo({ className }: { className: string }) { + return ( + + + + + + ); +} + export function ProviderLogo({ provider, className = "h-4 w-4", @@ -306,6 +321,8 @@ export function ProviderLogo({ return ; case "traecli": return ; + case "grok": + return ; default: return ; } diff --git a/server/internal/daemon/config.go b/server/internal/daemon/config.go index ab702d69d..547a95ad2 100644 --- a/server/internal/daemon/config.go +++ b/server/internal/daemon/config.go @@ -85,7 +85,7 @@ type Config struct { CLIVersion string // multica CLI version (e.g. "0.1.13") LaunchedBy string // "desktop" when spawned by the Electron app, empty for standalone Profile string // profile name (empty = default) - Agents map[string]AgentEntry // keyed by provider: claude, codebuddy, codex, copilot, opencode, openclaw, hermes, pi, cursor, kimi, kiro, antigravity, qoder, traecli + Agents map[string]AgentEntry // keyed by provider: claude, codebuddy, codex, copilot, opencode, openclaw, hermes, pi, cursor, kimi, kiro, antigravity, qoder, traecli, grok WorkspacesRoot string // base path for execution envs (default: ~/multica_workspaces) KeepEnvAfterTask bool // preserve env after task for debugging HealthPort int // local HTTP port for health checks (default: 19514) @@ -326,8 +326,14 @@ func LoadConfig(overrides Overrides) (Config, error) { if e, ok := probe("MULTICA_TRAECLI_PATH", "traecli", "MULTICA_TRAECLI_MODEL"); ok { agents["traecli"] = e } + // xAI Grok Build CLI (`grok`), driven over ACP via + // `grok agent --always-approve stdio`. MULTICA_GROK_MODEL seeds the + // daemon-wide default (e.g. grok-4.5). + if e, ok := probe("MULTICA_GROK_PATH", "grok", "MULTICA_GROK_MODEL"); ok { + agents["grok"] = e + } if len(agents) == 0 { - return Config{}, fmt.Errorf("no agent CLI found: install claude, codebuddy, codex, copilot, opencode, deveco, openclaw, hermes, pi, cursor-agent, kimi, kiro-cli, agy, qodercli, or traecli and ensure it is on PATH") + return Config{}, fmt.Errorf("no agent CLI found: install claude, codebuddy, codex, copilot, opencode, deveco, openclaw, hermes, pi, cursor-agent, kimi, kiro-cli, agy, qodercli, traecli, or grok and ensure it is on PATH") } claudeArgs, err := shellArgsFromEnv("MULTICA_CLAUDE_ARGS") @@ -826,7 +832,7 @@ func isExecutableFile(path string) bool { // invocation, instead of paying the cost-per-miss. var defaultAgentCommandNames = []string{ "claude", "codex", "opencode", "deveco", "openclaw", "hermes", - "pi", "cursor-agent", "copilot", "kimi", "kiro-cli", "codebuddy", "agy", "traecli", + "pi", "cursor-agent", "copilot", "kimi", "kiro-cli", "codebuddy", "agy", "traecli", "grok", } // codexDesktopAppBundlePaths returns candidate macOS app-bundle locations for diff --git a/server/internal/daemon/config_test.go b/server/internal/daemon/config_test.go index 2855b9f11..bfc67579b 100644 --- a/server/internal/daemon/config_test.go +++ b/server/internal/daemon/config_test.go @@ -837,6 +837,7 @@ func pinNonCodexAgentsToMissingPaths(t *testing.T) { "MULTICA_COPILOT_PATH", "MULTICA_KIMI_PATH", "MULTICA_KIRO_PATH", + "MULTICA_GROK_PATH", } { t.Setenv(name, filepath.Join(missingDir, strings.ToLower(name))) } diff --git a/server/internal/daemon/daemon.go b/server/internal/daemon/daemon.go index e9486d057..3a7803b09 100644 --- a/server/internal/daemon/daemon.go +++ b/server/internal/daemon/daemon.go @@ -3467,6 +3467,7 @@ func gcMetaForTask(task Task) (execenv.GCMeta, bool) { // here fall back to capitalizing the key (claude → "Claude", codex → "Codex"). var runtimeDisplayNameOverrides = map[string]string{ "traecli": "Trae", + "grok": "Grok", } // providerDisplayName returns the human-facing runtime name for a provider key. diff --git a/server/internal/daemon/execenv/context.go b/server/internal/daemon/execenv/context.go index caf78e9d0..57a4854eb 100644 --- a/server/internal/daemon/execenv/context.go +++ b/server/internal/daemon/execenv/context.go @@ -393,6 +393,11 @@ func skillsDirPath(workDir, provider string) string { // workspace skill layout; see https://antigravity.google/docs/gcli-migration // under "Workspace skills". return filepath.Join(workDir, ".agents", "skills") + case "grok": + // Grok Build CLI discovers project-level skills from .grok/skills/ + // (and also scans .agents/skills/). Prefer the native .grok tree. + // See Grok user-guide skills.md. + return filepath.Join(workDir, ".grok", "skills") default: // Fallback: write to .agent_context/skills/ (referenced by meta config). return filepath.Join(workDir, ".agent_context", "skills") diff --git a/server/internal/daemon/execenv/runtime_config.go b/server/internal/daemon/execenv/runtime_config.go index 121910092..d20fc1559 100644 --- a/server/internal/daemon/execenv/runtime_config.go +++ b/server/internal/daemon/execenv/runtime_config.go @@ -170,6 +170,7 @@ func formatProjectResource(r ProjectResourceForEnv) string { // For Qoder: writes {workDir}/AGENTS.md (skills discovered from .qoder/skills/, user-level ~/.qoder/skills is unaffected) // For Antigravity: writes {workDir}/AGENTS.md (agy CLI reads AGENTS.md natively; skills discovered natively from .agents/skills/ — see https://antigravity.google/docs/gcli-migration) // For Traecli: writes {workDir}/AGENTS.md (traecli reads .trae/rules/ not AGENTS.md, so the brief is delivered inline via providerNeedsInlineSystemPrompt; the file is written for parity/visibility only) +// For Grok: writes {workDir}/AGENTS.md (Grok Build CLI reads AGENTS.md natively from the workdir) func InjectRuntimeConfig(workDir, provider string, ctx TaskContextForEnv) (string, error) { content := buildMetaSkillContent(provider, ctx) path := runtimeConfigPath(workDir, provider) @@ -189,7 +190,7 @@ func runtimeConfigPath(workDir, provider string) string { switch provider { case "claude", "codebuddy": return filepath.Join(workDir, "CLAUDE.md") - case "codex", "copilot", "opencode", "deveco", "openclaw", "hermes", "pi", "cursor", "kimi", "kiro", "antigravity", "qoder", "traecli": + case "codex", "copilot", "opencode", "deveco", "openclaw", "hermes", "pi", "cursor", "kimi", "kiro", "antigravity", "qoder", "traecli", "grok": return filepath.Join(workDir, "AGENTS.md") default: return "" diff --git a/server/internal/daemon/local_skills.go b/server/internal/daemon/local_skills.go index fa31a874d..01af325ad 100644 --- a/server/internal/daemon/local_skills.go +++ b/server/internal/daemon/local_skills.go @@ -104,6 +104,7 @@ const ( // - Qoder: ~/.qoder/skills mirrors Qoder CLI's project-level .qoder/skills layout // - Antigravity: ~/.gemini/antigravity-cli/skills user-level skill root // (https://antigravity.google/docs/gcli-migration "Global skills") +// - Grok: ~/.grok/skills is Grok Build CLI's user-level skill root // // The universal ~/.agents/skills root is documented as a cross-tool skill // location by Codex (https://developers.openai.com/codex/skills) and Gemini @@ -156,6 +157,9 @@ func localSkillRootsForProvider(provider string) ([]localSkillRoot, bool, error) // agy inherits Gemini CLI's global skill root; see // https://antigravity.google/docs/gcli-migration ("Global skills"). providerRoot = filepath.Join(home, ".gemini", "antigravity-cli", "skills") + case "grok": + // Grok Build CLI user-level skills: ~/.grok/skills + providerRoot = filepath.Join(home, ".grok", "skills") default: return nil, false, nil } diff --git a/server/internal/daemon/local_skills_test.go b/server/internal/daemon/local_skills_test.go index 2c85ef11c..f850b9d60 100644 --- a/server/internal/daemon/local_skills_test.go +++ b/server/internal/daemon/local_skills_test.go @@ -200,6 +200,12 @@ func TestLocalSkills_DiscoversACPProviderRoots(t *testing.T) { wantPath: "~/.qoder/skills/review-helper", wantName: "Qoder Review", }, + { + provider: "grok", + root: filepath.Join(".grok", "skills"), + wantPath: "~/.grok/skills/review-helper", + wantName: "Grok Review", + }, } for _, tc := range tests { diff --git a/server/internal/metrics/labels.go b/server/internal/metrics/labels.go index 3404e9f8f..04776501f 100644 --- a/server/internal/metrics/labels.go +++ b/server/internal/metrics/labels.go @@ -121,6 +121,7 @@ var ( "copilot": "copilot", "cursor": "cursor", "gemini": "gemini", + "grok": "grok", "hermes": "hermes", "kiro": "kiro", "kimi": "kimi", diff --git a/server/migrations/179_runtime_profile_add_grok.down.sql b/server/migrations/179_runtime_profile_add_grok.down.sql new file mode 100644 index 000000000..946086ec0 --- /dev/null +++ b/server/migrations/179_runtime_profile_add_grok.down.sql @@ -0,0 +1,23 @@ +ALTER TABLE runtime_profile DROP CONSTRAINT IF EXISTS runtime_profile_protocol_family_check; + +-- Restore the pre-179 whitelist (migration 175 shape: with deveco, without +-- grok). NOT VALID keeps the historical Gemini tolerance so the rollback cannot +-- fail on old rows. +ALTER TABLE runtime_profile ADD CONSTRAINT runtime_profile_protocol_family_check + CHECK (protocol_family IN ( + 'claude', + 'codebuddy', + 'codex', + 'copilot', + 'opencode', + 'openclaw', + 'hermes', + 'pi', + 'cursor', + 'kimi', + 'kiro', + 'antigravity', + 'qoder', + 'traecli', + 'deveco' + )) NOT VALID; diff --git a/server/migrations/179_runtime_profile_add_grok.up.sql b/server/migrations/179_runtime_profile_add_grok.up.sql new file mode 100644 index 000000000..2be5668da --- /dev/null +++ b/server/migrations/179_runtime_profile_add_grok.up.sql @@ -0,0 +1,26 @@ +ALTER TABLE runtime_profile DROP CONSTRAINT IF EXISTS runtime_profile_protocol_family_check; + +-- Widen the whitelist to include Grok Build CLI (`grok`), driven over ACP via +-- `grok agent --always-approve stdio` (#2895). Builds on migration 175's shape +-- (which added `deveco`), so both stay in the whitelist. NOT VALID mirrors +-- migrations 126/134/136/175 so a historical Gemini row they intentionally +-- tolerated does not block the upgrade. +ALTER TABLE runtime_profile ADD CONSTRAINT runtime_profile_protocol_family_check + CHECK (protocol_family IN ( + 'claude', + 'codebuddy', + 'codex', + 'copilot', + 'opencode', + 'openclaw', + 'hermes', + 'pi', + 'cursor', + 'kimi', + 'kiro', + 'antigravity', + 'qoder', + 'traecli', + 'deveco', + 'grok' + )) NOT VALID; diff --git a/server/pkg/agent/agent.go b/server/pkg/agent/agent.go index e2450ceb8..e92fb18dd 100644 --- a/server/pkg/agent/agent.go +++ b/server/pkg/agent/agent.go @@ -1,7 +1,7 @@ // Package agent provides a unified interface for executing prompts via // coding agents (Claude Code, CodeBuddy, Codex, Copilot, OpenCode, DevEco Code, -// OpenClaw, Hermes, Pi, Cursor, Kimi, Kiro, Antigravity, Qoder). It mirrors the -// happy-cli AgentBackend pattern, translated to idiomatic Go. +// OpenClaw, Hermes, Pi, Cursor, Kimi, Kiro, Antigravity, Qoder, Trae, Grok). It +// mirrors the happy-cli AgentBackend pattern, translated to idiomatic Go. package agent import ( @@ -53,8 +53,9 @@ type ExecOptions struct { // "use the runtime/model default" — // every backend that consumes this skips its --effort / reasoning_effort // injection so the upstream CLI's own default applies. Currently honoured - // by the claude, codex, and opencode backends; other backends ignore the - // field rather than fail (so MUL-2339 can grow runtime support + // by the claude, codex, opencode, codebuddy, and grok (ACP + // `--effort` on `grok agent`) backends; other backends ignore + // the field rather than fail (so MUL-2339 can grow runtime support // incrementally without breaking unrelated agents). ThinkingLevel string // OpenclawMode chooses between local (embedded) and gateway routing for @@ -138,25 +139,26 @@ type Result struct { // Config configures a Backend instance. type Config struct { - ExecutablePath string // path to CLI binary (claude, codebuddy, codex, copilot, opencode, openclaw, hermes, pi, cursor, kimi, kiro-cli, agy, qodercli) + ExecutablePath string // path to CLI binary (claude, codebuddy, codex, copilot, opencode, openclaw, hermes, pi, cursor, kimi, kiro-cli, agy, qodercli, traecli, grok) Env map[string]string // extra environment variables Logger *slog.Logger } // New creates a Backend for the given agent type. -// Supported types: "claude", "codebuddy", "codex", "copilot", "opencode", "deveco", "openclaw", "hermes", "pi", "cursor", "kimi", "kiro", "antigravity", "qoder", "traecli". +// Supported types: "claude", "codebuddy", "codex", "copilot", "opencode", "deveco", "openclaw", "hermes", "pi", "cursor", "kimi", "kiro", "antigravity", "qoder", "traecli", "grok". // // SupportedTypes is the canonical whitelist of agent types eligible to back a // custom runtime profile. It MUST stay in lockstep with the // runtime_profile.protocol_family CHECK constraint (migration 120, widened by -// migration 134 to add qoder, migration 136 to add traecli, and migration 175 -// to add deveco): a custom -// runtime profile may only be based on a backend Multica officially supports. +// migration 134 to add qoder, migration 136 to add traecli, migration 175 to +// add deveco, and migration 179 to add grok): a custom runtime profile may only +// be based on a backend Multica officially supports. // qoder is exposed here so Qoder CN (`qoderclicn`) users can point the Qoder // backend at a non-default binary instead of misrouting through Kiro/ACP with // incompatible arguments (#4883). traecli (Trae) has a New backend, launch // header and provider branding but was previously missing from this whitelist, -// so the family picker rejected it (#4945). +// so the family picker rejected it (#4945). grok is the xAI Grok Build CLI +// ACP backend (`grok agent --always-approve stdio`). var SupportedTypes = []string{ "claude", "codebuddy", @@ -173,6 +175,7 @@ var SupportedTypes = []string{ "antigravity", "qoder", "traecli", + "grok", } // IsSupportedType reports whether agentType is in the SupportedTypes whitelist. @@ -223,8 +226,10 @@ func New(agentType string, cfg Config) (Backend, error) { return &qoderBackend{cfg: cfg}, nil case "traecli": return &traecliBackend{cfg: cfg}, nil + case "grok": + return &grokBackend{cfg: cfg}, nil default: - return nil, fmt.Errorf("unknown agent type: %q (supported: claude, codebuddy, codex, copilot, opencode, deveco, openclaw, hermes, pi, cursor, kimi, kiro, antigravity, qoder, traecli)", agentType) + return nil, fmt.Errorf("unknown agent type: %q (supported: claude, codebuddy, codex, copilot, opencode, deveco, openclaw, hermes, pi, cursor, kimi, kiro, antigravity, qoder, traecli, grok)", agentType) } } @@ -255,6 +260,7 @@ var launchHeaders = map[string]string{ "pi": "pi (json mode)", "qoder": "qodercli --acp", "traecli": "traecli acp serve", + "grok": "grok agent stdio", } // LaunchHeader returns the user-visible launch skeleton for agentType, or an diff --git a/server/pkg/agent/agent_supported_types_test.go b/server/pkg/agent/agent_supported_types_test.go index d3040c720..7a4174362 100644 --- a/server/pkg/agent/agent_supported_types_test.go +++ b/server/pkg/agent/agent_supported_types_test.go @@ -40,7 +40,7 @@ func TestSupportedTypesMatchesMigrationWhitelist(t *testing.T) { "claude": true, "codebuddy": true, "codex": true, "copilot": true, "opencode": true, "deveco": true, "openclaw": true, "hermes": true, "pi": true, "cursor": true, "kimi": true, "kiro": true, "antigravity": true, - "qoder": true, "traecli": true, + "qoder": true, "traecli": true, "grok": true, } if len(SupportedTypes) != len(want) { t.Fatalf("SupportedTypes has %d entries, migration whitelist has %d; keep them in lockstep", len(SupportedTypes), len(want)) diff --git a/server/pkg/agent/agent_test.go b/server/pkg/agent/agent_test.go index 5ec17c2cc..0769adc68 100644 --- a/server/pkg/agent/agent_test.go +++ b/server/pkg/agent/agent_test.go @@ -175,7 +175,7 @@ func TestLaunchHeaderCoversAllSupportedBackends(t *testing.T) { // entry to launchHeaders in agent.go and extend this list. supported := []string{ "antigravity", "claude", "codebuddy", "codex", "copilot", "cursor", - "hermes", "kimi", "kiro", "openclaw", "opencode", "pi", "qoder", "traecli", + "grok", "hermes", "kimi", "kiro", "openclaw", "opencode", "pi", "qoder", "traecli", } for _, t_ := range supported { if header := LaunchHeader(t_); header == "" { diff --git a/server/pkg/agent/grok.go b/server/pkg/agent/grok.go new file mode 100644 index 000000000..c19148b18 --- /dev/null +++ b/server/pkg/agent/grok.go @@ -0,0 +1,525 @@ +package agent + +import ( + "bufio" + "context" + "fmt" + "io" + "os/exec" + "strings" + "sync" + "sync/atomic" + "time" +) + +// grokBlockedArgs are flags/subcommands hardcoded by the daemon that must not +// be overridden by user-configured custom_args. `agent` + `stdio` select the +// ACP transport; `--always-approve` is daemon-owned so headless Multica runs +// do not block on interactive permission prompts. Switching into +// headless/serve/leader/print modes would break the daemon↔grok ACP contract. +// Model / thinking are managed via session/set_model and --effort. +var grokBlockedArgs = map[string]blockedArgMode{ + "agent": blockedStandalone, + "stdio": blockedStandalone, + "headless": blockedStandalone, + "serve": blockedStandalone, + "leader": blockedStandalone, + "--always-approve": blockedStandalone, + "--yolo": blockedStandalone, + "--no-auto-update": blockedStandalone, + "--no-alt-screen": blockedStandalone, + "-p": blockedStandalone, + "--print": blockedStandalone, + "--single": blockedWithValue, + "--output-format": blockedWithValue, + "--permission-mode": blockedWithValue, + "-m": blockedWithValue, + "--model": blockedWithValue, + "--reasoning-effort": blockedWithValue, + "--effort": blockedWithValue, + "-r": blockedWithValue, + "--resume": blockedWithValue, + "-c": blockedStandalone, + "--continue": blockedStandalone, + "-s": blockedWithValue, + "--session-id": blockedWithValue, + "--system-prompt-override": blockedWithValue, +} + +// grokBackend implements Backend by spawning +// `grok --no-auto-update agent --always-approve [--effort ] stdio` +// and communicating via the standard ACP (Agent Client Protocol) JSON-RPC 2.0 +// transport over stdin/stdout. +// +// This targets xAI's Grok Build CLI (the `grok` binary). Grok Build exposes +// ACP via `grok agent stdio` (flags such as --always-approve and +// --effort belong on the `agent` command, before the transport +// subcommand). We reuse hermesClient (same as traecli/kimi/kiro/qoder) with +// provider-specific launch args and tool-name normalization. +// +// Capability notes: we attempt session/load on resume and session/set_model +// when a model is requested; if a particular Grok Build version lacks those +// methods, the run fails with a clear error rather than silently continuing +// on the wrong session/model. Real initialize (0.2.x) advertises +// loadSession:true and mcpCapabilities {http, …}. +type grokBackend struct { + cfg Config +} + +var grokReaderDrainGrace = 2 * time.Second + +// grokMessageStream serializes sends and the final close so a late stdout +// reader cannot send on a closed channel. Mirrors traecli/qoder. +type grokMessageStream struct { + ch chan Message + mu sync.Mutex + closed bool +} + +func newGrokMessageStream(size int) *grokMessageStream { + return &grokMessageStream{ch: make(chan Message, size)} +} + +func (s *grokMessageStream) send(msg Message) { + s.mu.Lock() + defer s.mu.Unlock() + if s.closed { + return + } + trySend(s.ch, msg) +} + +func (s *grokMessageStream) close() { + s.mu.Lock() + defer s.mu.Unlock() + if s.closed { + return + } + s.closed = true + close(s.ch) +} + +func (b *grokBackend) Execute(ctx context.Context, prompt string, opts ExecOptions) (*Session, error) { + execPath := b.cfg.ExecutablePath + if execPath == "" { + execPath = "grok" + } + if _, err := exec.LookPath(execPath); err != nil { + return nil, fmt.Errorf("grok executable not found at %q: %w", execPath, err) + } + + // Translate the agent's mcp_config (Claude-style object of objects) into + // the array shape ACP session/new and session/load expect. Fail closed on + // malformed JSON so the launch surfaces the real error instead of silently + // dropping every MCP server. + mcpServers, err := buildACPMcpServers(opts.McpConfig, b.cfg.Logger) + if err != nil { + return nil, fmt.Errorf("grok: invalid mcp_config: %w", err) + } + + timeout := opts.Timeout + runCtx, cancel := runContext(ctx, timeout) + + // Flags on `grok agent` come before the transport subcommand (`stdio`). + // Thinking is a process-level flag on the agent command; model is set + // after session create via session/set_model (ACP). + // + // `--no-auto-update` is a *global* flag (before the `agent` subcommand): + // xAI recommends it for headless/ACP/CI runs so a background update check + // never interferes with an unattended daemon task. It is daemon-owned + // (grokBlockedArgs) so user custom_args cannot double it or strip it. + grokArgs := []string{"--no-auto-update", "agent", "--always-approve"} + if opts.ThinkingLevel != "" { + grokArgs = append(grokArgs, "--effort", opts.ThinkingLevel) + } + grokArgs = append(grokArgs, "stdio") + grokArgs = append(grokArgs, filterCustomArgs(opts.CustomArgs, grokBlockedArgs, b.cfg.Logger)...) + + cmd := exec.CommandContext(runCtx, execPath, grokArgs...) + hideAgentWindow(cmd) + b.cfg.Logger.Info("agent command", "exec", execPath, "args", grokArgs) + if opts.Cwd != "" { + cmd.Dir = opts.Cwd + } + childEnv := buildEnv(b.cfg.Env) + cmd.Env = childEnv + + stdout, err := cmd.StdoutPipe() + if err != nil { + cancel() + return nil, fmt.Errorf("grok stdout pipe: %w", err) + } + stdin, err := cmd.StdinPipe() + if err != nil { + cancel() + return nil, fmt.Errorf("grok stdin pipe: %w", err) + } + // StderrPipe + an explicit copier give us a join point (`stderrDone`) that + // fires before the failure-promotion decision; see hermes.go for why the + // io.MultiWriter form races with stopReason=end_turn under load. + providerErr := newACPProviderErrorSniffer("grok") + stderr, err := cmd.StderrPipe() + if err != nil { + cancel() + return nil, fmt.Errorf("grok stderr pipe: %w", err) + } + + if err := cmd.Start(); err != nil { + cancel() + return nil, fmt.Errorf("start grok: %w", err) + } + + stderrSink := io.MultiWriter(newLogWriter(b.cfg.Logger, "[grok:stderr] "), providerErr) + stderrDone := make(chan struct{}) + go func() { + defer close(stderrDone) + _, _ = io.Copy(stderrSink, stderr) + }() + + b.cfg.Logger.Info("grok acp started", "pid", cmd.Process.Pid, "cwd", opts.Cwd) + + msgStream := newGrokMessageStream(256) + resCh := make(chan Result, 1) + + var outputMu sync.Mutex + var output strings.Builder + var streamingCurrentTurn atomic.Bool + + promptDone := make(chan hermesPromptResult, 1) + + c := &hermesClient{ + cfg: b.cfg, + stdin: stdin, + pending: make(map[int]*pendingRPC), + pendingTools: make(map[string]*pendingToolCall), + acceptNotification: func(string) bool { + return streamingCurrentTurn.Load() + }, + onMessage: func(msg Message) { + if !streamingCurrentTurn.Load() { + return + } + if msg.Type == MessageToolUse { + // Re-normalise capitalised titles ("Read file: …") the same way + // kimi/traecli do so the UI sees consistent snake_case names. + msg.Tool = kimiToolNameFromTitle(msg.Tool) + } + if msg.Type == MessageText { + outputMu.Lock() + output.WriteString(msg.Content) + outputMu.Unlock() + } + msgStream.send(msg) + }, + onPromptDone: func(result hermesPromptResult) { + if !streamingCurrentTurn.Load() { + return + } + select { + case promptDone <- result: + default: + } + }, + } + + readerDone := make(chan struct{}) + go func() { + defer close(readerDone) + scanner := bufio.NewScanner(stdout) + scanner.Buffer(make([]byte, 0, 1024*1024), 10*1024*1024) + for scanner.Scan() { + line := strings.TrimSpace(scanner.Text()) + if line == "" { + continue + } + c.handleLine(line) + } + c.closeAllPending(fmt.Errorf("grok process exited")) + }() + + go func() { + defer cancel() + defer msgStream.close() + defer close(resCh) + defer func() { + stdin.Close() + _ = cmd.Wait() + }() + + startTime := time.Now() + finalStatus := "completed" + var finalError string + var sessionID string + effectiveModel := strings.TrimSpace(opts.Model) + + initResult, err := c.request(runCtx, "initialize", map[string]any{ + "protocolVersion": 1, + "clientInfo": map[string]any{ + "name": "multica-agent-sdk", + "version": "0.2.0", + }, + "clientCapabilities": map[string]any{}, + }) + if err != nil { + finalStatus = "failed" + finalError = fmt.Sprintf("grok initialize failed: %v", err) + resCh <- Result{Status: finalStatus, Error: finalError, DurationMs: time.Since(startTime).Milliseconds()} + return + } + + // Grok's ACP surface requires an explicit auth handshake between + // `initialize` and any session operation: read the advertised + // authMethods, pick one, and send `authenticate` before session/new + // or session/load. Skipping this makes a real, logged-in CLI reject + // every session op (the fake ACP in tests happens to accept them, + // which is exactly why this must be asserted in tests too). xAI's + // documented preference is the API key when XAI_API_KEY is set and + // offered, otherwise the cached login token. + // Ref: https://docs.x.ai/build/cli/headless-scripting + if methodID, ok := selectGrokAuthMethod(extractACPAuthMethods(initResult), envHasNonEmpty(childEnv, "XAI_API_KEY")); ok { + if _, err := c.request(runCtx, "authenticate", map[string]any{ + "methodId": methodID, + "_meta": map[string]any{"headless": true}, + }); err != nil { + finalStatus = "failed" + finalError = fmt.Sprintf("grok authenticate (%s) failed: %v", methodID, err) + resCh <- Result{Status: finalStatus, Error: finalError, DurationMs: time.Since(startTime).Milliseconds()} + return + } + b.cfg.Logger.Info("grok authenticated", "method", methodID) + } + + // Drop MCP entries whose remote transport the runtime didn't advertise. + // See hermes.go for why sending an unsupported transport tanks session/new. + mcpServers = filterACPMcpServersByCapability(mcpServers, extractACPMcpCapabilities(initResult), "grok", b.cfg.Logger) + + cwd := opts.Cwd + if cwd == "" { + cwd = "." + } + + if opts.ResumeSessionID != "" { + result, err := c.request(runCtx, "session/load", map[string]any{ + "cwd": cwd, + "sessionId": opts.ResumeSessionID, + "mcpServers": mcpServers, + }) + if err != nil { + finalStatus = "failed" + finalError = fmt.Sprintf("grok session/load failed: %v", err) + resCh <- Result{Status: finalStatus, Error: finalError, DurationMs: time.Since(startTime).Milliseconds()} + return + } + var changed bool + sessionID, changed = resolveResumedSessionID(opts.ResumeSessionID, result) + if changed { + b.cfg.Logger.Warn("agent returned a different session id on resume — original was likely lost; continuing with the new id", + "backend", "grok", + "requested", opts.ResumeSessionID, + "actual", sessionID, + ) + } + if effectiveModel == "" { + effectiveModel = extractACPCurrentModelID(result) + } + } else { + result, err := c.request(runCtx, "session/new", map[string]any{ + "cwd": cwd, + "mcpServers": mcpServers, + }) + if err != nil { + finalStatus = "failed" + finalError = fmt.Sprintf("grok session/new failed: %v", err) + resCh <- Result{Status: finalStatus, Error: finalError, DurationMs: time.Since(startTime).Milliseconds()} + return + } + sessionID = extractACPSessionID(result) + if sessionID == "" { + finalStatus = "failed" + finalError = "grok session/new returned no session ID" + resCh <- Result{Status: finalStatus, Error: finalError, DurationMs: time.Since(startTime).Milliseconds()} + return + } + if effectiveModel == "" { + effectiveModel = extractACPCurrentModelID(result) + } + } + + c.sessionID = sessionID + // Early session pin so a cancelled run still preserves resume pointer. + msgStream.send(Message{Type: MessageStatus, Status: "running", SessionID: sessionID}) + b.cfg.Logger.Info("grok session created", "session_id", sessionID) + + if opts.Model != "" { + if _, err := c.request(runCtx, "session/set_model", map[string]any{ + "sessionId": sessionID, + "modelId": opts.Model, + }); err != nil { + b.cfg.Logger.Warn("grok set_session_model failed", "error", err, "requested_model", opts.Model) + finalStatus = "failed" + finalError = fmt.Sprintf("grok could not switch to model %q: %v", opts.Model, err) + if opts.ResumeSessionID != "" && isACPSessionNotFound(err) { + b.cfg.Logger.Warn("resumed session not found at set_model time; clearing session id so the daemon retries fresh", + "backend", "grok", + "session_id", sessionID, + ) + sessionID = "" + } + resCh <- Result{ + Status: finalStatus, + Error: finalError, + DurationMs: time.Since(startTime).Milliseconds(), + SessionID: sessionID, + } + return + } + b.cfg.Logger.Info("grok session model set", "model", opts.Model) + } + + userText := prompt + if opts.SystemPrompt != "" { + // Grok also reads AGENTS.md from cwd; inline system prompt covers + // Multica runtime brief delivery when file injection is not enough. + userText = opts.SystemPrompt + "\n\n---\n\n" + prompt + } + + streamingCurrentTurn.Store(true) + _, err = c.request(runCtx, "session/prompt", map[string]any{ + "sessionId": sessionID, + "prompt": []map[string]any{ + {"type": "text", "text": userText}, + }, + }) + if err != nil { + if runCtx.Err() == context.DeadlineExceeded { + finalStatus = "timeout" + finalError = fmt.Sprintf("grok timed out after %s", timeout) + } else if runCtx.Err() == context.Canceled { + finalStatus = "aborted" + finalError = "execution cancelled" + } else { + finalStatus = "failed" + finalError = fmt.Sprintf("grok session/prompt failed: %v", err) + if opts.ResumeSessionID != "" && isACPSessionNotFound(err) { + b.cfg.Logger.Warn("resumed session not found at prompt time; clearing session id so the daemon retries fresh", + "backend", "grok", + "session_id", sessionID, + ) + sessionID = "" + } + } + } else { + select { + case pr := <-promptDone: + if pr.stopReason == "cancelled" { + finalStatus = "aborted" + finalError = "grok cancelled the prompt" + } + c.usageMu.Lock() + c.usage.InputTokens += pr.usage.InputTokens + c.usage.OutputTokens += pr.usage.OutputTokens + c.usage.CacheReadTokens += pr.usage.CacheReadTokens + c.usageMu.Unlock() + default: + } + } + + duration := time.Since(startTime) + b.cfg.Logger.Info("grok finished", "pid", cmd.Process.Pid, "status", finalStatus, "duration", duration.Round(time.Millisecond).String()) + + stdin.Close() + cancel() + + // Grok ACP may keep the process — and the stdout/stderr pipes — open + // briefly after session/prompt returns. Bound the drain. + drainCtx, drainCancel := context.WithTimeout(context.Background(), grokReaderDrainGrace) + select { + case <-readerDone: + case <-drainCtx.Done(): + } + select { + case <-stderrDone: + case <-drainCtx.Done(): + } + drainCancel() + streamingCurrentTurn.Store(false) + + outputMu.Lock() + finalOutput := output.String() + outputMu.Unlock() + + // Promote completed→failed when stderr or the agent text stream show a + // terminal upstream-LLM failure (auth / rate-limit / HTTP 4xx). + finalStatus, finalError = promoteACPResultOnProviderError(finalStatus, finalError, finalOutput, providerErr) + + c.usageMu.Lock() + u := c.usage + c.usageMu.Unlock() + + var usageMap map[string]TokenUsage + if u.InputTokens > 0 || u.OutputTokens > 0 || u.CacheReadTokens > 0 || u.CacheWriteTokens > 0 { + model := effectiveModel + if model == "" { + model = "unknown" + } + usageMap = map[string]TokenUsage{model: u} + } + + resCh <- Result{ + Status: finalStatus, + Output: finalOutput, + Error: finalError, + DurationMs: duration.Milliseconds(), + SessionID: sessionID, + Usage: usageMap, + } + }() + + return &Session{Messages: msgStream.ch, Result: resCh}, nil +} + +// Grok's ACP `authenticate` method ids (from `initialize`.authMethods). +// `xai.api_key` consumes XAI_API_KEY from the environment; `cached_token` +// reuses the credentials written by `grok login`. +const ( + grokAuthMethodAPIKey = "xai.api_key" + grokAuthMethodCachedToken = "cached_token" +) + +// selectGrokAuthMethod chooses which advertised ACP auth method to use, +// following xAI's documented headless flow: prefer the API key when +// XAI_API_KEY is present in the child env and the CLI offers it, otherwise +// fall back to the cached login token. If the CLI advertises methods but +// offers neither known id, we still return the first advertised one so a +// future rename doesn't hard-break the handshake (the real CLI then surfaces +// a clear error). Returns ("", false) when no methods are advertised, which +// means the CLI needs no explicit authenticate step. +func selectGrokAuthMethod(methods []string, haveAPIKey bool) (string, bool) { + if len(methods) == 0 { + return "", false + } + offered := make(map[string]bool, len(methods)) + for _, m := range methods { + offered[m] = true + } + if haveAPIKey && offered[grokAuthMethodAPIKey] { + return grokAuthMethodAPIKey, true + } + if offered[grokAuthMethodCachedToken] { + return grokAuthMethodCachedToken, true + } + return methods[0], true +} + +// envHasNonEmpty reports whether an `os/exec`-style env slice +// ("KEY=value" entries) contains key with a non-empty value. Later entries +// win (matching how the OS resolves duplicate keys), so we scan from the end. +func envHasNonEmpty(env []string, key string) bool { + prefix := key + "=" + for i := len(env) - 1; i >= 0; i-- { + if strings.HasPrefix(env[i], prefix) { + return strings.TrimSpace(env[i][len(prefix):]) != "" + } + } + return false +} diff --git a/server/pkg/agent/grok_test.go b/server/pkg/agent/grok_test.go new file mode 100644 index 000000000..0eea43851 --- /dev/null +++ b/server/pkg/agent/grok_test.go @@ -0,0 +1,503 @@ +package agent + +import ( + "context" + "log/slog" + "os" + "os/exec" + "path/filepath" + "strings" + "testing" + "time" +) + +func TestNewReturnsGrokBackend(t *testing.T) { + t.Parallel() + b, err := New("grok", Config{ExecutablePath: "/nonexistent/grok"}) + if err != nil { + t.Fatalf("New(grok) error: %v", err) + } + if _, ok := b.(*grokBackend); !ok { + t.Fatalf("expected *grokBackend, got %T", b) + } +} + +// fakeGrokACPScript impersonates `grok agent --always-approve stdio` for unit +// tests. Wire format mirrors other Multica ACP fakes (traecli/kimi): method +// "session/update" with update.sessionUpdate discriminators, session/new +// returning sessionId + models, session/prompt returning stopReason=end_turn. +func fakeGrokACPScript() string { + return `#!/bin/sh +if [ -n "$GROK_ARGS_FILE" ]; then + for arg in "$@"; do + printf '%s\n' "$arg" >> "$GROK_ARGS_FILE" + done +fi +while IFS= read -r line; do + if [ -n "$GROK_REQUESTS_FILE" ]; then + printf '%s\n' "$line" >> "$GROK_REQUESTS_FILE" + fi + id=$(printf '%s' "$line" | sed -n 's/.*"id":\([0-9]*\).*/\1/p') + case "$line" in + *'"method":"initialize"'*) + printf '{"jsonrpc":"2.0","id":%s,"result":{"protocolVersion":1,"authMethods":[{"id":"cached_token","name":"Cached login"},{"id":"xai.api_key","name":"API key"}],"agentCapabilities":{"loadSession":true,"mcpCapabilities":{"http":true,"sse":true}}}}\n' "$id" + ;; + *'"method":"authenticate"'*) + if [ -n "$GROK_AUTH_FAIL" ]; then + printf '{"jsonrpc":"2.0","id":%s,"error":{"code":-32000,"message":"authentication required: run grok login"}}\n' "$id" + exit 0 + fi + printf '{"jsonrpc":"2.0","id":%s,"result":{}}\n' "$id" + ;; + *'"method":"session/new"'*) + printf '{"jsonrpc":"2.0","id":%s,"result":{"sessionId":"ses_new","models":{"availableModels":[{"modelId":"grok-4.5","name":"Grok 4.5","description":""},{"modelId":"grok-composer-2.5-fast","name":"Grok Composer 2.5 Fast","description":""}]}}}\n' "$id" + ;; + *'"method":"session/load"'*) + printf '{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"ses_loaded","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":"history replay ignored"}}}}\n' + printf '{"jsonrpc":"2.0","id":%s,"result":{}}\n' "$id" + ;; + *'"method":"session/set_model"'*) + case "$line" in + *bogus-model*) + printf '{"jsonrpc":"2.0","id":%s,"error":{"code":-32602,"message":"model not available: bogus-model"}}\n' "$id" + exit 0 + ;; + *) + printf '{"jsonrpc":"2.0","id":%s,"result":{}}\n' "$id" + ;; + esac + ;; + *'"method":"session/prompt"'*) + printf '{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"ses_new","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"thinking about it"}}}}\n' + printf '{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"ses_new","update":{"sessionUpdate":"tool_call","toolCallId":"tc-1","name":"Shell","status":"pending","parameters":{"command":"echo hi"}}}}\n' + printf '{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"ses_new","update":{"sessionUpdate":"tool_call_update","toolCallId":"tc-1","status":"completed","name":"Shell","output":"hi\\n"}}}\n' + printf '{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"ses_new","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":"pong"}}}}\n' + printf '{"jsonrpc":"2.0","id":%s,"result":{"stopReason":"end_turn"}}\n' "$id" + exit 0 + ;; + esac +done +` +} + +func TestGrokBackendStreamsAndCompletes(t *testing.T) { + t.Parallel() + fakePath := filepath.Join(t.TempDir(), "grok") + writeTestExecutable(t, fakePath, []byte(fakeGrokACPScript())) + + backend, err := New("grok", Config{ExecutablePath: fakePath, Logger: slog.Default()}) + if err != nil { + t.Fatalf("new grok backend: %v", err) + } + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + + session, err := backend.Execute(ctx, "say pong", ExecOptions{Timeout: 5 * time.Second}) + if err != nil { + t.Fatalf("execute: %v", err) + } + var messages []Message + done := make(chan struct{}) + go func() { + defer close(done) + for m := range session.Messages { + messages = append(messages, m) + } + }() + result := <-session.Result + <-done + + if result.Status != "completed" { + t.Fatalf("expected completed, got status=%q error=%q", result.Status, result.Error) + } + if !strings.Contains(result.Output, "pong") { + t.Fatalf("output = %q, want it to contain the assistant message 'pong'", result.Output) + } + if result.SessionID != "ses_new" { + t.Fatalf("session id = %q, want ses_new", result.SessionID) + } + var sawText, sawToolUse bool + for _, m := range messages { + if m.Type == MessageText && strings.Contains(m.Content, "pong") { + sawText = true + } + if m.Type == MessageToolUse && m.Tool == "terminal" { + sawToolUse = true + } + } + if !sawText { + t.Error("expected a MessageText carrying the assistant 'pong'") + } + if !sawToolUse { + t.Errorf("expected the Shell tool_call to normalize to 'terminal'; messages=%+v", messages) + } +} + +func TestGrokBlockedArgsFiltering(t *testing.T) { + t.Parallel() + tempDir := t.TempDir() + argsFile := filepath.Join(tempDir, "argv.txt") + fakePath := filepath.Join(tempDir, "grok") + writeTestExecutable(t, fakePath, []byte(fakeGrokACPScript())) + + backend, err := New("grok", Config{ + ExecutablePath: fakePath, + Logger: slog.Default(), + Env: map[string]string{"GROK_ARGS_FILE": argsFile}, + }) + if err != nil { + t.Fatalf("new grok backend: %v", err) + } + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + + session, err := backend.Execute(ctx, "task", ExecOptions{ + Timeout: 5 * time.Second, + ThinkingLevel: "high", + // Users must not strip ACP mode, disable auto-approve, or switch + // into print/headless transports. + CustomArgs: []string{"agent", "stdio", "--always-approve", "--yolo", "headless", "-p", "--output-format", "json", "--permission-mode", "default", "--model", "hijack", "--reasoning-effort", "low", "--effort", "low", "--rules", "extra"}, + }) + if err != nil { + t.Fatalf("execute: %v", err) + } + go func() { + for range session.Messages { + } + }() + <-session.Result + + raw, err := os.ReadFile(argsFile) + if err != nil { + t.Fatalf("read args file: %v", err) + } + lines := strings.Split(strings.TrimSpace(string(raw)), "\n") + wantPrefix := []string{"--no-auto-update", "agent", "--always-approve", "--effort", "high", "stdio"} + if len(lines) < len(wantPrefix) { + t.Fatalf("expected at least %d args, got %d: %q", len(wantPrefix), len(lines), lines) + } + for i, want := range wantPrefix { + if lines[i] != want { + t.Fatalf("arg[%d] = %q, want %q (full: %q)", i, lines[i], want, lines) + } + } + joined := strings.Join(lines, " ") + for _, once := range []string{"--no-auto-update", "agent", "--always-approve", "stdio"} { + if c := countTokens(lines, once); c != 1 { + t.Errorf("expected exactly one %q, got %d (full: %q)", once, c, joined) + } + } + for _, blocked := range []string{"headless", "-p", "--output-format", "json", "--permission-mode", "default", "--yolo", "hijack"} { + for _, got := range lines { + if got == blocked { + t.Errorf("blocked custom arg %q survived filtering: %q", blocked, lines) + } + } + } + // Daemon-owned thinking must win over custom --effort/--reasoning-effort low. + if strings.Count(joined, "--effort") != 1 || !strings.Contains(joined, "--effort high") { + t.Errorf("expected single --effort high, got %q", joined) + } + if strings.Contains(joined, "--reasoning-effort") { + t.Errorf("legacy --reasoning-effort must be stripped, got %q", joined) + } + // An allowed custom arg must survive (after the fixed prefix). + if !strings.Contains(joined, "--rules") || !strings.Contains(joined, "extra") { + t.Errorf("expected allowed custom arg --rules to survive, got %q", joined) + } +} + +func TestGrokSetModelFailureFailsTask(t *testing.T) { + t.Parallel() + fakePath := filepath.Join(t.TempDir(), "grok") + writeTestExecutable(t, fakePath, []byte(fakeGrokACPScript())) + + backend, err := New("grok", Config{ExecutablePath: fakePath, Logger: slog.Default()}) + if err != nil { + t.Fatalf("new grok backend: %v", err) + } + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + + session, err := backend.Execute(ctx, "task", ExecOptions{Model: "bogus-model", Timeout: 5 * time.Second}) + if err != nil { + t.Fatalf("execute: %v", err) + } + go func() { + for range session.Messages { + } + }() + result := <-session.Result + if result.Status != "failed" { + t.Fatalf("expected failed on set_model error, got %q", result.Status) + } + if !strings.Contains(result.Error, `could not switch to model "bogus-model"`) { + t.Errorf("expected error to name the model, got %q", result.Error) + } + if !strings.Contains(result.Error, "model not available") { + t.Errorf("expected upstream message surfaced, got %q", result.Error) + } +} + +func TestGrokUsesSessionLoadForResume(t *testing.T) { + t.Parallel() + tempDir := t.TempDir() + requestsFile := filepath.Join(tempDir, "requests.jsonl") + fakePath := filepath.Join(tempDir, "grok") + writeTestExecutable(t, fakePath, []byte(fakeGrokACPScript())) + + backend, err := New("grok", Config{ + ExecutablePath: fakePath, + Logger: slog.Default(), + Env: map[string]string{"GROK_REQUESTS_FILE": requestsFile}, + }) + if err != nil { + t.Fatalf("new grok backend: %v", err) + } + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + + session, err := backend.Execute(ctx, "continue", ExecOptions{ + ResumeSessionID: "ses_existing", + Timeout: 5 * time.Second, + }) + if err != nil { + t.Fatalf("execute: %v", err) + } + go func() { + for range session.Messages { + } + }() + result := <-session.Result + if result.Status != "completed" { + t.Fatalf("expected completed, got %q (error=%q)", result.Status, result.Error) + } + if result.SessionID != "ses_existing" { + t.Fatalf("session id = %q, want ses_existing", result.SessionID) + } + raw, err := os.ReadFile(requestsFile) + if err != nil { + t.Fatalf("read requests: %v", err) + } + requests := string(raw) + if !strings.Contains(requests, `"method":"session/load"`) { + t.Fatalf("expected session/load on resume, got:\n%s", requests) + } + if strings.Contains(requests, `"method":"session/resume"`) { + t.Fatalf("grok must use session/load when resuming, not session/resume:\n%s", requests) + } +} + +// TestGrokAuthenticatesBeforeSession asserts the ACP auth handshake happens in +// the order the real Grok CLI requires: `authenticate` must be sent after +// `initialize` and before any session operation (session/new or session/load). +// A fake ACP that blindly accepts session/new (as ours does) would otherwise +// hide a missing handshake — the exact gap this guards against. +func TestGrokAuthenticatesBeforeSession(t *testing.T) { + t.Parallel() + for _, tc := range []struct { + name string + resume string + wantSession string + }{ + {name: "new session", resume: "", wantSession: `"method":"session/new"`}, + {name: "resume", resume: "ses_existing", wantSession: `"method":"session/load"`}, + } { + tc := tc + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + tempDir := t.TempDir() + requestsFile := filepath.Join(tempDir, "requests.jsonl") + fakePath := filepath.Join(tempDir, "grok") + writeTestExecutable(t, fakePath, []byte(fakeGrokACPScript())) + + backend, err := New("grok", Config{ + ExecutablePath: fakePath, + Logger: slog.Default(), + Env: map[string]string{"GROK_REQUESTS_FILE": requestsFile}, + }) + if err != nil { + t.Fatalf("new grok backend: %v", err) + } + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + + session, err := backend.Execute(ctx, "task", ExecOptions{ + ResumeSessionID: tc.resume, + Timeout: 5 * time.Second, + }) + if err != nil { + t.Fatalf("execute: %v", err) + } + go func() { + for range session.Messages { + } + }() + <-session.Result + + raw, err := os.ReadFile(requestsFile) + if err != nil { + t.Fatalf("read requests: %v", err) + } + lines := strings.Split(strings.TrimSpace(string(raw)), "\n") + authIdx, sessionIdx, initIdx := -1, -1, -1 + for i, l := range lines { + switch { + case strings.Contains(l, `"method":"initialize"`): + initIdx = i + case strings.Contains(l, `"method":"authenticate"`): + authIdx = i + case strings.Contains(l, tc.wantSession): + if sessionIdx == -1 { + sessionIdx = i + } + } + } + if authIdx == -1 { + t.Fatalf("expected an authenticate request, got:\n%s", raw) + } + if sessionIdx == -1 { + t.Fatalf("expected a %s request, got:\n%s", tc.wantSession, raw) + } + if !(initIdx < authIdx && authIdx < sessionIdx) { + t.Fatalf("expected order initialize(%d) < authenticate(%d) < session(%d):\n%s", + initIdx, authIdx, sessionIdx, raw) + } + // The daemon has no XAI_API_KEY here, so it must fall back to the + // cached-token method advertised by the fake. + if !strings.Contains(lines[authIdx], `"methodId":"cached_token"`) { + t.Errorf("expected cached_token auth method, got: %s", lines[authIdx]) + } + if !strings.Contains(lines[authIdx], `"headless":true`) { + t.Errorf("expected headless meta on authenticate, got: %s", lines[authIdx]) + } + }) + } +} + +// TestGrokAuthFailureFailsTask asserts a rejected authenticate handshake fails +// the task with a clear error instead of falling through to session/new. +func TestGrokAuthFailureFailsTask(t *testing.T) { + t.Parallel() + fakePath := filepath.Join(t.TempDir(), "grok") + writeTestExecutable(t, fakePath, []byte(fakeGrokACPScript())) + + backend, err := New("grok", Config{ + ExecutablePath: fakePath, + Logger: slog.Default(), + Env: map[string]string{"GROK_AUTH_FAIL": "1"}, + }) + if err != nil { + t.Fatalf("new grok backend: %v", err) + } + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + + session, err := backend.Execute(ctx, "task", ExecOptions{Timeout: 5 * time.Second}) + if err != nil { + t.Fatalf("execute: %v", err) + } + go func() { + for range session.Messages { + } + }() + result := <-session.Result + if result.Status != "failed" { + t.Fatalf("expected failed on authenticate error, got %q", result.Status) + } + if !strings.Contains(result.Error, "authenticate") { + t.Errorf("expected error to mention authenticate, got %q", result.Error) + } +} + +// TestGrokSelectAuthMethod covers the auth-method selection preference. +func TestGrokSelectAuthMethod(t *testing.T) { + t.Parallel() + cases := []struct { + name string + methods []string + haveKey bool + wantID string + wantOK bool + }{ + {"none advertised", nil, false, "", false}, + {"cached only", []string{"cached_token"}, false, "cached_token", true}, + {"api key preferred when present", []string{"cached_token", "xai.api_key"}, true, "xai.api_key", true}, + {"api key ignored without env", []string{"cached_token", "xai.api_key"}, false, "cached_token", true}, + {"unknown falls back to first", []string{"future_method"}, true, "future_method", true}, + } + for _, tc := range cases { + got, ok := selectGrokAuthMethod(tc.methods, tc.haveKey) + if got != tc.wantID || ok != tc.wantOK { + t.Errorf("%s: selectGrokAuthMethod(%v, %v) = (%q, %v), want (%q, %v)", + tc.name, tc.methods, tc.haveKey, got, ok, tc.wantID, tc.wantOK) + } + } +} + +func TestGrokIsKnownThinkingValue(t *testing.T) { + t.Parallel() + for _, level := range []string{"", "none", "minimal", "low", "medium", "high", "xhigh"} { + if !IsKnownThinkingValue("grok", level) { + t.Errorf("IsKnownThinkingValue(grok, %q) = false", level) + } + } + // grok tops out at xhigh; "max" is not a grok --effort level, so it must + // not pass the persist gate (it would otherwise be dropped at execution). + for _, level := range []string{"bogus", "max"} { + if IsKnownThinkingValue("grok", level) { + t.Errorf("IsKnownThinkingValue(grok, %q) = true, want rejected", level) + } + } +} + +// TestGrokRealACPSmoke drives the REAL `grok agent stdio` binary end-to-end +// when it is installed and authenticated. Skipped automatically when grok is +// not on PATH or the session cannot be created, so CI stays green. +func TestGrokRealACPSmoke(t *testing.T) { + if testing.Short() { + t.Skip("skipping real-binary smoke test in -short mode") + } + path, err := exec.LookPath("grok") + if err != nil { + t.Skip("grok not on PATH; skipping real-binary smoke test") + } + + backend, err := New("grok", Config{ExecutablePath: path, Logger: slog.Default()}) + if err != nil { + t.Fatalf("new grok backend: %v", err) + } + ctx, cancel := context.WithTimeout(context.Background(), 90*time.Second) + defer cancel() + + session, err := backend.Execute(ctx, "Reply with exactly one word: pong. Do not use any tools.", ExecOptions{ + Cwd: t.TempDir(), + Timeout: 80 * time.Second, + }) + if err != nil { + t.Fatalf("execute: %v", err) + } + go func() { + for range session.Messages { + } + }() + + select { + case result := <-session.Result: + if result.Status == "failed" && (strings.Contains(result.Error, "session/new") || strings.Contains(result.Error, "initialize")) { + t.Skipf("grok not authenticated or ACP unavailable: %v", result.Error) + } + if result.Status != "completed" { + t.Fatalf("real grok run did not complete: status=%q error=%q", result.Status, result.Error) + } + if !strings.Contains(strings.ToLower(result.Output), "pong") { + t.Fatalf("expected real grok output to contain 'pong', got %q", result.Output) + } + if result.SessionID == "" { + t.Error("expected a non-empty session id from real grok") + } + t.Logf("real grok smoke OK: session=%s output=%q", result.SessionID, result.Output) + case <-time.After(90 * time.Second): + t.Fatal("timeout waiting for real grok result") + } +} diff --git a/server/pkg/agent/hermes.go b/server/pkg/agent/hermes.go index 1d4970fad..f9ce804b5 100644 --- a/server/pkg/agent/hermes.go +++ b/server/pkg/agent/hermes.go @@ -1534,6 +1534,32 @@ func extractACPSessionID(result json.RawMessage) string { return r.SessionID } +// extractACPAuthMethods returns the `authMethods` ids advertised in an ACP +// `initialize` response, in the order the agent listed them. Agents that +// require authentication (e.g. xAI's Grok Build) enumerate the accepted +// methods here; per the ACP flow the client MUST send `authenticate` with one +// of these ids before `session/new` / `session/load`. Agents that need no +// explicit auth omit the field, so an empty slice means "skip authenticate". +// A malformed response degrades to an empty slice (fail open on parsing so we +// don't wedge agents that never needed the step). +func extractACPAuthMethods(result json.RawMessage) []string { + var r struct { + AuthMethods []struct { + ID string `json:"id"` + } `json:"authMethods"` + } + if err := json.Unmarshal(result, &r); err != nil { + return nil + } + ids := make([]string, 0, len(r.AuthMethods)) + for _, m := range r.AuthMethods { + if id := strings.TrimSpace(m.ID); id != "" { + ids = append(ids, id) + } + } + return ids +} + // extractACPCurrentModelID pulls the model selected by the ACP runtime out of // a session/new or session/resume response. Hermes returns this when it uses // its own default model, so token usage can still be attributed to a real model diff --git a/server/pkg/agent/models.go b/server/pkg/agent/models.go index 781ae153e..03f1d064d 100644 --- a/server/pkg/agent/models.go +++ b/server/pkg/agent/models.go @@ -11,6 +11,7 @@ import ( "os/exec" "regexp" "sort" + "strconv" "strings" "sync" "time" @@ -164,6 +165,13 @@ func ListModels(ctx context.Context, providerType, executablePath string) ([]Mod annotateCodebuddyThinking(ctx, models, executablePath) return models, nil }) + case "grok": + // xAI Grok Build is ACP-native (`grok agent stdio`); model catalog + // comes from session/new. Falls back to a small static list so the + // UI picker stays usable offline / unauthenticated. + return cachedDiscovery(providerType, func() ([]Model, error) { + return discoverGrokModels(ctx, executablePath) + }) default: return nil, fmt.Errorf("unknown agent type: %q", providerType) } @@ -895,6 +903,12 @@ type acpDiscoveryProvider struct { // acpArgs is the argv passed to the binary to start it in ACP // server mode. Defaults to []string{"acp"} when nil/empty. acpArgs []string + // authMethodID, when non-empty, inserts an ACP `authenticate` request + // (with the given methodId) between `initialize` and `session/new`. + // Agents that need no auth (kiro/qoder/trae) leave this empty and the + // handshake is unchanged. Grok Build requires it or session/new is + // rejected, degrading discovery to the static fallback catalog. + authMethodID string } // discoverACPModels runs the ACP handshake for any agent CLI that @@ -977,14 +991,29 @@ func discoverACPModels(ctx context.Context, executablePath string, p acpDiscover } defer os.RemoveAll(tmp) - if err := writeACP(2, "session/new", map[string]any{ + // Agents that gate session ops behind an auth handshake (Grok Build) + // need `authenticate` before session/new; others leave authMethodID + // empty and this step is skipped, keeping their id sequence unchanged. + sessionNewID := 2 + if p.authMethodID != "" { + if err := writeACP(2, "authenticate", map[string]any{ + "methodId": p.authMethodID, + "_meta": map[string]any{"headless": true}, + }); err != nil { + return []Model{}, nil + } + sessionNewID = 3 + } + + if err := writeACP(sessionNewID, "session/new", map[string]any{ "cwd": tmp, "mcpServers": []any{}, }); err != nil { return []Model{}, nil } + wantID := strconv.Itoa(sessionNewID) - // Read responses until we see the one for id=2 (session/new). + // Read responses until we see the one for session/new. scanner := bufio.NewScanner(stdout) scanner.Buffer(make([]byte, 0, 1024*1024), 4*1024*1024) deadline := time.After(12 * time.Second) @@ -1003,7 +1032,7 @@ func discoverACPModels(ctx context.Context, executablePath string, p acpDiscover if err := json.Unmarshal([]byte(line), &env); err != nil { continue } - if env.ID.String() != "2" || len(env.Result) == 0 { + if env.ID.String() != wantID || len(env.Result) == 0 { continue } done <- parseACPSessionNewModels(env.Result) @@ -1157,6 +1186,73 @@ func parseAntigravityModels(output string) []Model { return models } +// discoverGrokModels spins up `grok agent --always-approve stdio` and parses +// the model catalog from session/new (same shape as Kiro/Qoder/Trae). Requires +// an authenticated Grok CLI; on any failure falls back to grokStaticModels. +func discoverGrokModels(ctx context.Context, executablePath string) ([]Model, error) { + // Match the daemon's runtime launch: `--no-auto-update` (global) so a + // background update check can't stall discovery, and the same auth + // handshake — prefer the API key when XAI_API_KEY is set, else the cached + // login token — so session/new returns the real catalog instead of being + // rejected (which silently falls back to grokStaticModels). + authMethod := grokAuthMethodCachedToken + if strings.TrimSpace(os.Getenv("XAI_API_KEY")) != "" { + authMethod = grokAuthMethodAPIKey + } + models, err := discoverACPModels(ctx, executablePath, acpDiscoveryProvider{ + defaultBin: "grok", + clientName: "multica-model-discovery", + tmpdirPrefix: "multica-grok-discovery-", + acpArgs: []string{"--no-auto-update", "agent", "--always-approve", "stdio"}, + authMethodID: authMethod, + }) + if err != nil || len(models) == 0 { + return grokStaticModels(), nil + } + for i := range models { + if models[i].Provider == "" { + models[i].Provider = "xai" + } + } + annotateGrokThinking(models) + return models, nil +} + +// grokStaticModels is the offline fallback catalog for the Grok Build CLI. +// IDs match a typical signed-in `session/new` / `grok models` listing. +func grokStaticModels() []Model { + models := []Model{ + {ID: "grok-4.5", Label: "Grok 4.5", Provider: "xai", Default: true}, + {ID: "grok-composer-2.5-fast", Label: "Grok Composer 2.5 Fast", Provider: "xai"}, + } + annotateGrokThinking(models) + return models +} + +// annotateGrokThinking attaches the fixed --effort vocabulary to +// every discovered Grok model. Per-model capability is not exposed by +// session/new, so the full canonical set is offered and the CLI rejects +// unsupported levels at runtime if a model cannot reason. grok tops out at +// `xhigh`; this list must stay in lockstep with the grok entry in +// providerThinkingEnums (thinking.go) so a persisted level is never silently +// dropped at execution by ValidateThinkingLevel. +func annotateGrokThinking(models []Model) { + levels := []ThinkingLevel{ + {Value: "none", Label: "None"}, + {Value: "minimal", Label: "Minimal"}, + {Value: "low", Label: "Low"}, + {Value: "medium", Label: "Medium"}, + {Value: "high", Label: "High"}, + {Value: "xhigh", Label: "Extra high"}, + } + for i := range models { + models[i].Thinking = &ModelThinking{ + DefaultLevel: "", + SupportedLevels: levels, + } + } +} + // discoverCursorModels runs `cursor-agent --list-models` and parses // the `id - Label` rows. Cursor's catalog changes often and ships // many variants of the same base model (thinking / fast / max diff --git a/server/pkg/agent/thinking.go b/server/pkg/agent/thinking.go index df3a80f6e..4e899743f 100644 --- a/server/pkg/agent/thinking.go +++ b/server/pkg/agent/thinking.go @@ -666,6 +666,18 @@ var providerThinkingEnums = map[string]map[string]bool{ "high": true, "xhigh": true, }, + // Grok Build CLI --effort canonical levels (see Grok headless docs). + // grok tops out at `xhigh`; keep this in lockstep with + // annotateGrokThinking's SupportedLevels so a persisted level is never + // silently dropped at execution by ValidateThinkingLevel. + "grok": { + "none": true, + "minimal": true, + "low": true, + "medium": true, + "high": true, + "xhigh": true, + }, } // IsKnownThinkingValue reports whether `value` is a recognised effort diff --git a/server/pkg/agent/thinking_test.go b/server/pkg/agent/thinking_test.go index e2d3b15a0..c36e3791c 100644 --- a/server/pkg/agent/thinking_test.go +++ b/server/pkg/agent/thinking_test.go @@ -450,6 +450,12 @@ func TestIsKnownThinkingValue(t *testing.T) { {"opencode", "bad value", false}, // spaces are not valid variant names {"hermes", "", true}, {"hermes", "low", false}, // hermes has no thinking concept + {"grok", "", true}, + {"grok", "none", true}, + {"grok", "low", true}, + {"grok", "xhigh", true}, + {"grok", "max", false}, // grok tops out at xhigh; max is not a grok level + {"grok", "bogus", false}, } for _, tc := range tests { if got := IsKnownThinkingValue(tc.provider, tc.value); got != tc.want { diff --git a/server/pkg/agent/version.go b/server/pkg/agent/version.go index 93c8dbc3d..aa3eeb221 100644 --- a/server/pkg/agent/version.go +++ b/server/pkg/agent/version.go @@ -14,6 +14,7 @@ var MinVersions = map[string]string{ "claude": "2.0.0", "codex": "0.100.0", // app-server --listen stdio:// added in 0.100.0 "copilot": "1.0.0", // --output-format json envelope stable from 1.0.x + "grok": "0.2.89", // ACP + authenticate/session-load/set_model/MCP and --effort thinking flag } // MinQuickCreateCLIVersion gates the agent-create (quick-create) flow against diff --git a/server/pkg/agent/version_test.go b/server/pkg/agent/version_test.go index 66d5ed66e..c78b908b4 100644 --- a/server/pkg/agent/version_test.go +++ b/server/pkg/agent/version_test.go @@ -155,6 +155,10 @@ func TestCheckMinVersion(t *testing.T) { {"codex", "codex-cli 0.100.0", false}, {"codex", "codex-cli 0.99.0", true}, {"codex", "codex-cli 0.50.0", true}, + {"grok", "grok 0.2.93 (f00f96316d4b) [stable]", false}, + {"grok", "0.2.89", false}, + {"grok", "0.2.0", true}, + {"grok", "0.1.9", true}, {"unknown", "1.0.0", false}, } for _, tt := range tests {