agentmux_srv\backend/
providers.rs

1// Copyright 2025-2026, AgentMux Corp.
2// SPDX-License-Identifier: Apache-2.0
3
4//! Static provider registry — Rust equivalent of
5//! `frontend/app/view/agent/providers/index.ts`.
6//!
7//! All string data is `&'static str` / `&'static [&'static str]` so lookups
8//! are zero-allocation.  The registry is initialised once via `LazyLock` and
9//! then read-only for the lifetime of the process.
10
11use std::collections::HashMap;
12use std::sync::LazyLock;
13
14// ─── Controller type ─────────────────────────────────────────────────────────
15
16/// How the provider process is managed.
17#[derive(Debug, Clone, Copy, PartialEq, Eq)]
18pub enum ControllerType {
19    /// A single long-running process; input is streamed to stdin.
20    Persistent,
21    /// A fresh subprocess is spawned for every turn; prior sessions are
22    /// resumed via `resume_flag`.
23    Subprocess,
24    /// Agent Client Protocol (ACP): JSON-RPC 2.0 over stdio.
25    /// Sessions are managed by the protocol — no resume flags needed.
26    Acp,
27}
28
29// ─── ProviderConfig ──────────────────────────────────────────────────────────
30
31/// All configuration needed to launch, authenticate, and stream output from
32/// a provider CLI.
33#[derive(Debug)]
34pub struct ProviderConfig {
35    /// Canonical provider identifier (e.g. `"claude"`).
36    pub id: &'static str,
37    /// Human-readable name shown in the UI.
38    pub display_name: &'static str,
39    /// Executable name on PATH (e.g. `"claude"`).
40    pub cli_command: &'static str,
41    /// Whether the provider keeps a persistent subprocess or spawns per turn.
42    pub controller_type: ControllerType,
43    /// Complete CLI args for a single-turn (subprocess) invocation.
44    /// The user prompt is written to the process's stdin.
45    pub launch_args: &'static [&'static str],
46    /// CLI args for persistent (long-running) mode.
47    /// `None` when `controller_type` is `Subprocess`.
48    pub persistent_launch_args: Option<&'static [&'static str]>,
49    /// Flag passed to resume a prior session, e.g. `"--resume"`.
50    /// `None` when the provider does not support simple-flag resume.
51    pub resume_flag: Option<&'static str>,
52    /// JSON field name in the CLI's init event that carries the session /
53    /// thread ID, e.g. `"session_id"` or `"thread_id"`.
54    pub session_id_field: &'static str,
55    /// Output format produced by the CLI in styled / streaming mode.
56    pub styled_output_format: &'static str,
57    // ── Auth isolation ───────────────────────────────────────────────────────
58    /// Environment variable that redirects the provider's config / auth
59    /// directory, e.g. `"CLAUDE_CONFIG_DIR"`.
60    pub auth_config_dir_env_var: &'static str,
61    /// Sub-directory name for this provider's auth/config dir, e.g. `"claude"`.
62    /// Resolved under `shared/providers/<name>/` (the default, account-wide and
63    /// instance-independent) and `shared/identities/<bundle>/<name>/` (per-identity)
64    /// — see `DataPaths::provider_auth_dir` and `identity_dir`.
65    pub auth_dir_name: &'static str,
66    /// Extra environment variables required for auth isolation.
67    /// Each entry is a `(key, value)` pair.
68    pub auth_extra_env: &'static [(&'static str, &'static str)],
69    /// Environment variables that must be *unset* before launching the CLI
70    /// (guards against nested-session issues, etc.).
71    pub unset_env: &'static [&'static str],
72    // ── npm install ──────────────────────────────────────────────────────────
73    /// npm package name used for local installation, e.g.
74    /// `"@anthropic-ai/claude-code"`.
75    pub npm_package: &'static str,
76    /// Version string passed to `npm install`, e.g. `"latest"` or `"0.116.0"`.
77    pub pinned_version: &'static str,
78    // ── Misc ─────────────────────────────────────────────────────────────────
79    /// Icon identifier used by the frontend.
80    pub icon: &'static str,
81    /// URL of the provider's documentation.
82    pub docs_url: &'static str,
83}
84
85impl ProviderConfig {
86    /// Return the controller type as the string used in block metadata.
87    pub fn controller_type_str(&self) -> &'static str {
88        match self.controller_type {
89            ControllerType::Persistent => "persistent",
90            ControllerType::Subprocess => "subprocess",
91            ControllerType::Acp => "acp",
92        }
93    }
94}
95
96// ─── Provider definitions ────────────────────────────────────────────────────
97
98static CLAUDE: ProviderConfig = ProviderConfig {
99    id: "claude",
100    display_name: "Claude Code",
101    cli_command: "claude",
102    // Persistent (bidirectional stream-json) + the Agent SDK CONTROL PROTOCOL is
103    // the only way AskUserQuestion (and interactive tool-permission) works headless.
104    // The CLI auto-rejects AskUserQuestion with `Error: Answer questions?` in any
105    // mode UNLESS the driver speaks the control protocol: launch with
106    // `--permission-prompt-tool stdio` (+ a non-bypass `--permission-mode`), then
107    // answer the CLI's `can_use_tool` control_request with a control_response
108    // carrying `updatedInput.answers`. See SPEC_AGENT_CONTROL_PROTOCOL_2026_06_15.md
109    // (§2 captured the exact wire bytes against bundled CLI v2.1.178).
110    //
111    // CRITICAL: `--dangerously-skip-permissions` DISABLES that routing (it bypasses
112    // canUseTool), so it must NOT be in persistent_launch_args. The persistent
113    // controller's ControlChannel auto-allows ordinary tools to preserve today's
114    // yolo UX; only AskUserQuestion is surfaced to the user.
115    controller_type: ControllerType::Persistent,
116    launch_args: &[
117        "-p",
118        "--output-format",
119        "stream-json",
120        "--verbose",
121        "--include-partial-messages",
122        "--dangerously-skip-permissions",
123    ],
124    persistent_launch_args: Some(&[
125        "--input-format",
126        "stream-json",
127        "--output-format",
128        "stream-json",
129        "--verbose",
130        "--include-partial-messages",
131        // Enable the control protocol so the sidecar can answer can_use_tool /
132        // AskUserQuestion. Replaces --dangerously-skip-permissions (which bypasses it).
133        "--permission-prompt-tool",
134        "stdio",
135        "--permission-mode",
136        "default",
137    ]),
138    resume_flag: Some("--resume"),
139    session_id_field: "session_id",
140    styled_output_format: "claude-stream-json",
141    auth_config_dir_env_var: "CLAUDE_CONFIG_DIR",
142    auth_dir_name: "claude",
143    auth_extra_env: &[],
144    unset_env: &["CLAUDECODE"],
145    npm_package: "@anthropic-ai/claude-code",
146    pinned_version: "latest",
147    icon: "sparkles",
148    docs_url: "https://docs.anthropic.com/claude-code",
149};
150
151static CODEX: ProviderConfig = ProviderConfig {
152    id: "codex",
153    display_name: "Codex CLI",
154    cli_command: "codex",
155    controller_type: ControllerType::Subprocess,
156    // exec subcommand runs non-interactively; --json emits NDJSON events; - reads prompt from stdin
157    launch_args: &[
158        "exec",
159        "--json",
160        "--dangerously-bypass-approvals-and-sandbox",
161        "-",
162    ],
163    // Codex resume requires a subcommand change (exec resume <id>), not a simple flag.
164    // Multi-turn is handled by re-running exec; None disables automatic --resume append.
165    persistent_launch_args: None,
166    resume_flag: None,
167    session_id_field: "thread_id",
168    styled_output_format: "codex-json",
169    auth_config_dir_env_var: "CODEX_HOME",
170    auth_dir_name: "codex",
171    auth_extra_env: &[],
172    unset_env: &[],
173    npm_package: "@openai/codex",
174    pinned_version: "0.116.0",
175    icon: "robot",
176    docs_url: "https://platform.openai.com/docs/codex",
177};
178
179static GEMINI: ProviderConfig = ProviderConfig {
180    id: "gemini",
181    display_name: "Gemini CLI",
182    cli_command: "gemini",
183    controller_type: ControllerType::Subprocess,
184    // --output-format stream-json: NDJSON events; --yolo: auto-approve all tools;
185    // -p "": enable headless/non-interactive mode (prompt comes from stdin)
186    launch_args: &["--output-format", "stream-json", "--yolo", "-p", ""],
187    persistent_launch_args: None,
188    resume_flag: Some("-r"),
189    session_id_field: "session_id",
190    styled_output_format: "gemini-json",
191    auth_config_dir_env_var: "GEMINI_CLI_HOME",
192    auth_dir_name: "gemini",
193    auth_extra_env: &[("GEMINI_FORCE_FILE_STORAGE", "true")],
194    unset_env: &[],
195    npm_package: "@google/gemini-cli",
196    pinned_version: "0.32.1",
197    icon: "diamond",
198    docs_url: "https://ai.google.dev/gemini-cli",
199};
200
201// Qwen Code — Alibaba's open-source coding agent, a fork of Gemini CLI.
202// Same stream-json headless surface, so it reuses the Gemini translator.
203// Backend is OpenAI-compatible (OPENAI_BASE_URL=https://openrouter.ai/api/v1
204// + OPENAI_API_KEY/OPENAI_MODEL), so it runs any OpenRouter model.
205static QWEN: ProviderConfig = ProviderConfig {
206    id: "qwen",
207    display_name: "Qwen Code",
208    cli_command: "qwen",
209    controller_type: ControllerType::Subprocess,
210    // -p: non-interactive; --output-format stream-json: NDJSON events;
211    // --yolo: auto-approve all tools. Mirrors GEMINI (its upstream).
212    launch_args: &["--output-format", "stream-json", "--yolo", "-p", ""],
213    persistent_launch_args: None,
214    // Docs mention --resume <id>/--continue but it's unconfirmed for the
215    // headless stream-json path; multi-turn re-runs like Codex/Kimi (None).
216    resume_flag: None,
217    session_id_field: "session_id",
218    // Gemini-CLI fork → same stream-json schema; reuse the gemini translator.
219    styled_output_format: "gemini-json",
220    // QWEN_HOME relocates the config/credentials dir (default ~/.qwen),
221    // the Qwen analogue of GEMINI_CLI_HOME — gives per-agent auth isolation.
222    auth_config_dir_env_var: "QWEN_HOME",
223    auth_dir_name: "qwen",
224    auth_extra_env: &[],
225    unset_env: &[],
226    npm_package: "@qwen-code/qwen-code",
227    pinned_version: "latest",
228    icon: "feather",
229    docs_url: "https://qwenlm.github.io/qwen-code-docs",
230};
231
232static KIMI: ProviderConfig = ProviderConfig {
233    id: "kimi",
234    display_name: "Kimi Code CLI",
235    cli_command: "kimi",
236    controller_type: ControllerType::Subprocess,
237    launch_args: &[
238        "--print",
239        "--output-format",
240        "stream-json",
241        "--yolo",
242        "-p",
243        "",
244    ],
245    persistent_launch_args: None,
246    resume_flag: None,
247    session_id_field: "session_id",
248    styled_output_format: "kimi-stream-json",
249    auth_config_dir_env_var: "KIMI_SHARE_DIR",
250    auth_dir_name: "kimi",
251    auth_extra_env: &[],
252    unset_env: &[],
253    npm_package: "",
254    pinned_version: "",
255    icon: "moon",
256    docs_url: "https://moonshotai.github.io/kimi-cli/",
257};
258
259static OPENCLAW: ProviderConfig = ProviderConfig {
260    id: "openclaw",
261    display_name: "OpenClaw",
262    // `openclaw acp` runs OpenClaw's ACP bridge — speaks ACP over stdio
263    // for IDE/tool clients (us) and forwards turns to the local
264    // OpenClaw Gateway over WebSocket. The Gateway is OpenClaw's own
265    // daemon (`openclaw gateway`) and MUST be running before this
266    // bridge can establish a session — surfaced to the user as an
267    // onboarding requirement in SPEC_OPENCLAW_AGENT_2026_05_17.md §6β.
268    //
269    // The previous scaffold pointed at `acpx` / `@openclaw/acpx`,
270    // which is not a real package. The canonical binary is `openclaw`
271    // (npm: `openclaw`) and the ACP subcommand is `openclaw acp`.
272    // Verified against docs.openclaw.ai/cli/acp + GitHub README.
273    cli_command: "openclaw",
274    controller_type: ControllerType::Acp,
275    launch_args: &["acp"],
276    persistent_launch_args: None,
277    // ACP handles sessions natively — no resume flag or session ID parsing needed
278    resume_flag: None,
279    session_id_field: "sessionId",
280    styled_output_format: "acp",
281    auth_config_dir_env_var: "OPENCLAW_HOME",
282    auth_dir_name: "openclaw",
283    auth_extra_env: &[],
284    unset_env: &[],
285    npm_package: "openclaw",
286    pinned_version: "latest",
287    icon: "lobster",
288    docs_url: "https://docs.openclaw.ai",
289};
290
291static PI: ProviderConfig = ProviderConfig {
292    id: "pi",
293    display_name: "Pi",
294    cli_command: "pi",
295    controller_type: ControllerType::Acp,
296    launch_args: &["--json"],
297    persistent_launch_args: None,
298    resume_flag: None,
299    session_id_field: "sessionId",
300    styled_output_format: "acp",
301    auth_config_dir_env_var: "PI_HOME",
302    auth_dir_name: "pi",
303    auth_extra_env: &[],
304    unset_env: &[],
305    npm_package: "@mariozechner/pi-coding-agent",
306    pinned_version: "latest",
307    icon: "terminal",
308    docs_url: "https://github.com/badlogic/pi-mono",
309};
310
311// Mux Code — AgentMux's first-party agentic coding CLI.
312// Local GGUF inference via llama-server or cloud APIs (Anthropic,
313// OpenAI, OpenAI-compat). Emits claude-compatible stream-json NDJSON
314// (same `session_id` field, same event envelope), so ClaudeTranslator
315// handles it without modification.  `--resume <id>` resumes a prior
316// session.  npm: `@a5af/muxcode`.
317static MUX_CODE: ProviderConfig = ProviderConfig {
318    id: "muxcode",
319    display_name: "Mux Code",
320    cli_command: "muxcode",
321    controller_type: ControllerType::Subprocess,
322    // muxcode emits NDJSON unconditionally; no --output-format flag exists.
323    // The `run` subcommand is explicit even though it is Commander's default,
324    // so the invocation is unambiguous: `muxcode run -p "<prompt>"`.
325    launch_args: &["run", "-p"],
326    // muxcode takes a single prompt and exits; persistent mode not supported.
327    persistent_launch_args: None,
328    resume_flag: Some("--resume"),
329    session_id_field: "session_id",
330    styled_output_format: "claude-stream-json",
331    auth_config_dir_env_var: "MUXCODE_CONFIG_DIR",
332    auth_dir_name: "muxcode",
333    auth_extra_env: &[],
334    unset_env: &[],
335    npm_package: "@a5af/muxcode",
336    pinned_version: "latest",
337    icon: "brain",
338    docs_url: "https://github.com/agentmuxai/muxcode",
339};
340
341// GitHub Copilot CLI — Microsoft's coding agent. Runs in ACP mode via
342// `--acp` so the existing ACP controller drives it. Non-interactive
343// `-p`/`--prompt` doesn't accept stdin prompts (github/copilot-cli#96,
344// #1046), hence ACP.
345static COPILOT: ProviderConfig = ProviderConfig {
346    id: "copilot",
347    display_name: "GitHub Copilot CLI",
348    cli_command: "copilot",
349    controller_type: ControllerType::Acp,
350    launch_args: &["--acp"],
351    persistent_launch_args: None,
352    resume_flag: None,
353    session_id_field: "sessionId",
354    styled_output_format: "acp",
355    auth_config_dir_env_var: "COPILOT_HOME",
356    auth_dir_name: "copilot",
357    auth_extra_env: &[],
358    unset_env: &[],
359    npm_package: "@github/copilot",
360    pinned_version: "latest",
361    icon: "github",
362    docs_url: "https://docs.github.com/copilot/concepts/agents/about-copilot-cli",
363};
364
365// ─── Static registry ─────────────────────────────────────────────────────────
366
367static REGISTRY: LazyLock<HashMap<&'static str, &'static ProviderConfig>> = LazyLock::new(|| {
368    let mut m = HashMap::new();
369    m.insert(CLAUDE.id, &CLAUDE);
370    m.insert(CODEX.id, &CODEX);
371    m.insert(GEMINI.id, &GEMINI);
372    m.insert(QWEN.id, &QWEN);
373    m.insert(KIMI.id, &KIMI);
374    m.insert(OPENCLAW.id, &OPENCLAW);
375    m.insert(PI.id, &PI);
376    m.insert(COPILOT.id, &COPILOT);
377    m.insert(MUX_CODE.id, &MUX_CODE);
378    m
379});
380
381// Aliases for provider IDs from older databases or alternate naming.
382static ALIASES: LazyLock<HashMap<&'static str, &'static str>> = LazyLock::new(|| {
383    let mut m = HashMap::new();
384    m.insert("claude-code", "claude");
385    m.insert("claude_code", "claude");
386    m.insert("codex-cli", "codex");
387    m.insert("gemini-cli", "gemini");
388    m.insert("qwen-code", "qwen");
389    m.insert("qwen3-coder", "qwen");
390    m.insert("kimi-cli", "kimi");
391    m.insert("kimi_code", "kimi");
392    m.insert("openclaw-cli", "openclaw");
393    m.insert("open-claw", "openclaw");
394    m.insert("copilot-cli", "copilot");
395    m.insert("github-copilot", "copilot");
396    m.insert("copilot_cli", "copilot");
397    m.insert("mux-code", "muxcode");
398    m.insert("mux_code", "muxcode");
399    m
400});
401
402// ─── Public API ──────────────────────────────────────────────────────────────
403
404/// Resolve a provider alias to its canonical ID.
405///
406/// Returns `id` unchanged if it is not a known alias.
407pub fn resolve_provider_alias(id: &str) -> &'static str {
408    ALIASES.get(id).copied().unwrap_or_else(|| {
409        // If the id itself is a canonical key return the interned key, otherwise
410        // return a best-effort static ref. The caller should treat the return
411        // value as a lookup key only.
412        REGISTRY
413            .get_key_value(id)
414            .map(|(k, _)| *k)
415            .unwrap_or("") // unknown — get_provider will return None
416    })
417}
418
419/// Look up a provider by canonical ID or alias.
420///
421/// Returns `None` when the ID (and any resolved alias) does not match a known
422/// provider.
423pub fn get_provider(id: &str) -> Option<&'static ProviderConfig> {
424    // Direct lookup first.
425    if let Some(p) = REGISTRY.get(id) {
426        return Some(p);
427    }
428    // Fall back to alias resolution.
429    let canonical = ALIASES.get(id).copied()?;
430    REGISTRY.get(canonical).copied()
431}
432
433/// Return an iterator over all registered providers in insertion order.
434pub fn get_provider_list() -> impl Iterator<Item = &'static ProviderConfig> {
435    // Stable canonical order matches the TypeScript PROVIDERS object order.
436    static ORDER: &[&str] =
437        &["claude", "codex", "muxcode", "gemini", "qwen", "kimi", "openclaw", "pi", "copilot"];
438    ORDER.iter().filter_map(|id| REGISTRY.get(*id).copied())
439}
440
441// ─── Tests ───────────────────────────────────────────────────────────────────
442
443#[cfg(test)]
444mod tests {
445    use super::*;
446
447    #[test]
448    fn canonical_ids_resolve() {
449        assert!(get_provider("claude").is_some());
450        assert!(get_provider("codex").is_some());
451        assert!(get_provider("gemini").is_some());
452        assert!(get_provider("kimi").is_some());
453        assert!(get_provider("openclaw").is_some());
454        assert!(get_provider("qwen").is_some());
455        assert!(get_provider("muxcode").is_some());
456    }
457
458    #[test]
459    fn mux_code_is_subprocess_with_claude_stream_json() {
460        let p = get_provider("muxcode").unwrap();
461        assert_eq!(p.controller_type, ControllerType::Subprocess);
462        assert_eq!(p.controller_type_str(), "subprocess");
463        assert_eq!(p.styled_output_format, "claude-stream-json");
464        assert_eq!(p.cli_command, "muxcode");
465        assert_eq!(p.session_id_field, "session_id");
466        assert_eq!(p.resume_flag, Some("--resume"));
467        assert_eq!(p.npm_package, "@a5af/muxcode");
468        assert_eq!(p.launch_args, &["run", "-p"]);
469        assert!(p.persistent_launch_args.is_none());
470    }
471
472    #[test]
473    fn aliases_resolve() {
474        assert_eq!(get_provider("claude-code").unwrap().id, "claude");
475        assert_eq!(get_provider("claude_code").unwrap().id, "claude");
476        assert_eq!(get_provider("codex-cli").unwrap().id, "codex");
477        assert_eq!(get_provider("gemini-cli").unwrap().id, "gemini");
478        assert_eq!(get_provider("qwen-code").unwrap().id, "qwen");
479        assert_eq!(get_provider("qwen3-coder").unwrap().id, "qwen");
480        assert_eq!(get_provider("kimi-cli").unwrap().id, "kimi");
481        assert_eq!(get_provider("openclaw-cli").unwrap().id, "openclaw");
482    }
483
484    #[test]
485    fn unknown_returns_none() {
486        assert!(get_provider("unknown-provider").is_none());
487    }
488
489    #[test]
490    fn provider_list_has_nine_entries() {
491        // Update this when a provider is added or removed; a stale
492        // count is the cheapest detection mechanism for accidental
493        // additions.
494        assert_eq!(get_provider_list().count(), 9);
495    }
496
497    #[test]
498    fn claude_persistent_with_persistent_args_present() {
499        let p = get_provider("claude").unwrap();
500        // Claude runs on the persistent controller so AskUserQuestion can block
501        // on a tool_use and consume a tool_result over live stdin (see the
502        // controller_type comment on `static CLAUDE`).
503        assert!(p.persistent_launch_args.is_some());
504        assert_eq!(p.controller_type, ControllerType::Persistent);
505        assert_eq!(p.controller_type_str(), "persistent");
506    }
507
508    #[test]
509    fn codex_resume_flag_is_none() {
510        let p = get_provider("codex").unwrap();
511        assert!(p.resume_flag.is_none());
512        assert_eq!(p.controller_type, ControllerType::Subprocess);
513    }
514
515    #[test]
516    fn gemini_auth_extra_env() {
517        let p = get_provider("gemini").unwrap();
518        assert!(p
519            .auth_extra_env
520            .iter()
521            .any(|(k, v)| *k == "GEMINI_FORCE_FILE_STORAGE" && *v == "true"));
522    }
523
524    #[test]
525    fn kimi_is_subprocess_controller() {
526        let p = get_provider("kimi").unwrap();
527        assert_eq!(p.controller_type, ControllerType::Subprocess);
528        assert_eq!(p.controller_type_str(), "subprocess");
529        assert_eq!(p.styled_output_format, "kimi-stream-json");
530        assert_eq!(p.cli_command, "kimi");
531        assert!(p.npm_package.is_empty());
532    }
533
534    #[test]
535    fn openclaw_is_acp_controller() {
536        let p = get_provider("openclaw").unwrap();
537        assert_eq!(p.controller_type, ControllerType::Acp);
538        assert_eq!(p.controller_type_str(), "acp");
539        assert_eq!(p.styled_output_format, "acp");
540        assert!(p.resume_flag.is_none());
541    }
542
543    #[test]
544    fn pi_is_acp_controller() {
545        let p = get_provider("pi").unwrap();
546        assert_eq!(p.controller_type, ControllerType::Acp);
547        assert_eq!(p.controller_type_str(), "acp");
548        assert_eq!(p.styled_output_format, "acp");
549        assert_eq!(p.cli_command, "pi");
550        assert_eq!(p.npm_package, "@mariozechner/pi-coding-agent");
551    }
552}