Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
14 changes: 13 additions & 1 deletion crates/sprout-acp/src/config.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
}
}
Expand Down Expand Up @@ -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");
Expand Down
2 changes: 1 addition & 1 deletion desktop/scripts/check-file-sizes.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
64 changes: 55 additions & 9 deletions desktop/src-tauri/src/managed_agents/discovery.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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://github.com/block/sprout/main/docs/assets/sprout-icon.png";

fn common_binary_paths() -> &'static [PathBuf] {
use std::sync::OnceLock;
Expand Down Expand Up @@ -46,20 +52,35 @@ const KNOWN_ACP_PROVIDERS: &[KnownAcpProvider] = &[
commands: &["goose"],
aliases: &[],
avatar_url: GOOSE_AVATAR_URL,
mcp_command: None,
mcp_hooks: false,
},
KnownAcpProvider {
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,
},
KnownAcpProvider {
id: "codex",
label: "Codex",
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,
},
];

Expand Down Expand Up @@ -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| {
Expand All @@ -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,
}
}
Expand Down Expand Up @@ -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()
Expand All @@ -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]
Expand Down Expand Up @@ -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 =
Expand Down
47 changes: 44 additions & 3 deletions desktop/src-tauri/src/managed_agents/runtime.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
};
Expand All @@ -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",
Expand All @@ -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.
Expand Down Expand Up @@ -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());
Expand Down Expand Up @@ -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());
}
}
2 changes: 2 additions & 0 deletions desktop/src-tauri/src/managed_agents/types.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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)]
Expand Down
2 changes: 2 additions & 0 deletions desktop/src-tauri/tauri.conf.json
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,8 @@
"externalBin": [
"binaries/sprout-acp",
"binaries/sprout-mcp-server",
"binaries/sprout-agent",
"binaries/sprout-dev-mcp",
"binaries/git-credential-nostr"
],
"icon": [
Expand Down
6 changes: 3 additions & 3 deletions desktop/src/features/agents/channelAgents.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@ import type {

type ChannelAgentProvider = Pick<
AcpProvider,
"id" | "label" | "command" | "defaultArgs"
"id" | "label" | "command" | "defaultArgs" | "mcpCommand"
>;

export type AttachManagedAgentToChannelInput = {
Expand Down Expand Up @@ -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, {
Expand Down Expand Up @@ -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,
Expand Down
1 change: 1 addition & 0 deletions desktop/src/features/agents/hooks.ts
Original file line number Diff line number Diff line change
Expand Up @@ -435,6 +435,7 @@ export function useEnsureGooseInChannelMutation(channelId: string | null) {
label: "Goose",
command: "goose",
defaultArgs: ["acp"],
mcpCommand: null,
},
role: "bot",
});
Expand Down
1 change: 1 addition & 0 deletions desktop/src/features/agents/ui/AddTeamToChannelDialog.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
2 changes: 2 additions & 0 deletions desktop/src/features/agents/ui/CreateAgentDialog.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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) {
Expand Down
2 changes: 2 additions & 0 deletions desktop/src/shared/api/tauri.ts
Original file line number Diff line number Diff line change
Expand Up @@ -246,6 +246,7 @@ type RawAcpProvider = {
command: string;
binary_path: string;
default_args: string[];
mcp_command: string | null;
};

type RawCommandAvailability = {
Expand Down Expand Up @@ -842,6 +843,7 @@ function fromRawAcpProvider(provider: RawAcpProvider): AcpProvider {
command: provider.command,
binaryPath: provider.binary_path,
defaultArgs: provider.default_args,
mcpCommand: provider.mcp_command,
};
}

Expand Down
2 changes: 2 additions & 0 deletions desktop/src/shared/api/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 = {
Expand Down
Loading
Loading