mirror of
https://github.com/block/buzz.git
synced 2026-11-03 07:51:22 +00:00
Signed-off-by: Will Pfleger <pfleger.will@gmail.com> Co-authored-by: npub1mn7jgtj4w2pd0g0zeuhxsa6jy6p0rewxz4kujt98my82ahfmp72sxjexk7 <dcfd242e557282d7a1e2cf2e6877522682f1e5c6156dc92ca7d90eaedd3b0f95@sprout-oss.stage.blox.sqprod.co>
1124 lines
41 KiB
Rust
1124 lines
41 KiB
Rust
use std::path::{Path, PathBuf};
|
|
use std::process::Command;
|
|
|
|
use crate::managed_agents::{
|
|
AcpAvailabilityStatus, AcpRuntimeCatalogEntry, CommandAvailabilityInfo,
|
|
};
|
|
|
|
pub(crate) struct KnownAcpRuntime {
|
|
pub id: &'static str,
|
|
pub label: &'static str,
|
|
pub commands: &'static [&'static str],
|
|
pub aliases: &'static [&'static str],
|
|
pub avatar_url: &'static str,
|
|
/// Legacy MCP server binary field. Vestigial — all agents now use the bundled CLI.
|
|
/// directly. Will be removed when runtime discovery is simplified.
|
|
pub mcp_command: Option<&'static str>,
|
|
/// Whether to enable MCP hook tools (`_Stop`, `_PostCompact`) for this agent.
|
|
pub mcp_hooks: bool,
|
|
/// CLI binary that indicates partial install (e.g. `"claude"` when `claude-agent-acp` is missing).
|
|
pub underlying_cli: Option<&'static str>,
|
|
/// Shell commands to install the runtime CLI itself (run sequentially).
|
|
pub cli_install_commands: &'static [&'static str],
|
|
/// Shell commands to install the ACP adapter (run sequentially, after CLI).
|
|
pub adapter_install_commands: &'static [&'static str],
|
|
/// Link to docs/repo for manual instructions.
|
|
pub install_instructions_url: &'static str,
|
|
/// Human-readable hint about installing the CLI binary.
|
|
pub cli_install_hint: &'static str,
|
|
/// Human-readable hint about installing the ACP adapter.
|
|
pub adapter_install_hint: &'static str,
|
|
/// Harness-specific skill discovery directory (e.g. `.goose/skills`).
|
|
/// `Some(dir)` → Buzz creates a symlink at `<nest>/<dir>/buzz-cli`
|
|
/// pointing to the canonical `.agents/skills/buzz-cli`. `None` → this
|
|
/// runtime reads the canonical path directly or has no skill support.
|
|
pub skill_dir: Option<&'static str>,
|
|
/// Whether this runtime handles model switching via ACP protocol natively.
|
|
/// Currently unused — env var injection runs unconditionally regardless of
|
|
/// this value. Retained as scaffolding for when ACP model switching matures.
|
|
#[allow(dead_code)]
|
|
pub supports_acp_model_switching: bool,
|
|
pub model_env_var: Option<&'static str>,
|
|
pub provider_env_var: Option<&'static str>,
|
|
pub provider_locked: bool,
|
|
pub default_env: &'static [(&'static str, &'static str)],
|
|
pub config_file_path: Option<&'static str>,
|
|
#[allow(dead_code)] // reserved for format-based dispatch when readers are unified
|
|
pub config_file_format: Option<&'static str>,
|
|
pub supports_acp_native_config: bool, // tier 1a: config/read+write
|
|
pub thinking_env_var: Option<&'static str>,
|
|
/// Env var for normalizing `max_output_tokens`. `None` when the harness
|
|
/// does not have a first-class env var for this field (config-file only).
|
|
pub max_tokens_env_var: Option<&'static str>,
|
|
/// Env var for normalizing `context_limit`. `None` when not applicable.
|
|
pub context_limit_env_var: Option<&'static str>,
|
|
/// Normalized field keys that must be set for this harness to function.
|
|
/// Used by the config bridge to mark fields as required in the UI.
|
|
/// Keys match the camelCase names used in `NormalizedConfig` (e.g. "model", "provider").
|
|
pub required_normalized_fields: &'static [&'static str],
|
|
}
|
|
|
|
const GOOSE_AVATAR_URL: &str = "https://goose-docs.ai/img/logo_dark.png";
|
|
const CLAUDE_CODE_AVATAR_URL: &str = "https://anthropic.gallerycdn.vsassets.io/extensions/anthropic/claude-code/2.1.77/1773707456892/Microsoft.VisualStudio.Services.Icons.Default";
|
|
const CODEX_AVATAR_URL: &str = "https://openai.gallerycdn.vsassets.io/extensions/openai/chatgpt/26.5313.41514/1773706730621/Microsoft.VisualStudio.Services.Icons.Default";
|
|
const BUZZ_AGENT_AVATAR_URL: &str =
|
|
"https://raw.githubusercontent.com/block/buzz/refs/heads/main/crates/buzz-agent/buzz-agent.png";
|
|
|
|
fn common_binary_paths() -> &'static [PathBuf] {
|
|
use std::sync::OnceLock;
|
|
static PATHS: OnceLock<Vec<PathBuf>> = OnceLock::new();
|
|
PATHS.get_or_init(|| {
|
|
let mut paths = vec![
|
|
PathBuf::from("/opt/homebrew/bin"),
|
|
PathBuf::from("/usr/local/bin"),
|
|
PathBuf::from("/usr/bin"),
|
|
PathBuf::from("/home/linuxbrew/.linuxbrew/bin"),
|
|
];
|
|
if let Some(home) = dirs::home_dir() {
|
|
paths.extend([
|
|
home.join(".local/share/mise/shims"),
|
|
home.join(".local/bin"),
|
|
home.join(".volta/bin"),
|
|
home.join(".asdf/shims"),
|
|
]);
|
|
}
|
|
paths
|
|
})
|
|
}
|
|
|
|
const KNOWN_ACP_RUNTIMES: &[KnownAcpRuntime] = &[
|
|
KnownAcpRuntime {
|
|
id: "goose",
|
|
label: "Goose",
|
|
commands: &["goose"],
|
|
aliases: &[],
|
|
avatar_url: GOOSE_AVATAR_URL,
|
|
mcp_command: None,
|
|
mcp_hooks: false,
|
|
underlying_cli: Some("goose"),
|
|
cli_install_commands: &["curl -fsSL https://github.com/block-open-source/goose/releases/download/stable/download_cli.sh | CONFIGURE=false bash"],
|
|
adapter_install_commands: &[],
|
|
install_instructions_url: "https://block.github.io/goose/",
|
|
cli_install_hint: "Install Goose via the official install script.",
|
|
adapter_install_hint: "",
|
|
skill_dir: Some(".goose/skills"),
|
|
supports_acp_model_switching: false,
|
|
model_env_var: Some("GOOSE_MODEL"),
|
|
provider_env_var: Some("GOOSE_PROVIDER"),
|
|
provider_locked: false,
|
|
default_env: &[("GOOSE_MODE", "auto")],
|
|
config_file_path: Some("~/.config/goose/config.yaml"),
|
|
config_file_format: Some("yaml"),
|
|
supports_acp_native_config: true,
|
|
thinking_env_var: Some("GOOSE_THINKING_EFFORT"),
|
|
max_tokens_env_var: Some("GOOSE_MAX_TOKENS"),
|
|
context_limit_env_var: Some("GOOSE_CONTEXT_LIMIT"),
|
|
required_normalized_fields: &["model", "provider"],
|
|
},
|
|
KnownAcpRuntime {
|
|
id: "claude",
|
|
label: "Claude Code",
|
|
commands: &["claude-agent-acp", "claude-code-acp"],
|
|
aliases: &["claude-code", "claudecode"],
|
|
avatar_url: CLAUDE_CODE_AVATAR_URL,
|
|
mcp_command: None,
|
|
mcp_hooks: false,
|
|
underlying_cli: Some("claude"),
|
|
cli_install_commands: &["curl -fsSL https://claude.ai/install.sh | bash"],
|
|
adapter_install_commands: &["npm install -g @agentclientprotocol/claude-agent-acp"],
|
|
install_instructions_url: "https://github.com/agentclientprotocol/claude-agent-acp",
|
|
cli_install_hint: "Install the Claude Code CLI via the official install script.",
|
|
adapter_install_hint: "Install the Claude Code ACP adapter via npm.",
|
|
skill_dir: Some(".claude/skills"),
|
|
supports_acp_model_switching: false,
|
|
model_env_var: None,
|
|
provider_env_var: None,
|
|
provider_locked: true,
|
|
default_env: &[],
|
|
config_file_path: Some("~/.claude/settings.json"),
|
|
config_file_format: Some("json"),
|
|
supports_acp_native_config: false,
|
|
thinking_env_var: None,
|
|
max_tokens_env_var: None,
|
|
context_limit_env_var: None,
|
|
required_normalized_fields: &[],
|
|
},
|
|
KnownAcpRuntime {
|
|
id: "codex",
|
|
label: "Codex",
|
|
commands: &["codex-acp"],
|
|
aliases: &[],
|
|
avatar_url: CODEX_AVATAR_URL,
|
|
mcp_command: Some("buzz-dev-mcp"),
|
|
mcp_hooks: false,
|
|
underlying_cli: Some("codex"),
|
|
cli_install_commands: &["curl -fsSL https://chatgpt.com/codex/install.sh | sh"],
|
|
adapter_install_commands: &["npm install -g @zed-industries/codex-acp"],
|
|
install_instructions_url: "https://github.com/zed-industries/codex-acp",
|
|
cli_install_hint: "Install the Codex CLI via the official install script.",
|
|
adapter_install_hint: "Install the Codex ACP adapter via npm.",
|
|
skill_dir: Some(".codex/skills"),
|
|
supports_acp_model_switching: false,
|
|
model_env_var: None,
|
|
provider_env_var: None,
|
|
provider_locked: false,
|
|
default_env: &[],
|
|
config_file_path: Some("~/.codex/config.toml"),
|
|
config_file_format: Some("toml"),
|
|
supports_acp_native_config: false,
|
|
thinking_env_var: None,
|
|
max_tokens_env_var: None,
|
|
context_limit_env_var: None,
|
|
required_normalized_fields: &[],
|
|
},
|
|
KnownAcpRuntime {
|
|
id: "buzz-agent",
|
|
label: "Buzz Agent",
|
|
commands: &["buzz-agent"],
|
|
aliases: &[],
|
|
avatar_url: BUZZ_AGENT_AVATAR_URL,
|
|
mcp_command: Some("buzz-dev-mcp"),
|
|
mcp_hooks: true,
|
|
underlying_cli: None,
|
|
cli_install_commands: &[],
|
|
adapter_install_commands: &[],
|
|
install_instructions_url: "https://github.com/block/buzz",
|
|
cli_install_hint: "Ships with the Buzz desktop app.",
|
|
adapter_install_hint: "",
|
|
skill_dir: None,
|
|
supports_acp_model_switching: true,
|
|
model_env_var: Some("BUZZ_AGENT_MODEL"),
|
|
provider_env_var: Some("BUZZ_AGENT_PROVIDER"),
|
|
provider_locked: false,
|
|
default_env: &[],
|
|
config_file_path: None,
|
|
config_file_format: None,
|
|
supports_acp_native_config: false,
|
|
thinking_env_var: Some("BUZZ_AGENT_THINKING_EFFORT"),
|
|
max_tokens_env_var: Some("BUZZ_AGENT_MAX_OUTPUT_TOKENS"),
|
|
context_limit_env_var: Some("BUZZ_AGENT_MAX_CONTEXT_TOKENS"),
|
|
required_normalized_fields: &["model", "provider"],
|
|
},
|
|
];
|
|
|
|
/// Skill discovery directories declared by known runtimes.
|
|
pub(crate) fn known_skill_dirs() -> impl Iterator<Item = &'static str> {
|
|
KNOWN_ACP_RUNTIMES.iter().filter_map(|p| p.skill_dir)
|
|
}
|
|
|
|
fn workspace_root_dir() -> PathBuf {
|
|
PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../..")
|
|
}
|
|
|
|
fn command_looks_like_path(command: &str) -> bool {
|
|
let path = Path::new(command);
|
|
path.is_absolute() || path.components().count() > 1
|
|
}
|
|
|
|
fn executable_basename(command: &str) -> String {
|
|
let suffix = std::env::consts::EXE_SUFFIX;
|
|
if suffix.is_empty() || command.ends_with(suffix) {
|
|
command.to_string()
|
|
} else {
|
|
format!("{command}{suffix}")
|
|
}
|
|
}
|
|
|
|
fn normalize_command_identity(command: &str) -> String {
|
|
let normalized = command.trim().replace('\\', "/");
|
|
let basename = normalized.rsplit('/').next().unwrap_or(normalized.as_str());
|
|
let lower = basename
|
|
.chars()
|
|
.map(|character| match character {
|
|
' ' | '_' => '-',
|
|
_ => character.to_ascii_lowercase(),
|
|
})
|
|
.collect::<String>();
|
|
let lower = lower.strip_suffix(".exe").unwrap_or(&lower).to_string();
|
|
|
|
if let Some(suffix) = std::env::consts::EXE_SUFFIX.strip_prefix('.') {
|
|
return lower
|
|
.strip_suffix(&format!(".{suffix}"))
|
|
.unwrap_or(&lower)
|
|
.to_string();
|
|
}
|
|
|
|
if !std::env::consts::EXE_SUFFIX.is_empty() {
|
|
return lower
|
|
.strip_suffix(std::env::consts::EXE_SUFFIX)
|
|
.unwrap_or(&lower)
|
|
.to_string();
|
|
}
|
|
|
|
lower
|
|
}
|
|
|
|
pub(crate) fn known_acp_runtime(command: &str) -> Option<&'static KnownAcpRuntime> {
|
|
let normalized = normalize_command_identity(command);
|
|
|
|
KNOWN_ACP_RUNTIMES.iter().find(|runtime| {
|
|
normalized == runtime.id
|
|
|| runtime
|
|
.commands
|
|
.iter()
|
|
.any(|command| normalized == normalize_command_identity(command))
|
|
|| runtime.aliases.iter().any(|alias| normalized == *alias)
|
|
})
|
|
}
|
|
|
|
pub(crate) fn known_acp_runtime_exact(id: &str) -> Option<&'static KnownAcpRuntime> {
|
|
KNOWN_ACP_RUNTIMES.iter().find(|p| p.id == id)
|
|
}
|
|
|
|
/// The agent command a freshly-created agent defaults to when the create
|
|
/// request supplies none. Resolves the bundled `buzz-agent` from the catalog —
|
|
/// the same shape `mesh_llm::preset` uses — so the default can't drift from the
|
|
/// provider definition. Falls back to the id if the catalog entry is missing.
|
|
///
|
|
/// The previous default was the bare global `goose`, which is not on PATH on a
|
|
/// stock Windows install: every worker failed with `program not found`. The
|
|
/// bundled `buzz-agent` ships with the app and resolves on every platform.
|
|
pub fn default_agent_command() -> String {
|
|
known_acp_runtime_exact("buzz-agent")
|
|
.and_then(|p| p.commands.first().copied())
|
|
.unwrap_or("buzz-agent")
|
|
.to_string()
|
|
}
|
|
|
|
/// Resolve the agent command (harness) for a spawn/deploy/summary. The linked
|
|
/// persona wins so persona harness edits propagate on the next spawn. An
|
|
/// explicit per-instance override (`agent_command_override`) takes precedence.
|
|
///
|
|
/// Resolution order:
|
|
/// 1. explicit override (non-empty) — a deliberate per-instance pin;
|
|
/// 2. the linked persona's `runtime` id mapped to its primary command;
|
|
/// 3. `default_agent_command()` — no persona/runtime, or persona deleted.
|
|
pub fn effective_agent_command(
|
|
persona_id: Option<&str>,
|
|
personas: &[crate::managed_agents::types::PersonaRecord],
|
|
agent_command_override: Option<&str>,
|
|
) -> String {
|
|
if let Some(pin) = agent_command_override
|
|
.map(str::trim)
|
|
.filter(|value| !value.is_empty())
|
|
{
|
|
return pin.to_string();
|
|
}
|
|
|
|
persona_id
|
|
.and_then(|pid| personas.iter().find(|p| p.id == pid))
|
|
.and_then(|persona| persona.runtime.as_deref())
|
|
.and_then(known_acp_runtime_exact)
|
|
.and_then(|r| r.commands.first().copied())
|
|
.map(str::to_string)
|
|
.unwrap_or_else(default_agent_command)
|
|
}
|
|
|
|
/// Decide whether a user-picked harness command is an explicit per-instance
|
|
/// pin or merely the persona's own runtime restated. Returns the override to
|
|
/// persist: `Some(picked)` when it diverges from the persona, `None` when it
|
|
/// inherits.
|
|
///
|
|
/// Comparison is by RUNTIME IDENTITY, not raw string: a persona on the `claude`
|
|
/// runtime resolves to `claude-agent-acp`, but a client with only the
|
|
/// `claude-code-acp` adapter installed sends that command instead. Both map to
|
|
/// the same `claude` runtime, so neither is a real divergence — string equality
|
|
/// would wrongly bake a pin. An unknown/custom command (no matching runtime)
|
|
/// only inherits when it exactly equals the persona command.
|
|
pub fn divergent_agent_command_override(
|
|
persona_id: Option<&str>,
|
|
personas: &[crate::managed_agents::types::PersonaRecord],
|
|
picked_command: Option<&str>,
|
|
) -> Option<String> {
|
|
let picked = picked_command
|
|
.map(str::trim)
|
|
.filter(|value| !value.is_empty())?;
|
|
let persona_command = effective_agent_command(persona_id, personas, None);
|
|
let same_runtime = match (
|
|
known_acp_runtime(picked),
|
|
known_acp_runtime(&persona_command),
|
|
) {
|
|
(Some(a), Some(b)) => std::ptr::eq(a, b),
|
|
_ => picked == persona_command,
|
|
};
|
|
if same_runtime {
|
|
None
|
|
} else {
|
|
Some(picked.to_string())
|
|
}
|
|
}
|
|
|
|
/// Decide the `agent_command_override` to persist at AGENT CREATE time.
|
|
///
|
|
/// A persona-backed create receives its harness command from
|
|
/// `resolvePersonaRuntime` (frontend), which produces a divergent command in two
|
|
/// distinct cases that the backend MUST tell apart:
|
|
///
|
|
/// - DELIBERATE OVERRIDE (`harness_override` true): the user explicitly picked a
|
|
/// runtime command in UI that exposes a runtime selector. This is a real pin
|
|
/// and is preserved when it differs from the command inheritance would spawn,
|
|
/// including installed aliases such as `claude-code-acp`.
|
|
/// - MISSING-RUNTIME FALLBACK (`harness_override` false): the persona's runtime
|
|
/// isn't installed locally, so `resolvePersonaRuntime` substitutes a fallback
|
|
/// default. This is NOT a pin — baking it would freeze the agent on the fallback
|
|
/// harness even after the persona's runtime is installed and the persona is
|
|
/// re-edited, the exact bug this resolver chain exists to prevent. Stores `None`
|
|
/// so the persona stays authoritative.
|
|
///
|
|
/// `isOverridden` from `resolvePersonaRuntime` cannot distinguish these — it is
|
|
/// `true` for BOTH — so the caller must thread the explicit user-intent bit.
|
|
///
|
|
/// Persona-less creates (`persona_id` is `None`, e.g. the standalone
|
|
/// CreateAgentDialog) have no persona to inherit, so the picked command is always a
|
|
/// real pin and is preserved via `divergent_agent_command_override` regardless of
|
|
/// `harness_override`.
|
|
pub fn create_time_agent_command_override(
|
|
persona_id: Option<&str>,
|
|
personas: &[crate::managed_agents::types::PersonaRecord],
|
|
picked_command: Option<&str>,
|
|
harness_override: bool,
|
|
) -> Option<String> {
|
|
if persona_id.is_some() && !harness_override {
|
|
return None;
|
|
}
|
|
|
|
if persona_id.is_some() && harness_override {
|
|
let picked = picked_command
|
|
.map(str::trim)
|
|
.filter(|value| !value.is_empty())?;
|
|
let inherited_command = effective_agent_command(persona_id, personas, None);
|
|
return (picked != inherited_command).then(|| picked.to_string());
|
|
}
|
|
|
|
divergent_agent_command_override(persona_id, personas, picked_command)
|
|
}
|
|
|
|
fn default_agent_args(command: &str) -> Option<Vec<String>> {
|
|
match normalize_command_identity(command).as_str() {
|
|
"goose" => Some(vec!["acp".to_string()]),
|
|
"codex" | "codex-acp" | "claude-agent-acp" | "claude-code-acp" | "claude-code"
|
|
| "claudecode" | "buzz-agent" => Some(Vec::new()),
|
|
_ => None,
|
|
}
|
|
}
|
|
|
|
pub fn normalize_agent_args(command: &str, agent_args: Vec<String>) -> Vec<String> {
|
|
let normalized = agent_args
|
|
.into_iter()
|
|
.map(|arg| arg.trim().to_string())
|
|
.filter(|arg| !arg.is_empty())
|
|
.collect::<Vec<_>>();
|
|
|
|
let Some(default_args) = default_agent_args(command) else {
|
|
return normalized;
|
|
};
|
|
|
|
if normalized.is_empty() {
|
|
return default_args;
|
|
}
|
|
|
|
if normalized.len() == 1 && normalized[0].eq_ignore_ascii_case("acp") && default_args.is_empty()
|
|
{
|
|
return default_args;
|
|
}
|
|
|
|
normalized
|
|
}
|
|
|
|
fn command_search_dirs() -> Vec<PathBuf> {
|
|
let mut dirs = vec![
|
|
workspace_root_dir().join("target/release"),
|
|
workspace_root_dir().join("target/debug"),
|
|
];
|
|
|
|
if let Ok(current_dir) = std::env::current_dir() {
|
|
dirs.push(current_dir.join("target/release"));
|
|
dirs.push(current_dir.join("target/debug"));
|
|
}
|
|
|
|
if let Ok(exe_path) = std::env::current_exe() {
|
|
if let Some(parent) = exe_path.parent() {
|
|
dirs.push(parent.to_path_buf());
|
|
}
|
|
}
|
|
|
|
let mut unique = Vec::new();
|
|
for dir in dirs {
|
|
if unique.iter().any(|candidate: &PathBuf| candidate == &dir) {
|
|
continue;
|
|
}
|
|
unique.push(dir);
|
|
}
|
|
|
|
unique
|
|
}
|
|
|
|
fn is_executable_file(path: &Path) -> bool {
|
|
let Ok(metadata) = path.metadata() else {
|
|
return false;
|
|
};
|
|
if !metadata.is_file() {
|
|
return false;
|
|
}
|
|
|
|
#[cfg(unix)]
|
|
{
|
|
use std::os::unix::fs::PermissionsExt;
|
|
metadata.permissions().mode() & 0o111 != 0
|
|
}
|
|
|
|
#[cfg(not(unix))]
|
|
{
|
|
true
|
|
}
|
|
}
|
|
|
|
fn resolve_workspace_command(command: &str) -> Option<PathBuf> {
|
|
if command_looks_like_path(command) {
|
|
let path = PathBuf::from(command);
|
|
return is_executable_file(&path).then_some(path);
|
|
}
|
|
|
|
let file_name = executable_basename(command);
|
|
command_search_dirs()
|
|
.into_iter()
|
|
.map(|dir| dir.join(&file_name))
|
|
.find(|candidate| is_executable_file(candidate))
|
|
}
|
|
|
|
fn resolve_cache() -> &'static std::sync::Mutex<std::collections::HashMap<String, Option<PathBuf>>>
|
|
{
|
|
use std::collections::HashMap;
|
|
use std::sync::{Mutex, OnceLock};
|
|
static CACHE: OnceLock<Mutex<HashMap<String, Option<PathBuf>>>> = OnceLock::new();
|
|
CACHE.get_or_init(|| Mutex::new(HashMap::new()))
|
|
}
|
|
|
|
/// Resolve a command to an absolute path, caching results for the app lifetime.
|
|
/// The cache eliminates redundant login-shell spawns when multiple agents share
|
|
/// the same binaries (e.g. `npx`, `uvx`).
|
|
pub fn resolve_command(command: &str) -> Option<PathBuf> {
|
|
let cache = resolve_cache();
|
|
|
|
// Fast path: return cached result without allocating a key.
|
|
if let Ok(guard) = cache.lock() {
|
|
if let Some(result) = guard.get(command) {
|
|
return result.clone();
|
|
}
|
|
}
|
|
|
|
// Slow path: resolve and cache.
|
|
let result = resolve_command_uncached(command);
|
|
|
|
if result.is_some() {
|
|
if let Ok(mut guard) = cache.lock() {
|
|
guard.insert(command.to_string(), result.clone());
|
|
}
|
|
}
|
|
|
|
result
|
|
}
|
|
|
|
/// Clear the resolve_command cache so that newly-installed binaries are detected.
|
|
pub fn clear_resolve_cache() {
|
|
let mut guard = resolve_cache().lock().unwrap_or_else(|e| e.into_inner());
|
|
guard.clear();
|
|
}
|
|
|
|
fn resolve_command_uncached(command: &str) -> Option<PathBuf> {
|
|
if let Some(path) = resolve_workspace_command(command) {
|
|
return Some(path);
|
|
}
|
|
|
|
if command_looks_like_path(command) {
|
|
let path = PathBuf::from(command);
|
|
return path.exists().then_some(path);
|
|
}
|
|
|
|
for candidate in path_candidates_from_env(command) {
|
|
if is_executable_file(&candidate) {
|
|
return Some(candidate);
|
|
}
|
|
}
|
|
|
|
if let Some(path) = find_via_login_shell(command) {
|
|
return Some(path);
|
|
}
|
|
for dir in common_binary_paths() {
|
|
let candidate = dir.join(executable_basename(command));
|
|
if is_executable_file(&candidate) {
|
|
return Some(candidate);
|
|
}
|
|
}
|
|
|
|
None
|
|
}
|
|
|
|
fn path_candidates_from_env(command: &str) -> Vec<PathBuf> {
|
|
std::env::var_os("PATH")
|
|
.map(|paths| {
|
|
std::env::split_paths(&paths)
|
|
.map(|dir| dir.join(executable_basename(command)))
|
|
.collect::<Vec<_>>()
|
|
})
|
|
.unwrap_or_default()
|
|
}
|
|
|
|
/// Run a command in a login shell (tries zsh then bash).
|
|
/// Returns trimmed stdout if the command succeeds with non-empty output.
|
|
fn run_in_login_shell(args: &[&str]) -> Option<String> {
|
|
for shell in ["/bin/zsh", "/bin/bash"] {
|
|
let Ok(output) = Command::new(shell).args(args).output() else {
|
|
continue;
|
|
};
|
|
if !output.status.success() {
|
|
continue;
|
|
}
|
|
let stdout = String::from_utf8_lossy(&output.stdout).trim().to_string();
|
|
if !stdout.is_empty() {
|
|
return Some(stdout);
|
|
}
|
|
}
|
|
None
|
|
}
|
|
|
|
fn find_via_login_shell(command: &str) -> Option<PathBuf> {
|
|
let stdout = run_in_login_shell(&["-l", "-c", r#"command -v -- "$1""#, "_", command])?;
|
|
let resolved = stdout.lines().rfind(|line| !line.trim().is_empty())?;
|
|
let path = PathBuf::from(resolved.trim());
|
|
(path.is_absolute() && is_executable_file(&path)).then_some(path)
|
|
}
|
|
|
|
/// Return the user's full PATH from a login shell.
|
|
/// Cached via OnceLock so we only spawn one shell per app lifetime.
|
|
pub fn login_shell_path() -> Option<String> {
|
|
use std::sync::OnceLock;
|
|
static CACHED: OnceLock<Option<String>> = OnceLock::new();
|
|
CACHED
|
|
.get_or_init(|| {
|
|
let stdout = run_in_login_shell(&["-l", "-c", "echo $PATH"])?;
|
|
let last_line = stdout.lines().rfind(|l| !l.trim().is_empty())?;
|
|
Some(last_line.trim().to_string())
|
|
})
|
|
.clone()
|
|
}
|
|
|
|
fn find_command(command: &str) -> Option<PathBuf> {
|
|
resolve_command(command)
|
|
}
|
|
|
|
pub fn command_availability(command: &str) -> CommandAvailabilityInfo {
|
|
let resolved_path = resolve_command(command).map(|path| path.display().to_string());
|
|
CommandAvailabilityInfo {
|
|
command: command.to_string(),
|
|
available: resolved_path.is_some(),
|
|
resolved_path,
|
|
}
|
|
}
|
|
|
|
pub fn missing_command_message(command: &str, role: &str) -> String {
|
|
if command_looks_like_path(command) {
|
|
return format!("{role} `{command}` does not exist.");
|
|
}
|
|
|
|
format!(
|
|
"{role} `{command}` was not found. Build the workspace binaries (`cargo build --release --workspace`) or add `target/release` to PATH as described in TESTING.md."
|
|
)
|
|
}
|
|
|
|
fn classify_runtime(
|
|
adapter_result: Option<(&str, PathBuf)>,
|
|
underlying_cli: Option<&str>,
|
|
underlying_cli_found: bool,
|
|
) -> (AcpAvailabilityStatus, Option<String>, Option<String>) {
|
|
if let Some((cmd, path)) = adapter_result {
|
|
if underlying_cli.is_some() && !underlying_cli_found {
|
|
(
|
|
AcpAvailabilityStatus::CliMissing,
|
|
Some(cmd.to_string()),
|
|
Some(path.display().to_string()),
|
|
)
|
|
} else {
|
|
(
|
|
AcpAvailabilityStatus::Available,
|
|
Some(cmd.to_string()),
|
|
Some(path.display().to_string()),
|
|
)
|
|
}
|
|
} else if underlying_cli.is_some() && underlying_cli_found {
|
|
(AcpAvailabilityStatus::AdapterMissing, None, None)
|
|
} else {
|
|
(AcpAvailabilityStatus::NotInstalled, None, None)
|
|
}
|
|
}
|
|
|
|
pub fn discover_acp_runtimes() -> Vec<AcpRuntimeCatalogEntry> {
|
|
KNOWN_ACP_RUNTIMES
|
|
.iter()
|
|
.map(|runtime| {
|
|
// Try to find the ACP adapter binary.
|
|
let adapter_result = runtime
|
|
.commands
|
|
.iter()
|
|
.find_map(|command| find_command(command).map(|path| (*command, path)));
|
|
|
|
let underlying_cli_found = runtime
|
|
.underlying_cli
|
|
.map(|cli| find_command(cli).is_some())
|
|
.unwrap_or(false);
|
|
let (availability, command, binary_path) =
|
|
classify_runtime(adapter_result, runtime.underlying_cli, underlying_cli_found);
|
|
|
|
let underlying_cli_path = runtime
|
|
.underlying_cli
|
|
.and_then(find_command)
|
|
.map(|p| p.display().to_string());
|
|
|
|
let default_args = command
|
|
.as_deref()
|
|
.map(|cmd| normalize_agent_args(cmd, Vec::new()))
|
|
.unwrap_or_default();
|
|
|
|
let can_auto_install = !runtime.cli_install_commands.is_empty()
|
|
|| !runtime.adapter_install_commands.is_empty();
|
|
|
|
let cli_hint = runtime.cli_install_hint;
|
|
let adapter_hint = runtime.adapter_install_hint;
|
|
let install_hint = match availability {
|
|
AcpAvailabilityStatus::Available => cli_hint.to_string(),
|
|
AcpAvailabilityStatus::CliMissing => cli_hint.to_string(),
|
|
AcpAvailabilityStatus::AdapterMissing => adapter_hint.to_string(),
|
|
AcpAvailabilityStatus::NotInstalled => {
|
|
if !cli_hint.is_empty() && !adapter_hint.is_empty() {
|
|
format!("{cli_hint} {adapter_hint}")
|
|
} else if !cli_hint.is_empty() {
|
|
cli_hint.to_string()
|
|
} else {
|
|
adapter_hint.to_string()
|
|
}
|
|
}
|
|
};
|
|
|
|
AcpRuntimeCatalogEntry {
|
|
id: runtime.id.to_string(),
|
|
label: runtime.label.to_string(),
|
|
avatar_url: runtime.avatar_url.to_string(),
|
|
availability,
|
|
command,
|
|
binary_path,
|
|
default_args,
|
|
mcp_command: runtime.mcp_command.map(str::to_string),
|
|
install_hint,
|
|
install_instructions_url: runtime.install_instructions_url.to_string(),
|
|
can_auto_install,
|
|
underlying_cli_path,
|
|
}
|
|
})
|
|
.collect()
|
|
}
|
|
|
|
pub fn managed_agent_avatar_url(command: &str) -> Option<String> {
|
|
let runtime = known_acp_runtime(command)?;
|
|
Some(runtime.avatar_url.to_string())
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use std::path::PathBuf;
|
|
|
|
use super::{
|
|
classify_runtime, create_time_agent_command_override, default_agent_command,
|
|
divergent_agent_command_override, effective_agent_command, find_via_login_shell,
|
|
managed_agent_avatar_url, normalize_agent_args, BUZZ_AGENT_AVATAR_URL,
|
|
CLAUDE_CODE_AVATAR_URL, CODEX_AVATAR_URL, GOOSE_AVATAR_URL,
|
|
};
|
|
use crate::managed_agents::AcpAvailabilityStatus;
|
|
|
|
#[test]
|
|
fn resolves_known_avatar_for_bare_command() {
|
|
let avatar_url = managed_agent_avatar_url("goose").expect("goose avatar should resolve");
|
|
|
|
assert_eq!(avatar_url, GOOSE_AVATAR_URL);
|
|
}
|
|
|
|
#[test]
|
|
fn resolves_known_avatar_for_command_paths_and_aliases() {
|
|
assert_eq!(
|
|
managed_agent_avatar_url("/usr/local/bin/codex-acp"),
|
|
Some(CODEX_AVATAR_URL.to_string())
|
|
);
|
|
assert_eq!(
|
|
managed_agent_avatar_url("Claude Code"),
|
|
Some(CLAUDE_CODE_AVATAR_URL.to_string())
|
|
);
|
|
assert_eq!(
|
|
managed_agent_avatar_url(r"C:\Tools\claude-agent-acp.exe"),
|
|
Some(CLAUDE_CODE_AVATAR_URL.to_string())
|
|
);
|
|
assert_eq!(
|
|
managed_agent_avatar_url("/usr/local/bin/claude-code-acp"),
|
|
Some(CLAUDE_CODE_AVATAR_URL.to_string())
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn returns_none_for_unknown_commands() {
|
|
assert!(managed_agent_avatar_url("custom-agent").is_none());
|
|
}
|
|
|
|
#[test]
|
|
fn default_agent_command_resolves_bundled_buzz_agent() {
|
|
// The create-path default must be the bundled buzz-agent, never the
|
|
// bare `goose` that isn't on PATH on a stock Windows install.
|
|
assert_eq!(default_agent_command(), "buzz-agent");
|
|
// And buzz-agent takes no `acp` arg — confirm no arg leakage from the default.
|
|
assert_eq!(
|
|
normalize_agent_args(&default_agent_command(), vec!["acp".into()]),
|
|
Vec::<String>::new()
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn normalizes_claude_and_codex_args_to_empty() {
|
|
assert_eq!(
|
|
normalize_agent_args("claude-agent-acp", vec!["acp".into()]),
|
|
Vec::<String>::new()
|
|
);
|
|
assert_eq!(
|
|
normalize_agent_args("claude-code-acp", vec!["acp".into()]),
|
|
Vec::<String>::new()
|
|
);
|
|
assert_eq!(
|
|
normalize_agent_args("codex-acp", vec!["acp".into()]),
|
|
Vec::<String>::new()
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn resolves_buzz_agent_avatar() {
|
|
assert_eq!(
|
|
managed_agent_avatar_url("buzz-agent"),
|
|
Some(BUZZ_AGENT_AVATAR_URL.to_string())
|
|
);
|
|
assert_eq!(
|
|
managed_agent_avatar_url("/usr/local/bin/buzz-agent"),
|
|
Some(BUZZ_AGENT_AVATAR_URL.to_string())
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn normalizes_buzz_agent_args_to_empty() {
|
|
assert_eq!(
|
|
normalize_agent_args("buzz-agent", Vec::new()),
|
|
Vec::<String>::new()
|
|
);
|
|
assert_eq!(
|
|
normalize_agent_args("buzz-agent", vec!["acp".into()]),
|
|
Vec::<String>::new()
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn login_shell_lookup_treats_command_as_data() {
|
|
let marker =
|
|
std::env::temp_dir().join(format!("buzz-discovery-marker-{}", uuid::Uuid::new_v4()));
|
|
let payload = format!("doesnotexist; touch {} #", marker.display());
|
|
|
|
let resolved = find_via_login_shell(&payload);
|
|
|
|
assert!(
|
|
resolved.is_none(),
|
|
"payload should not resolve to a command"
|
|
);
|
|
assert!(
|
|
!marker.exists(),
|
|
"shell lookup must not execute injected commands"
|
|
);
|
|
}
|
|
|
|
#[cfg(unix)]
|
|
#[test]
|
|
fn explicit_path_resolution_ignores_non_executable_files() {
|
|
use std::os::unix::fs::PermissionsExt;
|
|
|
|
let dir =
|
|
std::env::temp_dir().join(format!("buzz-discovery-path-{}", uuid::Uuid::new_v4()));
|
|
std::fs::create_dir_all(&dir).expect("create temp dir");
|
|
let bin = dir.join("buzz-acp");
|
|
std::fs::write(&bin, "").expect("write placeholder");
|
|
std::fs::set_permissions(&bin, std::fs::Permissions::from_mode(0o644))
|
|
.expect("chmod placeholder");
|
|
|
|
assert!(
|
|
super::resolve_workspace_command(bin.to_str().expect("utf8 path")).is_none(),
|
|
"non-executable placeholder must not resolve"
|
|
);
|
|
|
|
std::fs::set_permissions(&bin, std::fs::Permissions::from_mode(0o755))
|
|
.expect("chmod executable");
|
|
assert_eq!(
|
|
super::resolve_workspace_command(bin.to_str().expect("utf8 path")),
|
|
Some(bin.clone())
|
|
);
|
|
|
|
let _ = std::fs::remove_dir_all(dir);
|
|
}
|
|
|
|
#[test]
|
|
fn classifies_available_when_adapter_found() {
|
|
let (status, cmd, path) = classify_runtime(
|
|
Some(("goose", PathBuf::from("/usr/local/bin/goose"))),
|
|
None,
|
|
false,
|
|
);
|
|
assert_eq!(status, AcpAvailabilityStatus::Available);
|
|
assert_eq!(cmd.as_deref(), Some("goose"));
|
|
assert_eq!(path.as_deref(), Some("/usr/local/bin/goose"));
|
|
}
|
|
|
|
#[test]
|
|
fn classifies_adapter_missing_when_cli_present() {
|
|
let (status, cmd, path) = classify_runtime(None, Some("claude"), true);
|
|
assert_eq!(status, AcpAvailabilityStatus::AdapterMissing);
|
|
assert!(cmd.is_none());
|
|
assert!(path.is_none());
|
|
}
|
|
|
|
#[test]
|
|
fn classifies_not_installed_when_nothing_found() {
|
|
let (status, cmd, path) = classify_runtime(None, Some("claude"), false);
|
|
assert_eq!(status, AcpAvailabilityStatus::NotInstalled);
|
|
assert!(cmd.is_none());
|
|
assert!(path.is_none());
|
|
}
|
|
|
|
#[test]
|
|
fn classifies_not_installed_when_no_underlying_cli() {
|
|
let (status, cmd, path) = classify_runtime(None, None, false);
|
|
assert_eq!(status, AcpAvailabilityStatus::NotInstalled);
|
|
assert!(cmd.is_none());
|
|
assert!(path.is_none());
|
|
}
|
|
|
|
#[test]
|
|
fn classifies_cli_missing_when_adapter_found_but_cli_absent() {
|
|
let (status, cmd, path) = classify_runtime(
|
|
Some(("codex-acp", PathBuf::from("/opt/homebrew/bin/codex-acp"))),
|
|
Some("codex"),
|
|
false,
|
|
);
|
|
assert_eq!(status, AcpAvailabilityStatus::CliMissing);
|
|
assert_eq!(cmd.as_deref(), Some("codex-acp"));
|
|
assert_eq!(path.as_deref(), Some("/opt/homebrew/bin/codex-acp"));
|
|
}
|
|
|
|
fn persona_with_runtime(
|
|
id: &str,
|
|
runtime: Option<&str>,
|
|
) -> crate::managed_agents::PersonaRecord {
|
|
crate::managed_agents::PersonaRecord {
|
|
id: id.to_string(),
|
|
display_name: id.to_string(),
|
|
avatar_url: None,
|
|
system_prompt: String::new(),
|
|
runtime: runtime.map(str::to_string),
|
|
model: None,
|
|
provider: None,
|
|
name_pool: Vec::new(),
|
|
is_builtin: false,
|
|
is_active: true,
|
|
source_team: None,
|
|
source_team_persona_slug: None,
|
|
env_vars: std::collections::BTreeMap::new(),
|
|
created_at: "2026-06-09T00:00:00Z".to_string(),
|
|
updated_at: "2026-06-09T00:00:00Z".to_string(),
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn effective_agent_command_explicit_override_wins() {
|
|
// An explicit pin beats the persona's runtime.
|
|
let personas = vec![persona_with_runtime("p1", Some("claude"))];
|
|
assert_eq!(
|
|
effective_agent_command(Some("p1"), &personas, Some("codex-acp")),
|
|
"codex-acp"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn effective_agent_command_inherits_persona_runtime() {
|
|
// No override → persona runtime id maps to its primary command.
|
|
let personas = vec![persona_with_runtime("p1", Some("claude"))];
|
|
assert_eq!(
|
|
effective_agent_command(Some("p1"), &personas, None),
|
|
"claude-agent-acp"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn effective_agent_command_empty_override_is_inherit() {
|
|
// A blank/whitespace override is treated as "inherit", not a pin.
|
|
let personas = vec![persona_with_runtime("p1", Some("goose"))];
|
|
assert_eq!(
|
|
effective_agent_command(Some("p1"), &personas, Some(" ")),
|
|
"goose"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn effective_agent_command_falls_back_to_default() {
|
|
// No override, no persona runtime, and a deleted persona all fall back
|
|
// to the bundled default.
|
|
let personas = vec![persona_with_runtime("p1", None)];
|
|
assert_eq!(
|
|
effective_agent_command(Some("p1"), &personas, None),
|
|
default_agent_command()
|
|
);
|
|
assert_eq!(
|
|
effective_agent_command(Some("gone"), &personas, None),
|
|
default_agent_command()
|
|
);
|
|
assert_eq!(
|
|
effective_agent_command(None, &personas, None),
|
|
default_agent_command()
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn divergent_override_none_when_picked_matches_persona_runtime() {
|
|
// The persona-backed create/edit flow sends the persona's resolved
|
|
// command. It must be treated as "inherit" (None), not a pin.
|
|
let personas = vec![persona_with_runtime("p1", Some("goose"))];
|
|
assert_eq!(
|
|
divergent_agent_command_override(Some("p1"), &personas, Some("goose")),
|
|
None
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn divergent_override_none_for_alternate_command_of_same_runtime() {
|
|
// A client with only `claude-code-acp` installed sends that command for
|
|
// a `claude` persona whose primary command is `claude-agent-acp`. Both
|
|
// map to the `claude` runtime, so it inherits — string equality would
|
|
// wrongly bake a pin (CRITICAL-3).
|
|
let personas = vec![persona_with_runtime("p1", Some("claude"))];
|
|
assert_eq!(
|
|
divergent_agent_command_override(Some("p1"), &personas, Some("claude-code-acp")),
|
|
None
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn divergent_override_some_when_picked_is_different_runtime() {
|
|
// A deliberate pin to a different runtime is preserved.
|
|
let personas = vec![persona_with_runtime("p1", Some("goose"))];
|
|
assert_eq!(
|
|
divergent_agent_command_override(Some("p1"), &personas, Some("codex-acp")),
|
|
Some("codex-acp".to_string())
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn divergent_override_none_for_empty_or_absent_pick() {
|
|
// The "Inherit from persona" sentinel (empty) and a name-only edit
|
|
// (absent) both clear the pin.
|
|
let personas = vec![persona_with_runtime("p1", Some("goose"))];
|
|
assert_eq!(
|
|
divergent_agent_command_override(Some("p1"), &personas, Some(" ")),
|
|
None
|
|
);
|
|
assert_eq!(
|
|
divergent_agent_command_override(Some("p1"), &personas, None),
|
|
None
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn create_time_override_none_when_persona_runtime_not_installed() {
|
|
// CRITICAL-3 (Case 3): a `claude`-persona agent created on a machine
|
|
// where the claude adapter isn't installed. `resolvePersonaRuntime`
|
|
// falls back to the default (`buzz-agent`) and sends THAT command with
|
|
// `harness_override` false (the user did not pick it). At create this
|
|
// is a fallback, not a deliberate pin — it must store `None` so the
|
|
// agent inherits the persona's runtime once it's installed and the
|
|
// persona is re-edited. Baking `Some("buzz-agent")` here is the exact
|
|
// bug this resolver chain exists to kill.
|
|
let personas = vec![persona_with_runtime("p1", Some("claude"))];
|
|
assert_eq!(
|
|
create_time_agent_command_override(Some("p1"), &personas, Some("buzz-agent"), false),
|
|
None
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn create_time_override_some_when_user_deliberately_overrides_installed_runtime() {
|
|
// Case 2 + deliberate override: the persona's `claude` runtime IS
|
|
// available, but the user explicitly picked `codex` in a deploy dialog's
|
|
// runtime selector ("overriding persona preferences"), so the frontend
|
|
// sends `codex-acp` with `harness_override` true. This is a real pin and
|
|
// MUST be preserved — returning `None` would silently swallow the
|
|
// deliberate override and inherit `claude` on spawn.
|
|
let personas = vec![persona_with_runtime("p1", Some("claude"))];
|
|
assert_eq!(
|
|
create_time_agent_command_override(Some("p1"), &personas, Some("codex-acp"), true),
|
|
Some("codex-acp".to_string())
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn create_time_override_none_when_persona_runtime_installed() {
|
|
// Case 2: the persona's runtime is available, so `resolvePersonaRuntime`
|
|
// sends the persona's own command with no override. Inherits — no pin.
|
|
let personas = vec![persona_with_runtime("p1", Some("goose"))];
|
|
assert_eq!(
|
|
create_time_agent_command_override(Some("p1"), &personas, Some("goose"), false),
|
|
None
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn create_time_override_preserves_selected_runtime_alias() {
|
|
// A `claude` persona inherits the primary command `claude-agent-acp`,
|
|
// but discovery may select an installed alias such as `claude-code-acp`.
|
|
// When UI marks that create-time selection as explicit, preserve the
|
|
// alias so the first spawn uses a command known to be installed.
|
|
let personas = vec![persona_with_runtime("p1", Some("claude"))];
|
|
assert_eq!(
|
|
create_time_agent_command_override(
|
|
Some("p1"),
|
|
&personas,
|
|
Some("claude-code-acp"),
|
|
true
|
|
),
|
|
Some("claude-code-acp".to_string())
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn create_time_override_inherits_exact_persona_command() {
|
|
let personas = vec![persona_with_runtime("p1", Some("claude"))];
|
|
assert_eq!(
|
|
create_time_agent_command_override(
|
|
Some("p1"),
|
|
&personas,
|
|
Some("claude-agent-acp"),
|
|
true
|
|
),
|
|
None
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn create_time_override_preserves_pin_for_persona_less_create() {
|
|
// The standalone CreateAgentDialog creates persona-LESS agents. With no
|
|
// persona to inherit, the picked command IS the agent's harness and must
|
|
// be preserved as a real pin (divergence from the bundled default),
|
|
// regardless of the override flag.
|
|
let personas = vec![persona_with_runtime("p1", Some("goose"))];
|
|
assert_eq!(
|
|
create_time_agent_command_override(None, &personas, Some("codex-acp"), false),
|
|
Some("codex-acp".to_string())
|
|
);
|
|
}
|
|
}
|