diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index a61af4398d..43b7f2b6b1 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -346,6 +346,8 @@ jobs: mkdir -p desktop/src-tauri/binaries touch "desktop/src-tauri/binaries/sprout-acp-$TARGET" touch "desktop/src-tauri/binaries/sprout-mcp-server-$TARGET" + touch "desktop/src-tauri/binaries/sprout-agent-$TARGET" + touch "desktop/src-tauri/binaries/sprout-dev-mcp-$TARGET" touch "desktop/src-tauri/binaries/git-credential-nostr-$TARGET" - name: Build Tauri app run: cd desktop && pnpm tauri build diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 4125bed68a..855e7b08a0 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -52,7 +52,7 @@ jobs: - name: Build sidecars run: | - cargo build --release -p sprout-acp -p sprout-mcp -p git-credential-nostr + cargo build --release -p sprout-acp -p sprout-mcp -p sprout-agent -p sprout-dev-mcp -p git-credential-nostr ./scripts/bundle-sidecars.sh - name: Build unsigned Tauri app diff --git a/crates/sprout-acp/src/config.rs b/crates/sprout-acp/src/config.rs index 1cf45fc9b1..ed354526dc 100644 --- a/crates/sprout-acp/src/config.rs +++ b/crates/sprout-acp/src/config.rs @@ -460,7 +460,7 @@ fn default_agent_args(command: &str) -> Option> { match normalize_agent_command_identity(command).as_str() { "goose" => Some(vec!["acp".to_string()]), "codex" | "codex-acp" | "claude-agent-acp" | "claude-code-acp" | "claude-code" - | "claudecode" => Some(Vec::new()), + | "claudecode" | "sprout-agent" => Some(Vec::new()), _ => None, } } @@ -1228,6 +1228,18 @@ mod tests { ); } + #[test] + fn normalizes_sprout_agent_args_to_empty() { + assert_eq!( + normalize_agent_args("sprout-agent", Vec::new()), + Vec::::new() + ); + assert_eq!( + normalize_agent_args("sprout-agent", vec!["acp".into()]), + Vec::::new() + ); + } + #[test] fn normalize_agent_command_identity_variants() { assert_eq!(normalize_agent_command_identity("goose"), "goose"); diff --git a/desktop/scripts/check-file-sizes.mjs b/desktop/scripts/check-file-sizes.mjs index 7ed345442b..c5c9400551 100644 --- a/desktop/scripts/check-file-sizes.mjs +++ b/desktop/scripts/check-file-sizes.mjs @@ -50,7 +50,7 @@ const overrides = new Map([ ["src-tauri/src/commands/agents.rs", 881], // remote agent lifecycle routing (local + provider branches) + scope enforcement + persona pack metadata wiring + mcp_toolsets field + NIP-OA auth_tag in deploy payload ["src-tauri/src/commands/messages.rs", 510], // feed multi-query + NIP-50 search + forum thread resolution + thread ref + reactions via REQ ["src-tauri/src/nostr_convert.rs", 870], // 12 Nostr event→model converters (channels, profiles, members, notes, search, agents, relay members) + 20 unit tests - ["src-tauri/src/managed_agents/runtime.rs", 740], // KNOWN_AGENT_BINARIES const + process_belongs_to_us FFI (macOS proc_name + Linux /proc/comm) + terminate_process + start/stop/sync lifecycle + pack persona live-read + login shell PATH augmentation + observer endpoint wiring + git credential helper env injection + ["src-tauri/src/managed_agents/runtime.rs", 780], // KNOWN_AGENT_BINARIES const + process_belongs_to_us FFI (macOS proc_name + Linux /proc/comm) + terminate_process + start/stop/sync lifecycle + pack persona live-read + login shell PATH augmentation + observer endpoint wiring + git credential helper env injection + sprout-agent mcp_hooks wiring + tests ["src-tauri/src/managed_agents/backend.rs", 530], // provider IPC, validation, discovery, binary resolution + tests ["src/features/huddle/HuddleContext.tsx", 650], // huddle lifecycle context + joinHuddle + connectAndSetupMedia shared helper + activeSpeakers/isReconnecting state + PTT (reusable AudioContext) + TTS subscription + mic level analyser (10fps throttle) + agent pubkey refresh ["src/features/agents/hooks.ts", 540], // agent query/mutation surface now includes built-in persona library activation + useUpdateManagedAgentMutation diff --git a/desktop/src-tauri/src/managed_agents/discovery.rs b/desktop/src-tauri/src/managed_agents/discovery.rs index 06a2321879..5746d90941 100644 --- a/desktop/src-tauri/src/managed_agents/discovery.rs +++ b/desktop/src-tauri/src/managed_agents/discovery.rs @@ -5,17 +5,23 @@ use tauri::AppHandle; use crate::managed_agents::{AcpProviderInfo, CommandAvailabilityInfo}; -struct KnownAcpProvider { - id: &'static str, - label: &'static str, - commands: &'static [&'static str], - aliases: &'static [&'static str], - avatar_url: &'static str, +pub(crate) struct KnownAcpProvider { + pub id: &'static str, + pub label: &'static str, + pub commands: &'static [&'static str], + pub aliases: &'static [&'static str], + pub avatar_url: &'static str, + /// MCP server binary to use instead of the default `sprout-mcp-server`. + pub mcp_command: Option<&'static str>, + /// Whether to enable MCP hook tools (`_Stop`, `_PostCompact`) for this agent. + pub mcp_hooks: bool, } 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 SPROUT_AGENT_AVATAR_URL: &str = + "https://raw.githubusercontent.com/block/sprout/main/docs/assets/sprout-icon.png"; fn common_binary_paths() -> &'static [PathBuf] { use std::sync::OnceLock; @@ -46,6 +52,8 @@ const KNOWN_ACP_PROVIDERS: &[KnownAcpProvider] = &[ commands: &["goose"], aliases: &[], avatar_url: GOOSE_AVATAR_URL, + mcp_command: None, + mcp_hooks: false, }, KnownAcpProvider { id: "claude", @@ -53,6 +61,8 @@ const KNOWN_ACP_PROVIDERS: &[KnownAcpProvider] = &[ commands: &["claude-agent-acp", "claude-code-acp"], aliases: &["claude-code", "claudecode"], avatar_url: CLAUDE_CODE_AVATAR_URL, + mcp_command: None, + mcp_hooks: false, }, KnownAcpProvider { id: "codex", @@ -60,6 +70,17 @@ const KNOWN_ACP_PROVIDERS: &[KnownAcpProvider] = &[ commands: &["codex-acp"], aliases: &[], avatar_url: CODEX_AVATAR_URL, + mcp_command: None, + mcp_hooks: false, + }, + KnownAcpProvider { + id: "sprout-agent", + label: "Sprout Agent", + commands: &["sprout-agent"], + aliases: &[], + avatar_url: SPROUT_AGENT_AVATAR_URL, + mcp_command: Some("sprout-dev-mcp"), + mcp_hooks: true, }, ]; @@ -110,7 +131,7 @@ fn normalize_command_identity(command: &str) -> String { lower } -fn known_acp_provider(command: &str) -> Option<&'static KnownAcpProvider> { +pub(crate) fn known_acp_provider(command: &str) -> Option<&'static KnownAcpProvider> { let normalized = normalize_command_identity(command); KNOWN_ACP_PROVIDERS.iter().find(|provider| { @@ -127,7 +148,7 @@ fn default_agent_args(command: &str) -> Option> { 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" => Some(Vec::new()), + | "claudecode" | "sprout-agent" => Some(Vec::new()), _ => None, } } @@ -342,6 +363,7 @@ pub fn discover_local_acp_providers() -> Vec { command: command.to_string(), binary_path: binary_path.display().to_string(), default_args: normalize_agent_args(command, Vec::new()), + mcp_command: provider.mcp_command.map(str::to_string), }) }) .collect() @@ -356,7 +378,7 @@ pub fn managed_agent_avatar_url(command: &str) -> Option { mod tests { use super::{ find_via_login_shell, managed_agent_avatar_url, normalize_agent_args, - CLAUDE_CODE_AVATAR_URL, CODEX_AVATAR_URL, GOOSE_AVATAR_URL, + CLAUDE_CODE_AVATAR_URL, CODEX_AVATAR_URL, GOOSE_AVATAR_URL, SPROUT_AGENT_AVATAR_URL, }; #[test] @@ -407,6 +429,30 @@ mod tests { ); } + #[test] + fn resolves_sprout_agent_avatar() { + assert_eq!( + managed_agent_avatar_url("sprout-agent"), + Some(SPROUT_AGENT_AVATAR_URL.to_string()) + ); + assert_eq!( + managed_agent_avatar_url("/usr/local/bin/sprout-agent"), + Some(SPROUT_AGENT_AVATAR_URL.to_string()) + ); + } + + #[test] + fn normalizes_sprout_agent_args_to_empty() { + assert_eq!( + normalize_agent_args("sprout-agent", Vec::new()), + Vec::::new() + ); + assert_eq!( + normalize_agent_args("sprout-agent", vec!["acp".into()]), + Vec::::new() + ); + } + #[test] fn login_shell_lookup_treats_command_as_data() { let marker = diff --git a/desktop/src-tauri/src/managed_agents/runtime.rs b/desktop/src-tauri/src/managed_agents/runtime.rs index 6125f9183e..7f9c34f488 100644 --- a/desktop/src-tauri/src/managed_agents/runtime.rs +++ b/desktop/src-tauri/src/managed_agents/runtime.rs @@ -4,9 +4,9 @@ use tauri::AppHandle; use crate::{ managed_agents::{ - append_log_marker, login_shell_path, managed_agent_log_path, missing_command_message, - normalize_agent_args, open_log_file, resolve_command, ManagedAgentProcess, - ManagedAgentRecord, ManagedAgentSummary, + append_log_marker, known_acp_provider, login_shell_path, managed_agent_log_path, + missing_command_message, normalize_agent_args, open_log_file, resolve_command, + ManagedAgentProcess, ManagedAgentRecord, ManagedAgentSummary, }, util::now_iso, }; @@ -19,6 +19,8 @@ use crate::{ pub(crate) const KNOWN_AGENT_BINARIES: &[&str] = &[ "sprout-acp", "sprout_acp", + "sprout-agent", + "sprout_agent", "claude-agent-acp", "claude_agent_acp", "claude-code-acp", @@ -28,6 +30,11 @@ pub(crate) const KNOWN_AGENT_BINARIES: &[&str] = &[ "goose", "sprout-mcp", "sprout_mcp", + // sprout-dev-mcp's multicall personalities (rg, tree, sprout, + // git-credential-nostr, git-sign-nostr) are short-lived per-tool-call + // invocations — not listed here. + "sprout-dev-mcp", + "sprout_dev_mcp", ]; /// Check if a process name matches any of our known agent binaries. @@ -484,6 +491,11 @@ pub fn spawn_agent_child( command.env("SPROUT_ACP_AGENT_COMMAND", &resolved_agent_command); command.env("SPROUT_ACP_AGENT_ARGS", agent_args.join(",")); command.env("SPROUT_ACP_MCP_COMMAND", &resolved_mcp_command); + // Enable MCP hook tools (_Stop, _PostCompact) for agents that need them. + // Uses "*" because build_mcp_servers() hard-codes the server name to "sprout-mcp". + if known_acp_provider(&record.agent_command).is_some_and(|p| p.mcp_hooks) { + command.env("MCP_HOOK_SERVERS", "*"); + } if let Some(idle) = record.idle_timeout_seconds { command.env("SPROUT_ACP_IDLE_TIMEOUT", idle.to_string()); command.env("SPROUT_ACP_TURN_TIMEOUT", idle.to_string()); @@ -729,3 +741,32 @@ pub fn stop_managed_agent_process( Ok(()) } + +#[cfg(test)] +mod tests { + use crate::managed_agents::known_acp_provider; + + #[test] + fn sprout_agent_has_mcp_hooks() { + let p = known_acp_provider("sprout-agent").expect("should resolve"); + assert!(p.mcp_hooks); + assert_eq!(p.mcp_command, Some("sprout-dev-mcp")); + } + + #[test] + fn sprout_agent_resolved_via_path() { + assert!(known_acp_provider("/usr/local/bin/sprout-agent").is_some_and(|p| p.mcp_hooks)); + } + + #[test] + fn goose_has_no_mcp_hooks() { + let p = known_acp_provider("goose").expect("should resolve"); + assert!(!p.mcp_hooks); + assert_eq!(p.mcp_command, None); + } + + #[test] + fn unknown_command_returns_none() { + assert!(known_acp_provider("custom-agent").is_none()); + } +} diff --git a/desktop/src-tauri/src/managed_agents/types.rs b/desktop/src-tauri/src/managed_agents/types.rs index 2258925b26..67551ead84 100644 --- a/desktop/src-tauri/src/managed_agents/types.rs +++ b/desktop/src-tauri/src/managed_agents/types.rs @@ -237,6 +237,8 @@ pub struct AcpProviderInfo { pub command: String, pub binary_path: String, pub default_args: Vec, + /// MCP server binary override. `None` means use the default (`sprout-mcp-server`). + pub mcp_command: Option, } #[derive(Debug, Clone, Serialize)] diff --git a/desktop/src-tauri/tauri.conf.json b/desktop/src-tauri/tauri.conf.json index 8247f6a13e..c47e8f107c 100644 --- a/desktop/src-tauri/tauri.conf.json +++ b/desktop/src-tauri/tauri.conf.json @@ -49,6 +49,8 @@ "externalBin": [ "binaries/sprout-acp", "binaries/sprout-mcp-server", + "binaries/sprout-agent", + "binaries/sprout-dev-mcp", "binaries/git-credential-nostr" ], "icon": [ diff --git a/desktop/src/features/agents/channelAgents.ts b/desktop/src/features/agents/channelAgents.ts index 04872f916d..cf39a0506f 100644 --- a/desktop/src/features/agents/channelAgents.ts +++ b/desktop/src/features/agents/channelAgents.ts @@ -17,7 +17,7 @@ import type { type ChannelAgentProvider = Pick< AcpProvider, - "id" | "label" | "command" | "defaultArgs" + "id" | "label" | "command" | "defaultArgs" | "mcpCommand" >; export type AttachManagedAgentToChannelInput = { @@ -248,7 +248,7 @@ export async function ensureChannelAgentPresetInChannel( acpCommand: "sprout-acp", agentCommand: input.provider.command, agentArgs: input.provider.defaultArgs, - mcpCommand: "sprout-mcp-server", + mcpCommand: input.provider.mcpCommand ?? "sprout-mcp-server", spawnAfterCreate: false, }); const attached = await attachManagedAgentToChannel(channelId, { @@ -299,7 +299,7 @@ export async function createChannelManagedAgent( acpCommand: "sprout-acp", agentCommand: input.provider.command, agentArgs: input.provider.defaultArgs, - mcpCommand: "sprout-mcp-server", + mcpCommand: input.provider.mcpCommand ?? "sprout-mcp-server", personaId: input.personaId ?? undefined, systemPrompt: input.systemPrompt?.trim() || undefined, avatarUrl: resolvedAvatarUrl, diff --git a/desktop/src/features/agents/hooks.ts b/desktop/src/features/agents/hooks.ts index 6674414fb0..de1784e5c2 100644 --- a/desktop/src/features/agents/hooks.ts +++ b/desktop/src/features/agents/hooks.ts @@ -435,6 +435,7 @@ export function useEnsureGooseInChannelMutation(channelId: string | null) { label: "Goose", command: "goose", defaultArgs: ["acp"], + mcpCommand: null, }, role: "bot", }); diff --git a/desktop/src/features/agents/ui/AddTeamToChannelDialog.tsx b/desktop/src/features/agents/ui/AddTeamToChannelDialog.tsx index 8db73e905b..6a82dafe10 100644 --- a/desktop/src/features/agents/ui/AddTeamToChannelDialog.tsx +++ b/desktop/src/features/agents/ui/AddTeamToChannelDialog.tsx @@ -132,6 +132,7 @@ export function AddTeamToChannelDialog({ label: providerToUse.label, command: providerToUse.command, defaultArgs: providerToUse.defaultArgs, + mcpCommand: providerToUse.mcpCommand, }, name: persona.displayName, systemPrompt: persona.systemPrompt, diff --git a/desktop/src/features/agents/ui/CreateAgentDialog.tsx b/desktop/src/features/agents/ui/CreateAgentDialog.tsx index a288f1b967..5c68900a41 100644 --- a/desktop/src/features/agents/ui/CreateAgentDialog.tsx +++ b/desktop/src/features/agents/ui/CreateAgentDialog.tsx @@ -109,6 +109,7 @@ export function CreateAgentDialog({ setSelectedProviderId(remembered.id); setAgentCommand(remembered.command); setAgentArgs(remembered.defaultArgs.join(",")); + setMcpCommand(remembered.mcpCommand ?? "sprout-mcp-server"); } else { const matchingProvider = providers.find((provider) => provider.command === agentCommand) ?? null; @@ -241,6 +242,7 @@ export function CreateAgentDialog({ setLastProvider(nextProviderId); setAgentCommand(provider.command); setAgentArgs(provider.defaultArgs.join(",")); + setMcpCommand(provider.mcpCommand ?? "sprout-mcp-server"); } function handleRunOnChange(value: string) { diff --git a/desktop/src/shared/api/tauri.ts b/desktop/src/shared/api/tauri.ts index d4e8cb5b30..87a0196c47 100644 --- a/desktop/src/shared/api/tauri.ts +++ b/desktop/src/shared/api/tauri.ts @@ -246,6 +246,7 @@ type RawAcpProvider = { command: string; binary_path: string; default_args: string[]; + mcp_command: string | null; }; type RawCommandAvailability = { @@ -842,6 +843,7 @@ function fromRawAcpProvider(provider: RawAcpProvider): AcpProvider { command: provider.command, binaryPath: provider.binary_path, defaultArgs: provider.default_args, + mcpCommand: provider.mcp_command, }; } diff --git a/desktop/src/shared/api/types.ts b/desktop/src/shared/api/types.ts index 62c6b36b7d..08af973f7e 100644 --- a/desktop/src/shared/api/types.ts +++ b/desktop/src/shared/api/types.ts @@ -348,6 +348,8 @@ export type AcpProvider = { command: string; binaryPath: string; defaultArgs: string[]; + /** MCP server binary override, or `null` for the default (`sprout-mcp-server`). */ + mcpCommand: string | null; }; export type CommandAvailability = { diff --git a/desktop/src/testing/e2eBridge.ts b/desktop/src/testing/e2eBridge.ts index c7f9fceaa7..08c32a58d1 100644 --- a/desktop/src/testing/e2eBridge.ts +++ b/desktop/src/testing/e2eBridge.ts @@ -17,6 +17,7 @@ type MockAcpProvider = { command: string; binaryPath: string; defaultArgs: string[]; + mcpCommand?: string | null; }; type MockCommandAvailability = { @@ -319,6 +320,7 @@ type RawAcpProvider = { command: string; binary_path: string; default_args: string[]; + mcp_command: string | null; }; type RawCommandAvailability = { @@ -3445,6 +3447,7 @@ async function handleDiscoverAcpProviders( command: provider.command, binary_path: provider.binaryPath, default_args: [...provider.defaultArgs], + mcp_command: provider.mcpCommand ?? null, })); } @@ -3455,6 +3458,7 @@ async function handleDiscoverAcpProviders( command: "goose", binary_path: "/usr/local/bin/goose", default_args: ["acp"], + mcp_command: null, }, { id: "codex", @@ -3462,6 +3466,7 @@ async function handleDiscoverAcpProviders( command: "codex-acp", binary_path: "/usr/local/bin/codex-acp", default_args: [], + mcp_command: null, }, ]; } diff --git a/justfile b/justfile index 39323cfb65..d2425dab36 100644 --- a/justfile +++ b/justfile @@ -92,7 +92,7 @@ _ensure-sidecar-stubs: set -euo pipefail TARGET=$(rustc -vV | sed -n 's|host: ||p') mkdir -p desktop/src-tauri/binaries - for bin in sprout-acp sprout-mcp-server git-credential-nostr; do + for bin in sprout-acp sprout-mcp-server sprout-agent sprout-dev-mcp git-credential-nostr; do touch "desktop/src-tauri/binaries/${bin}-${TARGET}" done @@ -108,6 +108,8 @@ desktop-release-build target="aarch64-apple-darwin": mkdir -p desktop/src-tauri/binaries touch "desktop/src-tauri/binaries/sprout-acp-$TARGET" touch "desktop/src-tauri/binaries/sprout-mcp-server-$TARGET" + touch "desktop/src-tauri/binaries/sprout-agent-$TARGET" + touch "desktop/src-tauri/binaries/sprout-dev-mcp-$TARGET" touch "desktop/src-tauri/binaries/git-credential-nostr-$TARGET" pnpm install cd {{desktop_dir}} && pnpm tauri build --target {{target}} diff --git a/scripts/bundle-sidecars.sh b/scripts/bundle-sidecars.sh index 6d199a680e..2446f32752 100755 --- a/scripts/bundle-sidecars.sh +++ b/scripts/bundle-sidecars.sh @@ -1,7 +1,7 @@ #!/usr/bin/env bash set -euo pipefail -SIDECARS=(sprout-acp sprout-mcp-server git-credential-nostr) +SIDECARS=(sprout-acp sprout-mcp-server sprout-agent sprout-dev-mcp git-credential-nostr) TARGET=${1:-$(rustc -vV | sed -n 's|host: ||p')} BINARIES_DIR="desktop/src-tauri/binaries" @@ -11,7 +11,7 @@ for bin in "${SIDECARS[@]}"; do done if [[ ${#missing[@]} -gt 0 ]]; then echo "Error: missing release binaries: ${missing[*]}" >&2 - echo "Run 'cargo build --release -p sprout-acp -p sprout-mcp -p git-credential-nostr' first." >&2 + echo "Run 'cargo build --release -p sprout-acp -p sprout-mcp -p sprout-agent -p sprout-dev-mcp -p git-credential-nostr' first." >&2 exit 1 fi