Launch sprout-agent from the desktop GUI (#529)

This commit is contained in:
tlongwell-block
2026-05-11 12:36:53 -04:00
committed by GitHub
parent f94e44c382
commit 22f1e0b724
17 changed files with 141 additions and 21 deletions

View File

@@ -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

View File

@@ -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

View File

@@ -460,7 +460,7 @@ fn default_agent_args(command: &str) -> Option<Vec<String>> {
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::<String>::new()
);
assert_eq!(
normalize_agent_args("sprout-agent", vec!["acp".into()]),
Vec::<String>::new()
);
}
#[test]
fn normalize_agent_command_identity_variants() {
assert_eq!(normalize_agent_command_identity("goose"), "goose");

View File

@@ -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

View File

@@ -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<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" => Some(Vec::new()),
| "claudecode" | "sprout-agent" => Some(Vec::new()),
_ => None,
}
}
@@ -342,6 +363,7 @@ pub fn discover_local_acp_providers() -> Vec<AcpProviderInfo> {
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<String> {
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::<String>::new()
);
assert_eq!(
normalize_agent_args("sprout-agent", vec!["acp".into()]),
Vec::<String>::new()
);
}
#[test]
fn login_shell_lookup_treats_command_as_data() {
let marker =

View File

@@ -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());
}
}

View File

@@ -237,6 +237,8 @@ pub struct AcpProviderInfo {
pub command: String,
pub binary_path: String,
pub default_args: Vec<String>,
/// MCP server binary override. `None` means use the default (`sprout-mcp-server`).
pub mcp_command: Option<String>,
}
#[derive(Debug, Clone, Serialize)]

View File

@@ -49,6 +49,8 @@
"externalBin": [
"binaries/sprout-acp",
"binaries/sprout-mcp-server",
"binaries/sprout-agent",
"binaries/sprout-dev-mcp",
"binaries/git-credential-nostr"
],
"icon": [

View File

@@ -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,

View File

@@ -435,6 +435,7 @@ export function useEnsureGooseInChannelMutation(channelId: string | null) {
label: "Goose",
command: "goose",
defaultArgs: ["acp"],
mcpCommand: null,
},
role: "bot",
});

View File

@@ -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,

View File

@@ -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) {

View File

@@ -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,
};
}

View File

@@ -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 = {

View File

@@ -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,
},
];
}

View File

@@ -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}}

View File

@@ -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