Skip to content
Closed
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
1 change: 1 addition & 0 deletions desktop/scripts/check-file-sizes.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,7 @@ const overrides = new Map([
["src-tauri/src/nostr_convert.rs", 1150], // 12 Nostr event→model converters (channels, profiles, members, notes, search, agents, relay members) + rank_user_search_results helper for NIP-50 user search + 33 unit tests
["src-tauri/src/managed_agents/runtime.rs", 1110], // ... + respond-to gate env (SPROUT_ACP_RESPOND_TO[_ALLOWLIST]) + per-mode env builder + tests + persona/agent env_vars spawn merge (helper + tests now in env_vars.rs)
["src-tauri/src/managed_agents/types.rs", 715], // ManagedAgentRecord/Summary + Create/Update request structs + RespondTo enum + validate_respond_to_allowlist + tests + persona/agent env_vars field
["src-tauri/src/managed_agents/nest.rs", 800], // regenerate_nest_context + render_dynamic_section + upsert_managed_section + marker helpers + 19 unit tests
["src-tauri/src/managed_agents/backend.rs", 700], // provider IPC, validation, discovery, binary resolution + tests + redact_secrets_with for user env values + env_secrets_from_request + redact_env_values_in (shared with model discovery)
["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
9 changes: 7 additions & 2 deletions desktop/src-tauri/src/commands/agent_models.rs
Original file line number Diff line number Diff line change
Expand Up @@ -8,8 +8,9 @@ use crate::{
managed_agents::{
build_managed_agent_summary, default_agent_workdir, find_managed_agent_mut,
load_managed_agents, managed_agent_avatar_url, missing_command_message,
normalize_agent_args, resolve_command, save_managed_agents, sync_managed_agent_processes,
AgentModelInfo, AgentModelsResponse, UpdateManagedAgentRequest, UpdateManagedAgentResponse,
normalize_agent_args, regenerate_nest_context, resolve_command, save_managed_agents,
sync_managed_agent_processes, AgentModelInfo, AgentModelsResponse,
UpdateManagedAgentRequest, UpdateManagedAgentResponse,
},
relay::{relay_ws_url_with_override, sync_managed_agent_profile},
util::now_iso,
Expand Down Expand Up @@ -246,6 +247,10 @@ pub async fn update_managed_agent(
(summary, sync_params)
}; // lock dropped here

if let Err(error) = regenerate_nest_context(&app) {
eprintln!("sprout-desktop: nest context regeneration failed: {error}");
}

// Phase 2: relay profile sync (async, best-effort, outside lock)
let profile_sync_error =
if let Some((agent_keys, relay_url, display_name, avatar_url, auth_tag)) = sync_params {
Expand Down
95 changes: 53 additions & 42 deletions desktop/src-tauri/src/commands/agents.rs
Original file line number Diff line number Diff line change
Expand Up @@ -8,12 +8,13 @@ use crate::{
build_managed_agent_summary, discover_provider_candidates, ensure_persona_is_active,
find_managed_agent_mut, invoke_provider, load_managed_agents, load_personas,
managed_agent_avatar_url, managed_agent_log_path, managed_agents_base_dir,
normalize_agent_args, provider_deploy, read_log_tail, resolve_provider_binary,
save_managed_agents, start_managed_agent_process, stop_managed_agent_process,
sync_managed_agent_processes, validate_provider_config, BackendKind, BackendProviderInfo,
CreateManagedAgentRequest, CreateManagedAgentResponse, ManagedAgentLogResponse,
ManagedAgentRecord, ManagedAgentSummary, DEFAULT_ACP_COMMAND, DEFAULT_AGENT_COMMAND,
DEFAULT_AGENT_PARALLELISM, DEFAULT_AGENT_TURN_TIMEOUT_SECONDS, DEFAULT_MCP_COMMAND,
normalize_agent_args, provider_deploy, read_log_tail, regenerate_nest_context,
resolve_provider_binary, save_managed_agents, start_managed_agent_process,
stop_managed_agent_process, sync_managed_agent_processes, validate_provider_config,
BackendKind, BackendProviderInfo, CreateManagedAgentRequest, CreateManagedAgentResponse,
ManagedAgentLogResponse, ManagedAgentRecord, ManagedAgentSummary, DEFAULT_ACP_COMMAND,
DEFAULT_AGENT_COMMAND, DEFAULT_AGENT_PARALLELISM, DEFAULT_AGENT_TURN_TIMEOUT_SECONDS,
DEFAULT_MCP_COMMAND,
},
relay::{relay_ws_url_with_override, sync_managed_agent_profile},
util::now_iso,
Expand Down Expand Up @@ -453,6 +454,10 @@ pub async fn create_managed_agent(
(agent, spawn_error)
};

if let Err(error) = regenerate_nest_context(&app) {
eprintln!("sprout-desktop: nest context regeneration failed: {error}");
}

// ── Phase 4: sync agent profile on relay (async, outside lock) ───────────
let avatar_url = input
.avatar_url
Expand Down Expand Up @@ -673,48 +678,54 @@ pub fn delete_managed_agent(
app: AppHandle,
state: State<'_, AppState>,
) -> Result<(), String> {
let _store_guard = state
.managed_agents_store_lock
.lock()
.map_err(|error| error.to_string())?;
let mut records = load_managed_agents(&app)?;
let mut runtimes = state
.managed_agent_processes
.lock()
.map_err(|error| error.to_string())?;
{
let _store_guard = state
.managed_agents_store_lock
.lock()
.map_err(|error| error.to_string())?;
let mut records = load_managed_agents(&app)?;
let mut runtimes = state
.managed_agent_processes
.lock()
.map_err(|error| error.to_string())?;

if sync_managed_agent_processes(&mut records, &mut runtimes) {
save_managed_agents(&app, &records)?;
}
if sync_managed_agent_processes(&mut records, &mut runtimes) {
save_managed_agents(&app, &records)?;
}

// Guard: reject deletion of deployed remote agents unless explicitly forced.
// This turns "don't orphan remote infra" from a UI convention into a backend
// invariant — a buggy or compromised IPC caller cannot silently orphan a live
// remote deployment. The frontend sends force_remote_delete: true only after
// the user confirms the orphan warning.
if let Some(record) = records.iter().find(|r| r.pubkey == pubkey) {
if record.backend != BackendKind::Local
&& record.backend_agent_id.is_some()
&& !force_remote_delete.unwrap_or(false)
{
return Err(
"cannot delete a deployed remote agent without force_remote_delete: true"
.to_string(),
);
// Guard: reject deletion of deployed remote agents unless explicitly forced.
// This turns "don't orphan remote infra" from a UI convention into a backend
// invariant — a buggy or compromised IPC caller cannot silently orphan a live
// remote deployment. The frontend sends force_remote_delete: true only after
// the user confirms the orphan warning.
if let Some(record) = records.iter().find(|r| r.pubkey == pubkey) {
if record.backend != BackendKind::Local
&& record.backend_agent_id.is_some()
&& !force_remote_delete.unwrap_or(false)
{
return Err(
"cannot delete a deployed remote agent without force_remote_delete: true"
.to_string(),
);
}
}
}

if let Some(record) = records.iter_mut().find(|record| record.pubkey == pubkey) {
// For local agents: kills the process. For remote agents: no-op (the frontend
// sends !shutdown via WebSocket before calling delete). Either way, safe.
stop_managed_agent_process(&app, record, &mut runtimes)?;
if let Some(record) = records.iter_mut().find(|record| record.pubkey == pubkey) {
// For local agents: kills the process. For remote agents: no-op (the frontend
// sends !shutdown via WebSocket before calling delete). Either way, safe.
stop_managed_agent_process(&app, record, &mut runtimes)?;
}
let initial_len = records.len();
records.retain(|record| record.pubkey != pubkey);
if records.len() == initial_len {
return Err(format!("agent {pubkey} not found"));
}
save_managed_agents(&app, &records)?;
}
let initial_len = records.len();
records.retain(|record| record.pubkey != pubkey);
if records.len() == initial_len {
return Err(format!("agent {pubkey} not found"));
if let Err(error) = regenerate_nest_context(&app) {
eprintln!("sprout-desktop: nest context regeneration failed: {error}");
}
save_managed_agents(&app, &records)
Ok(())
}

#[tauri::command]
Expand Down
34 changes: 26 additions & 8 deletions desktop/src-tauri/src/commands/personas.rs
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ use crate::{
managed_agents::{
encode_persona_json, import_persona_pack, list_installed_packs, load_managed_agents,
load_personas, load_teams, parse_json_persona, parse_md_persona, parse_png_persona,
parse_zip_personas, save_managed_agents, save_personas,
parse_zip_personas, regenerate_nest_context, save_managed_agents, save_personas,
uninstall_persona_pack as do_uninstall_persona_pack, validate_persona_activation_change,
validate_persona_deletion, CreatePersonaRequest, PackSummary, ParsePersonaFilesResult,
PersonaRecord, UpdatePersonaRequest,
Expand Down Expand Up @@ -85,6 +85,9 @@ pub fn create_persona(
};
personas.push(persona.clone());
save_personas(&app, &personas)?;
if let Err(error) = regenerate_nest_context(&app) {
eprintln!("sprout-desktop: nest context regeneration failed: {error}");
}
Ok(persona)
}

Expand Down Expand Up @@ -125,19 +128,20 @@ pub fn update_persona(
.filter(|s| !s.is_empty())
.collect();
if let Some(env_vars) = input.env_vars {
// Caller explicitly sent env_vars — replace entirely (empty = clear).
crate::managed_agents::validate_user_env_keys(&env_vars)?;
persona.env_vars = env_vars;
}
// Absent env_vars means "don't touch" — preserve existing creds when
// the caller only meant to edit a different field.
persona.updated_at = now_iso();

save_personas(&app, &personas)?;
personas
let result = personas
.into_iter()
.find(|record| record.id == input.id)
.ok_or_else(|| format!("persona {} disappeared unexpectedly", input.id))
.ok_or_else(|| format!("persona {} disappeared unexpectedly", input.id))?;
if let Err(error) = regenerate_nest_context(&app) {
eprintln!("sprout-desktop: nest context regeneration failed: {error}");
}
Ok(result)
}

#[tauri::command]
Expand Down Expand Up @@ -182,6 +186,9 @@ pub fn delete_persona(
if changed_agents {
save_managed_agents(&app, &agents)?;
}
if let Err(error) = regenerate_nest_context(&app) {
eprintln!("sprout-desktop: nest context regeneration failed: {error}");
}

Ok(())
}
Expand Down Expand Up @@ -230,6 +237,9 @@ pub fn set_persona_active(

let updated = persona.clone();
save_personas(&app, &personas)?;
if let Err(error) = regenerate_nest_context(&app) {
eprintln!("sprout-desktop: nest context regeneration failed: {error}");
}
Ok(updated)
}

Expand Down Expand Up @@ -383,7 +393,11 @@ pub fn install_persona_pack(
if !source.is_dir() {
return Err(format!("pack path is not a directory: {path}"));
}
import_persona_pack(&app, &source)
let result = import_persona_pack(&app, &source)?;
if let Err(error) = regenerate_nest_context(&app) {
eprintln!("sprout-desktop: nest context regeneration failed: {error}");
}
Ok(result)
}

#[tauri::command]
Expand All @@ -396,7 +410,11 @@ pub fn uninstall_persona_pack(
.managed_agents_store_lock
.lock()
.map_err(|e| e.to_string())?;
do_uninstall_persona_pack(&app, &pack_id)
do_uninstall_persona_pack(&app, &pack_id)?;
if let Err(error) = regenerate_nest_context(&app) {
eprintln!("sprout-desktop: nest context regeneration failed: {error}");
}
Ok(())
}

#[tauri::command]
Expand Down
10 changes: 9 additions & 1 deletion desktop/src-tauri/src/commands/workspace.rs
Original file line number Diff line number Diff line change
@@ -1,8 +1,9 @@
use nostr::Keys;
use serde::Serialize;
use tauri::State;
use tauri::{AppHandle, State};

use crate::app_state::AppState;
use crate::managed_agents::regenerate_nest_context;
use crate::relay;

#[derive(Serialize)]
Expand Down Expand Up @@ -30,6 +31,7 @@ pub fn get_active_workspace(state: State<'_, AppState>) -> Result<ActiveWorkspac
pub fn apply_workspace(
relay_url: String,
nsec: Option<String>,
app: AppHandle,
state: State<'_, AppState>,
) -> Result<(), String> {
// ── Validate before mutating ──────────────────────────────────────────
Expand All @@ -51,5 +53,11 @@ pub fn apply_workspace(
*keys_guard = keys;
}

if let Err(error) = regenerate_nest_context(&app) {
eprintln!(
"sprout-desktop: failed to regenerate nest context after workspace switch: {error}"
);
}

Ok(())
}
6 changes: 5 additions & 1 deletion desktop/src-tauri/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@ use huddle::{
speak_agent_message, start_huddle, start_stt_pipeline,
};
use managed_agents::{
ensure_nest, kill_stale_tracked_processes, load_managed_agents,
ensure_nest, kill_stale_tracked_processes, load_managed_agents, regenerate_nest_context,
restore_managed_agents_on_launch, save_managed_agents, sync_managed_agent_processes,
BackendKind, ManagedAgentProcess,
};
Expand Down Expand Up @@ -393,6 +393,10 @@ pub fn run() {
eprintln!("sprout-desktop: failed to create nest: {error}");
}

if let Err(error) = regenerate_nest_context(&app_handle) {
eprintln!("sprout-desktop: failed to regenerate nest context: {error}");
}

// Pre-download voice models in the background so they're ready
// when the user starts their first huddle. Idempotent — no-op if
// already downloaded. ~87 MB total (50 MB Moonshine + 87 MB Kokoro).
Expand Down
Loading
Loading